备份: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/suppliers', require('./routes/suppliers'));
app.use('/api/subcontractors', require('./routes/subcontractors'));
app.use('/api/project-receipts', require('./routes/project-receipts'));
app.use('/api/projects', require('./routes/projects'));
app.use('/api/upload', require('./routes/upload'));
app.use('/api/construction', require('./routes/construction'));
@@ -69,6 +70,7 @@ app.use('/api/process-templates', require('./routes/process-templates'));
app.use('/api/receiving', require('./routes/receiving'));
app.use('/api/expense-categories', require('./routes/expense-categories'));
app.use('/api/financial-records', require('./routes/financial-records'));
app.use('/api/cash-management', require('./routes/cash-management'));
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, '../frontend/dist/index.html'));
+215 -215
View File
@@ -1,216 +1,216 @@
const express = require('express');
const db = require('../db');
const { authenticate, requireAdmin } = require('../middleware/auth');
const { body, validationResult } = require('express-validator');
const router = express.Router();
// 验证错误处理中间件
const validate = (req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
errors: errors.array()
});
}
next();
};
router.get('/', async (req, res) => {
try {
const result = await db.query(`
SELECT a.*, u.name as user_name, p.name as project_name
FROM advances a
LEFT JOIN users u ON a.applicant_id = u.id
LEFT JOIN projects p ON a.project_id = p.id
ORDER BY a.created_at DESC
`);
// 解析每个预支申请的 attachments 字段为数组
const data = result.rows.map(item => {
if (item.attachments) {
try {
item.attachments = JSON.parse(item.attachments);
} catch (error) {
item.attachments = [];
}
} else {
item.attachments = [];
}
return item;
});
res.json({ success: true, data, count: data.length });
} catch (error) {
console.error('获取预支款失败:', error);
res.status(500).json({
success: false,
message: '获取预支款失败',
error: process.env.NODE_ENV === 'development' ? error.message : '操作失败'
});
}
});
router.post('/', [
body('amount').isFloat({ min: 0.01 }),
body('reason').notEmpty()
], validate, async (req, res) => {
try {
const { amount, reason, project_id, currency, advance_date, attachments, amount_cny, applicant, status } = req.body;
const user_id = 1; // 临时使用admin用户
// 生成预支编号
const advanceCode = `ADV-${Date.now()}`;
const result = await db.query(
'INSERT INTO advances (applicant_id, project_id, amount, currency, amount_cny, reason, advance_date, advance_code, status, applicant, attachments) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id',
[applicant_id, project_id, amount, currency || 'CNY', amount_cny || 0, reason, advance_date, advanceCode, status || 'pending', applicant, JSON.stringify(attachments || [])]
);
const data = { id: result.rows[0]?.id, applicant_id, project_id, amount, currency, reason, advance_date, advance_code: advanceCode, status, applicant };
res.json({ success: true, data });
} catch (error) {
console.error('创建预支申请失败:', error);
res.status(500).json({ success: false, message: '创建预支申请失败' });
}
});
router.get('/:id', async (req, res) => {
try {
const { id } = req.params;
const result = await db.query('SELECT * FROM advances WHERE id = $1', [id]);
if (result.rows.length > 0) {
const data = result.rows[0];
// 解析 attachments 字段为数组
if (data.attachments) {
try {
data.attachments = JSON.parse(data.attachments);
} catch (error) {
data.attachments = [];
}
} else {
data.attachments = [];
}
res.json({ success: true, data });
} else {
res.status(404).json({ success: false, message: '预支申请不存在' });
}
} catch (error) {
console.error('获取预支申请失败:', error);
res.status(500).json({ success: false, message: '获取预支申请失败' });
}
});
router.put('/:id', async (req, res) => {
try {
const { id } = req.params;
const { amount, reason, project_id, currency, advance_date, attachments, amount_cny, applicant, status } = req.body;
const result = await db.query(
'UPDATE advances SET amount = $1, reason = $2, project_id = $3, currency = $4, advance_date = $5, attachments = $6, amount_cny = $7, applicant = $8, status = $9 WHERE id = $10',
[amount, reason, project_id, currency, advance_date, JSON.stringify(attachments || []), amount_cny, applicant, status, id]
);
if (result.changes > 0) {
res.json({ success: true, message: '更新成功' });
} else {
res.status(404).json({ success: false, message: '预支申请不存在' });
}
} catch (error) {
console.error('更新预支申请失败:', error);
res.status(500).json({ success: false, message: '更新预支申请失败' });
}
});
router.delete('/:id', async (req, res) => {
try {
const { id } = req.params;
const result = await db.query('DELETE FROM advances WHERE id = $1', [id]);
if (result.changes > 0) {
res.json({ success: true, message: '删除成功' });
} else {
res.status(404).json({ success: false, message: '预支申请不存在' });
}
} catch (error) {
console.error('删除预支申请失败:', error);
res.status(500).json({ success: false, message: '删除预支申请失败' });
}
});
router.post('/:id/submit', async (req, res) => {
try {
const { id } = req.params;
const result = await db.query('UPDATE advances SET status = $1 WHERE id = $2', ['pending', id]);
if (result.changes > 0) {
res.json({ success: true, message: '提交成功' });
} else {
res.status(404).json({ success: false, message: '预支申请不存在' });
}
} catch (error) {
console.error('提交预支申请失败:', error);
res.status(500).json({ success: false, message: '提交预支申请失败' });
}
});
router.post('/:id/withdraw', async (req, res) => {
try {
const { id } = req.params;
const result = await db.query('UPDATE advances SET status = $1 WHERE id = $2', ['withdrawn', id]);
if (result.changes > 0) {
res.json({ success: true, message: '撤回成功' });
} else {
res.status(404).json({ success: false, message: '预支申请不存在' });
}
} catch (error) {
console.error('撤回预支申请失败:', error);
res.status(500).json({ success: false, message: '撤回预支申请失败' });
}
});
router.post('/:id/approve', async (req, res) => {
try {
const { id } = req.params;
const { remark } = req.body;
const result = await db.query('UPDATE advances SET status = $1, approval_remark = $2 WHERE id = $3', ['approved', remark, id]);
if (result.changes > 0) {
res.json({ success: true, message: '审批通过成功' });
} else {
res.status(404).json({ success: false, message: '预支申请不存在' });
}
} catch (error) {
console.error('审批预支申请失败:', error);
res.status(500).json({ success: false, message: '审批预支申请失败' });
}
});
router.post('/:id/reject', async (req, res) => {
try {
const { id } = req.params;
const { rejectReason } = req.body;
const result = await db.query('UPDATE advances SET status = $1 WHERE id = $2', ['pending_edit', id]);
if (result.changes > 0) {
res.json({ success: true, message: '退回成功' });
} else {
res.status(404).json({ success: false, message: '预支申请不存在' });
}
} catch (error) {
console.error('退回预支申请失败:', error);
res.status(500).json({ success: false, message: '退回预支申请失败' });
}
});
const express = require('express');
const db = require('../db');
const { authenticate, requireAdmin } = require('../middleware/auth');
const { body, validationResult } = require('express-validator');
const router = express.Router();
// 验证错误处理中间件
const validate = (req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
errors: errors.array()
});
}
next();
};
router.get('/', async (req, res) => {
try {
const result = await db.query(`
SELECT a.*, u.name as user_name, p.name as project_name
FROM advances a
LEFT JOIN users u ON a.applicant_id = u.id
LEFT JOIN projects p ON a.project_id = p.id
ORDER BY a.created_at DESC
`);
// 解析每个预支申请的 attachments 字段为数组
const data = result.rows.map(item => {
if (item.attachments) {
try {
item.attachments = JSON.parse(item.attachments);
} catch (error) {
item.attachments = [];
}
} else {
item.attachments = [];
}
return item;
});
res.json({ success: true, data, count: data.length });
} catch (error) {
console.error('获取预支款失败:', error);
res.status(500).json({
success: false,
message: '获取预支款失败',
error: process.env.NODE_ENV === 'development' ? error.message : '操作失败'
});
}
});
router.post('/', [
body('amount').isFloat({ min: 0.01 }),
body('reason').notEmpty()
], validate, async (req, res) => {
try {
const { amount, reason, project_id, currency, advance_date, attachments, amount_cny, applicant, status } = req.body;
const user_id = 1; // 临时使用admin用户
// 生成预支编号
const advanceCode = `ADV-${Date.now()}`;
const result = await db.query(
'INSERT INTO advances (applicant_id, project_id, amount, currency, amount_cny, reason, advance_date, advance_code, status, applicant, attachments) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id',
[applicant_id, project_id, amount, currency || 'CNY', amount_cny || 0, reason, advance_date, advanceCode, status || 'pending', applicant, JSON.stringify(attachments || [])]
);
const data = { id: result.rows[0]?.id, applicant_id, project_id, amount, currency, reason, advance_date, advance_code: advanceCode, status, applicant };
res.json({ success: true, data });
} catch (error) {
console.error('创建预支申请失败:', error);
res.status(500).json({ success: false, message: '创建预支申请失败' });
}
});
router.get('/:id', async (req, res) => {
try {
const { id } = req.params;
const result = await db.query('SELECT * FROM advances WHERE id = $1', [id]);
if (result.rows.length > 0) {
const data = result.rows[0];
// 解析 attachments 字段为数组
if (data.attachments) {
try {
data.attachments = JSON.parse(data.attachments);
} catch (error) {
data.attachments = [];
}
} else {
data.attachments = [];
}
res.json({ success: true, data });
} else {
res.status(404).json({ success: false, message: '预支申请不存在' });
}
} catch (error) {
console.error('获取预支申请失败:', error);
res.status(500).json({ success: false, message: '获取预支申请失败' });
}
});
router.put('/:id', async (req, res) => {
try {
const { id } = req.params;
const { amount, reason, project_id, currency, advance_date, attachments, amount_cny, applicant, status } = req.body;
const result = await db.query(
'UPDATE advances SET amount = $1, reason = $2, project_id = $3, currency = $4, advance_date = $5, attachments = $6, amount_cny = $7, applicant = $8, status = $9 WHERE id = $10',
[amount, reason, project_id, currency, advance_date, JSON.stringify(attachments || []), amount_cny, applicant, status, id]
);
if (result.rowCount > 0) {
res.json({ success: true, message: '更新成功' });
} else {
res.status(404).json({ success: false, message: '预支申请不存在' });
}
} catch (error) {
console.error('更新预支申请失败:', error);
res.status(500).json({ success: false, message: '更新预支申请失败' });
}
});
router.delete('/:id', async (req, res) => {
try {
const { id } = req.params;
const result = await db.query('DELETE FROM advances WHERE id = $1', [id]);
if (result.rowCount > 0) {
res.json({ success: true, message: '删除成功' });
} else {
res.status(404).json({ success: false, message: '预支申请不存在' });
}
} catch (error) {
console.error('删除预支申请失败:', error);
res.status(500).json({ success: false, message: '删除预支申请失败' });
}
});
router.post('/:id/submit', async (req, res) => {
try {
const { id } = req.params;
const result = await db.query('UPDATE advances SET status = $1 WHERE id = $2', ['pending', id]);
if (result.rowCount > 0) {
res.json({ success: true, message: '提交成功' });
} else {
res.status(404).json({ success: false, message: '预支申请不存在' });
}
} catch (error) {
console.error('提交预支申请失败:', error);
res.status(500).json({ success: false, message: '提交预支申请失败' });
}
});
router.post('/:id/withdraw', async (req, res) => {
try {
const { id } = req.params;
const result = await db.query('UPDATE advances SET status = $1 WHERE id = $2', ['withdrawn', id]);
if (result.rowCount > 0) {
res.json({ success: true, message: '撤回成功' });
} else {
res.status(404).json({ success: false, message: '预支申请不存在' });
}
} catch (error) {
console.error('撤回预支申请失败:', error);
res.status(500).json({ success: false, message: '撤回预支申请失败' });
}
});
router.post('/:id/approve', async (req, res) => {
try {
const { id } = req.params;
const { remark } = req.body;
const result = await db.query('UPDATE advances SET status = $1, approval_remark = $2 WHERE id = $3', ['approved', remark, id]);
if (result.rowCount > 0) {
res.json({ success: true, message: '审批通过成功' });
} else {
res.status(404).json({ success: false, message: '预支申请不存在' });
}
} catch (error) {
console.error('审批预支申请失败:', error);
res.status(500).json({ success: false, message: '审批预支申请失败' });
}
});
router.post('/:id/reject', async (req, res) => {
try {
const { id } = req.params;
const { rejectReason } = req.body;
const result = await db.query('UPDATE advances SET status = $1 WHERE id = $2', ['pending', id]);
if (result.rowCount > 0) {
res.json({ success: true, message: '退回成功' });
} else {
res.status(404).json({ success: false, message: '预支申请不存在' });
}
} catch (error) {
console.error('退回预支申请失败:', error);
res.status(500).json({ success: false, message: '退回预支申请失败' });
}
});
module.exports = router;
+4 -1
View File
@@ -16,7 +16,7 @@ router.post('/login', async (req, res) => {
}
const result = await db.query(
'SELECT id, username, name, email, phone, role, password_hash FROM users WHERE username = $1',
'SELECT id, username, name, email, phone, role, avatar, passport, driver_license, password_hash FROM users WHERE username = $1',
[username]
);
@@ -53,6 +53,9 @@ router.post('/login', async (req, res) => {
phone: user.phone,
role: user.role,
department: '',
avatar: user.avatar || null,
passport: user.passport || null,
driverLicense: user.driver_license || null,
token: token
}
});
+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();
// 获取施工项目列表
router.get('/my-projects', async (req, res) => {
try {
const result = await db.query(`
@@ -30,4 +31,298 @@ router.get('/my-projects', async (req, res) => {
}
});
// ==================== 施工日志 ====================
// 获取项目施工日志
router.get('/projects/:projectId/logs', async (req, res) => {
try {
const { projectId } = req.params;
const result = await db.query(
'SELECT * FROM construction_logs WHERE project_id = $1 ORDER BY log_date DESC, created_at DESC',
[projectId]
);
res.json({ success: true, data: result.rows });
} catch (error) {
console.error('获取施工日志失败:', error);
res.status(500).json({ success: false, message: '获取施工日志失败' });
}
});
// 新增施工日志
router.post('/projects/:projectId/logs', authenticate, async (req, res) => {
try {
const { projectId } = req.params;
const { log_date, weather, work_content, next_plan, issues, photos } = req.body;
const userId = req.user?.id || req.user?.userId;
const result = await db.query(
`INSERT INTO construction_logs (project_id, log_date, weather, recorded_by, work_content, next_plan, issues, photos)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`,
[projectId, log_date, weather || 'sunny', userId, work_content || '', next_plan || '', issues || '', photos || []]
);
res.json({ success: true, data: result.rows[0] });
} catch (error) {
console.error('新增施工日志失败:', error);
res.status(500).json({ success: false, message: '新增施工日志失败' });
}
});
// 删除施工日志
router.delete('/logs/:logId', authenticate, async (req, res) => {
try {
const { logId } = req.params;
const result = await db.query('DELETE FROM construction_logs WHERE id = $1 RETURNING id', [logId]);
if (result.rowCount === 0) {
return res.status(404).json({ success: false, message: '日志不存在' });
}
res.json({ success: true, message: '删除成功' });
} catch (error) {
console.error('删除施工日志失败:', error);
res.status(500).json({ success: false, message: '删除施工日志失败' });
}
});
// ==================== 施工进度 ====================
// 获取项目施工进度(包含phases和项目信息)
router.get('/projects/:projectId/progress', async (req, res) => {
try {
const { projectId } = req.params;
// 获取项目信息
const projectResult = await db.query(
`SELECT p.*, c.name as customer_name, u.name as manager_name
FROM projects p
LEFT JOIN customers c ON p.customer_id = c.id
LEFT JOIN users u ON p.project_manager_id = u.id
WHERE p.id = $1`,
[projectId]
);
if (projectResult.rowCount === 0) {
return res.status(404).json({ success: false, message: '项目不存在' });
}
// 获取阶段
const phasesResult = await db.query(
'SELECT * FROM project_phases WHERE project_id = $1 ORDER BY phase_order',
[projectId]
);
res.json({
success: true,
data: {
project: projectResult.rows[0],
phases: phasesResult.rows
}
});
} catch (error) {
console.error('获取施工进度失败:', error);
res.status(500).json({ success: false, message: '获取施工进度失败' });
}
});
// 完成阶段
router.post('/projects/:projectId/phases/:phaseId/complete', authenticate, async (req, res) => {
try {
const { projectId, phaseId } = req.params;
const { remark, photos } = req.body;
const userId = req.user?.id || req.user?.userId;
// 标记阶段完成
await db.query(
`UPDATE project_phases SET status = 'completed', completed_at = CURRENT_TIMESTAMP, completed_by = $1, remark = COALESCE(remark, '') || $2, photos = COALESCE(photos, '{}') || $3 WHERE id = $4 AND project_id = $5`,
[userId, remark ? '\n' + remark : '', photos || [], phaseId, projectId]
);
// 计算总体进度
const progressResult = await db.query(
'SELECT COUNT(*) as total, COUNT(CASE WHEN status = $1 THEN 1 END) as completed FROM project_phases WHERE project_id = $2',
['completed', projectId]
);
const total = parseInt(progressResult.rows[0].total);
const completed = parseInt(progressResult.rows[0].completed);
const progress = total > 0 ? Math.round((completed / total) * 100) : 0;
// 找下一个待开始的阶段
const nextPhaseResult = await db.query(
`SELECT * FROM project_phases WHERE project_id = $1 AND status = 'pending' ORDER BY phase_order LIMIT 1`,
[projectId]
);
if (nextPhaseResult.rows.length > 0) {
await db.query(
`UPDATE project_phases SET status = 'in_progress', started_at = CURRENT_TIMESTAMP WHERE id = $1`,
[nextPhaseResult.rows[0].id]
);
// 更新项目当前阶段
await db.query(
`UPDATE projects SET current_phase = $1, phase_progress = $2 WHERE id = $3`,
[nextPhaseResult.rows[0].phase_name, progress, projectId]
);
} else {
// 所有阶段完成
await db.query(
`UPDATE projects SET current_phase = '已完成', phase_progress = 100, status = 'completed' WHERE id = $1`,
[projectId]
);
}
res.json({ success: true, progress });
} catch (error) {
console.error('完成阶段失败:', error);
res.status(500).json({ success: false, message: '完成阶段失败' });
}
});
// 重新打开阶段
router.put('/phases/:phaseId/reopen', authenticate, async (req, res) => {
try {
const { phaseId } = req.params;
await db.query(
`UPDATE project_phases SET status = 'in_progress', completed_at = NULL, completed_by = NULL WHERE id = $1`,
[phaseId]
);
// 重新计算进度
const phaseResult = await db.query('SELECT project_id FROM project_phases WHERE id = $1', [phaseId]);
if (phaseResult.rows.length > 0) {
const projectId = phaseResult.rows[0].project_id;
const progressResult = await db.query(
'SELECT COUNT(*) as total, COUNT(CASE WHEN status = $1 THEN 1 END) as completed FROM project_phases WHERE project_id = $2',
['completed', projectId]
);
const total = parseInt(progressResult.rows[0].total);
const completed = parseInt(progressResult.rows[0].completed);
const progress = total > 0 ? Math.round((completed / total) * 100) : 0;
await db.query(
`UPDATE projects SET phase_progress = $1, status = 'active' WHERE id = $2`,
[progress, projectId]
);
}
res.json({ success: true });
} catch (error) {
console.error('重新打开阶段失败:', error);
res.status(500).json({ success: false, message: '重新打开阶段失败' });
}
});
// 更新子项状态(支持完成时间、照片、与资料管理共享)
router.put('/phases/:phaseId/sub-item', authenticate, async (req, res) => {
try {
const { phaseId } = req.params;
const { itemIndex, completed, completed_at, photos } = req.body;
const userId = req.user?.id || req.user?.userId;
const phaseResult = await db.query('SELECT sub_items, project_id FROM project_phases WHERE id = $1', [phaseId]);
if (phaseResult.rowCount === 0) {
return res.status(404).json({ success: false, message: '阶段不存在' });
}
const subItems = phaseResult.rows[0].sub_items || [];
const projectId = phaseResult.rows[0].project_id;
if (itemIndex >= 0 && itemIndex < subItems.length) {
subItems[itemIndex].completed = completed;
if (completed) {
subItems[itemIndex].completed_at = completed_at || new Date().toISOString();
if (photos && photos.length > 0) {
subItems[itemIndex].photos = photos;
// 同步写入 project_documents 实现与资料管理共享
for (const url of photos) {
const fileName = url.split('/').pop() || 'photo.jpg';
await db.query(
`INSERT INTO project_documents (project_id, doc_type, file_name, file_url, description, uploaded_by)
VALUES ($1, 'image', $2, $3, $4, $5)`,
[projectId, fileName, url, `阶段子项: ${subItems[itemIndex].name}`, userId]
);
}
}
} else {
subItems[itemIndex].completed_at = null;
subItems[itemIndex].photos = [];
}
await db.query('UPDATE project_phases SET sub_items = $1 WHERE id = $2', [JSON.stringify(subItems), phaseId]);
}
res.json({ success: true, subItems });
} catch (error) {
console.error('更新子项状态失败:', error);
res.status(500).json({ success: false, message: '更新子项状态失败' });
}
});
// ==================== 资料管理 ====================
// 获取项目资料列表
router.get('/projects/:projectId/documents', async (req, res) => {
try {
const { projectId } = req.params;
const { doc_type } = req.query;
let query = 'SELECT d.*, u.name as uploader_name FROM project_documents d LEFT JOIN users u ON d.uploaded_by = u.id WHERE d.project_id = $1';
const params = [projectId];
if (doc_type) {
query += ' AND d.doc_type = $2';
params.push(doc_type);
}
query += ' ORDER BY d.created_at DESC';
const result = await db.query(query, params);
res.json({ success: true, data: result.rows });
} catch (error) {
console.error('获取项目资料失败:', error);
res.status(500).json({ success: false, message: '获取项目资料失败' });
}
});
// 上传项目资料
router.post('/projects/:projectId/documents', authenticate, async (req, res) => {
try {
const { projectId } = req.params;
const { doc_type, file_name, file_url, description } = req.body;
const userId = req.user?.id || req.user?.userId;
const result = await db.query(
`INSERT INTO project_documents (project_id, doc_type, file_name, file_url, description, uploaded_by)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`,
[projectId, doc_type || 'document', file_name, file_url, description || '', userId]
);
res.json({ success: true, data: result.rows[0] });
} catch (error) {
console.error('上传项目资料失败:', error);
res.status(500).json({ success: false, message: '上传项目资料失败' });
}
});
// 删除项目资料
router.delete('/documents/:docId', authenticate, async (req, res) => {
try {
const { docId } = req.params;
const userId = req.user?.id || req.user?.userId;
const isAdmin = req.user?.role === 'admin';
// 管理员或上传者可删除
const docResult = await db.query('SELECT * FROM project_documents WHERE id = $1', [docId]);
if (docResult.rowCount === 0) {
return res.status(404).json({ success: false, message: '资料不存在' });
}
if (!isAdmin && docResult.rows[0].uploaded_by !== userId) {
return res.status(403).json({ success: false, message: '无权删除' });
}
await db.query('DELETE FROM project_documents WHERE id = $1', [docId]);
res.json({ success: true, message: '删除成功' });
} catch (error) {
console.error('删除项目资料失败:', error);
res.status(500).json({ success: false, message: '删除项目资料失败' });
}
});
module.exports = router;
+1 -1
View File
@@ -85,7 +85,7 @@ router.post('/', async (req, res) => {
let status = action === 'execute' ? 'executed' : 'rejected';
if (action === 'reject') {
status = 'pending_edit';
status = 'pending';
}
const executeDate = new Date().toISOString().split('T')[0];
+29 -4
View File
@@ -236,8 +236,14 @@ router.post('/batch', authenticate, async (req, res) => {
for (let i = 0; i < records.length; i++) {
const r = records[i];
try {
if (!r.txn_type || !r.category_level1 || !r.category_level2 || !r.amount_original || !r.record_date) {
errors.push({ row: i + 1, message: '缺少必填字段' });
const missingFields = [];
if (!r.txn_type) missingFields.push('收支类型');
if (!r.category_level1) missingFields.push('一级分类');
if (!r.category_level2) missingFields.push('二级分类');
if (!r.amount_original) missingFields.push('金额');
if (!r.record_date) missingFields.push('日期');
if (missingFields.length > 0) {
errors.push({ row: i + 1, message: `缺少必填字段: ${missingFields.join(', ')}`, data: r });
continue;
}
@@ -246,15 +252,34 @@ router.post('/batch', authenticate, async (req, res) => {
let projectId = r.project_id || null;
if (!projectId && r.project_name) {
// 先精确匹配
const projResult = await db.query("SELECT id FROM projects WHERE name = $1", [r.project_name]);
if (projResult.rows.length > 0) {
projectId = projResult.rows[0].id;
} else {
// 模糊匹配:去除空格后比较
const fuzzyResult = await db.query(
"SELECT id, name FROM projects WHERE REPLACE(name, ' ', '') = REPLACE($1, ' ', '')",
[r.project_name]
);
if (fuzzyResult.rows.length > 0) {
projectId = fuzzyResult.rows[0].id;
} else {
// 包含匹配
const likeResult = await db.query(
"SELECT id, name FROM projects WHERE name LIKE '%' || $1 || '%' OR $1 LIKE '%' || name || '%'",
[r.project_name]
);
if (likeResult.rows.length > 0) {
projectId = likeResult.rows[0].id;
}
}
}
}
let userId = r.user_id || null;
if (!userId && r.user_name) {
const userResult = await db.query("SELECT id FROM users WHERE name = $1", [r.user_name]);
const userResult = await db.query("SELECT id FROM users WHERE name = $1 OR username = $1", [r.user_name]);
if (userResult.rows.length > 0) {
userId = userResult.rows[0].id;
}
@@ -280,7 +305,7 @@ router.post('/batch', authenticate, async (req, res) => {
);
results.push(result.rows[0]);
} catch (err) {
errors.push({ row: i + 1, message: err.message });
errors.push({ row: i + 1, message: err.message, data: { txn_type: r.txn_type, category_level1: r.category_level1, category_level2: r.category_level2, project_name: r.project_name, amount: r.amount_original } });
}
}
+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) => {
try {
const { project_id, product_id, quantity, unit_price, total_amount, operator, remark } = req.body;
+242 -242
View File
@@ -4,256 +4,256 @@ const { authenticate, requireAdmin } = require('../middleware/auth');
const router = express.Router();
router.get('/', async (req, res) => {
try {
const result = await db.query(`
SELECT * FROM payment_requests
ORDER BY created_at DESC
`);
const data = result.rows.map(item => {
if (item.attachments) {
try {
item.attachments = JSON.parse(item.attachments);
} catch (error) {
item.attachments = [];
}
} else {
item.attachments = [];
}
if (item.detail_items) {
try {
item.detail_items = JSON.parse(item.detail_items);
} catch (error) {
item.detail_items = [];
}
} else {
item.detail_items = [];
}
return item;
});
res.json({ success: true, data, count: data.length });
} catch (error) {
console.error('获取付款申请失败:', error);
res.status(500).json({ success: false, message: '获取付款申请失败' });
}
});
router.get('/', async (req, res) => {
try {
const result = await db.query(`
SELECT * FROM payment_requests
ORDER BY created_at DESC
`);
const data = result.rows.map(item => {
if (item.attachments) {
try {
item.attachments = JSON.parse(item.attachments);
} catch (error) {
item.attachments = [];
}
} else {
item.attachments = [];
}
if (item.detail_items) {
try {
item.detail_items = JSON.parse(item.detail_items);
} catch (error) {
item.detail_items = [];
}
} else {
item.detail_items = [];
}
return item;
});
res.json({ success: true, data, count: data.length });
} catch (error) {
console.error('获取付款申请失败:', error);
res.status(500).json({ success: false, message: '获取付款申请失败' });
}
});
router.post('/', async (req, res) => {
try {
const {
payment_date, payee, bank_account, bank_name, currency, reason,
detail_items, attachments, applicant,
payee_type, payee_id, expense_type, expense_category, project_id, amount
} = req.body;
// 生成付款申请编号
const requestCode = `PAY-${Date.now()}`;
// 使用默认值处理可选字段
const finalBankAccount = bank_account || '';
const finalBankName = bank_name || '';
const finalAmount = amount || 0;
const result = await db.query(
`INSERT INTO payment_requests (
payee, bank_account, bank_name, amount, currency, reason, payment_date,
request_code, status, applicant, detail_items, attachments,
payee_type, payee_id, expense_type, expense_category, project_id
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17`,
[
payee, finalBankAccount, finalBankName, finalAmount, currency || 'CNY',
reason, payment_date, requestCode, 'pending', applicant,
JSON.stringify(detail_items || []), JSON.stringify(attachments || []),
payee_type || 'other', payee_id || null, expense_type || 'company',
expense_category || '', project_id || null
]
);
// SQLite不支持RETURNING,所以需要查询刚插入的数据
const lastInsert = await db.query('SELECT * FROM payment_requests ORDER BY id DESC LIMIT 1');
res.json({ success: true, data: lastInsert.rows[0] });
} catch (error) {
console.error('创建付款申请失败:', error);
res.status(500).json({ success: false, message: '创建付款申请失败' });
}
});
router.post('/', async (req, res) => {
try {
const {
payment_date, payee, bank_account, bank_name, currency, reason,
detail_items, attachments, applicant,
payee_type, payee_id, expense_type, expense_category, project_id, amount
} = req.body;
// 生成付款申请编号
const requestCode = `PAY-${Date.now()}`;
// 使用默认值处理可选字段
const finalBankAccount = bank_account || '';
const finalBankName = bank_name || '';
const finalAmount = amount || 0;
const result = await db.query(
`INSERT INTO payment_requests (
payee, bank_account, bank_name, amount, currency, reason, payment_date,
request_code, status, applicant, detail_items, attachments,
payee_type, payee_id, expense_type, expense_category, project_id
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)`,
[
payee, finalBankAccount, finalBankName, finalAmount, currency || 'CNY',
reason, payment_date, requestCode, 'pending', applicant,
JSON.stringify(detail_items || []), JSON.stringify(attachments || []),
payee_type || 'other', payee_id || null, expense_type || 'company',
expense_category || '', project_id || null
]
);
// SQLite不支持RETURNING,所以需要查询刚插入的数据
const lastInsert = await db.query('SELECT * FROM payment_requests ORDER BY id DESC LIMIT 1');
res.json({ success: true, data: lastInsert.rows[0] });
} catch (error) {
console.error('创建付款申请失败:', error);
res.status(500).json({ success: false, message: '创建付款申请失败' });
}
});
router.get('/:id', async (req, res) => {
try {
const { id } = req.params;
const result = await db.query('SELECT * FROM payment_requests WHERE id = $1', [id]);
if (result.rows.length > 0) {
const data = result.rows[0];
if (data.attachments) {
try {
data.attachments = JSON.parse(data.attachments);
} catch (error) {
data.attachments = [];
}
} else {
data.attachments = [];
}
if (data.detail_items) {
try {
data.detail_items = JSON.parse(data.detail_items);
} catch (error) {
data.detail_items = [];
}
} else {
data.detail_items = [];
}
res.json({ success: true, data });
} else {
res.status(404).json({ success: false, message: '付款申请不存在' });
}
} catch (error) {
console.error('获取付款申请失败:', error);
res.status(500).json({ success: false, message: '获取付款申请失败' });
}
});
router.get('/:id', async (req, res) => {
try {
const { id } = req.params;
const result = await db.query('SELECT * FROM payment_requests WHERE id = $1', [id]);
if (result.rows.length > 0) {
const data = result.rows[0];
if (data.attachments) {
try {
data.attachments = JSON.parse(data.attachments);
} catch (error) {
data.attachments = [];
}
} else {
data.attachments = [];
}
if (data.detail_items) {
try {
data.detail_items = JSON.parse(data.detail_items);
} catch (error) {
data.detail_items = [];
}
} else {
data.detail_items = [];
}
res.json({ success: true, data });
} else {
res.status(404).json({ success: false, message: '付款申请不存在' });
}
} catch (error) {
console.error('获取付款申请失败:', error);
res.status(500).json({ success: false, message: '获取付款申请失败' });
}
});
router.put('/:id', async (req, res) => {
try {
const { id } = req.params;
const {
payment_date, payee, bank_account, bank_name, currency, reason,
detail_items, attachments, applicant, status,
payee_type, payee_id, expense_type, expense_category, project_id, amount
} = req.body;
// 构建动态更新SQL,只更新提供的字段
const updates = [];
const params = [];
if (payment_date !== undefined) { updates.push('payment_date = $1'); params.push(payment_date); }
if (payee !== undefined) { updates.push('payee = $1'); params.push(payee); }
if (bank_account !== undefined) { updates.push('bank_account = $1'); params.push(bank_account); }
if (bank_name !== undefined) { updates.push('bank_name = $1'); params.push(bank_name); }
if (amount !== undefined) { updates.push('amount = $1'); params.push(amount); }
if (currency !== undefined) { updates.push('currency = $1'); params.push(currency); }
if (reason !== undefined) { updates.push('reason = $1'); params.push(reason); }
if (detail_items !== undefined) { updates.push('detail_items = $1'); params.push(JSON.stringify(detail_items || [])); }
if (attachments !== undefined) { updates.push('attachments = $1'); params.push(JSON.stringify(attachments || [])); }
if (applicant !== undefined) { updates.push('applicant = $1'); params.push(applicant); }
if (status !== undefined) { updates.push('status = $1'); params.push(status); }
if (payee_type !== undefined) { updates.push('payee_type = $1'); params.push(payee_type); }
if (payee_id !== undefined) { updates.push('payee_id = $1'); params.push(payee_id); }
if (expense_type !== undefined) { updates.push('expense_type = $1'); params.push(expense_type); }
if (expense_category !== undefined) { updates.push('expense_category = $1'); params.push(expense_category); }
if (project_id !== undefined) { updates.push('project_id = $1'); params.push(project_id); }
if (updates.length === 0) {
return res.status(400).json({ success: false, message: '没有要更新的字段' });
}
params.push(id);
const result = await db.query(
`UPDATE payment_requests SET ${updates.join(', ')} WHERE id = $1`,
params
);
if (result.changes > 0) {
res.json({ success: true, message: '更新成功' });
} else {
res.status(404).json({ success: false, message: '付款申请不存在' });
}
} catch (error) {
console.error('更新付款申请失败:', error);
res.status(500).json({ success: false, message: '更新付款申请失败' });
}
});
router.put('/:id', async (req, res) => {
try {
const { id } = req.params;
const {
payment_date, payee, bank_account, bank_name, currency, reason,
detail_items, attachments, applicant, status,
payee_type, payee_id, expense_type, expense_category, project_id, amount
} = req.body;
// 构建动态更新SQL,只更新提供的字段
const updates = [];
const params = [];
if (payment_date !== undefined) { updates.push('payment_date = $1'); params.push(payment_date); }
if (payee !== undefined) { updates.push('payee = $1'); params.push(payee); }
if (bank_account !== undefined) { updates.push('bank_account = $1'); params.push(bank_account); }
if (bank_name !== undefined) { updates.push('bank_name = $1'); params.push(bank_name); }
if (amount !== undefined) { updates.push('amount = $1'); params.push(amount); }
if (currency !== undefined) { updates.push('currency = $1'); params.push(currency); }
if (reason !== undefined) { updates.push('reason = $1'); params.push(reason); }
if (detail_items !== undefined) { updates.push('detail_items = $1'); params.push(JSON.stringify(detail_items || [])); }
if (attachments !== undefined) { updates.push('attachments = $1'); params.push(JSON.stringify(attachments || [])); }
if (applicant !== undefined) { updates.push('applicant = $1'); params.push(applicant); }
if (status !== undefined) { updates.push('status = $1'); params.push(status); }
if (payee_type !== undefined) { updates.push('payee_type = $1'); params.push(payee_type); }
if (payee_id !== undefined) { updates.push('payee_id = $1'); params.push(payee_id); }
if (expense_type !== undefined) { updates.push('expense_type = $1'); params.push(expense_type); }
if (expense_category !== undefined) { updates.push('expense_category = $1'); params.push(expense_category); }
if (project_id !== undefined) { updates.push('project_id = $1'); params.push(project_id); }
if (updates.length === 0) {
return res.status(400).json({ success: false, message: '没有要更新的字段' });
}
params.push(id);
const result = await db.query(
`UPDATE payment_requests SET ${updates.join(', ')} WHERE id = $1`,
params
);
if (result.rowCount > 0) {
res.json({ success: true, message: '更新成功' });
} else {
res.status(404).json({ success: false, message: '付款申请不存在' });
}
} catch (error) {
console.error('更新付款申请失败:', error);
res.status(500).json({ success: false, message: '更新付款申请失败' });
}
});
router.delete('/:id', async (req, res) => {
try {
const { id } = req.params;
const result = await db.query('DELETE FROM payment_requests WHERE id = $1', [id]);
if (result.changes > 0) {
res.json({ success: true, message: '删除成功' });
} else {
res.status(404).json({ success: false, message: '付款申请不存在' });
}
} catch (error) {
console.error('删除付款申请失败:', error);
res.status(500).json({ success: false, message: '删除付款申请失败' });
}
});
router.delete('/:id', async (req, res) => {
try {
const { id } = req.params;
const result = await db.query('DELETE FROM payment_requests WHERE id = $1', [id]);
if (result.rowCount > 0) {
res.json({ success: true, message: '删除成功' });
} else {
res.status(404).json({ success: false, message: '付款申请不存在' });
}
} catch (error) {
console.error('删除付款申请失败:', error);
res.status(500).json({ success: false, message: '删除付款申请失败' });
}
});
router.post('/:id/submit', async (req, res) => {
try {
const { id } = req.params;
const result = await db.query('UPDATE payment_requests SET status = $1 WHERE id = $2', ['pending', id]);
if (result.changes > 0) {
res.json({ success: true, message: '提交成功' });
} else {
res.status(404).json({ success: false, message: '付款申请不存在' });
}
} catch (error) {
console.error('提交付款申请失败:', error);
res.status(500).json({ success: false, message: '提交付款申请失败' });
}
});
router.post('/:id/submit', async (req, res) => {
try {
const { id } = req.params;
const result = await db.query('UPDATE payment_requests SET status = $1 WHERE id = $2', ['pending', id]);
if (result.rowCount > 0) {
res.json({ success: true, message: '提交成功' });
} else {
res.status(404).json({ success: false, message: '付款申请不存在' });
}
} catch (error) {
console.error('提交付款申请失败:', error);
res.status(500).json({ success: false, message: '提交付款申请失败' });
}
});
router.post('/:id/withdraw', async (req, res) => {
try {
const { id } = req.params;
const result = await db.query('UPDATE payment_requests SET status = $1 WHERE id = $2', ['withdrawn', id]);
if (result.changes > 0) {
res.json({ success: true, message: '撤回成功' });
} else {
res.status(404).json({ success: false, message: '付款申请不存在' });
}
} catch (error) {
console.error('撤回报销申请失败:', error);
res.status(500).json({ success: false, message: '撤回报销申请失败' });
}
});
router.post('/:id/withdraw', async (req, res) => {
try {
const { id } = req.params;
const result = await db.query('UPDATE payment_requests SET status = $1 WHERE id = $2', ['withdrawn', id]);
if (result.rowCount > 0) {
res.json({ success: true, message: '撤回成功' });
} else {
res.status(404).json({ success: false, message: '付款申请不存在' });
}
} catch (error) {
console.error('撤回报销申请失败:', error);
res.status(500).json({ success: false, message: '撤回报销申请失败' });
}
});
router.post('/:id/approve', async (req, res) => {
try {
const { id } = req.params;
const { remark } = req.body;
const result = await db.query('UPDATE payment_requests SET status = $1, approval_remark = $2 WHERE id = $3', ['approved', remark, id]);
if (result.changes > 0) {
res.json({ success: true, message: '审批通过成功' });
} else {
res.status(404).json({ success: false, message: '付款申请不存在' });
}
} catch (error) {
console.error('审批付款申请失败:', error);
res.status(500).json({ success: false, message: '审批付款申请失败' });
}
});
router.post('/:id/approve', async (req, res) => {
try {
const { id } = req.params;
const { remark } = req.body;
const result = await db.query('UPDATE payment_requests SET status = $1, approval_remark = $2 WHERE id = $3', ['approved', remark, id]);
if (result.rowCount > 0) {
res.json({ success: true, message: '审批通过成功' });
} else {
res.status(404).json({ success: false, message: '付款申请不存在' });
}
} catch (error) {
console.error('审批付款申请失败:', error);
res.status(500).json({ success: false, message: '审批付款申请失败' });
}
});
router.post('/:id/reject', async (req, res) => {
try {
const { id } = req.params;
const { rejectReason } = req.body;
const result = await db.query('UPDATE payment_requests SET status = $1 WHERE id = $2', ['pending_edit', id]);
if (result.changes > 0) {
res.json({ success: true, message: '退回成功' });
} else {
res.status(404).json({ success: false, message: '付款申请不存在' });
}
} catch (error) {
console.error('退回报销申请失败:', error);
res.status(500).json({ success: false, message: '退回报销申请失败' });
}
});
router.post('/:id/reject', async (req, res) => {
try {
const { id } = req.params;
const { rejectReason } = req.body;
const result = await db.query('UPDATE payment_requests SET status = $1 WHERE id = $2', ['pending', id]);
if (result.rowCount > 0) {
res.json({ success: true, message: '退回成功' });
} else {
res.status(404).json({ success: false, message: '付款申请不存在' });
}
} catch (error) {
console.error('退回报销申请失败:', error);
res.status(500).json({ success: false, message: '退回报销申请失败' });
}
});
module.exports = router;
+1 -1
View File
@@ -106,7 +106,7 @@ router.post('/:id/copy', async (req, res) => {
const src = existing.rows[0];
const result = await db.query(
'INSERT INTO project_type_templates (name, description, phases, is_system) VALUES ($1, $2, $3, false) RETURNING id',
[name || `${src.name} (副本)`, src.description, src.phases]
[name || `${src.name} (副本)`, src.description, JSON.stringify(src.phases)]
);
res.json({ success: true, message: '模板复制成功', data: { id: result.rows[0].id } });
} catch (error) {
+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;
+109 -35
View File
@@ -19,10 +19,17 @@ router.get('/', async (req, res) => {
p.status,
p.location,
c.name as customer_name,
u.name as manager_name
u.name as manager_name,
COALESCE(expense_summary.total_expense, 0) as total_expense
FROM projects p
LEFT JOIN customers c ON p.customer_id = c.id
LEFT JOIN users u ON p.project_manager_id = u.id
LEFT JOIN (
SELECT project_id, SUM(amount_cny::numeric) as total_expense
FROM financial_records
WHERE txn_type = 'expense' AND status = 'confirmed'
GROUP BY project_id
) expense_summary ON p.id = expense_summary.project_id
ORDER BY p.created_at DESC
LIMIT 50
`);
@@ -472,6 +479,14 @@ router.put('/:id', async (req, res) => {
);
}
// 合同金额变更时,自动按比例更新付款节点金额
if (contract_amount !== undefined && contract_amount !== null) {
await db.query(
`UPDATE project_milestones SET amount = ROUND($1 * percentage / 100, 2) WHERE project_id = $2`,
[contract_amount, id]
);
}
if (start_date && end_date) {
const start = new Date(start_date);
const end = new Date(end_date);
@@ -553,6 +568,12 @@ router.put('/:id/contract', async (req, res) => {
[id, node.name, node.condition || '', node.percentage, node.amount, 'pending']
);
}
} else if (contract_total) {
// 没有传付款节点但合同金额变了,按比例更新现有里程碑金额
await db.query(
`UPDATE project_milestones SET amount = ROUND($1 * percentage / 100, 2) WHERE project_id = $2`,
[contract_total, id]
);
}
// 4. 处理单价项
@@ -581,55 +602,108 @@ router.put('/:id/contract', async (req, res) => {
}
});
router.get('/:id/financial-details', async (req, res) => {
try {
const { id } = req.params;
const { txn_type, category_level1, category_level2, date_from, date_to, page = 1, pageSize = 20 } = req.query;
let sql = `SELECT fr.*, p.name as project_name FROM financial_records fr LEFT JOIN projects p ON fr.project_id = p.id WHERE fr.project_id = $1 AND fr.status != 'voided'`;
const params = [id];
let idx = 2;
if (txn_type) { params.push(txn_type); sql += ` AND fr.txn_type = $${idx++}`; }
if (category_level1) { params.push(category_level1); sql += ` AND fr.category_level1 = $${idx++}`; }
if (category_level2) { params.push(category_level2); sql += ` AND fr.category_level2 = $${idx++}`; }
if (date_from) { params.push(date_from); sql += ` AND fr.record_date >= $${idx++}`; }
if (date_to) { params.push(date_to); sql += ` AND fr.record_date <= $${idx++}`; }
const countResult = await db.query(`SELECT COUNT(*) as total FROM (${sql}) sub`, params);
const total = parseInt(countResult.rows[0].total);
sql += ' ORDER BY fr.record_date DESC, fr.created_at DESC';
const offset = (parseInt(page) - 1) * parseInt(pageSize);
params.push(parseInt(pageSize));
sql += ` LIMIT $${idx++}`;
params.push(offset);
sql += ` OFFSET $${idx++}`;
const result = await db.query(sql, params);
res.json({
success: true,
data: result.rows,
pagination: { page: parseInt(page), pageSize: parseInt(pageSize), total, totalPages: Math.ceil(total / parseInt(pageSize)) }
});
} catch (error) {
console.error('获取项目财务明细失败:', error);
res.status(500).json({ success: false, message: '获取项目财务明细失败' });
}
});
router.get('/:id/cost-summary', async (req, res) => {
try {
const { id } = req.params;
const purchaseResult = await db.query(`
SELECT
expense_category,
SUM(total_amount) as total_amount
FROM purchase_requests
WHERE project_id = $1 AND status IN ('approved', 'executed')
GROUP BY expense_category
`, [id]);
const paymentResult = await db.query(`
SELECT
SUM(amount) as total_payment
FROM payment_requests
WHERE project_id = $1 AND status = 'approved' AND payment_type = 'company'
`, [id]);
const projectResult = await db.query('SELECT * FROM projects WHERE id = $1', [id]);
if (projectResult.rows.length === 0) {
return res.status(404).json({ success: false, message: '项目不存在' });
}
const project = projectResult.rows[0];
const purchaseByCategory = {};
let totalPurchase = 0;
purchaseResult.rows.forEach(row => {
purchaseByCategory[row.expense_category] = row.total_amount;
totalPurchase += row.total_amount;
const incomeResult = await db.query(`
SELECT category_level2, COALESCE(SUM(amount_cny), 0) as total_amount
FROM financial_records
WHERE project_id = $1 AND txn_type = 'income' AND status != 'voided'
GROUP BY category_level2
`, [id]);
const expenseResult = await db.query(`
SELECT category_level1, category_level2, COALESCE(SUM(amount_cny), 0) as total_amount
FROM financial_records
WHERE project_id = $1 AND txn_type = 'expense' AND status != 'voided'
GROUP BY category_level1, category_level2
`, [id]);
const totalIncomeRow = await db.query(`
SELECT COALESCE(SUM(amount_cny), 0) as total FROM financial_records
WHERE project_id = $1 AND txn_type = 'income' AND status != 'voided'
`, [id]);
const totalExpenseRow = await db.query(`
SELECT COALESCE(SUM(amount_cny), 0) as total FROM financial_records
WHERE project_id = $1 AND txn_type = 'expense' AND status != 'voided'
`, [id]);
const incomeByCategory = {};
let totalIncome = parseFloat(totalIncomeRow.rows[0].total);
incomeResult.rows.forEach(row => {
incomeByCategory[row.category_level2] = parseFloat(row.total_amount);
});
const totalPayment = paymentResult.rows[0]?.total_payment || 0;
const expenseByCategory = {};
const expenseByLevel1 = {};
let totalExpense = parseFloat(totalExpenseRow.rows[0].total);
expenseResult.rows.forEach(row => {
expenseByCategory[row.category_level2] = parseFloat(row.total_amount);
if (!expenseByLevel1[row.category_level1]) expenseByLevel1[row.category_level1] = 0;
expenseByLevel1[row.category_level1] += parseFloat(row.total_amount);
});
res.json({
success: true,
data: {
project_name: project.name,
contract_amount: project.contract_amount || 0,
purchase_cost: {
total: totalPurchase,
by_category: purchaseByCategory
income: {
total: totalIncome,
by_category: incomeByCategory
},
payment_cost: totalPayment,
total_cost: totalPurchase + totalPayment,
profit: (project.contract_amount || 0) - (totalPurchase + totalPayment)
expense: {
total: totalExpense,
by_category: expenseByCategory,
by_level1: expenseByLevel1
},
total_cost: totalExpense,
profit: totalIncome - totalExpense
}
});
} catch (error) {
+6 -6
View File
@@ -203,7 +203,7 @@ router.put('/:id', async (req, res) => {
WHERE id = ?
`, [supplier_id, supplier_country, contract_url, quotation_url, remark, id]);
if (result.changes === 0) {
if (result.rowCount === 0) {
return res.status(404).json({ success: false, message: '采购订单不存在' });
}
@@ -303,7 +303,7 @@ router.post('/:id/cancel', async (req, res) => {
[id]
);
if (result.changes === 0) {
if (result.rowCount === 0) {
return res.status(404).json({ success: false, message: '采购订单不存在' });
}
@@ -391,7 +391,7 @@ router.put('/:id/items/:itemId', async (req, res) => {
WHERE id = ? AND purchase_order_id = ?
`, [product_id, product_name, specification, unit, quantity, unit_price, total_price, itemId, id]);
if (result.changes === 0) {
if (result.rowCount === 0) {
return res.status(404).json({ success: false, message: '商品明细不存在' });
}
@@ -418,7 +418,7 @@ router.delete('/:id/items/:itemId', async (req, res) => {
[itemId, id]
);
if (result.changes === 0) {
if (result.rowCount === 0) {
return res.status(404).json({ success: false, message: '商品明细不存在' });
}
@@ -506,7 +506,7 @@ router.put('/:id/payment-plans/:planId', async (req, res) => {
WHERE id = ? AND purchase_order_id = ?
`, [stage, planned_date, planned_amount, planned_percentage, remark, planId, id]);
if (result.changes === 0) {
if (result.rowCount === 0) {
return res.status(404).json({ success: false, message: '付款计划不存在' });
}
@@ -533,7 +533,7 @@ router.delete('/:id/payment-plans/:planId', async (req, res) => {
[planId, id]
);
if (result.changes === 0) {
if (result.rowCount === 0) {
return res.status(404).json({ success: false, message: '付款计划不存在' });
}
+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)
RETURNING id`,
[requestCode, project_id, purchase_type || 'inventory', total_amount || 0, currency || 'CNY',
'pending_edit', remark]);
'pending', remark]);
res.json({
success: true,
@@ -219,7 +219,7 @@ router.post('/:id/submit', async (req, res) => {
const { id } = req.params;
const result = await db.query(
'UPDATE purchase_requests SET status = $1, updated_at = datetime(\'now\') WHERE id = $2',
'UPDATE purchase_requests SET status = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
['pending', id]
);
@@ -256,7 +256,7 @@ router.post('/:id/approve', async (req, res) => {
try {
await db.query(
'UPDATE purchase_requests SET status = $1, updated_at = datetime(\'now\') WHERE id = $2',
'UPDATE purchase_requests SET status = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
['approved', id]
);
@@ -297,8 +297,8 @@ router.post('/:id/reject', async (req, res) => {
const { id } = req.params;
const result = await db.query(
'UPDATE purchase_requests SET status = $1, updated_at = datetime(\'now\') WHERE id = $2',
['pending_edit', id]
'UPDATE purchase_requests SET status = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
['pending', id]
);
if (result.rowCount === 0) {
@@ -321,7 +321,7 @@ router.post('/:id/withdraw', async (req, res) => {
const { id } = req.params;
const result = await db.query(
'UPDATE purchase_requests SET status = $1, updated_at = datetime(\'now\') WHERE id = $2',
'UPDATE purchase_requests SET status = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
['withdrawn', id]
);
@@ -345,7 +345,7 @@ router.post('/:id/execute', async (req, res) => {
const { id } = req.params;
await db.query(
'UPDATE purchase_requests SET status = $1, updated_at = datetime(\'now\') WHERE id = $2',
'UPDATE purchase_requests SET status = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
['executed', id]
);
+209 -209
View File
@@ -17,223 +17,223 @@ const validate = (req, res, next) => {
next();
};
router.get('/', async (req, res) => {
try {
const result = await db.query(`
SELECT r.*, u.name as user_name, p.name as project_name
FROM reimbursements r
LEFT JOIN users u ON r.applicant_id = u.id
LEFT JOIN projects p ON r.project_id = p.id
ORDER BY r.created_at DESC
`);
// 解析每个报销申请的 attachments 和 detail_items 字段为数组
const data = result.rows.map(item => {
// 解析 attachments 字段
if (item.attachments) {
try {
item.attachments = JSON.parse(item.attachments);
} catch (error) {
item.attachments = [];
}
} else {
item.attachments = [];
}
// 解析 detail_items 字段
if (item.detail_items) {
try {
item.detail_items = JSON.parse(item.detail_items);
} catch (error) {
item.detail_items = [];
}
} else {
item.detail_items = [];
}
return item;
});
res.json({ success: true, data, count: data.length });
} catch (error) {
console.error('获取报销记录失败:', error);
res.status(500).json({
success: false,
message: '获取报销记录失败',
error: process.env.NODE_ENV === 'development' ? error.message : '操作失败'
});
}
});
router.get('/', async (req, res) => {
try {
const result = await db.query(`
SELECT r.*, u.name as user_name, p.name as project_name
FROM reimbursements r
LEFT JOIN users u ON r.applicant_id = u.id
LEFT JOIN projects p ON r.project_id = p.id
ORDER BY r.created_at DESC
`);
// 解析每个报销申请的 attachments 和 detail_items 字段为数组
const data = result.rows.map(item => {
// 解析 attachments 字段
if (item.attachments) {
try {
item.attachments = JSON.parse(item.attachments);
} catch (error) {
item.attachments = [];
}
} else {
item.attachments = [];
}
// 解析 detail_items 字段
if (item.detail_items) {
try {
item.detail_items = JSON.parse(item.detail_items);
} catch (error) {
item.detail_items = [];
}
} else {
item.detail_items = [];
}
return item;
});
res.json({ success: true, data, count: data.length });
} catch (error) {
console.error('获取报销记录失败:', error);
res.status(500).json({
success: false,
message: '获取报销记录失败',
error: process.env.NODE_ENV === 'development' ? error.message : '操作失败'
});
}
});
router.post('/', [
body('amount').isFloat({ min: 0.01 }),
body('reason').notEmpty(),
body('expense_type').notEmpty()
], validate, async (req, res) => {
try {
const { amount, reason, project_id, currency, reimbursement_date, attachments, amount_cny, applicant, expense_type, detail_items } = req.body;
const user_id = 1; // 临时使用admin用户
// 生成报销编号
const reimbursementCode = `REIMB-${Date.now()}`;
const result = await db.query(
'INSERT INTO reimbursements (user_id, project_id, amount, currency, amount_cny, reason, reimbursement_date, reimbursement_code, status, applicant, expense_type, detail_items, attachments) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)',
[user_id, project_id, amount, currency || 'CNY', amount_cny || 0, reason, reimbursement_date, reimbursementCode, 'pending', applicant, expense_type, JSON.stringify(detail_items || []), JSON.stringify(attachments || [])]
);
// SQLite不支持RETURNING,所以需要查询刚插入的数据
const lastInsert = await db.query('SELECT * FROM reimbursements ORDER BY id DESC LIMIT 1');
res.json({ success: true, data: lastInsert.rows[0] });
} catch (error) {
console.error('创建报销申请失败:', error);
res.status(500).json({ success: false, message: '创建报销申请失败' });
}
});
router.post('/', [
body('amount').isFloat({ min: 0.01 }),
body('reason').notEmpty(),
body('expense_type').notEmpty()
], validate, async (req, res) => {
try {
const { amount, reason, project_id, currency, reimbursement_date, attachments, amount_cny, applicant, expense_type, detail_items } = req.body;
const user_id = 1; // 临时使用admin用户
// 生成报销编号
const reimbursementCode = `REIMB-${Date.now()}`;
const result = await db.query(
'INSERT INTO reimbursements (user_id, project_id, amount, currency, amount_cny, reason, reimbursement_date, reimbursement_code, status, applicant, expense_type, detail_items, attachments) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)',
[user_id, project_id, amount, currency || 'CNY', amount_cny || 0, reason, reimbursement_date, reimbursementCode, 'pending', applicant, expense_type, JSON.stringify(detail_items || []), JSON.stringify(attachments || [])]
);
// SQLite不支持RETURNING,所以需要查询刚插入的数据
const lastInsert = await db.query('SELECT * FROM reimbursements ORDER BY id DESC LIMIT 1');
res.json({ success: true, data: lastInsert.rows[0] });
} catch (error) {
console.error('创建报销申请失败:', error);
res.status(500).json({ success: false, message: '创建报销申请失败' });
}
});
router.get('/:id', async (req, res) => {
try {
const { id } = req.params;
const result = await db.query('SELECT * FROM reimbursements WHERE id = $1', [id]);
if (result.rows.length > 0) {
const data = result.rows[0];
// 解析 attachments 字段为数组
if (data.attachments) {
try {
data.attachments = JSON.parse(data.attachments);
} catch (error) {
data.attachments = [];
}
} else {
data.attachments = [];
}
// 解析 detail_items 字段为数组
if (data.detail_items) {
try {
data.detail_items = JSON.parse(data.detail_items);
} catch (error) {
data.detail_items = [];
}
} else {
data.detail_items = [];
}
res.json({ success: true, data });
} else {
res.status(404).json({ success: false, message: '报销申请不存在' });
}
} catch (error) {
console.error('获取报销申请失败:', error);
res.status(500).json({ success: false, message: '获取报销申请失败' });
}
});
router.get('/:id', async (req, res) => {
try {
const { id } = req.params;
const result = await db.query('SELECT * FROM reimbursements WHERE id = $1', [id]);
if (result.rows.length > 0) {
const data = result.rows[0];
// 解析 attachments 字段为数组
if (data.attachments) {
try {
data.attachments = JSON.parse(data.attachments);
} catch (error) {
data.attachments = [];
}
} else {
data.attachments = [];
}
// 解析 detail_items 字段为数组
if (data.detail_items) {
try {
data.detail_items = JSON.parse(data.detail_items);
} catch (error) {
data.detail_items = [];
}
} else {
data.detail_items = [];
}
res.json({ success: true, data });
} else {
res.status(404).json({ success: false, message: '报销申请不存在' });
}
} catch (error) {
console.error('获取报销申请失败:', error);
res.status(500).json({ success: false, message: '获取报销申请失败' });
}
});
router.put('/:id', async (req, res) => {
try {
const { id } = req.params;
const { amount, reason, project_id, currency, reimbursement_date, attachments, amount_cny, applicant, expense_type, detail_items, status } = req.body;
const result = await db.query(
'UPDATE reimbursements SET amount = $1, reason = $2, project_id = $3, currency = $4, reimbursement_date = $5, attachments = $6, amount_cny = $7, applicant = $8, expense_type = $9, detail_items = $10, status = $11 WHERE id = $12',
[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) {
res.json({ success: true, message: '更新成功' });
} else {
res.status(404).json({ success: false, message: '报销申请不存在' });
}
} catch (error) {
console.error('更新报销申请失败:', error);
res.status(500).json({ success: false, message: '更新报销申请失败' });
}
});
router.put('/:id', async (req, res) => {
try {
const { id } = req.params;
const { amount, reason, project_id, currency, reimbursement_date, attachments, amount_cny, applicant, expense_type, detail_items, status } = req.body;
const result = await db.query(
'UPDATE reimbursements SET amount = $1, reason = $2, project_id = $3, currency = $4, reimbursement_date = $5, attachments = $6, amount_cny = $7, applicant = $8, expense_type = $9, detail_items = $10, status = $11 WHERE id = $12',
[amount, reason, project_id, currency, reimbursement_date, JSON.stringify(attachments || []), amount_cny, applicant, expense_type, JSON.stringify(detail_items || []), status, id]
);
if (result.rowCount > 0) {
res.json({ success: true, message: '更新成功' });
} else {
res.status(404).json({ success: false, message: '报销申请不存在' });
}
} catch (error) {
console.error('更新报销申请失败:', error);
res.status(500).json({ success: false, message: '更新报销申请失败' });
}
});
router.delete('/:id', async (req, res) => {
try {
const { id } = req.params;
const result = await db.query('DELETE FROM reimbursements WHERE id = $1', [id]);
if (result.changes > 0) {
res.json({ success: true, message: '删除成功' });
} else {
res.status(404).json({ success: false, message: '报销申请不存在' });
}
} catch (error) {
console.error('删除报销申请失败:', error);
res.status(500).json({ success: false, message: '删除报销申请失败' });
}
});
router.delete('/:id', async (req, res) => {
try {
const { id } = req.params;
const result = await db.query('DELETE FROM reimbursements WHERE id = $1', [id]);
if (result.rowCount > 0) {
res.json({ success: true, message: '删除成功' });
} else {
res.status(404).json({ success: false, message: '报销申请不存在' });
}
} catch (error) {
console.error('删除报销申请失败:', error);
res.status(500).json({ success: false, message: '删除报销申请失败' });
}
});
router.post('/:id/submit', async (req, res) => {
try {
const { id } = req.params;
const result = await db.query('UPDATE reimbursements SET status = $1 WHERE id = $2', ['pending', id]);
if (result.changes > 0) {
res.json({ success: true, message: '提交成功' });
} else {
res.status(404).json({ success: false, message: '报销申请不存在' });
}
} catch (error) {
console.error('提交报销申请失败:', error);
res.status(500).json({ success: false, message: '提交报销申请失败' });
}
});
router.post('/:id/submit', async (req, res) => {
try {
const { id } = req.params;
const result = await db.query('UPDATE reimbursements SET status = $1 WHERE id = $2', ['pending', id]);
if (result.rowCount > 0) {
res.json({ success: true, message: '提交成功' });
} else {
res.status(404).json({ success: false, message: '报销申请不存在' });
}
} catch (error) {
console.error('提交报销申请失败:', error);
res.status(500).json({ success: false, message: '提交报销申请失败' });
}
});
router.post('/:id/withdraw', async (req, res) => {
try {
const { id } = req.params;
const result = await db.query('UPDATE reimbursements SET status = $1 WHERE id = $2', ['withdrawn', id]);
if (result.changes > 0) {
res.json({ success: true, message: '撤回成功' });
} else {
res.status(404).json({ success: false, message: '报销申请不存在' });
}
} catch (error) {
console.error('撤回报销申请失败:', error);
res.status(500).json({ success: false, message: '撤回报销申请失败' });
}
});
router.post('/:id/withdraw', async (req, res) => {
try {
const { id } = req.params;
const result = await db.query('UPDATE reimbursements SET status = $1 WHERE id = $2', ['withdrawn', id]);
if (result.rowCount > 0) {
res.json({ success: true, message: '撤回成功' });
} else {
res.status(404).json({ success: false, message: '报销申请不存在' });
}
} catch (error) {
console.error('撤回报销申请失败:', error);
res.status(500).json({ success: false, message: '撤回报销申请失败' });
}
});
router.post('/:id/approve', async (req, res) => {
try {
const { id } = req.params;
const { remark } = req.body;
const result = await db.query('UPDATE reimbursements SET status = $1, approval_remark = $2 WHERE id = $3', ['approved', remark, id]);
if (result.changes > 0) {
res.json({ success: true, message: '审批通过成功' });
} else {
res.status(404).json({ success: false, message: '报销申请不存在' });
}
} catch (error) {
console.error('审批报销申请失败:', error);
res.status(500).json({ success: false, message: '审批报销申请失败' });
}
});
router.post('/:id/approve', async (req, res) => {
try {
const { id } = req.params;
const { remark } = req.body;
const result = await db.query('UPDATE reimbursements SET status = $1, approval_remark = $2 WHERE id = $3', ['approved', remark, id]);
if (result.rowCount > 0) {
res.json({ success: true, message: '审批通过成功' });
} else {
res.status(404).json({ success: false, message: '报销申请不存在' });
}
} catch (error) {
console.error('审批报销申请失败:', error);
res.status(500).json({ success: false, message: '审批报销申请失败' });
}
});
router.post('/:id/reject', async (req, res) => {
try {
const { id } = req.params;
const { rejectReason } = req.body;
const result = await db.query('UPDATE reimbursements SET status = $1 WHERE id = $2', ['pending_edit', id]);
if (result.changes > 0) {
res.json({ success: true, message: '退回成功' });
} else {
res.status(404).json({ success: false, message: '报销申请不存在' });
}
} catch (error) {
console.error('退回报销申请失败:', error);
res.status(500).json({ success: false, message: '退回报销申请失败' });
}
});
router.post('/:id/reject', async (req, res) => {
try {
const { id } = req.params;
const { rejectReason } = req.body;
const result = await db.query('UPDATE reimbursements SET status = $1 WHERE id = $2', ['pending', id]);
if (result.rowCount > 0) {
res.json({ success: true, message: '退回成功' });
} else {
res.status(404).json({ success: false, message: '报销申请不存在' });
}
} catch (error) {
console.error('退回报销申请失败:', error);
res.status(500).json({ success: false, message: '退回报销申请失败' });
}
});
module.exports = router;
+1 -1
View File
@@ -248,7 +248,7 @@ router.post('/:id/reject', async (req, res) => {
WHERE id = ?
`, [reason || '无', id]);
if (result.changes === 0) {
if (result.rowCount === 0) {
return res.status(404).json({ success: false, message: '退库单不存在' });
}
+64 -6
View File
@@ -4,6 +4,34 @@ const db = require('../db');
const { hashPassword, verifyPassword } = require('../utils/auth');
const { authenticate, requireAdmin } = require('../middleware/auth');
router.get('/me', authenticate, async (req, res) => {
try {
const result = await db.query('SELECT id, username, name, email, phone, role, avatar, passport, driver_license, is_active, created_at, updated_at FROM users WHERE id = $1', [req.user.id]);
if (result.rows.length === 0) {
return res.status(404).json({ success: false, message: '用户不存在' });
}
const u = result.rows[0];
res.json({
success: true,
data: {
id: u.id,
username: u.username,
name: u.name,
email: u.email,
phone: u.phone,
role: u.role,
avatar: u.avatar,
passport: u.passport,
driverLicense: u.driver_license,
is_active: u.is_active
}
});
} catch (error) {
console.error('获取用户信息失败:', error);
res.status(500).json({ success: false, message: '获取用户信息失败' });
}
});
router.get('/', authenticate, requireAdmin, async (req, res) => {
try {
const usersResult = await db.query('SELECT id, username, name, email, phone, role, is_active, created_at, updated_at FROM users ORDER BY id');
@@ -45,25 +73,55 @@ router.post('/', authenticate, requireAdmin, async (req, res) => {
}
});
router.put('/:id/profile', authenticate, async (req, res) => {
try {
const { id } = req.params;
const userId = parseInt(id);
if (req.user.id !== userId && req.user.role !== 'admin') {
return res.status(403).json({ success: false, message: '只能修改自己的个人信息' });
}
const { name, email, phone, avatar, passport, driver_license } = req.body;
await db.query(
'UPDATE users SET name = $1, email = $2, phone = $3, avatar = $4, passport = $5, driver_license = $6, updated_at = NOW() WHERE id = $7',
[name, email || null, phone || null, avatar || null, passport || null, driver_license || null, userId]
);
const updatedUserResult = await db.query('SELECT id, username, name, email, phone, role, avatar, passport, driver_license, is_active, created_at, updated_at FROM users WHERE id = $1', [userId]);
const updatedUser = updatedUserResult.rows[0];
if (!updatedUser) {
return res.status(404).json({ success: false, message: '用户不存在' });
}
res.json({ success: true, data: updatedUser });
} catch (error) {
console.error('更新个人信息失败:', error);
res.status(500).json({ success: false, message: '更新个人信息失败' });
}
});
router.put('/:id', authenticate, requireAdmin, async (req, res) => {
try {
const { id } = req.params;
const { name, email, phone, role, password } = req.body;
const { name, email, phone, role, password, avatar, passport, driver_license } = req.body;
if (password) {
const passwordHash = hashPassword(password);
await db.query(
'UPDATE users SET name = $1, email = $2, phone = $3, role = $4, password_hash = $5, updated_at = NOW() WHERE id = $6',
[name, email || null, phone || null, role, passwordHash, id]
'UPDATE users SET name = $1, email = $2, phone = $3, role = $4, avatar = $5, passport = $6, driver_license = $7, password_hash = $8, updated_at = NOW() WHERE id = $9',
[name, email || null, phone || null, role, avatar || null, passport || null, driver_license || null, passwordHash, id]
);
} else {
await db.query(
'UPDATE users SET name = $1, email = $2, phone = $3, role = $4, updated_at = NOW() WHERE id = $5',
[name, email || null, phone || null, role, id]
'UPDATE users SET name = $1, email = $2, phone = $3, role = $4, avatar = $5, passport = $6, driver_license = $7, updated_at = NOW() WHERE id = $8',
[name, email || null, phone || null, role, avatar || null, passport || null, driver_license || null, id]
);
}
const updatedUserResult = await db.query('SELECT id, username, name, email, phone, role, is_active, created_at, updated_at FROM users WHERE id = $1', [id]);
const updatedUserResult = await db.query('SELECT id, username, name, email, phone, role, avatar, passport, driver_license, is_active, created_at, updated_at FROM users WHERE id = $1', [id]);
const updatedUser = updatedUserResult.rows[0];
if (!updatedUser) {
+1 -1
View File
@@ -284,7 +284,7 @@ router.post('/:id/reject', async (req, res) => {
WHERE id = ?
`, [reason || '无', id]);
if (result.changes === 0) {
if (result.rowCount === 0) {
return res.status(404).json({ success: false, message: '验收单不存在' });
}
+348 -348
View File
@@ -4,362 +4,362 @@ const { authenticate, requireAdmin } = require('../middleware/auth');
const router = express.Router();
router.get('/', async (req, res) => {
try {
const { advance_id } = req.query;
let query = `
SELECT v.*, a.advance_code, a.applicant_id as advance_applicant_id
FROM verifications v
LEFT JOIN advances a ON v.advance_id = a.id
`;
const params = [];
if (advance_id) {
query += ` WHERE v.advance_id = $1`;
params.push(advance_id);
}
query += ` ORDER BY v.created_at DESC`;
const result = await db.query(query, params);
const data = result.rows.map(item => {
if (item.attachments) {
try {
item.attachments = JSON.parse(item.attachments);
} catch (error) {
item.attachments = [];
}
} else {
item.attachments = [];
}
if (item.detail_items) {
try {
item.detail_items = JSON.parse(item.detail_items);
} catch (error) {
item.detail_items = [];
}
} else {
item.detail_items = [];
}
return item;
});
res.json({ success: true, data, count: data.length });
} catch (error) {
console.error('获取核销记录失败:', error);
res.status(500).json({ success: false, message: '获取核销记录失败' });
}
});
router.get('/', async (req, res) => {
try {
const { advance_id } = req.query;
let query = `
SELECT v.*, a.advance_code, a.applicant_id as advance_applicant_id
FROM verifications v
LEFT JOIN advances a ON v.advance_id = a.id
`;
const params = [];
if (advance_id) {
query += ` WHERE v.advance_id = $1`;
params.push(advance_id);
}
query += ` ORDER BY v.created_at DESC`;
const result = await db.query(query, params);
const data = result.rows.map(item => {
if (item.attachments) {
try {
item.attachments = JSON.parse(item.attachments);
} catch (error) {
item.attachments = [];
}
} else {
item.attachments = [];
}
if (item.detail_items) {
try {
item.detail_items = JSON.parse(item.detail_items);
} catch (error) {
item.detail_items = [];
}
} else {
item.detail_items = [];
}
return item;
});
res.json({ success: true, data, count: data.length });
} catch (error) {
console.error('获取核销记录失败:', error);
res.status(500).json({ success: false, message: '获取核销记录失败' });
}
});
router.post('/', async (req, res) => {
try {
const { verification_date, advance_id, advance_code, advance_amount, currency, reason, detail_items, attachments, applicant, expense_type, project_id, settlement, settlement_amount } = req.body;
// 生成核销编号
const verificationCode = `VER-${Date.now()}`;
const amount = detail_items?.reduce((sum, item) => sum + (item.amount || 0), 0) || 0;
// 验证关联预支单
if (!advance_id && !advance_code) {
return res.status(400).json({ success: false, message: '关联预支单是必填项' });
}
let finalAdvanceCode = advance_code;
let finalAdvanceId = advance_id;
// 如果advance_code为空,根据advance_id查询预支单的advance_code
if (!finalAdvanceCode && finalAdvanceId) {
const advanceResult = await db.query('SELECT advance_code FROM advances WHERE id = $1', [finalAdvanceId]);
if (advanceResult.rows.length > 0) {
finalAdvanceCode = advanceResult.rows[0].advance_code;
} else {
return res.status(400).json({ success: false, message: '关联的预支单不存在' });
}
}
// 如果advance_id为空,根据advance_code查询预支单的id
if (!finalAdvanceId && finalAdvanceCode) {
const advanceResult = await db.query('SELECT id FROM advances WHERE advance_code = $1', [finalAdvanceCode]);
if (advanceResult.rows.length > 0) {
finalAdvanceId = advanceResult.rows[0].id;
} else {
return res.status(400).json({ success: false, message: '关联的预支单不存在' });
}
}
// 如果仍然为空,返回错误
if (!finalAdvanceCode || !finalAdvanceId) {
return res.status(400).json({ success: false, message: '关联预支单不存在' });
}
// 开始事务
await db.query('BEGIN TRANSACTION');
try {
// 插入核销申请
const result = await db.query(
'INSERT INTO verifications (advance_id, amount, currency, reason, verification_date, verification_code, status, applicant, advance_code, advance_amount, detail_items, attachments, expense_type, project_id, settlement, settlement_amount) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)',
[finalAdvanceId, amount, currency || 'CNY', reason, verification_date, verificationCode, 'pending', applicant, finalAdvanceCode, advance_amount || 0, JSON.stringify(detail_items || []), JSON.stringify(attachments || []), expense_type || 'company', project_id, settlement ? 1 : 0, settlement_amount || 0]
);
// 提交事务
await db.query('COMMIT');
// SQLite不支持RETURNING,所以需要查询刚插入的数据
const lastInsert = await db.query('SELECT * FROM verifications ORDER BY id DESC LIMIT 1');
res.json({ success: true, data: lastInsert.rows[0] });
} catch (error) {
// 回滚事务
await db.query('ROLLBACK');
throw error;
}
} catch (error) {
console.error('创建核销申请失败:', error);
res.status(500).json({ success: false, message: '创建核销申请失败' });
}
});
router.post('/', async (req, res) => {
try {
const { verification_date, advance_id, advance_code, advance_amount, currency, reason, detail_items, attachments, applicant, expense_type, project_id, settlement, settlement_amount } = req.body;
// 生成核销编号
const verificationCode = `VER-${Date.now()}`;
const amount = detail_items?.reduce((sum, item) => sum + (item.amount || 0), 0) || 0;
// 验证关联预支单
if (!advance_id && !advance_code) {
return res.status(400).json({ success: false, message: '关联预支单是必填项' });
}
let finalAdvanceCode = advance_code;
let finalAdvanceId = advance_id;
// 如果advance_code为空,根据advance_id查询预支单的advance_code
if (!finalAdvanceCode && finalAdvanceId) {
const advanceResult = await db.query('SELECT advance_code FROM advances WHERE id = $1', [finalAdvanceId]);
if (advanceResult.rows.length > 0) {
finalAdvanceCode = advanceResult.rows[0].advance_code;
} else {
return res.status(400).json({ success: false, message: '关联的预支单不存在' });
}
}
// 如果advance_id为空,根据advance_code查询预支单的id
if (!finalAdvanceId && finalAdvanceCode) {
const advanceResult = await db.query('SELECT id FROM advances WHERE advance_code = $1', [finalAdvanceCode]);
if (advanceResult.rows.length > 0) {
finalAdvanceId = advanceResult.rows[0].id;
} else {
return res.status(400).json({ success: false, message: '关联的预支单不存在' });
}
}
// 如果仍然为空,返回错误
if (!finalAdvanceCode || !finalAdvanceId) {
return res.status(400).json({ success: false, message: '关联预支单不存在' });
}
// 开始事务
await db.query('BEGIN TRANSACTION');
try {
// 插入核销申请
const result = await db.query(
'INSERT INTO verifications (advance_id, amount, currency, reason, verification_date, verification_code, status, applicant, advance_code, advance_amount, detail_items, attachments, expense_type, project_id, settlement, settlement_amount) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)',
[finalAdvanceId, amount, currency || 'CNY', reason, verification_date, verificationCode, 'pending', applicant, finalAdvanceCode, advance_amount || 0, JSON.stringify(detail_items || []), JSON.stringify(attachments || []), expense_type || 'company', project_id, settlement ? 1 : 0, settlement_amount || 0]
);
// 提交事务
await db.query('COMMIT');
// SQLite不支持RETURNING,所以需要查询刚插入的数据
const lastInsert = await db.query('SELECT * FROM verifications ORDER BY id DESC LIMIT 1');
res.json({ success: true, data: lastInsert.rows[0] });
} catch (error) {
// 回滚事务
await db.query('ROLLBACK');
throw error;
}
} catch (error) {
console.error('创建核销申请失败:', error);
res.status(500).json({ success: false, message: '创建核销申请失败' });
}
});
router.get('/:id', async (req, res) => {
try {
const { id } = req.params;
const result = await db.query('SELECT * FROM verifications WHERE id = $1', [id]);
if (result.rows.length > 0) {
const data = result.rows[0];
if (data.attachments) {
try {
data.attachments = JSON.parse(data.attachments);
} catch (error) {
data.attachments = [];
}
} else {
data.attachments = [];
}
if (data.detail_items) {
try {
data.detail_items = JSON.parse(data.detail_items);
} catch (error) {
data.detail_items = [];
}
} else {
data.detail_items = [];
}
res.json({ success: true, data });
} else {
res.status(404).json({ success: false, message: '核销申请不存在' });
}
} catch (error) {
console.error('获取核销申请失败:', error);
res.status(500).json({ success: false, message: '获取核销申请失败' });
}
});
router.get('/:id', async (req, res) => {
try {
const { id } = req.params;
const result = await db.query('SELECT * FROM verifications WHERE id = $1', [id]);
if (result.rows.length > 0) {
const data = result.rows[0];
if (data.attachments) {
try {
data.attachments = JSON.parse(data.attachments);
} catch (error) {
data.attachments = [];
}
} else {
data.attachments = [];
}
if (data.detail_items) {
try {
data.detail_items = JSON.parse(data.detail_items);
} catch (error) {
data.detail_items = [];
}
} else {
data.detail_items = [];
}
res.json({ success: true, data });
} else {
res.status(404).json({ success: false, message: '核销申请不存在' });
}
} catch (error) {
console.error('获取核销申请失败:', error);
res.status(500).json({ success: false, message: '获取核销申请失败' });
}
});
router.put('/:id', async (req, res) => {
try {
const { id } = req.params;
const { verification_date, advance_id, advance_code, advance_amount, currency, reason, detail_items, attachments, applicant, status, expense_type, project_id, settlement, settlement_amount } = req.body;
const amount = detail_items?.reduce((sum, item) => sum + (item.amount || 0), 0) || 0;
// 开始事务
await db.query('BEGIN TRANSACTION');
try {
// 获取原核销金额
const oldVerification = await db.query('SELECT amount, advance_id FROM verifications WHERE id = $1', [id]);
const oldAmount = oldVerification.rows[0]?.amount || 0;
const oldAdvanceId = oldVerification.rows[0]?.advance_id;
let finalAdvanceCode = advance_code;
// 如果advance_code为空,根据advance_id查询预支单的advance_code
if (!finalAdvanceCode && advance_id) {
const advanceResult = await db.query('SELECT advance_code FROM advances WHERE id = $1', [advance_id]);
if (advanceResult.rows.length > 0) {
finalAdvanceCode = advanceResult.rows[0].advance_code;
}
}
// 如果仍然为空,使用默认值
if (!finalAdvanceCode) {
finalAdvanceCode = 'UNKNOWN';
}
// 更新核销申请
const result = await db.query(
'UPDATE verifications SET verification_date = $1, advance_id = $2, amount = $3, currency = $4, reason = $5, advance_code = $6, advance_amount = $7, detail_items = $8, attachments = $9, applicant = $10, status = $11, expense_type = $12, project_id = $13, settlement = $14, settlement_amount = $15 WHERE id = $16',
[verification_date, advance_id, amount, currency, reason, finalAdvanceCode, advance_amount || 0, JSON.stringify(detail_items || []), JSON.stringify(attachments || []), applicant, status, expense_type || 'company', project_id, settlement ? 1 : 0, settlement_amount || 0, id]
);
// 不在这里更新预支单已核销金额,而是在执行核销时更新
// if (oldAdvanceId) {
// const amountDiff = amount - oldAmount;
// if (amountDiff !== 0) {
// await db.query(
// 'UPDATE advances SET total_reimbursed = total_reimbursed + $1 WHERE id = $2',
// [amountDiff, oldAdvanceId]
// );
// }
// }
// 提交事务
await db.query('COMMIT');
if (result.changes > 0) {
res.json({ success: true, message: '更新成功' });
} else {
res.status(404).json({ success: false, message: '核销申请不存在' });
}
} catch (error) {
// 回滚事务
await db.query('ROLLBACK');
throw error;
}
} catch (error) {
console.error('更新核销申请失败:', error);
res.status(500).json({ success: false, message: '更新核销申请失败' });
}
});
router.put('/:id', async (req, res) => {
try {
const { id } = req.params;
const { verification_date, advance_id, advance_code, advance_amount, currency, reason, detail_items, attachments, applicant, status, expense_type, project_id, settlement, settlement_amount } = req.body;
const amount = detail_items?.reduce((sum, item) => sum + (item.amount || 0), 0) || 0;
// 开始事务
await db.query('BEGIN TRANSACTION');
try {
// 获取原核销金额
const oldVerification = await db.query('SELECT amount, advance_id FROM verifications WHERE id = $1', [id]);
const oldAmount = oldVerification.rows[0]?.amount || 0;
const oldAdvanceId = oldVerification.rows[0]?.advance_id;
let finalAdvanceCode = advance_code;
// 如果advance_code为空,根据advance_id查询预支单的advance_code
if (!finalAdvanceCode && advance_id) {
const advanceResult = await db.query('SELECT advance_code FROM advances WHERE id = $1', [advance_id]);
if (advanceResult.rows.length > 0) {
finalAdvanceCode = advanceResult.rows[0].advance_code;
}
}
// 如果仍然为空,使用默认值
if (!finalAdvanceCode) {
finalAdvanceCode = 'UNKNOWN';
}
// 更新核销申请
const result = await db.query(
'UPDATE verifications SET verification_date = $1, advance_id = $2, amount = $3, currency = $4, reason = $5, advance_code = $6, advance_amount = $7, detail_items = $8, attachments = $9, applicant = $10, status = $11, expense_type = $12, project_id = $13, settlement = $14, settlement_amount = $15 WHERE id = $16',
[verification_date, advance_id, amount, currency, reason, finalAdvanceCode, advance_amount || 0, JSON.stringify(detail_items || []), JSON.stringify(attachments || []), applicant, status, expense_type || 'company', project_id, settlement ? 1 : 0, settlement_amount || 0, id]
);
// 不在这里更新预支单已核销金额,而是在执行核销时更新
// if (oldAdvanceId) {
// const amountDiff = amount - oldAmount;
// if (amountDiff !== 0) {
// await db.query(
// 'UPDATE advances SET total_reimbursed = total_reimbursed + $1 WHERE id = $2',
// [amountDiff, oldAdvanceId]
// );
// }
// }
// 提交事务
await db.query('COMMIT');
if (result.rowCount > 0) {
res.json({ success: true, message: '更新成功' });
} else {
res.status(404).json({ success: false, message: '核销申请不存在' });
}
} catch (error) {
// 回滚事务
await db.query('ROLLBACK');
throw error;
}
} catch (error) {
console.error('更新核销申请失败:', error);
res.status(500).json({ success: false, message: '更新核销申请失败' });
}
});
router.delete('/:id', async (req, res) => {
try {
const { id } = req.params;
// 开始事务
await db.query('BEGIN TRANSACTION');
try {
// 获取核销金额和预支单ID
const verification = await db.query('SELECT amount, advance_id FROM verifications WHERE id = $1', [id]);
const amount = verification.rows[0]?.amount || 0;
const advanceId = verification.rows[0]?.advance_id;
// 删除核销申请
const result = await db.query('DELETE FROM verifications WHERE id = $1', [id]);
// 不在这里恢复预支单已核销金额,因为只有执行的核销才会增加已核销金额
// if (advanceId && amount > 0) {
// await db.query(
// 'UPDATE advances SET total_reimbursed = total_reimbursed - $1 WHERE id = $2',
// [amount, advanceId]
// );
// }
// 提交事务
await db.query('COMMIT');
if (result.changes > 0) {
res.json({ success: true, message: '删除成功' });
} else {
res.status(404).json({ success: false, message: '核销申请不存在' });
}
} catch (error) {
// 回滚事务
await db.query('ROLLBACK');
throw error;
}
} catch (error) {
console.error('删除核销申请失败:', error);
res.status(500).json({ success: false, message: '删除核销申请失败' });
}
});
router.delete('/:id', async (req, res) => {
try {
const { id } = req.params;
// 开始事务
await db.query('BEGIN TRANSACTION');
try {
// 获取核销金额和预支单ID
const verification = await db.query('SELECT amount, advance_id FROM verifications WHERE id = $1', [id]);
const amount = verification.rows[0]?.amount || 0;
const advanceId = verification.rows[0]?.advance_id;
// 删除核销申请
const result = await db.query('DELETE FROM verifications WHERE id = $1', [id]);
// 不在这里恢复预支单已核销金额,因为只有执行的核销才会增加已核销金额
// if (advanceId && amount > 0) {
// await db.query(
// 'UPDATE advances SET total_reimbursed = total_reimbursed - $1 WHERE id = $2',
// [amount, advanceId]
// );
// }
// 提交事务
await db.query('COMMIT');
if (result.rowCount > 0) {
res.json({ success: true, message: '删除成功' });
} else {
res.status(404).json({ success: false, message: '核销申请不存在' });
}
} catch (error) {
// 回滚事务
await db.query('ROLLBACK');
throw error;
}
} catch (error) {
console.error('删除核销申请失败:', error);
res.status(500).json({ success: false, message: '删除核销申请失败' });
}
});
router.post('/:id/submit', async (req, res) => {
try {
const { id } = req.params;
const result = await db.query('UPDATE verifications SET status = $1 WHERE id = $2', ['pending', id]);
if (result.changes > 0) {
res.json({ success: true, message: '提交成功' });
} else {
res.status(404).json({ success: false, message: '核销申请不存在' });
}
} catch (error) {
console.error('提交核销申请失败:', error);
res.status(500).json({ success: false, message: '提交核销申请失败' });
}
});
router.post('/:id/submit', async (req, res) => {
try {
const { id } = req.params;
const result = await db.query('UPDATE verifications SET status = $1 WHERE id = $2', ['pending', id]);
if (result.rowCount > 0) {
res.json({ success: true, message: '提交成功' });
} else {
res.status(404).json({ success: false, message: '核销申请不存在' });
}
} catch (error) {
console.error('提交核销申请失败:', error);
res.status(500).json({ success: false, message: '提交核销申请失败' });
}
});
router.post('/:id/withdraw', async (req, res) => {
try {
const { id } = req.params;
const result = await db.query('UPDATE verifications SET status = $1 WHERE id = $2', ['withdrawn', id]);
if (result.changes > 0) {
res.json({ success: true, message: '撤回成功' });
} else {
res.status(404).json({ success: false, message: '核销申请不存在' });
}
} catch (error) {
console.error('撤回核销申请失败:', error);
res.status(500).json({ success: false, message: '撤回核销申请失败' });
}
});
router.post('/:id/withdraw', async (req, res) => {
try {
const { id } = req.params;
const result = await db.query('UPDATE verifications SET status = $1 WHERE id = $2', ['withdrawn', id]);
if (result.rowCount > 0) {
res.json({ success: true, message: '撤回成功' });
} else {
res.status(404).json({ success: false, message: '核销申请不存在' });
}
} catch (error) {
console.error('撤回核销申请失败:', error);
res.status(500).json({ success: false, message: '撤回核销申请失败' });
}
});
router.post('/:id/approve', async (req, res) => {
try {
const { id } = req.params;
const { remark } = req.body;
const result = await db.query('UPDATE verifications SET status = $1, approval_remark = $2 WHERE id = $3', ['approved', remark, id]);
if (result.changes > 0) {
res.json({ success: true, message: '审批通过成功' });
} else {
res.status(404).json({ success: false, message: '核销申请不存在' });
}
} catch (error) {
console.error('审批核销申请失败:', error);
res.status(500).json({ success: false, message: '审批核销申请失败' });
}
});
router.post('/:id/approve', async (req, res) => {
try {
const { id } = req.params;
const { remark } = req.body;
const result = await db.query('UPDATE verifications SET status = $1, approval_remark = $2 WHERE id = $3', ['approved', remark, id]);
if (result.rowCount > 0) {
res.json({ success: true, message: '审批通过成功' });
} else {
res.status(404).json({ success: false, message: '核销申请不存在' });
}
} catch (error) {
console.error('审批核销申请失败:', error);
res.status(500).json({ success: false, message: '审批核销申请失败' });
}
});
router.post('/:id/reject', async (req, res) => {
try {
const { id } = req.params;
const { rejectReason } = req.body;
// 开始事务
await db.query('BEGIN TRANSACTION');
try {
// 获取核销金额和预支单ID
const verification = await db.query('SELECT amount, advance_id FROM verifications WHERE id = $1', [id]);
const amount = verification.rows[0]?.amount || 0;
const advanceId = verification.rows[0]?.advance_id;
// 退回核销申请
const result = await db.query('UPDATE verifications SET status = $1 WHERE id = $2', ['pending_edit', id]);
// 不在这里恢复预支单已核销金额,因为只有执行的核销才会增加已核销金额
// if (advanceId && amount > 0) {
// await db.query(
// 'UPDATE advances SET total_reimbursed = total_reimbursed - $1 WHERE id = $2',
// [amount, advanceId]
// );
// }
// 提交事务
await db.query('COMMIT');
if (result.changes > 0) {
res.json({ success: true, message: '退回成功' });
} else {
res.status(404).json({ success: false, message: '核销申请不存在' });
}
} catch (error) {
// 回滚事务
await db.query('ROLLBACK');
throw error;
}
} catch (error) {
console.error('退回核销申请失败:', error);
res.status(500).json({ success: false, message: '退回核销申请失败' });
}
});
router.post('/:id/reject', async (req, res) => {
try {
const { id } = req.params;
const { rejectReason } = req.body;
// 开始事务
await db.query('BEGIN TRANSACTION');
try {
// 获取核销金额和预支单ID
const verification = await db.query('SELECT amount, advance_id FROM verifications WHERE id = $1', [id]);
const amount = verification.rows[0]?.amount || 0;
const advanceId = verification.rows[0]?.advance_id;
// 退回核销申请
const result = await db.query('UPDATE verifications SET status = $1 WHERE id = $2', ['pending', id]);
// 不在这里恢复预支单已核销金额,因为只有执行的核销才会增加已核销金额
// if (advanceId && amount > 0) {
// await db.query(
// 'UPDATE advances SET total_reimbursed = total_reimbursed - $1 WHERE id = $2',
// [amount, advanceId]
// );
// }
// 提交事务
await db.query('COMMIT');
if (result.rowCount > 0) {
res.json({ success: true, message: '退回成功' });
} else {
res.status(404).json({ success: false, message: '核销申请不存在' });
}
} catch (error) {
// 回滚事务
await db.query('ROLLBACK');
throw error;
}
} catch (error) {
console.error('退回核销申请失败:', error);
res.status(500).json({ success: false, message: '退回核销申请失败' });
}
});
module.exports = router;
+3 -1
View File
@@ -73,6 +73,7 @@ import { useLanguageStore } from './store/languageStore'
// 路由守卫组件
const PrivateRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { isAuthenticated, user, isLoading } = useAuthStore()
const { t, currentLanguage } = useLanguageStore()
if (isLoading) {
return (
@@ -83,7 +84,7 @@ const PrivateRoute: React.FC<{ children: React.ReactNode }> = ({ children }) =>
height: '100vh',
fontSize: '24px'
}}>
...
{t('common.loading')}
</div>
)
}
@@ -143,6 +144,7 @@ function App() {
<Route path="budget-projects/create" element={<BudgetProjectCreate />} />
<Route path="budget-projects/:id" element={<BudgetProjectDetail />} />
<Route path="construction" element={<ConstructionOverview />} />
<Route path="construction/:id" element={<ConstructionProgress />} />
<Route path="construction/:id/logs" element={<ConstructionLog />} />
<Route path="construction/:id/milestones" element={<ConstructionMilestones />} />
<Route path="construction/progress/:id" element={<ConstructionProgress />} />
+40 -37
View File
@@ -1,6 +1,7 @@
import React from 'react'
import { Table, Card, Row, Col, Statistic, Empty, Tag } from 'antd'
import { DollarOutlined } from '@ant-design/icons'
import { useLanguageStore } from '../store/languageStore'
interface LedgerItem {
id: number
@@ -56,21 +57,23 @@ const formatAmount = (amount: number | undefined, currency?: string) => {
const getStatusTag = (status: string | undefined) => {
if (!status) return '-'
const t = useLanguageStore.getState().t
const map: Record<string, { color: string; text: string }> = {
completed: { color: 'success', text: '已完成' },
in_progress: { color: 'processing', text: '进行中' },
planning: { color: 'default', text: '规划中' },
pending: { color: 'default', text: '待处理' },
approved: { color: 'success', text: '已批准' },
paid: { color: 'green', text: '已支付' },
requested: { color: 'blue', text: '已申请' },
active: { color: 'processing', text: '进行中' },
completed: { color: 'success', text: t('businessLedger.completed') },
in_progress: { color: 'processing', text: t('businessLedger.inProgress') },
planning: { color: 'default', text: t('businessLedger.planning') },
pending: { color: 'default', text: t('businessLedger.pending') },
approved: { color: 'success', text: t('businessLedger.approved') },
paid: { color: 'green', text: t('businessLedger.paid') },
requested: { color: 'blue', text: t('businessLedger.applied') },
active: { color: 'processing', text: t('businessLedger.inProgress') },
}
const info = map[status] || { color: 'default', text: status }
return <Tag color={info.color}>{info.text}</Tag>
}
const BusinessLedgerTab: React.FC<BusinessLedgerTabProps> = ({ partnerType, summary, items, loading }) => {
const { t, currentLanguage } = useLanguageStore()
const renderSummaryCards = () => {
switch (partnerType) {
case 'subcontractor':
@@ -78,22 +81,22 @@ const BusinessLedgerTab: React.FC<BusinessLedgerTabProps> = ({ partnerType, summ
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={12} lg={6}>
<Card size="small" style={{ textAlign: 'center', background: '#e6f7ff', border: '1px solid #91d5ff' }}>
<Statistic title="合同总金额" value={summary.total_contract_amount || 0} prefix="¥" valueStyle={{ color: '#1890ff', fontSize: 20 }} />
<Statistic title={t('businessLedger.contractTotal')} value={summary.total_contract_amount || 0} prefix="¥" valueStyle={{ color: '#1890ff', fontSize: 20 }} />
</Card>
</Col>
<Col xs={12} lg={6}>
<Card size="small" style={{ textAlign: 'center', background: '#f6ffed', border: '1px solid #b7eb8f' }}>
<Statistic title="已付总额" value={summary.total_paid_amount || 0} prefix="¥" valueStyle={{ color: '#52c41a', fontSize: 20 }} />
<Statistic title={t('businessLedger.totalPaid')} value={summary.total_paid_amount || 0} prefix="¥" valueStyle={{ color: '#52c41a', fontSize: 20 }} />
</Card>
</Col>
<Col xs={12} lg={6}>
<Card size="small" style={{ textAlign: 'center', background: '#fff2f0', border: '1px solid #ffccc7' }}>
<Statistic title="未付总额" value={summary.total_unpaid_amount || 0} prefix="¥" valueStyle={{ color: '#ff4d4f', fontSize: 20 }} />
<Statistic title={t('businessLedger.totalUnpaid')} value={summary.total_unpaid_amount || 0} prefix="¥" valueStyle={{ color: '#ff4d4f', fontSize: 20 }} />
</Card>
</Col>
<Col xs={12} lg={6}>
<Card size="small" style={{ textAlign: 'center', background: '#fffbe6', border: '1px solid #ffe58f' }}>
<Statistic title="项目数量" value={summary.item_count || 0} suffix="个" valueStyle={{ color: '#faad14', fontSize: 20 }} />
<Statistic title={t('businessLedger.projectCount')} value={summary.item_count || 0} suffix={t('common.unit')} valueStyle={{ color: '#faad14', fontSize: 20 }} />
</Card>
</Col>
</Row>
@@ -103,22 +106,22 @@ const BusinessLedgerTab: React.FC<BusinessLedgerTabProps> = ({ partnerType, summ
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={12} lg={6}>
<Card size="small" style={{ textAlign: 'center', background: '#e6f7ff', border: '1px solid #91d5ff' }}>
<Statistic title="合同总金额" value={summary.total_contract_amount || 0} prefix="¥" valueStyle={{ color: '#1890ff', fontSize: 20 }} />
<Statistic title={t('businessLedger.contractTotal')} value={summary.total_contract_amount || 0} prefix="¥" valueStyle={{ color: '#1890ff', fontSize: 20 }} />
</Card>
</Col>
<Col xs={12} lg={6}>
<Card size="small" style={{ textAlign: 'center', background: '#f6ffed', border: '1px solid #b7eb8f' }}>
<Statistic title="已收总额" value={summary.total_received_amount || 0} prefix="¥" valueStyle={{ color: '#52c41a', fontSize: 20 }} />
<Statistic title={t('businessLedger.totalReceived')} value={summary.total_received_amount || 0} prefix="¥" valueStyle={{ color: '#52c41a', fontSize: 20 }} />
</Card>
</Col>
<Col xs={12} lg={6}>
<Card size="small" style={{ textAlign: 'center', background: '#fff2f0', border: '1px solid #ffccc7' }}>
<Statistic title="应收总额" value={summary.total_receivable_amount || 0} prefix="¥" valueStyle={{ color: '#ff4d4f', fontSize: 20 }} />
<Statistic title={t('businessLedger.totalReceivable')} value={summary.total_receivable_amount || 0} prefix="¥" valueStyle={{ color: '#ff4d4f', fontSize: 20 }} />
</Card>
</Col>
<Col xs={12} lg={6}>
<Card size="small" style={{ textAlign: 'center', background: '#fffbe6', border: '1px solid #ffe58f' }}>
<Statistic title="项目数量" value={summary.item_count || 0} suffix="个" valueStyle={{ color: '#faad14', fontSize: 20 }} />
<Statistic title={t('businessLedger.projectCount')} value={summary.item_count || 0} suffix={t('common.unit')} valueStyle={{ color: '#faad14', fontSize: 20 }} />
</Card>
</Col>
</Row>
@@ -128,22 +131,22 @@ const BusinessLedgerTab: React.FC<BusinessLedgerTabProps> = ({ partnerType, summ
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={12} lg={6}>
<Card size="small" style={{ textAlign: 'center', background: '#e6f7ff', border: '1px solid #91d5ff' }}>
<Statistic title="采购总额" value={summary.total_order_amount || 0} prefix="¥" valueStyle={{ color: '#1890ff', fontSize: 20 }} />
<Statistic title={t('businessLedger.purchaseTotal')} value={summary.total_order_amount || 0} prefix="¥" valueStyle={{ color: '#1890ff', fontSize: 20 }} />
</Card>
</Col>
<Col xs={12} lg={6}>
<Card size="small" style={{ textAlign: 'center', background: '#f6ffed', border: '1px solid #b7eb8f' }}>
<Statistic title="已付总额" value={summary.total_paid_amount || 0} prefix="¥" valueStyle={{ color: '#52c41a', fontSize: 20 }} />
<Statistic title={t('businessLedger.totalPaid')} value={summary.total_paid_amount || 0} prefix="¥" valueStyle={{ color: '#52c41a', fontSize: 20 }} />
</Card>
</Col>
<Col xs={12} lg={6}>
<Card size="small" style={{ textAlign: 'center', background: '#fff2f0', border: '1px solid #ffccc7' }}>
<Statistic title="未付总额" value={summary.total_unpaid_amount || 0} prefix="¥" valueStyle={{ color: '#ff4d4f', fontSize: 20 }} />
<Statistic title={t('businessLedger.totalUnpaid')} value={summary.total_unpaid_amount || 0} prefix="¥" valueStyle={{ color: '#ff4d4f', fontSize: 20 }} />
</Card>
</Col>
<Col xs={12} lg={6}>
<Card size="small" style={{ textAlign: 'center', background: '#fffbe6', border: '1px solid #ffe58f' }}>
<Statistic title="订单数量" value={summary.item_count || 0} suffix="个" valueStyle={{ color: '#faad14', fontSize: 20 }} />
<Statistic title={t('businessLedger.orderCount')} value={summary.item_count || 0} suffix={t('common.unit')} valueStyle={{ color: '#faad14', fontSize: 20 }} />
</Card>
</Col>
</Row>
@@ -153,22 +156,22 @@ const BusinessLedgerTab: React.FC<BusinessLedgerTabProps> = ({ partnerType, summ
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={12} lg={6}>
<Card size="small" style={{ textAlign: 'center', background: '#e6f7ff', border: '1px solid #91d5ff' }}>
<Statistic title="一次运费总额" value={summary.total_primary_freight || 0} suffix=" CNY" valueStyle={{ color: '#1890ff', fontSize: 20 }} />
<Statistic title={t('businessLedger.freight1Total')} value={summary.total_primary_freight || 0} suffix=" CNY" valueStyle={{ color: '#1890ff', fontSize: 20 }} />
</Card>
</Col>
<Col xs={12} lg={6}>
<Card size="small" style={{ textAlign: 'center', background: '#f6ffed', border: '1px solid #b7eb8f' }}>
<Statistic title="已付总额" value={summary.paid_amount || 0} prefix="¥" valueStyle={{ color: '#52c41a', fontSize: 20 }} />
<Statistic title={t('businessLedger.totalPaid')} value={summary.paid_amount || 0} prefix="¥" valueStyle={{ color: '#52c41a', fontSize: 20 }} />
</Card>
</Col>
<Col xs={12} lg={6}>
<Card size="small" style={{ textAlign: 'center', background: '#fff2f0', border: '1px solid #ffccc7' }}>
<Statistic title="未付总额" value={summary.unpaid_amount || 0} prefix="¥" valueStyle={{ color: '#ff4d4f', fontSize: 20 }} />
<Statistic title={t('businessLedger.totalUnpaid')} value={summary.unpaid_amount || 0} prefix="¥" valueStyle={{ color: '#ff4d4f', fontSize: 20 }} />
</Card>
</Col>
<Col xs={12} lg={6}>
<Card size="small" style={{ textAlign: 'center', background: '#fffbe6', border: '1px solid #ffe58f' }}>
<Statistic title="物流单数量" value={summary.item_count || 0} suffix="个" valueStyle={{ color: '#faad14', fontSize: 20 }} />
<Statistic title={t('businessLedger.logisticsCount')} value={summary.item_count || 0} suffix={t('common.unit')} valueStyle={{ color: '#faad14', fontSize: 20 }} />
</Card>
</Col>
</Row>
@@ -178,36 +181,36 @@ const BusinessLedgerTab: React.FC<BusinessLedgerTabProps> = ({ partnerType, summ
const getColumns = () => {
const baseColumns: any[] = [
{ title: '编号', dataIndex: 'code', key: 'code', width: 120 },
{ title: '名称', dataIndex: 'name', key: 'name', render: (v: string) => <span style={{ fontWeight: 500 }}>{v}</span> },
{ title: t('businessLedger.code'), dataIndex: 'code', key: 'code', width: 120 },
{ title: t('businessLedger.name'), dataIndex: 'name', key: 'name', render: (v: string) => <span style={{ fontWeight: 500 }}>{v}</span> },
]
switch (partnerType) {
case 'subcontractor':
return [
...baseColumns,
{ title: '合同金额', dataIndex: 'contract_amount', key: 'contract_amount', align: 'right' as const, render: (v: number) => formatAmount(v) },
{ title: '状态', dataIndex: 'status', key: 'status', align: 'center' as const, render: getStatusTag },
{ title: t('businessLedger.contractAmount'), dataIndex: 'contract_amount', key: 'contract_amount', align: 'right' as const, render: (v: number) => formatAmount(v) },
{ title: t('businessLedger.status'), dataIndex: 'status', key: 'status', align: 'center' as const, render: getStatusTag },
]
case 'customer':
return [
...baseColumns,
{ title: '合同金额', dataIndex: 'contract_amount', key: 'contract_amount', align: 'right' as const, render: (v: number) => formatAmount(v) },
{ title: '状态', dataIndex: 'status', key: 'status', align: 'center' as const, render: getStatusTag },
{ title: t('businessLedger.contractAmount'), dataIndex: 'contract_amount', key: 'contract_amount', align: 'right' as const, render: (v: number) => formatAmount(v) },
{ title: t('businessLedger.status'), dataIndex: 'status', key: 'status', align: 'center' as const, render: getStatusTag },
]
case 'supplier':
return [
...baseColumns,
{ title: '采购金额', dataIndex: 'order_amount', key: 'order_amount', align: 'right' as const, render: (v: number) => formatAmount(v) },
{ title: '状态', dataIndex: 'status', key: 'status', align: 'center' as const, render: getStatusTag },
{ title: t('businessLedger.purchaseAmount'), dataIndex: 'order_amount', key: 'order_amount', align: 'right' as const, render: (v: number) => formatAmount(v) },
{ title: t('businessLedger.status'), dataIndex: 'status', key: 'status', align: 'center' as const, render: getStatusTag },
]
case 'logistics':
return [
...baseColumns,
{ title: '项目', dataIndex: 'project_name', key: 'project_name', render: (v: string) => v || '-' },
{ title: '一次运费', dataIndex: 'primary_freight', key: 'primary_freight', align: 'right' as const, render: (v: number, r: LedgerItem) => formatAmount(v, r.primary_freight_currency) },
{ title: '一次运费状态', dataIndex: 'primary_freight_status', key: 'primary_freight_status', align: 'center' as const, width: 100, render: getStatusTag },
{ title: '状态', dataIndex: 'status', key: 'status', align: 'center' as const, render: getStatusTag },
{ title: t('businessLedger.project'), dataIndex: 'project_name', key: 'project_name', render: (v: string) => v || '-' },
{ title: t('businessLedger.freight1'), dataIndex: 'primary_freight', key: 'primary_freight', align: 'right' as const, render: (v: number, r: LedgerItem) => formatAmount(v, r.primary_freight_currency) },
{ title: t('businessLedger.freight1Status'), dataIndex: 'primary_freight_status', key: 'primary_freight_status', align: 'center' as const, width: 100, render: getStatusTag },
{ title: t('businessLedger.status'), dataIndex: 'status', key: 'status', align: 'center' as const, render: getStatusTag },
]
default:
return baseColumns
@@ -228,7 +231,7 @@ const BusinessLedgerTab: React.FC<BusinessLedgerTabProps> = ({ partnerType, summ
bordered
/>
) : (
<Empty description="暂无业务记录" image={Empty.PRESENTED_IMAGE_SIMPLE} />
<Empty description={t('businessLedger.noRecord')} image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</div>
)
+3 -3
View File
@@ -76,8 +76,8 @@ const ContactManager: React.FC<ContactManagerProps> = ({
render: (_, record) => (
<Space direction="vertical" size={2}>
{record.mobile && <div><PhoneOutlined style={{ marginRight: 4 }} />{record.mobile}</div>}
{record.phone && <div style={{ fontSize: '12px', color: '#666' }}>: {record.phone}</div>}
{record.wechat && <div style={{ fontSize: '12px', color: '#666' }}>: {record.wechat}</div>}
{record.phone && <div style={{ fontSize: '12px', color: '#666' }}>{t('component.phonePrefix')}{record.phone}</div>}
{record.wechat && <div style={{ fontSize: '12px', color: '#666' }}>{t('component.wechatPrefix')}{record.wechat}</div>}
</Space>
)
},
@@ -192,7 +192,7 @@ const ContactManager: React.FC<ContactManagerProps> = ({
</Form.Item>
</div>
<Form.Item name="whatsapp" label="WhatsApp">
<Input placeholder="输入WhatsApp号码" />
<Input placeholder={t('component.whatsappPlaceholder')} />
</Form.Item>
<Form.Item name="is_primary" label={t('contact.primaryContact')} valuePropName="checked">
<Switch />
+29 -8
View File
@@ -1,4 +1,5 @@
import React, { Component, ReactNode } from 'react'
import { useLanguageStore } from '../store/languageStore'
interface ErrorBoundaryProps {
children: ReactNode
@@ -8,24 +9,44 @@ interface ErrorBoundaryProps {
interface ErrorBoundaryState {
hasError: boolean
error?: Error
currentLanguage: string
}
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
unsubscribe: (() => void) | null = null
constructor(props: ErrorBoundaryProps) {
super(props)
this.state = { hasError: false }
this.state = {
hasError: false,
currentLanguage: useLanguageStore.getState().currentLanguage
}
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
static getDerivedStateFromError(error: Error): Partial<ErrorBoundaryState> {
return { hasError: true, error }
}
componentDidMount() {
this.unsubscribe = useLanguageStore.subscribe((state) => {
if (state.currentLanguage !== this.state.currentLanguage) {
this.setState({ currentLanguage: state.currentLanguage })
}
})
}
componentWillUnmount() {
this.unsubscribe?.()
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('组件渲染错误:', error)
console.error('错误信息:', errorInfo.componentStack)
}
render() {
const t = useLanguageStore.getState().t
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback
@@ -45,9 +66,9 @@ class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
boxShadow: '0 2px 8px rgba(0,0,0,0.1)'
}}>
<div style={{ fontSize: '48px', color: '#ff4d4f', marginBottom: '16px' }}></div>
<h3 style={{ color: '#333', marginBottom: '12px' }}></h3>
<h3 style={{ color: '#333', marginBottom: '12px' }}>{t('errorBoundary.title')}</h3>
<p style={{ color: '#666', marginBottom: '24px', maxWidth: '500px' }}>
{t('errorBoundary.description')}
</p>
{this.state.error && (
<div style={{
@@ -61,10 +82,10 @@ class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
maxWidth: '600px',
overflow: 'auto'
}}>
<div><strong>:</strong> {this.state.error.message}</div>
<div><strong>{t('errorBoundary.errorInfo')}</strong> {this.state.error.message}</div>
{this.state.error.stack && (
<div style={{ marginTop: '8px' }}>
<strong>:</strong>
<strong>{t('errorBoundary.stackTrace')}</strong>
<pre style={{ margin: '8px 0', whiteSpace: 'pre-wrap' }}>
{this.state.error.stack}
</pre>
@@ -85,7 +106,7 @@ class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
fontSize: '14px'
}}
>
{t('errorBoundary.refresh')}
</button>
</div>
</div>
@@ -96,4 +117,4 @@ class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
}
}
export default ErrorBoundary
export default ErrorBoundary
+203 -185
View File
@@ -1,185 +1,203 @@
import React, { useState, useEffect } from 'react';
import { Upload, Modal, Image, Spin, Progress, message } from 'antd';
import { PlusOutlined, FileOutlined, DeleteOutlined, EyeOutlined } from '@ant-design/icons';
import type { UploadFile, UploadProps } from 'antd/es/upload/interface';
interface FileUploadProps {
value?: string[];
onChange?: (urls: string[]) => void;
maxCount?: number;
accept?: string;
}
// 支持的图片格式
const imageFormats = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg'];
const officeFormats = ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'];
const isImage = (url: string) => {
const ext = url.split('.').pop()?.toLowerCase();
return imageFormats.includes(ext || '');
};
const isOfficeFile = (url: string) => {
const ext = url.split('.').pop()?.toLowerCase();
return officeFormats.includes(ext || '');
};
const getOfficePreviewUrl = (url: string) => {
// 使用微软的Office 365在线预览服务
return `https://view.officeapps.live.com/op/view.aspx?src=${encodeURIComponent(url)}`;
};
const FileUpload: React.FC<FileUploadProps> = ({
value = [],
onChange,
maxCount = 9,
accept = 'image/*'
}) => {
const [previewOpen, setPreviewOpen] = useState(false);
const [previewImage, setPreviewImage] = useState('');
const [fileList, setFileList] = useState<UploadFile[]>([]);
const [uploading, setUploading] = useState(false);
// 当 value 变化时,更新 fileList
useEffect(() => {
// 只有当 value 是数组时才更新 fileList
// 这样可以避免在上传过程中被重置
if (Array.isArray(value)) {
const newFileList = value.map((url, index) => ({
uid: `-${index}`,
name: url.split('/').pop() || `file-${index}`,
status: 'done',
url,
thumbUrl: isImage(url) ? url : undefined
}));
setFileList(newFileList);
}
}, [value]);
const handlePreview = async (file: UploadFile) => {
const url = file.url || '';
if (isImage(url)) {
setPreviewImage(url);
setPreviewOpen(true);
} else if (isOfficeFile(url)) {
// Office文件,使用微软的在线预览服务
const previewUrl = getOfficePreviewUrl(url);
window.open(previewUrl, '_blank');
} else {
// 其他文件,新窗口打开
window.open(url, '_blank');
}
};
const handleChange: UploadProps['onChange'] = (info) => {
const { fileList } = info;
setFileList(fileList);
// 只有当文件状态发生变化时才调用 onChange
// 避免在初始化时触发无限循环
if (info.file.status === 'done' || info.file.status === 'removed') {
// 提取已上传成功的URL
const urls = fileList
.filter(file => file.status === 'done')
.map(file => {
// 处理不同格式的文件对象
if (file.url) {
return file.url;
} else if (file.response && file.response.url) {
return file.response.url;
} else if (file.response && typeof file.response === 'string') {
return file.response;
}
return '';
})
.filter(url => url); // 过滤空字符串
onChange?.(urls);
}
};
const customRequest = async (options: any) => {
const { file, onSuccess, onError, onProgress } = options;
setUploading(true);
const formData = new FormData();
formData.append('file', file);
try {
const res = await fetch('/api/upload/single', {
method: 'POST',
body: formData
});
const data = await res.json();
if (data.success) {
onProgress({ percent: 100 });
// 传递包含url属性的对象,这是Ant Design Upload组件在customRequest中期望的格式
onSuccess({ url: data.data.url }, file);
message.success('上传成功');
} else {
onError(new Error(data.error));
message.error(data.error || '上传失败');
}
} catch (error) {
console.error('上传错误:', error);
onError(error);
message.error('上传失败');
} finally {
setUploading(false);
}
};
const uploadButton = (
<div>
<PlusOutlined />
<div style={{ marginTop: 8 }}></div>
</div>
);
return (
<>
<Upload
listType="picture-card"
fileList={fileList}
onPreview={handlePreview}
onChange={handleChange}
customRequest={customRequest}
accept={accept}
maxCount={maxCount}
multiple
>
{fileList.length >= maxCount ? null : uploadButton}
</Upload>
{/* 图片预览弹窗 */}
<Modal
open={previewOpen}
title="图片预览"
footer={null}
onCancel={() => setPreviewOpen(false)}
width="80%"
centered
>
<div style={{ textAlign: 'center' }}>
<Image
src={previewImage}
style={{ maxWidth: '100%', maxHeight: '80vh' }}
preview={false}
/>
</div>
</Modal>
{uploading && (
<div style={{ marginTop: 8 }}>
<Spin size="small" /> ...
</div>
)}
</>
);
};
export default FileUpload;
import React, { useState, useEffect } from 'react';
import { Upload, Modal, Image, Spin, Progress, message } from 'antd';
import { PlusOutlined, FileOutlined, DeleteOutlined, EyeOutlined } from '@ant-design/icons';
import type { UploadFile, UploadProps } from 'antd/es/upload/interface';
import { useLanguageStore } from '../store/languageStore';
interface FileUploadProps {
value?: string[];
onChange?: (urls: string[]) => void;
maxCount?: number;
accept?: string;
}
// 支持的图片格式
const imageFormats = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg'];
const officeFormats = ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'];
const isImage = (url: string) => {
const ext = url.split('.').pop()?.toLowerCase();
return imageFormats.includes(ext || '');
};
const isOfficeFile = (url: string) => {
const ext = url.split('.').pop()?.toLowerCase();
return officeFormats.includes(ext || '');
};
const getOfficePreviewUrl = (url: string) => {
// 使用微软的Office 365在线预览服务
return `https://view.officeapps.live.com/op/view.aspx?src=${encodeURIComponent(url)}`;
};
const FileUpload: React.FC<FileUploadProps> = ({
value = [],
onChange,
maxCount = 9,
accept = 'image/*'
}) => {
const [previewOpen, setPreviewOpen] = useState(false);
const [previewImage, setPreviewImage] = useState('');
const [fileList, setFileList] = useState<UploadFile[]>([]);
const [uploading, setUploading] = useState(false);
const { t, currentLanguage } = useLanguageStore();
// 当 value 变化时,更新 fileList
useEffect(() => {
// 只有当 value 是数组时才更新 fileList
// 这样可以避免在上传过程中被重置
if (Array.isArray(value)) {
const newFileList = value.map((url, index) => ({
uid: `-${index}`,
name: url.split('/').pop() || `file-${index}`,
status: 'done',
url,
thumbUrl: isImage(url) ? url : undefined
}));
setFileList(newFileList);
}
}, [value]);
const handlePreview = async (file: UploadFile) => {
const url = file.url || '';
if (isImage(url)) {
setPreviewImage(url);
setPreviewOpen(true);
} else if (isOfficeFile(url)) {
// Office文件,使用微软的在线预览服务
const previewUrl = getOfficePreviewUrl(url);
window.open(previewUrl, '_blank');
} else {
// 其他文件,新窗口打开
window.open(url, '_blank');
}
};
const handleChange: UploadProps['onChange'] = (info) => {
const { fileList } = info;
setFileList(fileList);
// 只有当文件状态发生变化时才调用 onChange
// 避免在初始化时触发无限循环
if (info.file.status === 'done' || info.file.status === 'removed') {
// 提取已上传成功的URL
const urls = fileList
.filter(file => file.status === 'done')
.map(file => {
// 处理不同格式的文件对象
if (file.url) {
return file.url;
} else if (file.response && file.response.url) {
return file.response.url;
} else if (file.response && typeof file.response === 'string') {
return file.response;
}
return '';
})
.filter(url => url); // 过滤空字符串
onChange?.(urls);
}
};
const customRequest = async (options: any) => {
const { file, onSuccess, onError, onProgress } = options;
setUploading(true);
const formData = new FormData();
formData.append('file', file);
try {
// 从 localStorage 读取认证 token
let authHeader = '';
try {
const authStorage = localStorage.getItem('auth-storage');
if (authStorage) {
const parsed = JSON.parse(authStorage);
const token = parsed?.state?.token;
if (token) {
authHeader = `Bearer ${token}`;
}
}
} catch (e) {
// 忽略解析错误
}
const res = await fetch('/api/upload/single', {
method: 'POST',
body: formData,
headers: authHeader ? { Authorization: authHeader } : {},
});
const data = await res.json();
if (data.success) {
onProgress({ percent: 100 });
// 传递包含url属性的对象,这是Ant Design Upload组件在customRequest中期望的格式
onSuccess({ url: data.data.url }, file);
message.success(t('fileUpload.uploadSuccess'));
} else {
onError(new Error(data.error));
message.error(data.error || t('fileUpload.uploadFailed'));
}
} catch (error) {
console.error('上传错误:', error);
onError(error);
message.error(t('fileUpload.uploadFailed'));
} finally {
setUploading(false);
}
};
const uploadButton = (
<div>
<PlusOutlined />
<div style={{ marginTop: 8 }}>{t('fileUpload.upload')}</div>
</div>
);
return (
<>
<Upload
listType="picture-card"
fileList={fileList}
onPreview={handlePreview}
onChange={handleChange}
customRequest={customRequest}
accept={accept}
maxCount={maxCount}
multiple
>
{fileList.length >= maxCount ? null : uploadButton}
</Upload>
{/* 图片预览弹窗 */}
<Modal
open={previewOpen}
title={t('fileUpload.preview')}
footer={null}
onCancel={() => setPreviewOpen(false)}
width="80%"
centered
>
<div style={{ textAlign: 'center' }}>
<Image
src={previewImage}
style={{ maxWidth: '100%', maxHeight: '80vh' }}
preview={false}
/>
</div>
</Modal>
{uploading && (
<div style={{ marginTop: 8 }}>
<Spin size="small" /> {t('fileUpload.uploading')}
</div>
)}
</>
);
};
export default FileUpload;
+64 -62
View File
@@ -1,62 +1,64 @@
import React from 'react'
import { Space, Typography } from 'antd'
import { ThunderboltOutlined } from '@ant-design/icons'
const { Text, Title } = Typography
interface CompanyLogoProps {
showText?: boolean
size?: 'small' | 'medium' | 'large'
}
const CompanyLogo: React.FC<CompanyLogoProps> = ({ showText = true, size = 'medium' }) => {
const sizeMap = {
small: { fontSize: 14, iconSize: 20 },
medium: { fontSize: 16, iconSize: 28 },
large: { fontSize: 20, iconSize: 36 }
}
const { fontSize, iconSize } = sizeMap[size]
return (
<Space align="center" style={{ cursor: 'pointer' }}>
{/* 图标 */}
<ThunderboltOutlined
style={{
fontSize: iconSize,
color: '#1890ff',
fontWeight: 'bold'
}}
/>
{/* 公司名称 */}
{showText && (
<div>
<Title
level={5}
style={{
margin: 0,
fontSize: fontSize,
color: '#262626',
fontWeight: 600
}}
>
ERP
</Title>
<Text
type="secondary"
style={{
fontSize: fontSize - 4,
display: 'block',
marginTop: -2
}}
>
Qingyuan Power Laos
</Text>
</div>
)}
</Space>
)
}
export default CompanyLogo
import React from 'react'
import { Space, Typography } from 'antd'
import { ThunderboltOutlined } from '@ant-design/icons'
import { useLanguageStore } from '../../store/languageStore'
const { Text, Title } = Typography
interface CompanyLogoProps {
showText?: boolean
size?: 'small' | 'medium' | 'large'
}
const CompanyLogo: React.FC<CompanyLogoProps> = ({ showText = true, size = 'medium' }) => {
const { t, currentLanguage } = useLanguageStore()
const sizeMap = {
small: { fontSize: 14, iconSize: 20 },
medium: { fontSize: 16, iconSize: 28 },
large: { fontSize: 20, iconSize: 36 }
}
const { fontSize, iconSize } = sizeMap[size]
return (
<Space align="center" style={{ cursor: 'pointer' }}>
{/* 图标 */}
<ThunderboltOutlined
style={{
fontSize: iconSize,
color: '#1890ff',
fontWeight: 'bold'
}}
/>
{/* 公司名称 */}
{showText && (
<div>
<Title
level={5}
style={{
margin: 0,
fontSize: fontSize,
color: '#262626',
fontWeight: 600
}}
>
{t('login.title')}
</Title>
<Text
type="secondary"
style={{
fontSize: fontSize - 4,
display: 'block',
marginTop: -2
}}
>
Qingyuan Power Laos
</Text>
</div>
)}
</Space>
)
}
export default CompanyLogo
+179 -177
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 {
Layout,
@@ -49,169 +49,6 @@ import LanguageSelector from '../common/LanguageSelector'
const { Header, Sider, Content } = Layout
const { Text } = Typography
const menuItems = [
{
key: '/dashboard',
icon: <DashboardOutlined />,
label: '工作台'
},
{
key: '/projects',
icon: <ProjectOutlined />,
label: '项目管理'
},
{
key: '/budget-projects',
icon: <CalculatorOutlined />,
label: '预算报价'
},
{
key: '/construction',
icon: <ToolOutlined />,
label: '施工管理',
children: [
{
key: '/construction',
label: '施工总览'
}
]
},
{
key: 'approval',
icon: <SolutionOutlined />,
label: '审批管理',
children: [
{
key: '/approval',
icon: <CheckCircleOutlined />,
label: '待审批'
},
{
key: '/execution',
icon: <DollarOutlined />,
label: '待执行'
}
]
},
{
key: 'finance-docs',
icon: <FileDoneOutlined />,
label: '财务申请',
children: [
{
key: '/advances',
icon: <WalletOutlined />,
label: '预支申请'
},
{
key: '/reimbursements',
icon: <FileTextOutlined />,
label: '报销申请'
},
{
key: '/payment-requests',
icon: <MoneyCollectOutlined />,
label: '付款申请'
},
{
key: '/verification',
icon: <AuditOutlined />,
label: '核销申请'
}
]
},
{
key: 'finance-group',
icon: <BarChartOutlined />,
label: '财务管理',
children: [
{
key: '/finance',
label: '财务概览'
},
{
key: '/exchange-rates',
icon: <DollarOutlined />,
label: '汇率管理'
},
{
key: '/project-cost',
icon: <DollarCircleOutlined />,
label: '项目成本'
},
{
key: '/advances/verification-status',
icon: <AuditOutlined />,
label: '预支核销状态'
}
]
},
{
key: '/reports',
icon: <FileSearchOutlined />,
label: '报表分析'
},
{
key: 'procurement',
icon: <ShoppingCartOutlined />,
label: '采购管理',
children: [
{
key: '/products',
icon: <AppstoreOutlined />,
label: '商品管理'
},
{
key: '/purchase-requests',
icon: <FileTextOutlined />,
label: '采购申请'
},
{
key: '/purchase-orders',
icon: <ShoppingCartOutlined />,
label: '采购订单'
},
{
key: '/payment-plans',
icon: <MoneyCollectOutlined />,
label: '付款计划'
},
{
key: '/inventory',
icon: <InboxOutlined />,
label: '库存管理'
}
]
},
{
key: 'partners',
icon: <TeamOutlined />,
label: '合作伙伴',
children: [
{
key: '/suppliers',
icon: <ShopOutlined />,
label: '供应商管理'
},
{
key: '/subcontractors',
icon: <SolutionOutlined />,
label: '分包商管理'
},
{
key: '/customers',
icon: <HomeOutlined />,
label: '客户管理'
},
{
key: '/logistics-companies',
icon: <CarOutlined />,
label: '物流管理'
}
]
}
]
const MainLayout: React.FC = () => {
const navigate = useNavigate()
const location = useLocation()
@@ -221,11 +58,174 @@ const MainLayout: React.FC = () => {
const [settingsVisible, setSettingsVisible] = useState(false)
const [openKeys, setOpenKeys] = useState<string[]>([])
const { user, logout } = useAuthStore()
const { t } = useLanguageStore()
const { t, currentLanguage } = useLanguageStore()
const {
token: { colorBgContainer, borderRadiusLG },
} = theme.useToken()
const menuItems = useMemo(() => [
{
key: '/dashboard',
icon: <DashboardOutlined />,
label: t('menu.dashboard')
},
{
key: '/projects',
icon: <ProjectOutlined />,
label: t('menu.projects')
},
{
key: '/budget-projects',
icon: <CalculatorOutlined />,
label: t('menu.budgetQuotation')
},
{
key: '/construction',
icon: <ToolOutlined />,
label: t('menu.construction'),
children: [
{
key: '/construction',
label: t('menu.constructionOverview')
}
]
},
{
key: 'approval',
icon: <SolutionOutlined />,
label: t('menu.approval'),
children: [
{
key: '/approval',
icon: <CheckCircleOutlined />,
label: t('menu.pendingApproval')
},
{
key: '/execution',
icon: <DollarOutlined />,
label: t('menu.pendingExecution')
}
]
},
{
key: 'finance-docs',
icon: <FileDoneOutlined />,
label: t('menu.financeDocs'),
children: [
{
key: '/advances',
icon: <WalletOutlined />,
label: t('menu.advanceApply')
},
{
key: '/reimbursements',
icon: <FileTextOutlined />,
label: t('menu.reimburseApply')
},
{
key: '/payment-requests',
icon: <MoneyCollectOutlined />,
label: t('menu.paymentApply')
},
{
key: '/verification',
icon: <AuditOutlined />,
label: t('menu.verificationApply')
}
]
},
{
key: 'finance-group',
icon: <BarChartOutlined />,
label: t('menu.financeManagement'),
children: [
{
key: '/finance',
label: t('menu.financeOverview')
},
{
key: '/exchange-rates',
icon: <DollarOutlined />,
label: t('menu.exchangeRate')
},
{
key: '/project-cost',
icon: <DollarCircleOutlined />,
label: t('menu.projectCost')
},
{
key: '/advances/verification-status',
icon: <AuditOutlined />,
label: t('menu.advanceVerificationStatus')
}
]
},
{
key: '/reports',
icon: <FileSearchOutlined />,
label: t('menu.reports')
},
{
key: 'procurement',
icon: <ShoppingCartOutlined />,
label: t('menu.procurement'),
children: [
{
key: '/products',
icon: <AppstoreOutlined />,
label: t('menu.productManagement')
},
{
key: '/purchase-requests',
icon: <FileTextOutlined />,
label: t('menu.purchaseRequest')
},
{
key: '/purchase-orders',
icon: <ShoppingCartOutlined />,
label: t('menu.purchaseOrder')
},
{
key: '/payment-plans',
icon: <MoneyCollectOutlined />,
label: t('menu.paymentPlan')
},
{
key: '/inventory',
icon: <InboxOutlined />,
label: t('menu.inventory')
}
]
},
{
key: 'partners',
icon: <TeamOutlined />,
label: t('menu.partners'),
children: [
{
key: '/suppliers',
icon: <ShopOutlined />,
label: t('menu.supplierManagement')
},
{
key: '/subcontractors',
icon: <SolutionOutlined />,
label: t('menu.subcontractorManagement')
},
{
key: '/customers',
icon: <HomeOutlined />,
label: t('menu.customerManagement')
},
{
key: '/logistics-companies',
icon: <CarOutlined />,
label: t('menu.logisticsManagement')
}
]
}
], [t, currentLanguage])
useEffect(() => {
const checkMobile = () => {
@@ -241,23 +241,23 @@ const MainLayout: React.FC = () => {
return () => window.removeEventListener('resize', checkMobile)
}, [])
const userMenuItems = [
const userMenuItems = useMemo(() => [
{
key: 'profile',
icon: <UserOutlined />,
label: '个人信息'
label: t('menu.profile')
},
{
key: 'settings',
icon: <SettingOutlined />,
label: '系统设置'
label: t('menu.settings')
},
...(user?.role === 'admin' ? [{
type: 'divider' as const
}, {
key: '/admin',
icon: <SettingOutlined />,
label: '后台管理'
label: t('menu.admin')
}] : []),
{
type: 'divider' as const
@@ -265,9 +265,9 @@ const MainLayout: React.FC = () => {
{
key: 'logout',
icon: <LogoutOutlined />,
label: '退出登录'
label: t('menu.logout')
}
]
], [t, currentLanguage, user?.role])
const handleMenuClick = ({ key }: { key: string }) => {
if (key === 'logout') {
@@ -313,7 +313,9 @@ const MainLayout: React.FC = () => {
path.startsWith('/inventory')) {
return ['procurement']
}
if (path.startsWith('/project-cost')) {
if (path.startsWith('/project-cost') ||
path.startsWith('/finance') ||
path.startsWith('/exchange-rates')) {
return ['finance-group']
}
return []
@@ -389,7 +391,7 @@ const MainLayout: React.FC = () => {
onClick={() => setCollapsed(!collapsed)}
style={{ width: collapsed ? '100%' : 'auto' }}
>
{!collapsed && '收起菜单'}
{!collapsed && t('menu.collapse')}
</Button>
</div>
</Sider>
@@ -446,7 +448,7 @@ const MainLayout: React.FC = () => {
<Dropdown menu={{ items: userMenuItems, onClick: handleMenuClick }} placement="bottomRight">
<Space style={{ cursor: 'pointer' }}>
<Avatar icon={<UserOutlined />} style={{ backgroundColor: '#1890ff' }} />
{!isMobile && <Text>{user?.name || user?.username || '用户'}</Text>}
{!isMobile && <Text>{user?.name || user?.username || t('user.userLabel')}</Text>}
</Space>
</Dropdown>
</Space>
@@ -462,15 +464,15 @@ const MainLayout: React.FC = () => {
</Layout>
<Modal
title="系统设置"
title={t('menu.settings')}
open={settingsVisible}
onCancel={() => setSettingsVisible(false)}
footer={null}
>
<p>...</p>
<p>{t('menu.settings')}...</p>
</Modal>
</Layout>
)
}
export default MainLayout
export default MainLayout
+137 -135
View File
@@ -1,135 +1,137 @@
import React from 'react';
import { Outlet, Navigate, useLocation } from 'react-router-dom';
import { Layout, Menu } from 'antd';
import {
UserOutlined,
SafetyOutlined,
FileTextOutlined,
DatabaseOutlined,
InfoCircleOutlined,
ArrowLeftOutlined,
SettingOutlined,
AppstoreOutlined,
AccountBookOutlined,
ImportOutlined
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
const { Sider, Content } = Layout;
const AdminLayout: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
const menuItems = [
{
key: '/admin/users',
icon: <UserOutlined />,
label: '用户管理'
},
{
key: '/admin/roles',
icon: <SafetyOutlined />,
label: '角色权限'
},
{
key: '/admin/process',
icon: <SettingOutlined />,
label: '流程管理'
},
{
key: '/admin/process-templates',
icon: <AppstoreOutlined />,
label: '工程模板管理'
},
{
key: '/admin/expense-categories',
icon: <AccountBookOutlined />,
label: '财务分类管理'
},
{
key: '/admin/excel-import',
icon: <ImportOutlined />,
label: 'Excel批量导入'
},
{
key: '/admin/logs',
icon: <FileTextOutlined />,
label: '系统日志'
},
{
key: '/admin/backup',
icon: <DatabaseOutlined />,
label: '数据备份'
},
{
key: '/admin/about',
icon: <InfoCircleOutlined />,
label: '关于系统'
}
];
return (
<Layout style={{ minHeight: '100vh' }}>
<Sider
width={220}
theme="light"
style={{
borderRight: '1px solid #f0f0f0',
background: '#fff'
}}
>
<div style={{
height: 64,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderBottom: '1px solid #f0f0f0',
background: '#1890ff',
color: '#fff',
fontWeight: 'bold',
fontSize: 16
}}>
</div>
<Menu
mode="inline"
selectedKeys={[location.pathname]}
items={menuItems}
onClick={({ key }) => navigate(key)}
style={{ borderRight: 0 }}
/>
<div style={{
position: 'absolute',
bottom: 20,
width: '100%',
padding: '0 16px'
}}>
<div
onClick={() => navigate('/dashboard')}
style={{
cursor: 'pointer',
color: '#1890ff',
display: 'flex',
alignItems: 'center',
gap: 8
}}
>
<ArrowLeftOutlined />
</div>
</div>
</Sider>
<Layout>
<Content style={{
margin: 0,
background: '#f5f5f5',
minHeight: '100vh'
}}>
<Outlet />
</Content>
</Layout>
</Layout>
);
};
export default AdminLayout;
import React from 'react';
import { Outlet, Navigate, useLocation } from 'react-router-dom';
import { Layout, Menu } from 'antd';
import {
UserOutlined,
SafetyOutlined,
FileTextOutlined,
DatabaseOutlined,
InfoCircleOutlined,
ArrowLeftOutlined,
SettingOutlined,
AppstoreOutlined,
AccountBookOutlined,
ImportOutlined
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { useLanguageStore } from '../store/languageStore';
const { Sider, Content } = Layout;
const AdminLayout: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
const { t, currentLanguage } = useLanguageStore();
const menuItems = [
{
key: '/admin/users',
icon: <UserOutlined />,
label: t('menu.userManagement')
},
{
key: '/admin/roles',
icon: <SafetyOutlined />,
label: t('menu.rolePermission')
},
{
key: '/admin/process',
icon: <SettingOutlined />,
label: t('menu.processManagement')
},
{
key: '/admin/process-templates',
icon: <AppstoreOutlined />,
label: t('menu.templateManagement')
},
{
key: '/admin/expense-categories',
icon: <AccountBookOutlined />,
label: t('menu.expenseCategory')
},
{
key: '/admin/excel-import',
icon: <ImportOutlined />,
label: t('menu.excelImport')
},
{
key: '/admin/logs',
icon: <FileTextOutlined />,
label: t('menu.systemLogs')
},
{
key: '/admin/backup',
icon: <DatabaseOutlined />,
label: t('menu.dataBackup')
},
{
key: '/admin/about',
icon: <InfoCircleOutlined />,
label: t('menu.aboutSystem')
}
];
return (
<Layout style={{ minHeight: '100vh' }}>
<Sider
width={220}
theme="light"
style={{
borderRight: '1px solid #f0f0f0',
background: '#fff'
}}
>
<div style={{
height: 64,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderBottom: '1px solid #f0f0f0',
background: '#1890ff',
color: '#fff',
fontWeight: 'bold',
fontSize: 16
}}>
{t('menu.admin')}
</div>
<Menu
mode="inline"
selectedKeys={[location.pathname]}
items={menuItems}
onClick={({ key }) => navigate(key)}
style={{ borderRight: 0 }}
/>
<div style={{
position: 'absolute',
bottom: 20,
width: '100%',
padding: '0 16px'
}}>
<div
onClick={() => navigate('/dashboard')}
style={{
cursor: 'pointer',
color: '#1890ff',
display: 'flex',
alignItems: 'center',
gap: 8
}}
>
<ArrowLeftOutlined /> {t('menu.backToFront')}
</div>
</div>
</Sider>
<Layout>
<Content style={{
margin: 0,
background: '#f5f5f5',
minHeight: '100vh'
}}>
<Outlet />
</Content>
</Layout>
</Layout>
);
};
export default AdminLayout;
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
+26 -24
View File
@@ -9,6 +9,7 @@ import {
} from '@ant-design/icons'
import apiClient from '../utils/request'
import BusinessLedgerTab from '../components/BusinessLedgerTab'
import { useLanguageStore } from '../store/languageStore'
const { Title, Text } = Typography
@@ -77,6 +78,7 @@ interface BudgetProject {
const CustomerDetail: React.FC = () => {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const { t, currentLanguage } = useLanguageStore()
const [customer, setCustomer] = useState<Customer | null>(null)
const [budgetProjects, setBudgetProjects] = useState<BudgetProject[]>([])
const [loading, setLoading] = useState(true)
@@ -109,30 +111,30 @@ const CustomerDetail: React.FC = () => {
}
if (loading) return <Spin style={{ display: 'flex', justifyContent: 'center', padding: 50 }} />
if (!customer) return <Empty description="客户不存在" style={{ marginTop: 100 }} />
if (!customer) return <Empty description={t('customer.notFound')} style={{ marginTop: 100 }} />
const budgetProjectColumns = [
{ title: '项目名称', dataIndex: 'name', key: 'name', render: (v: string, record: BudgetProject) => (
{ title: t('customer.projectName'), dataIndex: 'name', key: 'name', render: (v: string, record: BudgetProject) => (
<Text strong onClick={() => navigate(`/budget-projects/${record.id}`)} style={{ cursor: 'pointer', color: '#1890ff' }}>{v}</Text>
) },
{ title: '业务经理', dataIndex: 'manager_name', key: 'manager_name' },
{ title: '状态', dataIndex: 'status', key: 'status', align: 'center' as const, render: (v: string) => {
{ title: t('customer.businessManager'), dataIndex: 'manager_name', key: 'manager_name' },
{ title: t('common.status'), dataIndex: 'status', key: 'status', align: 'center' as const, render: (v: string) => {
const map: Record<string, { status: 'success' | 'processing' | 'error' | 'default'; text: string }> = {
negotiating: { status: 'processing', text: '商谈中' },
signed: { status: 'success', text: '已签约' },
unsigned: { status: 'error', text: '未签约' }
negotiating: { status: 'processing', text: t('customer.inNegotiation') },
signed: { status: 'success', text: t('customer.signed') },
unsigned: { status: 'error', text: t('customer.unsigned') }
}
const c = map[v] || { status: 'default', text: v }
return <Badge status={c.status} text={c.text} />
} },
{ title: '报价版本数', dataIndex: 'quotations', key: 'quotations', align: 'center' as const, render: (q: Quotation[]) => (q || []).length },
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', render: (v: string) => v?.split('T')[0] || '-' }
{ title: t('customer.quotationCount'), dataIndex: 'quotations', key: 'quotations', align: 'center' as const, render: (q: Quotation[]) => (q || []).length },
{ title: t('customer.createdAt'), dataIndex: 'created_at', key: 'created_at', render: (v: string) => v?.split('T')[0] || '-' }
]
return (
<div style={{ padding: '16px', maxWidth: 1200, margin: '0 auto' }}>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/customers')} style={{ marginBottom: 16 }} type="text">
{t('customer.returnToList')}
</Button>
<Title level={4} style={{ marginBottom: 24 }}>
@@ -143,42 +145,42 @@ const CustomerDetail: React.FC = () => {
<Card style={{ borderRadius: 8 }}>
<Tabs activeKey={activeTab} onChange={setActiveTab}>
{/* TAB1: 基本信息 */}
<Tabs.TabPane tab={<span><UserOutlined /> </span>} key="basic">
<Tabs.TabPane tab={<span><UserOutlined /> {t('customer.basicInfo')}</span>} key="basic">
<Descriptions bordered column={{ xs: 1, sm: 2 }} size="small">
<Descriptions.Item label="编号">{customer.code}</Descriptions.Item>
<Descriptions.Item label="地址">{customer.address || '-'}</Descriptions.Item>
<Descriptions.Item label={t('customer.code')}>{customer.code}</Descriptions.Item>
<Descriptions.Item label={t('customer.address')}>{customer.address || '-'}</Descriptions.Item>
</Descriptions>
{customer.remark && (
<div style={{ marginTop: 16 }}>
<Text type="secondary"></Text>
<Text type="secondary">{t('customer.remarkLabel')}</Text>
<div style={{ padding: 12, background: '#f6ffed', borderRadius: 4, border: '1px solid #b7eb8f', marginTop: 8 }}>{customer.remark}</div>
</div>
)}
</Tabs.TabPane>
{/* TAB2: 联系人 */}
<Tabs.TabPane tab={<span><PhoneOutlined /> </span>} key="contacts">
<Tabs.TabPane tab={<span><PhoneOutlined /> {t('customer.contact')}</span>} key="contacts">
<Row gutter={[16, 16]}>
{(customer.contacts || []).map((contact, i) => (
<Col key={i} xs={24} sm={12} lg={8}>
<Card size="small" style={{ borderLeft: contact.is_primary ? '3px solid #52c41a' : '3px solid #d9d9d9', background: contact.is_primary ? '#f6ffed' : '#fff' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<Text strong>{contact.name || '未命名'}</Text>
{contact.is_primary && <Tag color="green"></Tag>}
<Text strong>{contact.name || t('common.unnamed')}</Text>
{contact.is_primary && <Tag color="green">{t('customer.mainContactTag')}</Tag>}
</div>
<div style={{ color: '#666', fontSize: 13 }}>
{contact.position && <div>{contact.position}</div>}
{contact.phone && <div>{contact.phone}</div>}
{contact.position && <div>{t('customer.positionLabel')}{contact.position}</div>}
{contact.phone && <div>{t('customer.phoneLabel')}{contact.phone}</div>}
</div>
</Card>
</Col>
))}
</Row>
{(customer.contacts || []).length === 0 && <Empty description="暂无联系人" image={Empty.PRESENTED_IMAGE_SIMPLE} />}
{(customer.contacts || []).length === 0 && <Empty description={t('customer.noContact')} image={Empty.PRESENTED_IMAGE_SIMPLE} />}
</Tabs.TabPane>
{/* TAB3: 业务台账 */}
<Tabs.TabPane tab={<span><DollarOutlined /> </span>} key="ledger">
<Tabs.TabPane tab={<span><DollarOutlined /> {t('customer.ledger')}</span>} key="ledger">
<BusinessLedgerTab
partnerType="customer"
summary={customer.ledger?.summary || { item_count: 0, total_contract_amount: 0, total_received_amount: 0, total_receivable_amount: 0 }}
@@ -187,11 +189,11 @@ const CustomerDetail: React.FC = () => {
</Tabs.TabPane>
{/* TAB4: 关联预算 */}
<Tabs.TabPane tab={<span><FileTextOutlined /> </span>} key="budget">
<Tabs.TabPane tab={<span><FileTextOutlined /> {t('customer.relatedBudget')}</span>} key="budget">
{budgetProjects.length > 0 ? (
<Table columns={budgetProjectColumns} dataSource={budgetProjects} rowKey="id" size="small" pagination={{ pageSize: 10 }} bordered />
) : (
<Empty description="暂无关联预算项目" image={Empty.PRESENTED_IMAGE_SIMPLE} />
<Empty description={t('customer.noBudget')} image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</Tabs.TabPane>
</Tabs>
@@ -200,4 +202,4 @@ const CustomerDetail: React.FC = () => {
)
}
export default CustomerDetail
export default CustomerDetail
+67 -64
View File
@@ -5,6 +5,7 @@ import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, HomeOutline
import type { ColumnsType } from 'antd/es/table'
import FileUpload from '../components/FileUpload'
import useFormDraft from '../hooks/useFormDraft'
import { useLanguageStore } from '../store/languageStore'
interface Contact {
name: string
@@ -37,6 +38,7 @@ interface Customer {
const CustomerPage: React.FC = () => {
const navigate = useNavigate()
const { t, currentLanguage } = useLanguageStore()
const [customers, setCustomers] = useState<Customer[]>([])
const [loading, setLoading] = useState(false)
const [modalVisible, setModalVisible] = useState(false)
@@ -61,7 +63,7 @@ const CustomerPage: React.FC = () => {
const data = await response.json()
if (data.success) setCustomers(data.data || [])
} catch (error) {
message.error('获取客户列表失败')
message.error(t('customer.getListFailed'))
} finally {
setLoading(false)
}
@@ -87,32 +89,32 @@ const CustomerPage: React.FC = () => {
const columns: ColumnsType<Customer> = [
{
title: '名称', dataIndex: 'name', key: 'name',
title: t('customer.name'), dataIndex: 'name', key: 'name',
render: (text, record) => (
<Button type="link" style={{ padding: 0, fontWeight: 'bold' }} onClick={() => navigate(`/customers/${record.id}`)}>{text}</Button>
)
},
{ title: '地址', dataIndex: 'address', key: 'address', width: 150, render: (t) => t || '-' },
{ title: '主联系人', key: 'primary_contact', width: 100, render: (_, record) => getPrimaryContact(record.contacts || []) },
{ title: t('customer.address'), dataIndex: 'address', key: 'address', width: 150, render: (t) => t || '-' },
{ title: t('customer.mainContact'), key: 'primary_contact', width: 100, render: (_, record) => getPrimaryContact(record.contacts || []) },
{
title: '收款信息',
title: t('customer.paymentInfo'),
key: 'payment_info',
width: 200,
render: (_, record) => {
const primary = getPrimaryPaymentInfo(record.payment_infos || [])
if (!primary) return <Tag></Tag>
if (!primary) return <Tag>{t('common.notSet')}</Tag>
return (
<div style={{ fontSize: 12 }}>
<div><BankOutlined /> {primary.bank_name || '-'}</div>
<div>: {primary.account_name || '-'}</div>
<div>: {primary.bank_account ? primary.bank_account.slice(-4).padStart(primary.bank_account.length, '*') : '-'}</div>
<div>{t('customer.accountNameLabel')} {primary.account_name || '-'}</div>
<div>{t('customer.accountNumLabel')} {primary.bank_account ? primary.bank_account.slice(-4).padStart(primary.bank_account.length, '*') : '-'}</div>
</div>
)
}
},
{ title: '合同金额', dataIndex: 'total_contract_amount', key: 'total_contract_amount', width: 100, render: (v) => `¥${(v || 0).toLocaleString()}` },
{ title: '应收金额', dataIndex: 'total_receivable', key: 'total_receivable', width: 100, render: (v) => <span style={{ color: v > 0 ? '#ff4d4f' : '#52c41a' }}>¥{(v || 0).toLocaleString()}</span> },
{ title: '操作', key: 'actions', width: 100, render: (_, record) => (
{ title: t('customer.contractAmount'), dataIndex: 'total_contract_amount', key: 'total_contract_amount', width: 100, render: (v) => `¥${(v || 0).toLocaleString()}` },
{ title: t('customer.receivableAmount'), dataIndex: 'total_receivable', key: 'total_receivable', width: 100, render: (v) => <span style={{ color: v > 0 ? '#ff4d4f' : '#52c41a' }}>¥{(v || 0).toLocaleString()}</span> },
{ title: t('common.action'), key: 'actions', width: 100, render: (_, record) => (
<Space>
<Button type="text" icon={<EditOutlined />} onClick={() => handleEdit(record)} size="small" />
<Button type="text" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.id)} size="small" />
@@ -170,17 +172,17 @@ const CustomerPage: React.FC = () => {
})
const data = await response.json()
if (data.success) {
message.success(editingCustomer ? '更新成功' : '创建成功')
message.success(editingCustomer ? t('common.updateSuccess') : t('common.createSuccess'))
clearDraft()
setModalVisible(false)
form.resetFields()
setEditingCustomer(null)
fetchCustomers()
} else {
message.error(data.message || '操作失败')
message.error(data.message || t('common.operationFailed'))
}
} catch (error) {
message.error('操作失败')
message.error(t('common.operationFailed'))
}
}
@@ -198,14 +200,14 @@ const CustomerPage: React.FC = () => {
const handleDelete = async (id: number) => {
Modal.confirm({
title: '确认删除', content: '确定要删除此客户吗?', okText: '确定', cancelText: '取消',
title: t('common.confirmDelete'), content: t('customer.confirmDeleteMsg'), okText: t('common.confirm'), cancelText: t('common.cancel'),
onOk: async () => {
try {
const response = await fetch(`/api/customers/${id}`, { method: 'DELETE' })
const data = await response.json()
if (data.success) { message.success('删除成功'); fetchCustomers() }
else message.error(data.message || '删除失败')
} catch (error) { message.error('删除失败') }
if (data.success) { message.success(t('common.deleteSuccess')); fetchCustomers() }
else message.error(data.message || t('common.deleteFailed'))
} catch (error) { message.error(t('common.deleteFailed')) }
}
})
}
@@ -222,10 +224,10 @@ const CustomerPage: React.FC = () => {
setTimeout(() => {
if (hasDraft()) {
Modal.confirm({
title: '发现未完成的草稿',
content: '检测到上次未提交的客户信息,是否恢复?',
okText: '恢复草稿',
cancelText: '重新填写',
title: t('common.draftFound'),
content: t('common.draftRestore'),
okText: t('common.restoreDraft'),
cancelText: t('common.reFill'),
onOk: () => {
restoreDraft()
},
@@ -245,74 +247,75 @@ const CustomerPage: React.FC = () => {
return (
<div style={{ padding: 24 }}>
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={8}><Card><Statistic title="客户总数" value={stats.total} prefix={<HomeOutlined />} /></Card></Col>
<Col span={8}><Card><Statistic title="合同总金额" value={stats.totalContract} prefix="¥" valueStyle={{ color: '#1890ff' }} /></Card></Col>
<Col span={8}><Card><Statistic title="应收总金额" value={stats.totalReceivable} prefix="¥" valueStyle={{ color: stats.totalReceivable > 0 ? '#ff4d4f' : '#52c41a' }} /></Card></Col>
<Col span={8}><Card><Statistic title={t('customer.totalCount')} value={stats.total} prefix={<HomeOutlined />} /></Card></Col>
<Col span={8}><Card><Statistic title={t('customer.totalContract')} value={stats.totalContract} prefix="¥" valueStyle={{ color: '#1890ff' }} /></Card></Col>
<Col span={8}><Card><Statistic title={t('customer.totalReceivable')} value={stats.totalReceivable} prefix="¥" valueStyle={{ color: stats.totalReceivable > 0 ? '#ff4d4f' : '#52c41a' }} /></Card></Col>
</Row>
<Card style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Input placeholder="搜索客户编号、名称或地址" prefix={<SearchOutlined />} value={searchText} onChange={(e) => setSearchText(e.target.value)} allowClear style={{ width: 350 }} />
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}></Button>
<Input placeholder={t('customer.searchPlaceholder')} prefix={<SearchOutlined />} value={searchText} onChange={(e) => setSearchText(e.target.value)} allowClear style={{ width: 350 }} />
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>{t('customer.newCustomer')}</Button>
</div>
</Card>
<Card>
<Table columns={columns} dataSource={filteredCustomers} rowKey="id" loading={loading} pagination={{ pageSize: 10, showTotal: (total) => `${total}` }} scroll={{ x: 1100 }} />
<Table columns={columns} dataSource={filteredCustomers} rowKey="id" loading={loading} pagination={{ pageSize: 10, showTotal: (total) => t('common.totalCount', { total }) }} scroll={{ x: 1100 }} />
</Card>
<Modal
title={editingCustomer ? '编辑客户' : '新增客户'}
open={modalVisible}
<Modal
title={editingCustomer ? t('customer.editCustomer') : t('customer.newCustomer')}
open={modalVisible}
onCancel={() => {
if (form.isFieldsTouched()) {
Modal.confirm({
title: '确认关闭',
content: '表单数据尚未保存,关闭后可通过草稿恢复。确定关闭吗?',
okText: '关闭',
cancelText: '继续编辑',
title: t('common.closeConfirm'),
content: t('common.closeConfirmMsg'),
okText: t('common.close'),
cancelText: t('common.continueEdit'),
onOk: () => {
saveDraft()
form.resetFields();
setModalVisible(false)
form.resetFields()
setEditingCustomer(null)
},
})
} else {
form.resetFields();
setModalVisible(false)
form.resetFields()
setEditingCustomer(null)
}
}}
onOk={() => form.submit()}
}}
onOk={() => form.submit()}
destroyOnClose
width={800}
maskClosable={false}
>
<Form form={form} layout="vertical" onFinish={handleSubmit} onValuesChange={handleFormChange}>
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请输入名称' }]}>
<Input placeholder="客户名称" />
<Form.Item name="name" label={t('customer.name')} rules={[{ required: true, message: t('customer.nameRequired') }]}>
<Input placeholder={t('customer.namePlaceholder')} />
</Form.Item>
<Form.Item name="address" label="地址">
<Input placeholder="客户地址" />
<Form.Item name="address" label={t('customer.address')}>
<Input placeholder={t('customer.addressPlaceholder')} />
</Form.Item>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={2} placeholder="备注信息" />
<Form.Item name="remark" label={t('common.remark')}>
<Input.TextArea rows={2} placeholder={t('customer.remarkPlaceholder')} />
</Form.Item>
<h4></h4>
<h4>{t('customer.contact')}</h4>
<Form.List name="contacts" initialValue={[{ name: '', position: '', phone: '', is_primary: true }]}>
{(fields, { add, remove }) => (
<div>
{fields.map(({ key, name, ...restField }) => (
<div key={key} style={{ display: 'flex', gap: 8, marginBottom: 8, alignItems: 'center' }}>
<Form.Item {...restField} name={[name, 'name']} style={{ marginBottom: 0, flex: 1 }}>
<Input placeholder="姓名" />
<Input placeholder={t('logistics.name')} />
</Form.Item>
<Form.Item {...restField} name={[name, 'position']} style={{ marginBottom: 0, flex: 1 }}>
<Input placeholder="职位" />
<Input placeholder={t('common.position')} />
</Form.Item>
<Form.Item {...restField} name={[name, 'phone']} style={{ marginBottom: 0, flex: 1 }}>
<Input placeholder="电话" />
<Input placeholder={t('common.phone')} />
</Form.Item>
<div style={{ display: 'flex', alignItems: 'center' }}>
<Form.Item {...restField} name={[name, 'is_primary']} valuePropName="checked" style={{ marginBottom: 0, marginRight: 8 }}>
@@ -321,33 +324,33 @@ const CustomerPage: React.FC = () => {
onChange={(e) => handleContactChange(name, 'is_primary', e.target.checked)}
/>
</Form.Item>
<span></span>
<span>{t('customer.mainContact')}</span>
</div>
{fields.length > 1 && <Button type="link" danger onClick={() => remove(name)}></Button>}
{fields.length > 1 && <Button type="link" danger onClick={() => remove(name)}>{t('common.delete')}</Button>}
</div>
))}
<Button type="dashed" onClick={() => add()} style={{ width: '100%' }}>+ </Button>
<Button type="dashed" onClick={() => add()} style={{ width: '100%' }}>{t('customer.addContact')}</Button>
</div>
)}
</Form.List>
<h4 style={{ marginTop: 24 }}></h4>
<h4 style={{ marginTop: 24 }}>{t('customer.paymentInfo')}</h4>
<Form.List name="payment_infos" initialValue={[]}>
{(fields, { add, remove }) => (
<div>
{fields.map(({ key, name, ...restField }) => (
<div key={key} style={{ border: '1px solid #e8e8e8', padding: 16, marginBottom: 16, borderRadius: 4, backgroundColor: '#fafafa' }}>
<div style={{ display: 'flex', gap: 8, marginBottom: 8, alignItems: 'center' }}>
<Form.Item {...restField} name={[name, 'account_name']} label="收款户名" style={{ marginBottom: 0, flex: 1 }}>
<Input placeholder="收款户名" />
<Form.Item {...restField} name={[name, 'account_name']} label={t('customer.accountName')} style={{ marginBottom: 0, flex: 1 }}>
<Input placeholder={t('customer.accountName')} />
</Form.Item>
<Form.Item {...restField} name={[name, 'bank_name']} label="开户银行" style={{ marginBottom: 0, flex: 1 }}>
<Input placeholder="开户银行" />
<Form.Item {...restField} name={[name, 'bank_name']} label={t('customer.bankName')} style={{ marginBottom: 0, flex: 1 }}>
<Input placeholder={t('customer.bankName')} />
</Form.Item>
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 8, alignItems: 'center' }}>
<Form.Item {...restField} name={[name, 'bank_account']} label="银行账号" style={{ marginBottom: 0, flex: 1 }}>
<Input placeholder="银行账号" />
<Form.Item {...restField} name={[name, 'bank_account']} label={t('customer.bankAccount')} style={{ marginBottom: 0, flex: 1 }}>
<Input placeholder={t('customer.bankAccount')} />
</Form.Item>
<div style={{ display: 'flex', alignItems: 'center', marginTop: 30 }}>
<Form.Item {...restField} name={[name, 'is_primary']} valuePropName="checked" style={{ marginBottom: 0, marginRight: 8 }}>
@@ -356,19 +359,19 @@ const CustomerPage: React.FC = () => {
onChange={(e) => handlePaymentInfoChange(name, 'is_primary', e.target.checked)}
/>
</Form.Item>
<span></span>
<span>{t('customer.mainAccount')}</span>
</div>
</div>
<Form.Item {...restField} name={[name, 'qr_code']} label="收款码" style={{ marginBottom: 0 }}>
<Form.Item {...restField} name={[name, 'qr_code']} label={t('customer.qrCode')} style={{ marginBottom: 0 }}>
<FileUpload maxCount={1} accept="image/*" />
</Form.Item>
{fields.length > 0 && (
<Button type="link" danger onClick={() => remove(name)} style={{ marginTop: 8 }}></Button>
<Button type="link" danger onClick={() => remove(name)} style={{ marginTop: 8 }}>{t('customer.deletePaymentInfo')}</Button>
)}
</div>
))}
<Button type="dashed" onClick={() => add({ account_name: '', bank_account: '', bank_name: '', is_primary: false })} style={{ width: '100%' }}>
+
{t('customer.addPaymentInfo')}
</Button>
</div>
)}
@@ -379,4 +382,4 @@ const CustomerPage: React.FC = () => {
)
}
export default CustomerPage
export default CustomerPage
+378 -380
View File
@@ -1,380 +1,378 @@
import React, { useState, useEffect } from 'react';
import { Card, Row, Col, InputNumber, message, Typography, Divider, Spin, Button, Table, Space, Tag } from 'antd';
import { CheckOutlined, HistoryOutlined } from '@ant-design/icons';
import apiClient from '../utils/request';
import dayjs from 'dayjs';
const { Text, Title } = Typography;
const RATE_PAIRS = [
{ key: 'CNY_LAK', label: '中老汇率', from: 'CNY', to: 'LAK', fromLabel: '人民币', toLabel: '老挝基普' },
{ key: 'CNY_USD', label: '中美汇率', from: 'CNY', to: 'USD', fromLabel: '人民币', toLabel: '美元' },
{ key: 'CNY_THB', label: '中泰汇率', from: 'CNY', to: 'THB', fromLabel: '人民币', toLabel: '泰铢' },
{ key: 'USD_LAK', label: '美老汇率', from: 'USD', to: 'LAK', fromLabel: '美元', toLabel: '老挝基普' },
{ key: 'THB_LAK', label: '泰老汇率', from: 'THB', to: 'LAK', fromLabel: '泰铢', toLabel: '老挝基普' },
];
interface RateItem {
leftValue: number;
rightValue: number;
actualRate: number;
}
interface HistoryRate {
id: number;
pair_key: string;
rate: number;
effective_date: string;
created_at: string;
created_by_name?: string;
}
const ExchangeRatePage: React.FC = () => {
const [rates, setRates] = useState<Record<string, RateItem>>({});
const [initialRates, setInitialRates] = useState<Record<string, number>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [isMobile, setIsMobile] = useState(false);
const [historyRates, setHistoryRates] = useState<HistoryRate[]>([]);
const [lastUpdateTime, setLastUpdateTime] = useState<string>('');
useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth <= 768);
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
useEffect(() => {
fetchRates();
fetchHistory();
}, []);
const fetchRates = async () => {
setLoading(true);
try {
const res = await apiClient.get('/exchange-rates/latest');
if (res.data.success) {
const data = res.data.data;
const newRates: Record<string, RateItem> = {};
const newInitialRates: Record<string, number> = {};
RATE_PAIRS.forEach(pair => {
const rate = parseFloat(data[pair.key]) || 1;
newRates[pair.key] = { leftValue: 1, rightValue: rate, actualRate: rate };
newInitialRates[pair.key] = rate;
});
setRates(newRates);
setInitialRates(newInitialRates);
if (res.data.updated_at) {
setLastUpdateTime(res.data.updated_at);
}
}
} catch (error) {
message.error('获取汇率失败');
const defaultRates: Record<string, RateItem> = {};
const defaultInitialRates: Record<string, number> = {};
RATE_PAIRS.forEach(pair => {
const defaultRate = pair.key === 'CNY_LAK' ? 3000 : pair.key === 'CNY_USD' ? 0.14 : pair.key === 'CNY_THB' ? 4.5 : pair.key === 'USD_LAK' ? 21000 : 670;
defaultRates[pair.key] = { leftValue: 1, rightValue: defaultRate, actualRate: defaultRate };
defaultInitialRates[pair.key] = defaultRate;
});
setRates(defaultRates);
setInitialRates(defaultInitialRates);
} finally {
setLoading(false);
}
};
const fetchHistory = async () => {
try {
const res = await apiClient.get('/exchange-rates/history?limit=20');
if (res.data.success) {
setHistoryRates(res.data.data);
}
} catch (error) {
console.error('获取历史汇率失败:', error);
}
};
// 左侧输入 - 右侧自动变为1,重新计算汇率
const handleLeftChange = (key: string, value: number | null) => {
if (value === null || value <= 0) return;
const pair = RATE_PAIRS.find(p => p.key === key);
if (!pair) return;
// 当左侧输入值时,右侧变为1,计算新的汇率
const newRate = 1 / value;
setRates(prev => ({
...prev,
[key]: {
leftValue: value,
rightValue: 1,
actualRate: newRate
}
}));
};
// 右侧输入 - 左侧自动变为1,重新计算汇率
const handleRightChange = (key: string, value: number | null) => {
if (value === null || value <= 0) return;
const pair = RATE_PAIRS.find(p => p.key === key);
if (!pair) return;
// 当右侧输入值时,左侧变为1,计算新的汇率
const newRate = value;
setRates(prev => ({
...prev,
[key]: {
leftValue: 1,
rightValue: value,
actualRate: newRate
}
}));
};
// 计算实际汇率显示
const getActualRateDisplay = (key: string) => {
const item = rates[key];
if (!item) return '1 : 1.00';
const pair = RATE_PAIRS.find(p => p.key === key);
const actualRate = item.actualRate;
// 根据汇率对选择合适的小数位数
const decimalPlaces = pair?.key === 'CNY_USD' ? 5 : 2;
return `1 ${pair?.from} = ${actualRate.toFixed(decimalPlaces)} ${pair?.to}`;
};
// 确认保存
const handleConfirm = async () => {
setSaving(true);
try {
const savePromises = RATE_PAIRS.map(pair => {
const item = rates[pair.key];
if (!item) return null;
const actualRate = item.rightValue / item.leftValue;
const initialRate = initialRates[pair.key];
// 只保存有变化的汇率
if (Math.abs(actualRate - initialRate) < 0.0001) {
return null;
}
return apiClient.post('/exchange-rates', {
pair_key: pair.key,
rate: actualRate,
effective_date: dayjs().format('YYYY-MM-DD')
});
});
const validPromises = savePromises.filter(Boolean) as Promise<any>[];
if (validPromises.length === 0) {
message.info('没有汇率发生变化');
setSaving(false);
return;
}
await Promise.all(validPromises);
message.success('汇率保存成功');
setLastUpdateTime(dayjs().format('YYYY-MM-DD HH:mm:ss'));
fetchHistory();
// 更新初始汇率为当前汇率
const newInitialRates: Record<string, number> = {};
RATE_PAIRS.forEach(pair => {
const item = rates[pair.key];
if (item) {
newInitialRates[pair.key] = item.rightValue / item.leftValue;
}
});
setInitialRates(newInitialRates);
} catch (error) {
message.error('保存汇率失败');
} finally {
setSaving(false);
}
};
// 历史汇率表格列
const historyColumns = [
{
title: '汇率对',
dataIndex: 'from_currency',
key: 'from_currency',
render: (_: string, record: HistoryRate) => {
const pairKey = `${record.from_currency}_${record.to_currency}`;
const pair = RATE_PAIRS.find(p => p.key === pairKey);
return pair?.label || pairKey;
}
},
{
title: '汇率',
dataIndex: 'rate',
key: 'rate',
render: (rate: number, record: HistoryRate) => {
const pairKey = `${record.from_currency}_${record.to_currency}`;
const pair = RATE_PAIRS.find(p => p.key === pairKey);
return `1 ${record.from_currency} = ${parseFloat(rate).toFixed(pair?.key === 'CNY_USD' ? 4 : 2)} ${record.to_currency}`;
}
},
{
title: '生效日期',
dataIndex: 'effective_date',
key: 'effective_date',
render: (date: string) => dayjs(date).format('YYYY-MM-DD')
},
{
title: '设置时间',
dataIndex: 'created_at',
key: 'created_at',
render: (time: string) => dayjs(time).format('YYYY-MM-DD HH:mm')
},
{
title: '设置人',
dataIndex: 'created_by_name',
key: 'created_by_name',
render: (name: string) => name || '-'
}
];
if (loading) {
return <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: 400 }}><Spin size="large" /></div>;
}
return (
<div style={{ padding: isMobile ? 8 : 24 }}>
<div style={{ marginBottom: isMobile ? 12 : 24 }}>
<Title level={2} style={{ marginBottom: 8 }}></Title>
<Space>
<Text type="secondary"></Text>
{lastUpdateTime && (
<Tag color="blue">: {lastUpdateTime}</Tag>
)}
</Space>
</div>
<Row gutter={[16, 16]}>
{RATE_PAIRS.map(pair => {
const item = rates[pair.key];
if (!item) return null;
return (
<Col xs={24} sm={12} lg={8} key={pair.key}>
<Card title={pair.label} size="small" style={{ background: '#fafafa' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
<div style={{ flex: 1 }}>
<div style={{ marginBottom: 4, fontSize: 12, color: '#888' }}>{pair.fromLabel}</div>
<InputNumber
style={{
width: '100%',
borderColor: '#d9d9d9',
'&:hover': {
borderColor: '#1890ff',
},
'&:focus': {
borderColor: '#1890ff',
boxShadow: '0 0 0 2px rgba(24, 144, 255, 0.2)',
}
}}
value={item.leftValue}
onChange={(v) => handleLeftChange(pair.key, v)}
precision={6}
size="large"
min={0.000001}
onFocus={(e) => {
if (e.target && e.target.select) {
e.target.select();
}
}}
placeholder={`输入${pair.fromLabel}金额`}
/>
</div>
<div style={{ padding: '20px 8px 0', fontSize: 18, color: '#1890ff', fontWeight: 'bold' }}>=</div>
<div style={{ flex: 1 }}>
<div style={{ marginBottom: 4, fontSize: 12, color: '#888' }}>{pair.toLabel}</div>
<InputNumber
style={{
width: '100%',
borderColor: '#d9d9d9',
'&:hover': {
borderColor: '#1890ff',
},
'&:focus': {
borderColor: '#1890ff',
boxShadow: '0 0 0 2px rgba(24, 144, 255, 0.2)',
}
}}
value={item.rightValue}
onChange={(v) => handleRightChange(pair.key, v)}
precision={pair.key === 'CNY_USD' ? 4 : 2}
size="large"
min={0.000001}
onFocus={(e) => {
if (e.target && e.target.select) {
e.target.select();
}
}}
placeholder={`输入${pair.toLabel}金额`}
/>
</div>
</div>
<Divider style={{ margin: '12px 0' }} />
<div style={{ textAlign: 'center' }}>
<Text type="secondary" style={{ fontSize: 13 }}>
: {getActualRateDisplay(pair.key)}
</Text>
</div>
</Card>
</Col>
);
})}
</Row>
{/* 确认按钮 */}
<div style={{ marginTop: 24, textAlign: 'center' }}>
<Button
type="primary"
size="large"
icon={<CheckOutlined />}
onClick={handleConfirm}
loading={saving}
style={{ minWidth: 200 }}
>
</Button>
</div>
{/* 历史汇率表 */}
<Card
title={
<Space>
<HistoryOutlined />
<span></span>
</Space>
}
style={{ marginTop: 24 }}
>
<Table
dataSource={historyRates}
columns={historyColumns}
rowKey="id"
pagination={{ pageSize: 10 }}
size="small"
/>
</Card>
<Card style={{ marginTop: 16, background: '#fffbe6', borderColor: '#ffe58f' }}>
<Text type="warning">
1 = X右侧币种"确认保存汇率"
</Text>
</Card>
</div>
);
};
export default ExchangeRatePage;
import React, { useState, useEffect, useMemo } from 'react';
import { Card, Row, Col, InputNumber, message, Typography, Divider, Spin, Button, Table, Space, Tag } from 'antd';
import { CheckOutlined, HistoryOutlined } from '@ant-design/icons';
import apiClient from '../utils/request';
import dayjs from 'dayjs';
import { useLanguageStore } from '../store/languageStore';
const { Text, Title } = Typography;
const RATE_PAIR_KEYS = [
{ key: 'CNY_LAK', i18nKey: 'CNYLAK', from: 'CNY', to: 'LAK', fromI18n: 'CNY', toI18n: 'LAK' },
{ key: 'CNY_USD', i18nKey: 'CNYUSD', from: 'CNY', to: 'USD', fromI18n: 'CNY', toI18n: 'USD' },
{ key: 'CNY_THB', i18nKey: 'CNYTHB', from: 'CNY', to: 'THB', fromI18n: 'CNY', toI18n: 'THB' },
{ key: 'USD_LAK', i18nKey: 'USDLAK', from: 'USD', to: 'LAK', fromI18n: 'USD', toI18n: 'LAK' },
{ key: 'THB_LAK', i18nKey: 'THBLAK', from: 'THB', to: 'LAK', fromI18n: 'THB', toI18n: 'LAK' },
];
interface RateItem {
leftValue: number;
rightValue: number;
actualRate: number;
}
interface HistoryRate {
id: number;
pair_key: string;
rate: number;
effective_date: string;
created_at: string;
created_by_name?: string;
}
const ExchangeRatePage: React.FC = () => {
const { t, currentLanguage } = useLanguageStore();
const RATE_PAIRS = useMemo(() => RATE_PAIR_KEYS.map(p => ({
...p,
label: t(`exchangeRate.${p.i18nKey}`),
fromLabel: t(`exchangeRate.${p.fromI18n}`),
toLabel: t(`exchangeRate.${p.toI18n}`),
})), [t]);
const [rates, setRates] = useState<Record<string, RateItem>>({});
const [initialRates, setInitialRates] = useState<Record<string, number>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [isMobile, setIsMobile] = useState(false);
const [historyRates, setHistoryRates] = useState<HistoryRate[]>([]);
const [lastUpdateTime, setLastUpdateTime] = useState<string>('');
useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth <= 768);
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
useEffect(() => {
fetchRates();
fetchHistory();
}, []);
const fetchRates = async () => {
setLoading(true);
try {
const res = await apiClient.get('/exchange-rates/latest');
if (res.data.success) {
const data = res.data.data;
const newRates: Record<string, RateItem> = {};
const newInitialRates: Record<string, number> = {};
RATE_PAIRS.forEach(pair => {
const rate = parseFloat(data[pair.key]) || 1;
newRates[pair.key] = { leftValue: 1, rightValue: rate, actualRate: rate };
newInitialRates[pair.key] = rate;
});
setRates(newRates);
setInitialRates(newInitialRates);
if (res.data.updated_at) {
setLastUpdateTime(res.data.updated_at);
}
}
} catch (error) {
message.error(t('exchangeRate.getRateFailed'));
const defaultRates: Record<string, RateItem> = {};
const defaultInitialRates: Record<string, number> = {};
RATE_PAIRS.forEach(pair => {
const defaultRate = pair.key === 'CNY_LAK' ? 3000 : pair.key === 'CNY_USD' ? 0.14 : pair.key === 'CNY_THB' ? 4.5 : pair.key === 'USD_LAK' ? 21000 : 670;
defaultRates[pair.key] = { leftValue: 1, rightValue: defaultRate, actualRate: defaultRate };
defaultInitialRates[pair.key] = defaultRate;
});
setRates(defaultRates);
setInitialRates(defaultInitialRates);
} finally {
setLoading(false);
}
};
const fetchHistory = async () => {
try {
const res = await apiClient.get('/exchange-rates/history?limit=20');
if (res.data.success) {
setHistoryRates(res.data.data);
}
} catch (error) {
console.error('获取历史汇率失败:', error);
}
};
const handleLeftChange = (key: string, value: number | null) => {
if (value === null || value <= 0) return;
const pair = RATE_PAIRS.find(p => p.key === key);
if (!pair) return;
const newRate = 1 / value;
setRates(prev => ({
...prev,
[key]: {
leftValue: value,
rightValue: 1,
actualRate: newRate
}
}));
};
const handleRightChange = (key: string, value: number | null) => {
if (value === null || value <= 0) return;
const pair = RATE_PAIRS.find(p => p.key === key);
if (!pair) return;
const newRate = value;
setRates(prev => ({
...prev,
[key]: {
leftValue: 1,
rightValue: value,
actualRate: newRate
}
}));
};
const getActualRateDisplay = (key: string) => {
const item = rates[key];
if (!item) return '1 : 1.00';
const pair = RATE_PAIRS.find(p => p.key === key);
const actualRate = item.actualRate;
const decimalPlaces = pair?.key === 'CNY_USD' ? 5 : 2;
return `1 ${pair?.from} = ${actualRate.toFixed(decimalPlaces)} ${pair?.to}`;
};
const handleConfirm = async () => {
setSaving(true);
try {
const savePromises = RATE_PAIRS.map(pair => {
const item = rates[pair.key];
if (!item) return null;
const actualRate = item.rightValue / item.leftValue;
const initialRate = initialRates[pair.key];
if (Math.abs(actualRate - initialRate) < 0.0001) {
return null;
}
return apiClient.post('/exchange-rates', {
pair_key: pair.key,
rate: actualRate,
effective_date: dayjs().format('YYYY-MM-DD')
});
});
const validPromises = savePromises.filter(Boolean) as Promise<any>[];
if (validPromises.length === 0) {
message.info(t('exchangeRate.noChange'));
setSaving(false);
return;
}
await Promise.all(validPromises);
message.success(t('exchangeRate.saveSuccess'));
setLastUpdateTime(dayjs().format('YYYY-MM-DD HH:mm:ss'));
fetchHistory();
const newInitialRates: Record<string, number> = {};
RATE_PAIRS.forEach(pair => {
const item = rates[pair.key];
if (item) {
newInitialRates[pair.key] = item.rightValue / item.leftValue;
}
});
setInitialRates(newInitialRates);
} catch (error) {
message.error(t('exchangeRate.saveFailed'));
} finally {
setSaving(false);
}
};
const historyColumns = [
{
title: t('exchangeRate.ratePair'),
dataIndex: 'from_currency',
key: 'from_currency',
render: (_: string, record: HistoryRate) => {
const pairKey = `${record.from_currency}_${record.to_currency}`;
const pair = RATE_PAIRS.find(p => p.key === pairKey);
return pair?.label || pairKey;
}
},
{
title: t('exchangeRate.rate'),
dataIndex: 'rate',
key: 'rate',
render: (rate: number, record: HistoryRate) => {
const pairKey = `${record.from_currency}_${record.to_currency}`;
const pair = RATE_PAIRS.find(p => p.key === pairKey);
return `1 ${record.from_currency} = ${parseFloat(rate).toFixed(pair?.key === 'CNY_USD' ? 4 : 2)} ${record.to_currency}`;
}
},
{
title: t('exchangeRate.effectiveDate'),
dataIndex: 'effective_date',
key: 'effective_date',
render: (date: string) => dayjs(date).format('YYYY-MM-DD')
},
{
title: t('exchangeRate.setTime'),
dataIndex: 'created_at',
key: 'created_at',
render: (time: string) => dayjs(time).format('YYYY-MM-DD HH:mm')
},
{
title: t('exchangeRate.setBy'),
dataIndex: 'created_by_name',
key: 'created_by_name',
render: (name: string) => name || '-'
}
];
if (loading) {
return <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: 400 }}><Spin size="large" /></div>;
}
return (
<div style={{ padding: isMobile ? 8 : 24 }}>
<div style={{ marginBottom: isMobile ? 12 : 24 }}>
<Title level={2} style={{ marginBottom: 8 }}>{t('exchangeRate.title')}</Title>
<Space>
<Text type="secondary">{t('exchangeRate.description')}</Text>
{lastUpdateTime && (
<Tag color="blue">{t('exchangeRate.lastUpdated')}{lastUpdateTime}</Tag>
)}
</Space>
</div>
<Row gutter={[16, 16]}>
{RATE_PAIRS.map(pair => {
const item = rates[pair.key];
if (!item) return null;
return (
<Col xs={24} sm={12} lg={8} key={pair.key}>
<Card title={pair.label} size="small" style={{ background: '#fafafa' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
<div style={{ flex: 1 }}>
<div style={{ marginBottom: 4, fontSize: 12, color: '#888' }}>{pair.fromLabel}</div>
<InputNumber
style={{
width: '100%',
borderColor: '#d9d9d9',
'&:hover': {
borderColor: '#1890ff',
},
'&:focus': {
borderColor: '#1890ff',
boxShadow: '0 0 0 2px rgba(24, 144, 255, 0.2)',
}
}}
value={item.leftValue}
onChange={(v) => handleLeftChange(pair.key, v)}
precision={6}
size="large"
min={0.000001}
onFocus={(e) => {
if (e.target && e.target.select) {
e.target.select();
}
}}
placeholder={t('exchangeRate.inputFrom', { from: pair.fromLabel })}
/>
</div>
<div style={{ padding: '20px 8px 0', fontSize: 18, color: '#1890ff', fontWeight: 'bold' }}>=</div>
<div style={{ flex: 1 }}>
<div style={{ marginBottom: 4, fontSize: 12, color: '#888' }}>{pair.toLabel}</div>
<InputNumber
style={{
width: '100%',
borderColor: '#d9d9d9',
'&:hover': {
borderColor: '#1890ff',
},
'&:focus': {
borderColor: '#1890ff',
boxShadow: '0 0 0 2px rgba(24, 144, 255, 0.2)',
}
}}
value={item.rightValue}
onChange={(v) => handleRightChange(pair.key, v)}
precision={pair.key === 'CNY_USD' ? 4 : 2}
size="large"
min={0.000001}
onFocus={(e) => {
if (e.target && e.target.select) {
e.target.select();
}
}}
placeholder={t('exchangeRate.inputTo', { to: pair.toLabel })}
/>
</div>
</div>
<Divider style={{ margin: '12px 0' }} />
<div style={{ textAlign: 'center' }}>
<Text type="secondary" style={{ fontSize: 13 }}>
{t('exchangeRate.actualRate')}{getActualRateDisplay(pair.key)}
</Text>
</div>
</Card>
</Col>
);
})}
</Row>
<div style={{ marginTop: 24, textAlign: 'center' }}>
<Button
type="primary"
size="large"
icon={<CheckOutlined />}
onClick={handleConfirm}
loading={saving}
style={{ minWidth: 200 }}
>
{t('exchangeRate.confirmSave')}
</Button>
</div>
<Card
title={
<Space>
<HistoryOutlined />
<span>{t('exchangeRate.historyRate')}</span>
</Space>
}
style={{ marginTop: 24 }}
>
<Table
dataSource={historyRates}
columns={historyColumns}
rowKey="id"
pagination={{ pageSize: 10 }}
size="small"
/>
</Card>
<Card style={{ marginTop: 16, background: '#fffbe6', borderColor: '#ffe58f' }}>
<Text type="warning">
{t('exchangeRate.tipText')}
</Text>
</Card>
</div>
);
};
export default ExchangeRatePage;
+483 -328
View File
@@ -1,289 +1,116 @@
import React, { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import {
Table, Button, Modal, Form, Input, Select, message, Space, Tag, Card,
Row, Col, Statistic, DatePicker, InputNumber, Tabs
Row, Col, DatePicker, InputNumber, Tabs, Statistic, Descriptions, Badge, Tooltip, Divider
} from 'antd'
import {
PlusOutlined, SearchOutlined, InboxOutlined, ExportOutlined
PlusOutlined, EditOutlined, EditFilled, DeleteOutlined, EyeOutlined,
CheckOutlined, WarningOutlined, ArrowUpOutlined, ArrowDownOutlined
} from '@ant-design/icons'
import type { ColumnsType } from 'antd/es/table'
import dayjs from 'dayjs'
import { useLanguageStore } from '../store/languageStore'
// ==================== 类型定义 ====================
interface InventoryRecord {
interface InventoryItem {
id: number
record_type: string
project_id?: number | null
project_name?: string
purchase_request_id?: number | null
product_id: number
product_name?: string
quantity: number
unit_price?: number | null
total_amount?: number | null
record_date: string
operator?: string | null
remark?: string | null
created_at: string
}
interface InventorySummary {
product_id: number
product_name: string
unit?: string | null
total_in: number
total_out: number
current_quantity: number
product_code: string
product_category: string
product_specification: string
product_unit: string
stock_quantity: number
safety_stock: number
locked_quantity: number
last_in_date: string
last_out_date: string
}
interface InventoryLog {
id: number
product_id: number
product_name: string
type: string
quantity: number
before_quantity: number
after_quantity: number
purchase_order_id: number
order_code: string
remark: string
operator: string
created_at: string
}
interface Product {
id: number
name: string
code: string
category: string
specification: string
unit: string
stock_quantity: number
safety_stock: number
}
interface Project {
id: number
name: string
}
// ==================== 组件 ====================
const InventoryPage: React.FC = () => {
// 状态
const [records, setRecords] = useState<InventoryRecord[]>([])
const [summary, setSummary] = useState<InventorySummary[]>([])
const { t, currentLanguage } = useLanguageStore();
const [inventory, setInventory] = useState<InventoryItem[]>([])
const [logs, setLogs] = useState<InventoryLog[]>([])
const [loading, setLoading] = useState(false)
const [logLoading, setLogLoading] = useState(false)
const [products, setProducts] = useState<Product[]>([])
const [projects, setProjects] = useState<Project[]>([])
// 筛选状态
const [selectedProductId, setSelectedProductId] = useState<number | null>(null)
const [selectedProjectId, setSelectedProjectId] = useState<number | null>(null)
const [selectedRecordType, setSelectedRecordType] = useState<string | null>(null)
const [selectedCategory, setSelectedCategory] = useState<string | null>(null)
const [stockFilter, setStockFilter] = useState<string>('all')
const [activeTab, setActiveTab] = useState('inventory')
// 弹窗状态
const [inModalVisible, setInModalVisible] = useState(false)
const [outModalVisible, setOutModalVisible] = useState(false)
const [form] = Form.useForm()
const [inForm] = Form.useForm()
const [outForm] = Form.useForm()
// Tab状态
const [activeTab, setActiveTab] = useState('records')
// ==================== 渲染 ====================
const getRecordTypeTag = (type: string) => {
if (type === 'in') {
return <Tag color="green" icon={<InboxOutlined />}></Tag>
} else {
return <Tag color="orange" icon={<ExportOutlined />}></Tag>
}
}
const recordColumns: ColumnsType<InventoryRecord> = [
{
title: '记录类型',
dataIndex: 'record_type',
key: 'record_type',
width: 100,
render: getRecordTypeTag
},
{
title: '商品',
dataIndex: 'product_name',
key: 'product_name'
},
{
title: '项目',
dataIndex: 'project_name',
key: 'project_name'
},
{
title: '数量',
dataIndex: 'quantity',
key: 'quantity',
width: 100
},
{
title: '单价',
dataIndex: 'unit_price',
key: 'unit_price',
width: 120,
render: (price) => price ? price.toFixed(2) : '-'
},
{
title: '总金额',
dataIndex: 'total_amount',
key: 'total_amount',
width: 120,
render: (amount) => amount ? amount.toFixed(2) : '-'
},
{
title: '记录日期',
dataIndex: 'record_date',
key: 'record_date',
width: 120
},
{
title: '操作人',
dataIndex: 'operator',
key: 'operator',
width: 120
},
{
title: '备注',
dataIndex: 'remark',
key: 'remark'
}
]
const summaryColumns: ColumnsType<InventorySummary> = [
{
title: '商品',
dataIndex: 'product_name',
key: 'product_name'
},
{
title: '单位',
dataIndex: 'unit',
key: 'unit',
width: 80
},
{
title: '入库总量',
dataIndex: 'total_in',
key: 'total_in',
width: 120,
render: (val) => (val || 0).toFixed(2)
},
{
title: '出库总量',
dataIndex: 'total_out',
key: 'total_out',
width: 120,
render: (val) => (val || 0).toFixed(2)
},
{
title: '当前库存',
dataIndex: 'current_quantity',
key: 'current_quantity',
width: 120,
render: (val) => (
<span style={{ fontWeight: 'bold', color: (val || 0) < 0 ? '#ff4d4f' : '#52c41a' }}>
{(val || 0).toFixed(2)}
</span>
)
}
]
const tabItems = [
{
key: 'records',
label: '库存记录',
children: (
<>
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={6}>
<Select
placeholder="选择商品筛选"
allowClear
style={{ width: '100%' }}
onChange={(value) => setSelectedProductId(value)}
>
{products.map(product => (
<Select.Option key={product.id} value={product.id}>
{product.name}
</Select.Option>
))}
</Select>
</Col>
<Col span={6}>
<Select
placeholder="选择项目筛选"
allowClear
style={{ width: '100%' }}
onChange={(value) => setSelectedProjectId(value)}
>
{projects.map(project => (
<Select.Option key={project.id} value={project.id}>
{project.name}
</Select.Option>
))}
</Select>
</Col>
<Col span={6}>
<Select
placeholder="选择记录类型"
allowClear
style={{ width: '100%' }}
onChange={(value) => setSelectedRecordType(value)}
>
<Select.Option value="in"></Select.Option>
<Select.Option value="out"></Select.Option>
</Select>
</Col>
<Col span={6} style={{ textAlign: 'right' }}>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setOutModalVisible(true)}>
</Button>
</Col>
</Row>
<Table
columns={recordColumns}
dataSource={records}
rowKey="id"
loading={loading}
/>
</>
)
},
{
key: 'summary',
label: '库存汇总',
children: (
<Table
columns={summaryColumns}
dataSource={summary}
rowKey="product_id"
loading={loading}
/>
)
}
]
// ==================== 数据加载 ====================
const fetchRecords = async () => {
const navigate = useNavigate()
const fetchInventory = async () => {
setLoading(true)
try {
const params = new URLSearchParams()
if (selectedProductId) params.append('product_id', selectedProductId.toString())
if (selectedProjectId) params.append('project_id', selectedProjectId.toString())
if (selectedRecordType) params.append('record_type', selectedRecordType)
if (selectedCategory) params.append('category', selectedCategory)
if (stockFilter && stockFilter !== 'all') params.append('stock_filter', stockFilter)
const response = await fetch(`/api/inventory?${params}`)
const data = await response.json()
if (data.success) {
setRecords(data.data)
setInventory(data.data)
} else {
message.error('获取库存记录失败')
message.error(t('inventory.getListFailed'))
}
} catch (error) {
console.error('获取库存记录失败:', error)
message.error('获取库存记录失败')
console.error('获取库存列表失败:', error)
message.error(t('inventory.getListFailed'))
} finally {
setLoading(false)
}
}
const fetchSummary = async () => {
const fetchLogs = async () => {
setLogLoading(true)
try {
const response = await fetch('/api/inventory/summary')
const response = await fetch('/api/inventory?limit=100')
const data = await response.json()
if (data.success) {
setSummary(data.data)
setLogs(data.data)
} else {
message.error(t('inventory.getLogFailed'))
}
} catch (error) {
console.error('获取库存汇总失败:', error)
console.error('获取库存流水失败:', error)
message.error(t('inventory.getLogFailed'))
} finally {
setLogLoading(false)
}
}
@@ -295,127 +122,455 @@ const InventoryPage: React.FC = () => {
setProducts(data.data)
}
} catch (error) {
console.error('获取品列表失败:', error)
}
}
const fetchProjects = async () => {
try {
const response = await fetch('/api/projects')
const data = await response.json()
if (data.success) {
setProjects(data.data)
}
} catch (error) {
console.error('获取项目列表失败:', error)
console.error('获取品列表失败:', error)
}
}
useEffect(() => {
fetchProducts()
fetchProjects()
}, [])
useEffect(() => {
if (activeTab === 'records') {
fetchRecords()
} else {
fetchSummary()
}
}, [activeTab, selectedProductId, selectedProjectId, selectedRecordType])
fetchInventory()
}, [selectedCategory, stockFilter])
// ==================== 操作函数 ====================
useEffect(() => {
fetchLogs()
}, [activeTab])
const handleOutModalOk = async () => {
const handleStockIn = () => {
inForm.resetFields()
inForm.setFieldsValue({ type: 'in', date: dayjs() })
setInModalVisible(true)
}
const handleStockOut = () => {
outForm.resetFields()
outForm.setFieldsValue({ type: 'out', date: dayjs() })
setOutModalVisible(true)
}
const handleSaveStockIn = async () => {
try {
const values = await form.validateFields()
const values = await inForm.validateFields()
const data = {
...values,
date: values.date.format('YYYY-MM-DD HH:mm:ss')
}
const response = await fetch('/api/inventory/in', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
})
const result = await response.json()
if (result.success) {
message.success(t('inventory.stockInSuccess'))
setInModalVisible(false)
fetchInventory()
} else {
message.error(result.error || t('common.operationFailed'))
}
} catch (error) {
console.error('入库失败:', error)
message.error(t('inventory.stockInFailed'))
}
}
const handleSaveStockOut = async () => {
try {
const values = await outForm.validateFields()
const data = {
...values,
date: values.date.format('YYYY-MM-DD HH:mm:ss')
}
const response = await fetch('/api/inventory/out', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...values,
operator: '系统管理员'
})
body: JSON.stringify(data)
})
const result = await response.json()
const data = await response.json()
if (data.success) {
message.success('出库成功')
if (result.success) {
message.success(t('inventory.stockOutSuccess'))
setOutModalVisible(false)
form.resetFields()
fetchRecords()
fetchSummary()
fetchInventory()
} else {
message.error('出库失败')
message.error(result.error || t('common.operationFailed'))
}
} catch (error) {
console.error('出库失败:', error)
message.error('出库失败')
message.error(t('inventory.stockOutFailed'))
}
}
const getStockStatus = (item: InventoryItem) => {
if (item.stock_quantity <= 0) return 'out_of_stock'
if (item.stock_quantity <= item.safety_stock) return 'low_stock'
return 'normal'
}
const columns: ColumnsType<InventoryItem> = [
{
title: t('inventory.productCode'),
dataIndex: 'product_code',
key: 'product_code',
width: 110
},
{
title: t('inventory.productName'),
dataIndex: 'product_name',
key: 'product_name',
width: 150
},
{
title: t('inventory.spec'),
dataIndex: 'product_specification',
key: 'product_specification',
width: 130,
ellipsis: true
},
{
title: t('inventory.unit'),
dataIndex: 'product_unit',
key: 'product_unit',
width: 60
},
{
title: t('inventory.stock'),
dataIndex: 'stock_quantity',
key: 'stock_quantity',
width: 100,
align: 'right',
render: (v: number, r: InventoryItem) => {
const status = getStockStatus(r)
const color = status === 'out_of_stock' ? '#ff4d4f' : (status === 'low_stock' ? '#faad14' : '#52c41a')
return <span style={{ color, fontWeight: 500 }}>{v}</span>
}
},
{
title: t('inventory.safetyStock'),
dataIndex: 'safety_stock',
key: 'safety_stock',
width: 80,
align: 'right'
},
{
title: t('inventory.locked'),
dataIndex: 'locked_quantity',
key: 'locked_quantity',
width: 80,
align: 'right',
render: (v: number) => v || 0
},
{
title: t('inventory.lastIn'),
dataIndex: 'last_in_date',
key: 'last_in_date',
width: 100,
render: (v: string) => v ? dayjs(v).format('MM-DD') : '-'
},
{
title: t('inventory.lastOut'),
dataIndex: 'last_out_date',
key: 'last_out_date',
width: 100,
render: (v: string) => v ? dayjs(v).format('MM-DD') : '-'
},
{
title: t('inventory.status'),
key: 'status',
width: 80,
align: 'center',
render: (_, r: InventoryItem) => {
const status = getStockStatus(r)
const map: Record<string, { icon: React.ReactNode; text: string; color: string }> = {
normal: { icon: <CheckOutlined />, text: t('inventory.normal'), color: 'success' },
low_stock: { icon: <WarningOutlined />, text: t('inventory.lowStock'), color: 'warning' },
out_of_stock: { icon: <WarningOutlined />, text: t('inventory.outOfStock'), color: 'error' }
}
const info = map[status] || map.normal
return <Tag color={info.color} icon={info.icon}>{info.text}</Tag>
}
}
]
const logColumns: ColumnsType<InventoryLog> = [
{
title: t('inventory.time'),
dataIndex: 'created_at',
key: 'created_at',
width: 160,
render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm')
},
{
title: t('inventory.product'),
dataIndex: 'product_name',
key: 'product_name',
width: 120
},
{
title: t('inventory.type'),
dataIndex: 'type',
key: 'type',
width: 60,
align: 'center',
render: (v: string) => (
<Tag color={v === 'in' ? 'green' : 'red'}>
{v === 'in' ? t('inventory.in') : t('inventory.out')}
</Tag>
)
},
{
title: t('inventory.quantity'),
dataIndex: 'quantity',
key: 'quantity',
width: 80,
align: 'right'
},
{
title: t('inventory.before'),
dataIndex: 'before_quantity',
key: 'before_quantity',
width: 80,
align: 'right'
},
{
title: t('inventory.after'),
dataIndex: 'after_quantity',
key: 'after_quantity',
width: 80,
align: 'right'
},
{
title: t('inventory.orderCode'),
dataIndex: 'order_code',
key: 'order_code',
width: 120,
render: (v: string) => v || '-'
},
{
title: t('inventory.remark'),
dataIndex: 'remark',
key: 'remark',
width: 150,
ellipsis: true,
render: (v: string) => v || '-'
},
{
title: t('inventory.operator'),
dataIndex: 'operator',
key: 'operator',
width: 80
}
]
const categories = React.useMemo(() => {
const cats = new Set<string>()
inventory.forEach(item => {
if (item.product_category) cats.add(item.product_category)
})
return Array.from(cats)
}, [inventory])
return (
<div style={{ padding: 24 }}>
<Card>
<Tabs activeKey={activeTab} onChange={setActiveTab} items={tabItems} />
</Card>
<div style={{ marginBottom: 24 }}>
<h2 style={{ marginBottom: 8 }}>{t('inventory.title')}</h2>
<p style={{ color: '#888', marginBottom: 0 }}>{t('inventory.description')}</p>
</div>
<Card
extra={
<Space>
<Button type="primary" icon={<ArrowDownOutlined />} onClick={handleStockIn}>
{t('inventory.stockIn')}
</Button>
<Button icon={<ArrowUpOutlined />} onClick={handleStockOut}>
{t('inventory.stockOut')}
</Button>
</Space>
}
>
<Tabs activeKey={activeTab} onChange={setActiveTab}>
<Tabs.TabPane tab={t('inventory.tabInventory')} key="inventory">
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={6}>
<Select
placeholder={t('inventory.categoryFilter')}
allowClear
style={{ width: '100%' }}
onChange={(v) => setSelectedCategory(v)}
>
{categories.map(cat => (
<Select.Option key={cat} value={cat}>{cat}</Select.Option>
))}
</Select>
</Col>
<Col span={6}>
<Select
placeholder={t('inventory.stockFilter')}
allowClear
style={{ width: '100%' }}
value={stockFilter}
onChange={(v) => setStockFilter(v || 'all')}
>
<Select.Option value="all">{t('inventory.all')}</Select.Option>
<Select.Option value="low_stock">{t('inventory.lowStock')}</Select.Option>
<Select.Option value="out_of_stock">{t('inventory.outOfStock')}</Select.Option>
</Select>
</Col>
</Row>
<Table
columns={columns}
dataSource={inventory}
rowKey="id"
loading={loading}
pagination={{ pageSize: 20 }}
size="small"
scroll={{ x: 1100 }}
/>
</Tabs.TabPane>
<Tabs.TabPane tab={t('inventory.tabLog')} key="logs">
<Table
columns={logColumns}
dataSource={logs}
rowKey="id"
loading={logLoading}
pagination={{ pageSize: 20 }}
size="small"
scroll={{ x: 1100 }}
/>
</Tabs.TabPane>
</Tabs>
</Card>
{/* 入库弹窗 */}
<Modal
title={t('inventory.stockInTitle')}
open={inModalVisible}
onOk={handleSaveStockIn}
onCancel={() => {
inForm.resetFields();
setInModalVisible(false);
}}
destroyOnClose
width={500}
>
<Form form={inForm} layout="vertical">
<Row gutter={16}>
<Col span={24}>
<Form.Item
name="product_id"
label={t('inventory.selectProduct')}
rules={[{ required: true, message: t('inventory.selectProductRequired') }]}
>
<Select
placeholder={t('inventory.selectProduct')}
showSearch
optionFilterProp="children"
filterOption={(input, option) => (option?.children as unknown as string)?.includes(input)}
>
{products.map(p => (
<Select.Option key={p.id} value={p.id}>
{p.name} ({p.code})
</Select.Option>
))}
</Select>
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="quantity"
label={t('inventory.quantity')}
rules={[{ required: true, message: t('inventory.quantityRequired') }]}
>
<InputNumber min={1} style={{ width: '100%' }} placeholder={t('inventory.quantityPlaceholder')} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="date"
label={t('inventory.date')}
rules={[{ required: true, message: t('inventory.dateRequired') }]}
>
<DatePicker showTime style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
<Form.Item name="order_code" label={t('inventory.relatedOrder')}>
<Input placeholder={t('inventory.relatedOrderPlaceholder')} />
</Form.Item>
<Form.Item name="remark" label={t('inventory.remark')}>
<Input.TextArea rows={3} placeholder={t('inventory.remarkPlaceholder')} />
</Form.Item>
</Form>
</Modal>
{/* 出库弹窗 */}
<Modal
title="商品出库"
title={t('inventory.stockOutTitle')}
open={outModalVisible}
onOk={handleOutModalOk}
onCancel={() => setOutModalVisible(false)}
onOk={handleSaveStockOut}
onCancel={() => {
outForm.resetFields();
setOutModalVisible(false);
}}
destroyOnClose
width={500}
>
<Form form={form} layout="vertical">
<Form.Item
name="project_id"
label="关联项目"
rules={[{ required: true, message: '请选择项目' }]}
>
<Select placeholder="请选择项目">
{projects.map(project => (
<Select.Option key={project.id} value={project.id}>
{project.name}
</Select.Option>
))}
</Select>
<Form form={outForm} 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="product_id"
label="商品"
rules={[{ required: true, message: '请选择商品' }]}
>
<Select placeholder="请选择商品">
{products.map(product => (
<Select.Option key={product.id} value={product.id}>
{product.name}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item
name="quantity"
label="出库数量"
rules={[{ required: true, message: '请输入出库数量' }]}
>
<InputNumber style={{ width: '100%' }} min={0} placeholder="请输入出库数量" />
</Form.Item>
<Form.Item name="unit_price" label="单价">
<InputNumber style={{ width: '100%' }} min={0} placeholder="请输入单价" />
</Form.Item>
<Form.Item name="total_amount" label="总金额">
<InputNumber style={{ width: '100%' }} min={0} placeholder="请输入总金额" />
</Form.Item>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={3} placeholder="请输入备注" />
<Form.Item name="remark" label={t('inventory.remark')}>
<Input.TextArea rows={3} placeholder={t('inventory.remarkPlaceholder')} />
</Form.Item>
</Form>
</Modal>
@@ -423,4 +578,4 @@ const InventoryPage: React.FC = () => {
)
}
export default InventoryPage
export default InventoryPage
+127 -114
View File
@@ -22,6 +22,7 @@ import type { ColumnsType } from 'antd/es/table'
import dayjs from 'dayjs'
import BusinessLedgerTab from '../components/BusinessLedgerTab'
import useFormDraft from '../hooks/useFormDraft'
import { useLanguageStore } from '../store/languageStore'
interface LogisticsCompany {
id: number
@@ -85,6 +86,7 @@ interface OrderRecord {
}
const LogisticsCompaniesPage: React.FC = () => {
const { t, currentLanguage } = useLanguageStore()
const [companies, setCompanies] = useState<LogisticsCompany[]>([])
const [loading, setLoading] = useState(false)
@@ -123,11 +125,11 @@ const LogisticsCompaniesPage: React.FC = () => {
if (data.success) {
setCompanies(data.data)
} else {
message.error('获取物流公司列表失败')
message.error(t('logistics.getListFailed'))
}
} catch (error) {
console.error('获取物流公司列表失败:', error)
message.error('获取物流公司列表失败')
message.error(t('logistics.getListFailed'))
} finally {
setLoading(false)
}
@@ -142,11 +144,11 @@ const LogisticsCompaniesPage: React.FC = () => {
setDetailModalVisible(true)
setActiveDetailTab('basic')
} else {
message.error('获取物流公司详情失败')
message.error(t('logistics.getDetailFailed'))
}
} catch (error) {
console.error('获取物流公司详情失败:', error)
message.error('获取物流公司详情失败')
message.error(t('logistics.getDetailFailed'))
}
}
@@ -162,10 +164,10 @@ const LogisticsCompaniesPage: React.FC = () => {
setTimeout(() => {
if (hasDraft()) {
Modal.confirm({
title: '发现未完成的草稿',
content: '检测到上次未提交的物流公司信息,是否恢复?',
okText: '恢复草稿',
cancelText: '重新填写',
title: t('common.draftFound'),
content: t('common.draftRestore'),
okText: t('common.restoreDraft'),
cancelText: t('common.reFill'),
onOk: () => {
restoreDraft()
},
@@ -189,14 +191,14 @@ const LogisticsCompaniesPage: React.FC = () => {
const response = await fetch(`/api/logistics-companies/${id}`, { method: 'DELETE' })
const data = await response.json()
if (data.success) {
message.success('删除成功')
message.success(t('common.deleteSuccess'))
fetchCompanies()
} else {
message.error(data.message || '删除失败')
message.error(data.message || t('common.deleteFailed'))
}
} catch (error) {
console.error('删除失败:', error)
message.error('删除失败')
message.error(t('common.deleteFailed'))
}
}
@@ -230,12 +232,12 @@ const LogisticsCompaniesPage: React.FC = () => {
}
}
}
message.success(editingCompany ? '更新成功' : '创建成功')
message.success(editingCompany ? t('common.updateSuccess') : t('common.createSuccess'))
clearDraft()
setModalVisible(false)
fetchCompanies()
} else {
message.error('保存失败')
message.error(t('common.saveFailed'))
}
} catch (error) {
console.error('保存失败:', error)
@@ -270,11 +272,11 @@ const LogisticsCompaniesPage: React.FC = () => {
const data = await response.json()
if (data.success) {
message.success(editingContact ? '联系人更新成功' : '联系人添加成功')
message.success(editingContact ? t('logistics.contactUpdateSuccess') : t('logistics.contactAddSuccess'))
setContactModalVisible(false)
fetchCompanyDetail(currentCompany!.id)
} else {
message.error('操作失败')
message.error(t('common.operationFailed'))
}
} catch (error) {
console.error('保存联系人失败:', error)
@@ -288,10 +290,10 @@ const LogisticsCompaniesPage: React.FC = () => {
})
const data = await response.json()
if (data.success) {
message.success('联系人删除成功')
message.success(t('logistics.contactDeleteSuccess'))
fetchCompanyDetail(currentCompany!.id)
} else {
message.error('删除失败')
message.error(t('common.deleteFailed'))
}
} catch (error) {
console.error('删除联系人失败:', error)
@@ -326,11 +328,11 @@ const LogisticsCompaniesPage: React.FC = () => {
const data = await response.json()
if (data.success) {
message.success(editingPayment ? '收款信息更新成功' : '收款信息添加成功')
message.success(editingPayment ? t('logistics.paymentInfoUpdateSuccess') : t('logistics.paymentInfoAddSuccess'))
setPaymentModalVisible(false)
fetchCompanyDetail(currentCompany!.id)
} else {
message.error('操作失败')
message.error(t('common.operationFailed'))
}
} catch (error) {
console.error('保存收款信息失败:', error)
@@ -344,10 +346,10 @@ const LogisticsCompaniesPage: React.FC = () => {
})
const data = await response.json()
if (data.success) {
message.success('收款信息删除成功')
message.success(t('logistics.paymentInfoDeleteSuccess'))
fetchCompanyDetail(currentCompany!.id)
} else {
message.error('删除失败')
message.error(t('common.deleteFailed'))
}
} catch (error) {
console.error('删除收款信息失败:', error)
@@ -356,9 +358,9 @@ const LogisticsCompaniesPage: React.FC = () => {
const getFreightStatusTag = (status: string) => {
const statusMap: Record<string, { color: string; text: string }> = {
pending: { color: 'default', text: '待付款' },
requested: { color: 'blue', text: '已申请' },
paid: { color: 'green', text: '已支付' }
pending: { color: 'default', text: t('logistics.pendingPayment') },
requested: { color: 'blue', text: t('logistics.applied') },
paid: { color: 'green', text: t('logistics.paid') }
}
const info = statusMap[status] || { color: 'default', text: status }
return <Tag color={info.color}>{info.text}</Tag>
@@ -366,7 +368,7 @@ const LogisticsCompaniesPage: React.FC = () => {
const columns: ColumnsType<LogisticsCompany> = [
{
title: '公司名称',
title: t('logistics.companyName'),
dataIndex: 'name',
key: 'name',
width: 180,
@@ -375,13 +377,13 @@ const LogisticsCompaniesPage: React.FC = () => {
)
},
{
title: '联系电话',
title: t('logistics.phone'),
dataIndex: 'phone',
key: 'phone',
width: 120
},
{
title: '报价描述',
title: t('logistics.quoteDescription'),
dataIndex: 'quotation_description',
key: 'quotation_description',
width: 200,
@@ -389,14 +391,14 @@ const LogisticsCompaniesPage: React.FC = () => {
render: (v: string) => v || '-'
},
{
title: '创建时间',
title: t('logistics.createdAt'),
dataIndex: 'created_at',
key: 'created_at',
width: 100,
render: (v: string) => v ? dayjs(v).format('MM-DD') : '-'
},
{
title: '操作',
title: t('logistics.action'),
key: 'actions',
width: 150,
fixed: 'right',
@@ -404,7 +406,7 @@ const LogisticsCompaniesPage: React.FC = () => {
<Space size={4}>
<Button size="small" type="text" icon={<EyeOutlined />} onClick={() => fetchCompanyDetail(record.id)} />
<Button size="small" type="text" icon={<EditOutlined />} onClick={() => handleEdit(record)} />
<Popconfirm title="确定要删除吗?" onConfirm={() => handleDelete(record.id)}>
<Popconfirm title={t('logistics.confirmDelete')} onConfirm={() => handleDelete(record.id)}>
<Button size="small" type="text" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
@@ -413,18 +415,18 @@ const LogisticsCompaniesPage: React.FC = () => {
]
const contactColumns: ColumnsType<Contact> = [
{ title: '姓名', dataIndex: 'name', key: 'name', width: 100 },
{ title: '职位', dataIndex: 'position', key: 'position', width: 80 },
{ title: '电话', dataIndex: 'phone', key: 'phone', width: 120 },
{ title: '主联系人', dataIndex: 'is_primary', key: 'is_primary', width: 80, render: (v: boolean | number) => v ? <Tag color="blue"></Tag> : null },
{ title: t('logistics.name'), dataIndex: 'name', key: 'name', width: 100 },
{ title: t('logistics.position'), dataIndex: 'position', key: 'position', width: 80 },
{ title: t('logistics.phoneLabel'), dataIndex: 'phone', key: 'phone', width: 120 },
{ title: t('logistics.mainContact'), dataIndex: 'is_primary', key: 'is_primary', width: 80, render: (v: boolean | number) => v ? <Tag color="blue">{t('logistics.mainContact')}</Tag> : null },
{
title: '操作',
title: t('logistics.action'),
key: 'actions',
width: 100,
render: (_, record) => (
<Space size={4}>
<Button size="small" type="text" icon={<EditOutlined />} onClick={() => handleEditContact(record)} />
<Popconfirm title="确定删除?" onConfirm={() => handleDeleteContact(record.id)}>
<Popconfirm title={t('logistics.confirmDeleteShort')} onConfirm={() => handleDeleteContact(record.id)}>
<Button size="small" type="text" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
@@ -433,18 +435,18 @@ const LogisticsCompaniesPage: React.FC = () => {
]
const paymentColumns: ColumnsType<PaymentInfo> = [
{ title: '收款户名', dataIndex: 'account_name', key: 'account_name', width: 120 },
{ title: '银行账号', dataIndex: 'account_number', key: 'account_number', width: 150 },
{ title: '开户银行', dataIndex: 'bank_name', key: 'bank_name', width: 120 },
{ title: '默认', dataIndex: 'is_default', key: 'is_default', width: 60, render: (v: boolean | number) => v ? <Tag color="green"></Tag> : null },
{ title: t('logistics.accountName'), dataIndex: 'account_name', key: 'account_name', width: 120 },
{ title: t('logistics.bankAccount'), dataIndex: 'account_number', key: 'account_number', width: 150 },
{ title: t('logistics.bankName'), dataIndex: 'bank_name', key: 'bank_name', width: 120 },
{ title: t('logistics.defaultLabel'), dataIndex: 'is_default', key: 'is_default', width: 60, render: (v: boolean | number) => v ? <Tag color="green">{t('logistics.defaultLabel')}</Tag> : null },
{
title: '操作',
title: t('logistics.action'),
key: 'actions',
width: 100,
render: (_, record) => (
<Space size={4}>
<Button size="small" type="text" icon={<EditOutlined />} onClick={() => handleEditPayment(record)} />
<Popconfirm title="确定删除?" onConfirm={() => handleDeletePayment(record.id)}>
<Popconfirm title={t('logistics.confirmDeleteShort')} onConfirm={() => handleDeletePayment(record.id)}>
<Button size="small" type="text" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
@@ -453,92 +455,95 @@ const LogisticsCompaniesPage: React.FC = () => {
]
const orderColumns: ColumnsType<OrderRecord> = [
{ title: '物流单号', dataIndex: 'code', key: 'code', width: 120 },
{ title: '采购订单', dataIndex: 'order_code', key: 'order_code', width: 120 },
{ title: '发货日期', dataIndex: 'ship_date', key: 'ship_date', width: 100 },
{ title: '一次运费', dataIndex: 'primary_freight', key: 'primary_freight', width: 100, align: 'right', render: (v: number, r: OrderRecord) => `${r.primary_freight_currency || 'CNY'} ${v?.toFixed(2) || '0.00'}` },
{ title: '一次运费状态', dataIndex: 'primary_freight_status', key: 'primary_freight_status', width: 100, render: getFreightStatusTag },
{ title: '二次运费', dataIndex: 'secondary_freight', key: 'secondary_freight', width: 100, align: 'right', render: (v: number, r: OrderRecord) => `${r.secondary_freight_currency || 'LAK'} ${v?.toFixed(2) || '0.00'}` },
{ title: '二次运费状态', dataIndex: 'secondary_freight_status', key: 'secondary_freight_status', width: 100, render: getFreightStatusTag },
{ title: '状态', dataIndex: 'status', key: 'status', width: 80, render: (s: string) => <Tag>{s}</Tag> }
{ title: t('logistics.trackingNumber'), dataIndex: 'code', key: 'code', width: 120 },
{ title: t('logistics.purchaseOrder'), dataIndex: 'order_code', key: 'order_code', width: 120 },
{ title: t('logistics.deliveryDate'), dataIndex: 'ship_date', key: 'ship_date', width: 100 },
{ title: t('logistics.freight1'), dataIndex: 'primary_freight', key: 'primary_freight', width: 100, align: 'right', render: (v: number, r: OrderRecord) => `${r.primary_freight_currency || 'CNY'} ${v?.toFixed(2) || '0.00'}` },
{ title: t('logistics.freight1Status'), dataIndex: 'primary_freight_status', key: 'primary_freight_status', width: 100, render: getFreightStatusTag },
{ title: t('logistics.freight2'), dataIndex: 'secondary_freight', key: 'secondary_freight', width: 100, align: 'right', render: (v: number, r: OrderRecord) => `${r.secondary_freight_currency || 'LAK'} ${v?.toFixed(2) || '0.00'}` },
{ title: t('logistics.freight2Status'), dataIndex: 'secondary_freight_status', key: 'secondary_freight_status', width: 100, render: getFreightStatusTag },
{ title: t('logistics.status'), dataIndex: 'status', key: 'status', width: 80, render: (s: string) => <Tag>{s}</Tag> }
]
return (
<div style={{ padding: 24 }}>
<div style={{ marginBottom: 24 }}>
<h2 style={{ marginBottom: 8 }}></h2>
<p style={{ color: '#888', marginBottom: 0 }}></p>
<h2 style={{ marginBottom: 8 }}>{t('logistics.title')}</h2>
<p style={{ color: '#888', marginBottom: 0 }}>{t('logistics.description')}</p>
</div>
<Card extra={<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}></Button>}>
<Card extra={<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>{t('logistics.newCompany')}</Button>}>
<Table columns={columns} dataSource={companies} rowKey="id" loading={loading} pagination={{ pageSize: 20 }} size="small" scroll={{ x: 1100 }} />
</Card>
{/* 编辑/新建弹窗 */}
<Modal
title={editingCompany ? '编辑物流公司' : '新建物流公司'}
title={editingCompany ? t('logistics.editCompany') : t('logistics.newCompany')}
open={modalVisible}
onOk={handleSave}
onCancel={() => {
if (form.isFieldsTouched()) {
Modal.confirm({
title: '确认关闭',
content: '表单数据尚未保存,关闭后可通过草稿恢复。确定关闭吗?',
okText: '关闭',
cancelText: '继续编辑',
title: t('common.closeConfirm'),
content: t('common.closeConfirmMsg'),
okText: t('common.close'),
cancelText: t('common.continueEdit'),
onOk: () => {
saveDraft()
form.resetFields();
setModalVisible(false)
},
})
} else {
form.resetFields();
setModalVisible(false)
}
}}
destroyOnClose
width={700}
maskClosable={false}
>
<Form form={form} layout="vertical" onValuesChange={handleFormChange}>
<Form.Item name="name" label="公司名称" rules={[{ required: true }]}>
<Input placeholder="请输入公司名称" />
<Form.Item name="name" label={t('logistics.companyName')} rules={[{ required: true }]}>
<Input placeholder={t('common.inputPlaceholder') + t('logistics.companyName')} />
</Form.Item>
<Form.Item name="address" label="地址">
<Input placeholder="请输入地址" />
<Form.Item name="address" label={t('logistics.address')}>
<Input placeholder={t('logistics.addressPlaceholder')} />
</Form.Item>
<Form.Item name="quotation_description" label="报价描述">
<Input.TextArea rows={3} placeholder="请输入报价描述(如:中国-老挝陆运报价、时效等)" />
<Form.Item name="quotation_description" label={t('logistics.quoteDescription')}>
<Input.TextArea rows={3} placeholder={t('logistics.quotePlaceholder')} />
</Form.Item>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={2} placeholder="请输入备注" />
<Form.Item name="remark" label={t('logistics.remark')}>
<Input.TextArea rows={2} placeholder={t('logistics.remarkPlaceholder')} />
</Form.Item>
{!editingCompany && (
<Form.List name="contacts">
{(fields, { add, remove }) => (
<>
<div style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontWeight: 500 }}></span>
<Button type="dashed" size="small" icon={<PlusOutlined />} onClick={() => add()}></Button>
<span style={{ fontWeight: 500 }}>{t('logistics.contact')}</span>
<Button type="dashed" size="small" icon={<PlusOutlined />} onClick={() => add()}>{t('logistics.addContact')}</Button>
</div>
{fields.map(({ key, name, ...restField }) => (
<Row key={key} gutter={8} style={{ marginBottom: 8 }}>
<Col span={6}>
<Form.Item {...restField} name={[name, 'name']} rules={[{ required: true, message: '必填' }]}>
<Input placeholder="姓名" size="small" />
<Form.Item {...restField} name={[name, 'name']} rules={[{ required: true, message: t('common.required') }]}>
<Input placeholder={t('logistics.name')} size="small" />
</Form.Item>
</Col>
<Col span={5}>
<Form.Item {...restField} name={[name, 'phone']}>
<Input placeholder="电话" size="small" />
<Input placeholder={t('logistics.phoneLabel')} size="small" />
</Form.Item>
</Col>
<Col span={5}>
<Form.Item {...restField} name={[name, 'position']}>
<Input placeholder="职位" size="small" />
<Input placeholder={t('logistics.position')} size="small" />
</Form.Item>
</Col>
<Col span={5}>
<Form.Item {...restField} name={[name, 'is_primary']} valuePropName="checked">
<Checkbox></Checkbox>
<Checkbox>{t('logistics.mainContact')}</Checkbox>
</Form.Item>
</Col>
<Col span={3}>
@@ -555,7 +560,7 @@ const LogisticsCompaniesPage: React.FC = () => {
{/* 详情弹窗 - 多TAB */}
<Modal
title={`物流公司详情 - ${currentCompany?.name || ''}`}
title={t('logistics.detailTitle', { name: currentCompany?.name || '' })}
open={detailModalVisible}
onCancel={() => setDetailModalVisible(false)}
footer={null}
@@ -564,32 +569,32 @@ const LogisticsCompaniesPage: React.FC = () => {
{currentCompany && (
<Tabs activeKey={activeDetailTab} onChange={setActiveDetailTab}>
{/* TAB1: 基本信息 */}
<Tabs.TabPane tab={<span><FileTextOutlined /> </span>} key="basic">
<Tabs.TabPane tab={<span><FileTextOutlined /> {t('logistics.basicInfo')}</span>} key="basic">
<Descriptions bordered column={2}>
<Descriptions.Item label="公司名称">{currentCompany.name}</Descriptions.Item>
<Descriptions.Item label="联系电话">{currentCompany.phone || '-'}</Descriptions.Item>
<Descriptions.Item label="邮箱">{currentCompany.email || '-'}</Descriptions.Item>
<Descriptions.Item label="创建时间">{currentCompany.created_at}</Descriptions.Item>
<Descriptions.Item label="地址" span={2}>{currentCompany.address || '-'}</Descriptions.Item>
<Descriptions.Item label="报价描述" span={2}>{currentCompany.quotation_description || '-'}</Descriptions.Item>
{currentCompany.remark && <Descriptions.Item label="备注" span={2}>{currentCompany.remark}</Descriptions.Item>}
<Descriptions.Item label={t('logistics.companyName')}>{currentCompany.name}</Descriptions.Item>
<Descriptions.Item label={t('logistics.phone')}>{currentCompany.phone || '-'}</Descriptions.Item>
<Descriptions.Item label={t('logistics.email')}>{currentCompany.email || '-'}</Descriptions.Item>
<Descriptions.Item label={t('logistics.createdAt')}>{currentCompany.created_at}</Descriptions.Item>
<Descriptions.Item label={t('logistics.address')} span={2}>{currentCompany.address || '-'}</Descriptions.Item>
<Descriptions.Item label={t('logistics.quoteDescription')} span={2}>{currentCompany.quotation_description || '-'}</Descriptions.Item>
{currentCompany.remark && <Descriptions.Item label={t('logistics.remark')} span={2}>{currentCompany.remark}</Descriptions.Item>}
</Descriptions>
</Tabs.TabPane>
{/* TAB2: 联系人 */}
<Tabs.TabPane tab={<span><PhoneOutlined /> </span>} key="contacts">
<Button type="dashed" icon={<PlusOutlined />} onClick={handleAddContact} style={{ marginBottom: 16 }}></Button>
<Tabs.TabPane tab={<span><PhoneOutlined /> {t('logistics.contact')}</span>} key="contacts">
<Button type="dashed" icon={<PlusOutlined />} onClick={handleAddContact} style={{ marginBottom: 16 }}>{t('logistics.addContact')}</Button>
<Table columns={contactColumns} dataSource={currentCompany.contacts || []} rowKey="id" pagination={false} size="small" />
</Tabs.TabPane>
{/* TAB3: 收款信息 */}
<Tabs.TabPane tab={<span><BankOutlined /> </span>} key="payment">
<Button type="dashed" icon={<PlusOutlined />} onClick={handleAddPayment} style={{ marginBottom: 16 }}></Button>
<Tabs.TabPane tab={<span><BankOutlined /> {t('logistics.paymentTab')}</span>} key="payment">
<Button type="dashed" icon={<PlusOutlined />} onClick={handleAddPayment} style={{ marginBottom: 16 }}>{t('logistics.addPaymentInfo')}</Button>
<Table columns={paymentColumns} dataSource={currentCompany.payment_infos || []} rowKey="id" pagination={false} size="small" />
</Tabs.TabPane>
{/* TAB4: 业务台账 */}
<Tabs.TabPane tab={<span><DollarOutlined /> </span>} key="orders">
<Tabs.TabPane tab={<span><DollarOutlined /> {t('logistics.ledger')}</span>} key="orders">
<BusinessLedgerTab
partnerType="logistics"
summary={currentCompany.ledger?.summary || { item_count: 0, total_primary_freight: 0, total_secondary_freight: 0, total_freight: 0, paid_primary_freight: 0, paid_secondary_freight: 0, paid_amount: 0, unpaid_amount: 0 }}
@@ -602,36 +607,40 @@ const LogisticsCompaniesPage: React.FC = () => {
{/* 联系人编辑弹窗 */}
<Modal
title={editingContact ? '编辑联系人' : '添加联系人'}
title={editingContact ? t('logistics.editContact') : t('logistics.addContactTitle')}
open={contactModalVisible}
onOk={handleSaveContact}
onCancel={() => setContactModalVisible(false)}
onCancel={() => {
contactForm.resetFields();
setContactModalVisible(false);
}}
destroyOnClose
width={500}
>
<Form form={contactForm} layout="vertical">
<Row gutter={16}>
<Col span={12}>
<Form.Item name="name" label="姓名" rules={[{ required: true }]}>
<Input placeholder="请输入姓名" />
<Form.Item name="name" label={t('logistics.name')} rules={[{ required: true }]}>
<Input placeholder={t('common.inputPlaceholder') + t('logistics.name')} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="position" label="职位">
<Input placeholder="请输入职位" />
<Form.Item name="position" label={t('logistics.position')}>
<Input placeholder={t('common.inputPlaceholder') + t('logistics.position')} />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="phone" label="电话">
<Input placeholder="请输入电话" />
<Form.Item name="phone" label={t('logistics.phoneLabel')}>
<Input placeholder={t('common.inputPlaceholder') + t('logistics.phoneLabel')} />
</Form.Item>
</Col>
</Row>
<Form.Item name="is_primary" label="主联系人">
<Select placeholder="是否为主联系人">
<Select.Option value={true}></Select.Option>
<Select.Option value={false}></Select.Option>
<Form.Item name="is_primary" label={t('logistics.mainContact')}>
<Select placeholder={t('logistics.isMainContact')}>
<Select.Option value={true}>{t('common.is')}</Select.Option>
<Select.Option value={false}>{t('common.no')}</Select.Option>
</Select>
</Form.Item>
</Form>
@@ -639,35 +648,39 @@ const LogisticsCompaniesPage: React.FC = () => {
{/* 收款信息编辑弹窗 */}
<Modal
title={editingPayment ? '编辑收款信息' : '添加收款信息'}
title={editingPayment ? t('logistics.editPaymentInfo') : t('logistics.addPaymentInfoTitle')}
open={paymentModalVisible}
onOk={handleSavePayment}
onCancel={() => setPaymentModalVisible(false)}
onCancel={() => {
paymentForm.resetFields();
setPaymentModalVisible(false);
}}
destroyOnClose
width={500}
>
<Form form={paymentForm} layout="vertical">
<Form.Item name="account_name" label="收款户名" rules={[{ required: true }]}>
<Input placeholder="请输入收款户名" />
<Form.Item name="account_name" label={t('logistics.accountName')} rules={[{ required: true }]}>
<Input placeholder={t('common.inputPlaceholder') + t('logistics.accountName')} />
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="account_number" label="银行账号" rules={[{ required: true }]}>
<Input placeholder="请输入银行账号" />
<Form.Item name="account_number" label={t('logistics.bankAccount')} rules={[{ required: true }]}>
<Input placeholder={t('common.inputPlaceholder') + t('logistics.bankAccount')} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="bank_name" label="开户银行" rules={[{ required: true }]}>
<Input placeholder="请输入开户银行" />
<Form.Item name="bank_name" label={t('logistics.bankName')} rules={[{ required: true }]}>
<Input placeholder={t('common.inputPlaceholder') + t('logistics.bankName')} />
</Form.Item>
</Col>
</Row>
<Form.Item name="qr_code" label="收款码">
<Input placeholder="请输入收款码图片URL" />
<Form.Item name="qr_code" label={t('logistics.qrCode')}>
<Input placeholder={t('logistics.qrCodeRequired')} />
</Form.Item>
<Form.Item name="is_default" label="默认账户">
<Select placeholder="是否为默认账户">
<Select.Option value={true}></Select.Option>
<Select.Option value={false}></Select.Option>
<Form.Item name="is_default" label={t('logistics.defaultAccount')}>
<Select placeholder={t('logistics.isDefaultAccount')}>
<Select.Option value={true}>{t('common.is')}</Select.Option>
<Select.Option value={false}>{t('common.no')}</Select.Option>
</Select>
</Form.Item>
</Form>
@@ -676,4 +689,4 @@ const LogisticsCompaniesPage: React.FC = () => {
)
}
export default LogisticsCompaniesPage
export default LogisticsCompaniesPage
+84 -79
View File
@@ -10,6 +10,7 @@ import {
} from '@ant-design/icons'
import type { ColumnsType } from 'antd/es/table'
import dayjs from 'dayjs'
import { useLanguageStore } from '../store/languageStore'
// ==================== 类型定义 ====================
interface PaymentPlan {
@@ -37,6 +38,7 @@ interface PurchaseOrder {
// ==================== 组件 ====================
const PaymentPlansPage: React.FC = () => {
const { t, currentLanguage } = useLanguageStore();
// 状态
const [paymentPlans, setPaymentPlans] = useState<PaymentPlan[]>([])
const [loading, setLoading] = useState(false)
@@ -63,11 +65,11 @@ const PaymentPlansPage: React.FC = () => {
if (data.success) {
setPaymentPlans(data.data)
} else {
message.error('获取付款计划列表失败')
message.error(t('paymentPlan.getListFailed'))
}
} catch (error) {
console.error('获取付款计划列表失败:', error)
message.error('获取付款计划列表失败')
message.error(t('paymentPlan.getListFailed'))
} finally {
setLoading(false)
}
@@ -93,11 +95,11 @@ const PaymentPlansPage: React.FC = () => {
setViewingPlan(data.data)
setDetailModalVisible(true)
} else {
message.error('获取付款计划详情失败')
message.error(t('paymentPlan.getDetailFailed'))
}
} catch (error) {
console.error('获取付款计划详情失败:', error)
message.error('获取付款计划详情失败')
message.error(t('paymentPlan.getDetailFailed'))
}
}
@@ -119,7 +121,7 @@ const PaymentPlansPage: React.FC = () => {
currency: 'CNY',
payment_type: 'partial',
status: 'pending',
created_by: '系统管理员'
created_by: t('common.systemAdmin')
})
setModalVisible(true)
}
@@ -150,15 +152,15 @@ const PaymentPlansPage: React.FC = () => {
payment_type: fullRecord.payment_type || 'partial',
status: fullRecord.status || 'pending',
description: fullRecord.description,
created_by: fullRecord.created_by || '系统管理员'
created_by: fullRecord.created_by || t('common.systemAdmin')
});
}, 100);
} else {
message.error('获取付款计划详情失败')
message.error(t('paymentPlan.getDetailFailed'))
}
} catch (error) {
console.error('获取付款计划详情失败:', error)
message.error('获取付款计划详情失败')
message.error(t('paymentPlan.getDetailFailed'))
}
}
@@ -191,15 +193,15 @@ const PaymentPlansPage: React.FC = () => {
const data = await response.json()
if (data.success) {
message.success(editingPlan ? '保存成功' : '创建成功')
message.success(editingPlan ? t('common.saveSuccess') : t('common.createSuccess'))
setModalVisible(false)
fetchPaymentPlans()
} else {
message.error(editingPlan ? '保存失败' : '创建失败')
message.error(editingPlan ? t('common.saveFailed') : t('common.operationFailed'))
}
} catch (error) {
console.error('保存失败:', error)
message.error('保存失败')
message.error(t('common.saveFailed'))
}
}
@@ -207,10 +209,10 @@ const PaymentPlansPage: React.FC = () => {
const getStatusTag = (status: string) => {
const statusMap: Record<string, { color: string; text: string }> = {
pending: { color: 'blue', text: '待处理' },
approved: { color: 'green', text: '已审批' },
executed: { color: 'purple', text: '已执行' },
cancelled: { color: 'red', text: '已取消' }
pending: { color: 'blue', text: t('paymentPlan.pending') },
approved: { color: 'green', text: t('paymentPlan.approved') },
executed: { color: 'purple', text: t('paymentPlan.executed') },
cancelled: { color: 'red', text: t('paymentPlan.cancelled') }
}
const info = statusMap[status] || { color: 'default', text: status }
return <Tag color={info.color}>{info.text}</Tag>
@@ -218,8 +220,8 @@ const PaymentPlansPage: React.FC = () => {
const getPaymentTypeTag = (type: string) => {
const typeMap: Record<string, { color: string; text: string }> = {
partial: { color: 'blue', text: '部分付款' },
full: { color: 'green', text: '全额付款' }
partial: { color: 'blue', text: t('paymentPlan.partialPayment') },
full: { color: 'green', text: t('paymentPlan.fullPayment') }
}
const info = typeMap[type] || { color: 'default', text: type }
return <Tag color={info.color}>{info.text}</Tag>
@@ -227,14 +229,14 @@ const PaymentPlansPage: React.FC = () => {
const columns: ColumnsType<PaymentPlan> = [
{
title: '计划编号',
title: t('paymentPlan.planCode'),
dataIndex: 'code',
key: 'code',
width: 150,
ellipsis: true
},
{
title: '采购订单',
title: t('paymentPlan.purchaseOrder'),
dataIndex: 'purchase_order_id',
key: 'purchase_order_id',
width: 140,
@@ -244,14 +246,14 @@ const PaymentPlansPage: React.FC = () => {
}
},
{
title: '付款日期',
title: t('paymentPlan.paymentDate'),
dataIndex: 'payment_date',
key: 'payment_date',
width: 110,
render: (date) => dayjs(date).format('MM-DD')
},
{
title: '金额',
title: t('paymentPlan.amount'),
dataIndex: 'amount',
key: 'amount',
width: 120,
@@ -263,7 +265,7 @@ const PaymentPlansPage: React.FC = () => {
)
},
{
title: '付款类型',
title: t('paymentPlan.paymentType'),
dataIndex: 'payment_type',
key: 'payment_type',
width: 100,
@@ -271,7 +273,7 @@ const PaymentPlansPage: React.FC = () => {
render: getPaymentTypeTag
},
{
title: '状态',
title: t('paymentPlan.status'),
dataIndex: 'status',
key: 'status',
width: 90,
@@ -279,13 +281,13 @@ const PaymentPlansPage: React.FC = () => {
render: getStatusTag
},
{
title: '创建人',
title: t('paymentPlan.creator'),
dataIndex: 'created_by',
key: 'created_by',
width: 100
},
{
title: '操作',
title: t('paymentPlan.action'),
key: 'actions',
width: 150,
fixed: 'right',
@@ -297,7 +299,7 @@ const PaymentPlansPage: React.FC = () => {
icon={<EyeOutlined />}
onClick={() => fetchPlanDetail(record.id)}
>
{t('common.detail')}
</Button>
<Button
size="small"
@@ -305,7 +307,7 @@ const PaymentPlansPage: React.FC = () => {
icon={<EditOutlined />}
onClick={() => handleEdit(record)}
>
{t('common.edit')}
</Button>
</Space>
)
@@ -315,11 +317,11 @@ const PaymentPlansPage: React.FC = () => {
return (
<div style={{ padding: 24 }}>
<div style={{ marginBottom: 24 }}>
<h2 style={{ marginBottom: 8 }}></h2>
<p style={{ color: '#888', marginBottom: 0 }}></p>
<h2 style={{ marginBottom: 8 }}>{t('paymentPlan.title')}</h2>
<p style={{ color: '#888', marginBottom: 0 }}>{t('paymentPlan.description')}</p>
</div>
<Card extra={<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}></Button>}>
<Card extra={<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>{t('paymentPlan.newPlan')}</Button>}>
<Table
columns={columns}
dataSource={paymentPlans}
@@ -333,19 +335,22 @@ const PaymentPlansPage: React.FC = () => {
{/* 编辑/新建弹窗 */}
<Modal
title={editingPlan ? '编辑付款计划' : '新建付款计划'}
title={editingPlan ? t('paymentPlan.editPlan') : t('paymentPlan.newPlan')}
open={modalVisible}
onCancel={() => {
setModalVisible(false)
setEditingPlan(null)
form.resetFields();
setModalVisible(false);
setEditingPlan(null);
}}
footer={[
<Button key="cancel" onClick={() => {
setModalVisible(false)
setEditingPlan(null)
}}></Button>,
<Button key="save" type="primary" onClick={handleSave}></Button>
form.resetFields();
setModalVisible(false);
setEditingPlan(null);
}}>{t('common.cancel')}</Button>,
<Button key="save" type="primary" onClick={handleSave}>{t('common.save')}</Button>
]}
destroyOnClose
width={600}
>
<Form form={form} layout="vertical">
@@ -353,10 +358,10 @@ const PaymentPlansPage: React.FC = () => {
<Col span={24}>
<Form.Item
name="purchase_order_id"
label="关联采购订单"
rules={[{ required: true, message: '请选择采购订单' }]}
label={t('paymentPlan.purchaseOrder')}
rules={[{ required: true, message: t('paymentPlan.selectOrder') }]}
>
<Select placeholder="请选择采购订单" allowClear>
<Select placeholder={t('paymentPlan.selectOrder')} allowClear>
{purchaseOrders.map(order => (
<Select.Option key={order.id} value={order.id}>
{order.code} - {order.supplier_name} ({order.currency} {order.total_amount})
@@ -371,8 +376,8 @@ const PaymentPlansPage: React.FC = () => {
<Col span={12}>
<Form.Item
name="payment_date"
label="付款日期"
rules={[{ required: true, message: '请选择付款日期' }]}
label={t('paymentPlan.paymentDate')}
rules={[{ required: true, message: t('paymentPlan.selectDate') }]}
>
<DatePicker style={{ width: '100%' }} />
</Form.Item>
@@ -380,10 +385,10 @@ const PaymentPlansPage: React.FC = () => {
<Col span={12}>
<Form.Item
name="amount"
label="付款金额"
rules={[{ required: true, message: '请输入付款金额' }]}
label={t('paymentPlan.amount')}
rules={[{ required: true, message: t('paymentPlan.inputAmount') }]}
>
<InputNumber min={0} precision={2} style={{ width: '100%' }} placeholder="付款金额" />
<InputNumber min={0} precision={2} style={{ width: '100%' }} placeholder={t('paymentPlan.amountPlaceholder')} />
</Form.Item>
</Col>
</Row>
@@ -392,26 +397,26 @@ const PaymentPlansPage: React.FC = () => {
<Col span={12}>
<Form.Item
name="currency"
label="币种"
rules={[{ required: true, message: '请选择币种' }]}
label={t('common.currency')}
rules={[{ required: true, message: t('paymentPlan.selectCurrency') }]}
>
<Select placeholder="请选择币种">
<Select.Option value="CNY"></Select.Option>
<Select.Option value="USD"></Select.Option>
<Select.Option value="LAK"></Select.Option>
<Select.Option value="THB"></Select.Option>
<Select placeholder={t('paymentPlan.selectCurrency')}>
<Select.Option value="CNY">{t('paymentPlan.currencyCNY')}</Select.Option>
<Select.Option value="USD">{t('paymentPlan.currencyUSD')}</Select.Option>
<Select.Option value="LAK">{t('paymentPlan.currencyLAK')}</Select.Option>
<Select.Option value="THB">{t('paymentPlan.currencyTHB')}</Select.Option>
</Select>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="payment_type"
label="付款类型"
rules={[{ required: true, message: '请选择付款类型' }]}
label={t('paymentPlan.paymentType')}
rules={[{ required: true, message: t('paymentPlan.selectType') }]}
>
<Select placeholder="请选择付款类型">
<Select.Option value="partial"></Select.Option>
<Select.Option value="full"></Select.Option>
<Select placeholder={t('paymentPlan.selectType')}>
<Select.Option value="partial">{t('paymentPlan.partialPayment')}</Select.Option>
<Select.Option value="full">{t('paymentPlan.fullPayment')}</Select.Option>
</Select>
</Form.Item>
</Col>
@@ -421,40 +426,40 @@ const PaymentPlansPage: React.FC = () => {
<Col span={12}>
<Form.Item
name="status"
label="状态"
rules={[{ required: true, message: '请选择状态' }]}
label={t('paymentPlan.status')}
rules={[{ required: true, message: t('paymentPlan.selectStatus') }]}
>
<Select placeholder="请选择状态">
<Select.Option value="pending"></Select.Option>
<Select.Option value="approved"></Select.Option>
<Select.Option value="executed"></Select.Option>
<Select.Option value="cancelled"></Select.Option>
<Select placeholder={t('paymentPlan.selectStatus')}>
<Select.Option value="pending">{t('paymentPlan.pending')}</Select.Option>
<Select.Option value="approved">{t('paymentPlan.approved')}</Select.Option>
<Select.Option value="executed">{t('paymentPlan.executed')}</Select.Option>
<Select.Option value="cancelled">{t('paymentPlan.cancelled')}</Select.Option>
</Select>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="created_by"
label="创建人"
rules={[{ required: true, message: '请输入创建人' }]}
label={t('paymentPlan.creator')}
rules={[{ required: true, message: t('paymentPlan.inputCreator') }]}
>
<Input placeholder="请输入创建人" />
<Input placeholder={t('paymentPlan.inputCreator')} />
</Form.Item>
</Col>
</Row>
<Form.Item
name="description"
label="描述"
label={t('common.remark')}
>
<Input.TextArea rows={3} placeholder="请输入付款计划描述" />
<Input.TextArea rows={3} placeholder={t('paymentPlan.descPlaceholder')} />
</Form.Item>
</Form>
</Modal>
{/* 详情弹窗 */}
<Modal
title="付款计划详情"
title={t('paymentPlan.detailTitle')}
open={detailModalVisible}
onCancel={() => setDetailModalVisible(false)}
footer={null}
@@ -463,20 +468,20 @@ const PaymentPlansPage: React.FC = () => {
{viewingPlan && (
<>
<Descriptions bordered column={2} size="small">
<Descriptions.Item label="计划编号">{viewingPlan.code}</Descriptions.Item>
<Descriptions.Item label="状态">{getStatusTag(viewingPlan.status)}</Descriptions.Item>
<Descriptions.Item label="采购订单">
<Descriptions.Item label={t('paymentPlan.detailCode')}>{viewingPlan.code}</Descriptions.Item>
<Descriptions.Item label={t('paymentPlan.status')}>{getStatusTag(viewingPlan.status)}</Descriptions.Item>
<Descriptions.Item label={t('paymentPlan.purchaseOrder')}>
{(() => {
const order = purchaseOrders.find(o => o.id == viewingPlan.purchase_order_id)
return order ? order.code : viewingPlan.purchase_order_id
})()}
</Descriptions.Item>
<Descriptions.Item label="付款类型">{getPaymentTypeTag(viewingPlan.payment_type)}</Descriptions.Item>
<Descriptions.Item label="付款日期">{viewingPlan.payment_date}</Descriptions.Item>
<Descriptions.Item label="币种">{viewingPlan.currency}</Descriptions.Item>
<Descriptions.Item label="金额" span={2}>{viewingPlan.amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</Descriptions.Item>
<Descriptions.Item label="描述" span={2}>{viewingPlan.description || '-'}</Descriptions.Item>
<Descriptions.Item label="创建人" span={2}>{viewingPlan.created_by}</Descriptions.Item>
<Descriptions.Item label={t('paymentPlan.paymentType')}>{getPaymentTypeTag(viewingPlan.payment_type)}</Descriptions.Item>
<Descriptions.Item label={t('paymentPlan.paymentDate')}>{viewingPlan.payment_date}</Descriptions.Item>
<Descriptions.Item label={t('common.currency')}>{viewingPlan.currency}</Descriptions.Item>
<Descriptions.Item label={t('paymentPlan.amount')} span={2}>{viewingPlan.amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</Descriptions.Item>
<Descriptions.Item label={t('common.remark')} span={2}>{viewingPlan.description || '-'}</Descriptions.Item>
<Descriptions.Item label={t('paymentPlan.creator')} span={2}>{viewingPlan.created_by}</Descriptions.Item>
</Descriptions>
</>
)}
+150 -118
View File
@@ -5,6 +5,7 @@ import dayjs from 'dayjs';
import { useAuthStore } from '../store/authStore';
import FileUpload from '../components/FileUpload';
import useFormDraft from '../hooks/useFormDraft';
import { useLanguageStore } from '../store/languageStore';
const { Option } = Select;
const { TextArea } = Input;
@@ -59,6 +60,7 @@ interface PayeeEntity {
}
const PaymentRequestsPage: React.FC = () => {
const { t, currentLanguage } = useLanguageStore();
const { user } = useAuthStore();
const [requests, setRequests] = useState<any[]>([]);
const [completedRequests, setCompletedRequests] = useState<any[]>([]);
@@ -116,7 +118,7 @@ const PaymentRequestsPage: React.FC = () => {
}
} catch (error) {
console.error('获取付款申请列表失败:', error);
message.error('获取付款申请列表失败');
message.error(t('paymentRequest.getListFailed'));
} finally {
setLoading(false);
}
@@ -217,19 +219,21 @@ const PaymentRequestsPage: React.FC = () => {
form.setFieldsValue({
application_date: dayjs(),
currency: 'CNY',
applicant: user?.name || user?.username || '当前用户',
applicant: user?.name || user?.username || t('common.currentUser'),
attachments: [],
payee_type: 'other',
expense_type: 'company'
});
setWatchedAmount(null);
setWatchedCurrency('CNY');
setModalVisible(true);
setTimeout(() => {
if (hasDraft()) {
Modal.confirm({
title: '发现未完成的草稿',
content: '检测到上次未提交的付款申请,是否恢复?',
okText: '恢复草稿',
cancelText: '重新填写',
title: t('common.draftFound'),
content: t('common.draftRestore'),
okText: t('common.restoreDraft'),
cancelText: t('common.reFill'),
onOk: () => {
restoreDraft();
},
@@ -239,7 +243,7 @@ const PaymentRequestsPage: React.FC = () => {
form.setFieldsValue({
application_date: dayjs(),
currency: 'CNY',
applicant: user?.name || user?.username || '当前用户',
applicant: user?.name || user?.username || t('common.currentUser'),
attachments: [],
payee_type: 'other',
expense_type: 'company'
@@ -257,6 +261,8 @@ const PaymentRequestsPage: React.FC = () => {
application_date: record.application_date ? dayjs(record.application_date) : (record.payment_date ? dayjs(record.payment_date) : null),
attachments: record.attachments || []
});
setWatchedAmount(record.amount || null);
setWatchedCurrency(record.currency || 'CNY');
setModalVisible(true);
};
@@ -267,15 +273,15 @@ const PaymentRequestsPage: React.FC = () => {
const handleDelete = async (id: number) => {
Modal.confirm({
title: '确认删除',
content: '确定要删除这条付款申请吗?',
title: t('common.deleteConfirm'),
content: t('paymentRequest.deleteConfirmMsg') || t('common.confirmDeleteMsg'),
onOk: async () => {
try {
await fetch('/api/payment-requests/' + id, { method: 'DELETE' });
message.success('删除成功');
message.success(t('paymentRequest.deleteSuccess'));
fetchRequests();
} catch (error) {
message.error('删除失败');
message.error(t('paymentRequest.deleteFailed'));
}
}
});
@@ -283,15 +289,15 @@ const PaymentRequestsPage: React.FC = () => {
const handleWithdraw = async (id: number) => {
Modal.confirm({
title: '确认撤回',
content: '撤回后可重新编辑提交,确认撤回吗?',
title: t('paymentRequest.withdrawConfirm'),
content: t('paymentRequest.withdrawConfirmMsg'),
onOk: async () => {
try {
await fetch('/api/payment-requests/' + id + '/withdraw', { method: 'POST' });
message.success('已撤回,可重新编辑');
message.success(t('paymentRequest.withdrawSuccess'));
fetchRequests();
} catch (error) {
message.error('撤回失败');
message.error(t('paymentRequest.withdrawFailed'));
}
}
});
@@ -342,32 +348,56 @@ const PaymentRequestsPage: React.FC = () => {
});
const result = await res.json();
if (result.success) {
message.success(editingId ? '更新成功' : '创建成功');
message.success(editingId ? t('common.updateSuccess') : t('common.createSuccess'));
clearDraft();
setModalVisible(false);
fetchRequests();
} else {
message.error(result.error || '操作失败');
message.error(result.error || t('common.operationFailed'));
}
} catch (error) {
message.error('操作失败');
message.error(t('common.operationFailed'));
}
};
const convertToCNY = (amount: number, curr: string): number => {
if (curr === "CNY") return amount;
// 先尝试 XXX_CNY 格式
const rateKey = curr + "_CNY";
const rate = exchangeRates[rateKey] || 1;
return amount * rate;
if (exchangeRates[rateKey]) {
return amount * exchangeRates[rateKey];
}
// 尝试 CNY_XXX 格式的倒数
const reverseKey = "CNY_" + curr;
if (exchangeRates[reverseKey]) {
return amount / exchangeRates[reverseKey];
}
// 尝试通过 USD 中转: XXX -> USD -> CNY
const xxxUsdKey = curr + "_USD";
const usdCnyKey = "USD_CNY";
const cnyUsdKey = "CNY_USD";
if (exchangeRates[xxxUsdKey]) {
const usdAmount = amount * exchangeRates[xxxUsdKey];
if (exchangeRates[usdCnyKey]) return usdAmount * exchangeRates[usdCnyKey];
if (exchangeRates[cnyUsdKey]) return usdAmount / exchangeRates[cnyUsdKey];
}
// 通过 LAK 中转
const xxxLakKey = curr + "_LAK";
const cnyLakKey = "CNY_LAK";
if (exchangeRates[xxxLakKey] && exchangeRates[cnyLakKey]) {
const lakAmount = amount * exchangeRates[xxxLakKey];
return lakAmount / exchangeRates[cnyLakKey];
}
return amount;
};
const getStatusTag = (status: string) => {
const statusMap: Record<string, { color: string; text: string }> = {
pending: { color: 'processing', text: '待审批' },
approved: { color: 'success', text: '已批准' },
rejected: { color: 'error', text: '已退回' },
withdrawn: { color: 'default', text: '已撤回' },
paid: { color: 'blue', text: '已付款' },
pending: { color: 'processing', text: t('paymentRequest.pendingApproval') },
approved: { color: 'success', text: t('paymentRequest.approved') },
rejected: { color: 'error', text: t('paymentRequest.rejected') },
withdrawn: { color: 'default', text: t('paymentRequest.withdrawn') },
paid: { color: 'blue', text: t('paymentRequest.paid') },
};
const config = statusMap[status] || { color: 'default', text: status };
return <Tag color={config.color}>{config.text}</Tag>;
@@ -393,48 +423,48 @@ const PaymentRequestsPage: React.FC = () => {
};
const columns = [
{ title: '事由', dataIndex: 'reason', key: 'reason', ellipsis: true, render: (v: string, r: any) => <a onClick={() => handleView(r)}>{v}</a> },
{ title: '申请人', dataIndex: 'applicant', key: 'applicant', width: 80 },
{ title: '收款单位', dataIndex: 'payee', key: 'payee', width: 150, ellipsis: true },
{ title: '金额', dataIndex: 'amount', key: 'amount', width: 140, render: (v: number, r: any) => (
{ title: t('paymentRequest.subject'), dataIndex: 'reason', key: 'reason', ellipsis: true, render: (v: string, r: any) => <a onClick={() => handleView(r)}>{v}</a> },
{ title: t('paymentRequest.applicant'), dataIndex: 'applicant', key: 'applicant', width: 80 },
{ title: t('paymentRequest.payee'), dataIndex: 'payee', key: 'payee', width: 150, ellipsis: true },
{ title: t('paymentRequest.amount'), dataIndex: 'amount', key: 'amount', width: 140, render: (v: number, r: any) => (
<>
<div>{formatAmount(v, r.currency)}</div>
{r.currency !== 'CNY' && r.amount_cny && <div style={{ color: '#999', fontSize: 12 }}> ¥{r.amount_cny.toLocaleString('zh-CN', {minimumFractionDigits: 2})}</div>}
</>
) },
{ title: '申请日期', dataIndex: 'application_date', key: 'application_date', width: 100, render: (v: string, r: any) => v || r.payment_date },
{ title: '状态', dataIndex: 'status', key: 'status', width: 100, render: (status: string) => getStatusTag(status) },
{ title: '编号', dataIndex: 'request_code', key: 'request_code', width: 120 },
{ title: t('paymentRequest.applicationDate'), dataIndex: 'application_date', key: 'application_date', width: 100, render: (v: string, r: any) => v || r.payment_date },
{ title: t('paymentRequest.status'), dataIndex: 'status', key: 'status', width: 100, render: (status: string) => getStatusTag(status) },
{ title: t('paymentRequest.code'), dataIndex: 'request_code', key: 'request_code', width: 120 },
{
title: '操作', key: 'action', width: 250,
title: t('paymentRequest.action'), key: 'action', width: 250,
render: (_: any, record: any) => (
<Space wrap>
<Button size="small" icon={<EyeOutlined />} onClick={() => handleView(record)}></Button>
<Button size="small" icon={<EyeOutlined />} onClick={() => handleView(record)}>{t('common.detail')}</Button>
{record.status === 'pending' && (
<>
<Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)}></Button>
<Button size="small" icon={<UndoOutlined />} onClick={() => handleWithdraw(record.id)}></Button>
<Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)}>{t('paymentRequest.edit')}</Button>
<Button size="small" icon={<UndoOutlined />} onClick={() => handleWithdraw(record.id)}>{t('paymentRequest.withdraw')}</Button>
</>
)}
{(record.status === 'rejected' || record.status === 'withdrawn') && (
<Button size="small" type="primary" icon={<EditOutlined />} onClick={() => handleEdit(record)}></Button>
<Button size="small" type="primary" icon={<EditOutlined />} onClick={() => handleEdit(record)}>{t('paymentRequest.reEdit')}</Button>
)}
<Button size="small" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.id)}></Button>
<Button size="small" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.id)}>{t('paymentRequest.delete')}</Button>
</Space>
)
}
];
// 监听表单值变化
// 监听表单值变化 - 使用 useState + onValuesChange 替代 Form.useWatch 以确保稳定触发
const [watchedAmount, setWatchedAmount] = useState<number | null>(null);
const [watchedCurrency, setWatchedCurrency] = useState<string>('CNY');
const payeeType = Form.useWatch('payee_type', form);
const payeeSelect = Form.useWatch('payee_select', form);
const expenseType = Form.useWatch('expense_type', form);
const amount = Form.useWatch('amount', form);
const currency = Form.useWatch('currency', form);
const amountCNY = React.useMemo(() => {
return amount && currency ? convertToCNY(amount, currency) : 0;
}, [amount, currency, exchangeRates]);
return watchedAmount && watchedCurrency ? convertToCNY(watchedAmount, watchedCurrency) : 0;
}, [watchedAmount, watchedCurrency, exchangeRates]);
// 当选择收款单位时,自动填充收款信息
useEffect(() => {
@@ -454,45 +484,47 @@ const PaymentRequestsPage: React.FC = () => {
return (
<div style={{ padding: 24 }}>
<div style={{ marginBottom: 24 }}>
<h2 style={{ marginBottom: 8 }}></h2>
<p style={{ color: '#888', marginBottom: 0 }}></p>
<h2 style={{ marginBottom: 8 }}>{t('paymentRequest.title')}</h2>
<p style={{ color: '#888', marginBottom: 0 }}>{t('paymentRequest.description')}</p>
</div>
<Card extra={<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}></Button>}>
<Card extra={<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>{t('paymentRequest.newRequest')}</Button>}>
<Tabs activeKey={activeTab} onChange={setActiveTab}>
<Tabs.TabPane tab="活跃申请" key="active">
<Tabs.TabPane tab={t('paymentRequest.activeApplications')} key="active">
<Table dataSource={requests} columns={columns} rowKey="id" loading={loading} pagination={{ pageSize: 20 }} size="middle" scroll={{ x: 1200 }} />
</Tabs.TabPane>
<Tabs.TabPane tab="已完结" key="completed">
<Tabs.TabPane tab={t('paymentRequest.completed')} key="completed">
<Table dataSource={completedRequests} columns={columns} rowKey="id" loading={loading} pagination={{ pageSize: 20 }} size="middle" scroll={{ x: 1200 }} />
</Tabs.TabPane>
</Tabs>
</Card>
<Modal title={editingId ? '编辑付款申请' : '新建付款申请'} open={modalVisible} onOk={handleSubmit} onCancel={() => {
<Modal title={editingId ? t('paymentRequest.editPayment') : t('paymentRequest.newPayment')} open={modalVisible} onOk={handleSubmit} onCancel={() => {
if (form.isFieldsTouched()) {
Modal.confirm({
title: '确认关闭',
content: '表单数据尚未保存,关闭后可通过草稿恢复。确定关闭吗?',
okText: '关闭',
cancelText: '继续编辑',
title: t('common.closeConfirm'),
content: t('common.closeConfirmMsg'),
okText: t('common.close'),
cancelText: t('common.continueEdit'),
onOk: () => {
saveDraft();
form.resetFields();
setModalVisible(false);
},
});
} else {
form.resetFields();
setModalVisible(false);
}
}} maskClosable={false} width={900}>
}} maskClosable={false} width={900} destroyOnClose>
<Form form={form} layout="vertical" onValuesChange={handleFormChange}>
<Form.Item name="applicant" label="申请人">
<Form.Item name="applicant" label={t('paymentRequest.applicant')}>
<Input disabled style={{ color: 'rgba(0,0,0,0.85)', backgroundColor: '#f5f5f5' }} />
</Form.Item>
{/* 第2项:支出类型和支出分类 */}
<Form.Item name="expense_type" label="支出类型" rules={[{ required: true }]}>
<Select placeholder="选择支出类型">
<Form.Item name="expense_type" label={t('paymentRequest.expenseType')} rules={[{ required: true }]}>
<Select placeholder={t('paymentRequest.selectExpenseType')}>
{EXPENSE_TYPES.map(type => (
<Option key={type.value} value={type.value}>{type.label}</Option>
))}
@@ -501,8 +533,8 @@ const PaymentRequestsPage: React.FC = () => {
{/* 项目支出 - 选择项目 */}
{expenseType === 'project' && (
<Form.Item name="project_id" label="关联项目" rules={[{ required: true }]}>
<Select placeholder="选择项目" showSearch optionFilterProp="children">
<Form.Item name="project_id" label={t('paymentRequest.relatedProject')} rules={[{ required: true }]}>
<Select placeholder={t('paymentRequest.selectProject')} showSearch optionFilterProp="children">
{projects.map(proj => (
<Option key={proj.id} value={proj.id}>{proj.name}</Option>
))}
@@ -511,8 +543,8 @@ const PaymentRequestsPage: React.FC = () => {
)}
{/* 支出分类 */}
<Form.Item name="expense_category" label="支出分类" rules={[{ required: true }]}>
<Select placeholder="选择支出分类">
<Form.Item name="expense_category" label={t('paymentRequest.expenseCategory')} rules={[{ required: true }]}>
<Select placeholder={t('paymentRequest.selectCategory')}>
{(expenseType === 'project' ? PROJECT_EXPENSE_CATEGORIES : COMPANY_EXPENSE_CATEGORIES).map(cat => (
<Option key={cat.value} value={cat.value}>{cat.label}</Option>
))}
@@ -520,13 +552,13 @@ const PaymentRequestsPage: React.FC = () => {
</Form.Item>
{/* 申请日期(原付款日期,不显示) */}
<Form.Item name="application_date" label="申请日期" rules={[{ required: true }]} style={{ display: 'none' }}>
<Form.Item name="application_date" label={t('paymentRequest.applicationDate')} rules={[{ required: true }]} style={{ display: 'none' }}>
<DatePicker style={{ width: '100%' }} />
</Form.Item>
{/* 收款单位 - 二级选择 */}
<Form.Item name="payee_type" label="收款单位类型" rules={[{ required: true }]}>
<Select placeholder="选择收款单位类型">
<Form.Item name="payee_type" label={t('paymentRequest.payeeType')} rules={[{ required: true }]}>
<Select placeholder={t('paymentRequest.selectPayeeType')}>
{PAYEE_TYPES.map(type => (
<Option key={type.value} value={type.value}>{type.label}</Option>
))}
@@ -534,8 +566,8 @@ const PaymentRequestsPage: React.FC = () => {
</Form.Item>
{payeeType === 'subcontractor' && (
<Form.Item name="payee_select" label="选择分包商" rules={[{ required: true }]}>
<Select placeholder="选择分包商" showSearch optionFilterProp="children">
<Form.Item name="payee_select" label={t('paymentRequest.selectSubcontractor')} rules={[{ required: true }]}>
<Select placeholder={t('paymentRequest.selectSubcontractor')} showSearch optionFilterProp="children">
{subcontractors.map(sub => (
<Option key={sub.id} value={sub.id}>{sub.name}</Option>
))}
@@ -544,8 +576,8 @@ const PaymentRequestsPage: React.FC = () => {
)}
{payeeType === 'supplier' && (
<Form.Item name="payee_select" label="选择供应商" rules={[{ required: true }]}>
<Select placeholder="选择供应商" showSearch optionFilterProp="children">
<Form.Item name="payee_select" label={t('paymentRequest.selectSupplier')} rules={[{ required: true }]}>
<Select placeholder={t('paymentRequest.selectSupplier')} showSearch optionFilterProp="children">
{suppliers.map(sup => (
<Option key={sup.id} value={sup.id}>{sup.name}</Option>
))}
@@ -554,8 +586,8 @@ const PaymentRequestsPage: React.FC = () => {
)}
{payeeType === 'customer' && (
<Form.Item name="payee_select" label="选择客户" rules={[{ required: true }]}>
<Select placeholder="选择客户" showSearch optionFilterProp="children">
<Form.Item name="payee_select" label={t('paymentRequest.selectCustomer')} rules={[{ required: true }]}>
<Select placeholder={t('paymentRequest.selectCustomer')} showSearch optionFilterProp="children">
{customers.map(cust => (
<Option key={cust.id} value={cust.id}>{cust.name}</Option>
))}
@@ -564,102 +596,102 @@ const PaymentRequestsPage: React.FC = () => {
)}
{payeeType === 'other' && (
<Form.Item name="payee_input" label="收款单位" rules={[{ required: true }]}>
<Input placeholder="手动输入收款单位名称" />
<Form.Item name="payee_input" label={t('paymentRequest.payee')} rules={[{ required: true }]}>
<Input placeholder={t('paymentRequest.payeeNamePlaceholder')} />
</Form.Item>
)}
{/* 收款户名 - 新增字段 */}
<Form.Item name="account_name" label="收款户名">
<Input placeholder="收款户名(选择分包商/供应商/客户时自动填充)" readOnly={['subcontractor', 'supplier', 'customer'].includes(payeeType)} />
<Form.Item name="account_name" label={t('paymentRequest.accountName')}>
<Input placeholder={t('paymentRequest.accountNamePlaceholder')} readOnly={['subcontractor', 'supplier', 'customer'].includes(payeeType)} />
</Form.Item>
<Form.Item name="bank_account" label="银行账号">
<Input placeholder="收款银行账号(选择分包商/供应商/客户时自动填充)" readOnly={['subcontractor', 'supplier', 'customer'].includes(payeeType)} />
<Form.Item name="bank_account" label={t('paymentRequest.bankAccount')}>
<Input placeholder={t('paymentRequest.bankAccountPlaceholder')} readOnly={['subcontractor', 'supplier', 'customer'].includes(payeeType)} />
</Form.Item>
<Form.Item name="bank_name" label="开户银行">
<Input placeholder="开户银行名称(选择分包商/供应商/客户时自动填充)" readOnly={['subcontractor', 'supplier', 'customer'].includes(payeeType)} />
<Form.Item name="bank_name" label={t('paymentRequest.bankName')}>
<Input placeholder={t('paymentRequest.bankNamePlaceholder')} readOnly={['subcontractor', 'supplier', 'customer'].includes(payeeType)} />
</Form.Item>
{/* 收款码 - 新增字段 */}
<Form.Item name="qr_code" label="收款码">
<Form.Item name="qr_code" label={t('paymentRequest.qrCode')}>
<FileUpload maxCount={1} accept="image/*" />
</Form.Item>
<Form.Item name="currency" label="币种" rules={[{ required: true }]}>
<Select style={{ width: 200 }}>
<Option value="CNY"> (CNY)</Option>
<Option value="USD"> (USD)</Option>
<Option value="LAK"> (LAK)</Option>
<Option value="THB"> (THB)</Option>
<Form.Item name="currency" label={t('common.currency')} rules={[{ required: true }]}>
<Select style={{ width: 200 }} onChange={(value: string) => setWatchedCurrency(value)}>
<Option value="CNY">{t('paymentRequest.currencyCNY')}</Option>
<Option value="USD">{t('paymentRequest.currencyUSD')}</Option>
<Option value="LAK">{t('paymentRequest.currencyLAK')}</Option>
<Option value="THB">{t('paymentRequest.currencyTHB')}</Option>
</Select>
</Form.Item>
{/* 金额 - 直接输入 */}
<Form.Item name="amount" label="付款金额" rules={[{ required: true }]}>
<InputNumber min={0} precision={2} style={{ width: '100%' }} placeholder="输入付款金额" />
{amount && currency !== 'CNY' && amountCNY > 0 && (
<div style={{ marginTop: 8, color: '#888', fontSize: 13 }}>
¥ {amountCNY.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div>
)}
<Form.Item name="amount" label={t('paymentRequest.paymentAmount')} rules={[{ required: true }]}>
<InputNumber min={0} precision={2} style={{ width: '100%' }} placeholder={t('paymentRequest.paymentAmountPlaceholder')} onChange={(value) => setWatchedAmount(value)} />
</Form.Item>
{watchedAmount && watchedCurrency !== 'CNY' && amountCNY > 0 && (
<div style={{ marginTop: -20, marginBottom: 24, color: '#888', fontSize: 13 }}>
{t('paymentRequest.equivalentCNY')}{amountCNY.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div>
)}
<Form.Item name="reason" label={t('paymentRequest.paymentReason')} rules={[{ required: true }]}>
<TextArea rows={2} placeholder={t('paymentRequest.paymentReasonPlaceholder')} />
</Form.Item>
<Form.Item name="reason" label="付款事由" rules={[{ required: true }]}>
<TextArea rows={2} placeholder="付款原因" />
</Form.Item>
<Divider></Divider>
<Form.Item name="attachments" label="上传凭证附件">
<Divider>{t('paymentRequest.proofAttachment')}</Divider>
<Form.Item name="attachments" label={t('paymentRequest.uploadProof')}>
<FileUpload maxCount={9} accept="image/*" />
</Form.Item>
</Form>
</Modal>
<Modal title="付款申请详情" open={detailModalVisible} onCancel={() => setDetailModalVisible(false)} footer={null} width={900}>
<Modal title={t('paymentRequest.detailTitle')} open={detailModalVisible} onCancel={() => setDetailModalVisible(false)} footer={null} width={900}>
{selectedRecord && (
<>
<Descriptions bordered column={2} size="small">
<Descriptions.Item label="申请编号">{selectedRecord.request_code}</Descriptions.Item>
<Descriptions.Item label="状态">{getStatusTag(selectedRecord.status)}</Descriptions.Item>
<Descriptions.Item label="申请人">{selectedRecord.applicant}</Descriptions.Item>
<Descriptions.Item label="申请日期">{selectedRecord.application_date || selectedRecord.payment_date}</Descriptions.Item>
<Descriptions.Item label="收款单位类型">{getPayeeTypeLabel(selectedRecord.payee_type)}</Descriptions.Item>
<Descriptions.Item label="收款单位">{selectedRecord.payee}</Descriptions.Item>
<Descriptions.Item label="收款户名">{selectedRecord.account_name || '-'}</Descriptions.Item>
<Descriptions.Item label="银行账号">{selectedRecord.bank_account || '-'}</Descriptions.Item>
<Descriptions.Item label="开户银行">{selectedRecord.bank_name || '-'}</Descriptions.Item>
<Descriptions.Item label="支出类型">
{selectedRecord.expense_type === 'company' ? '公司支出' : '项目支出'}
<Descriptions.Item label={t('paymentRequest.applicationCode')}>{selectedRecord.request_code}</Descriptions.Item>
<Descriptions.Item label={t('common.status')}>{getStatusTag(selectedRecord.status)}</Descriptions.Item>
<Descriptions.Item label={t('paymentRequest.applicant')}>{selectedRecord.applicant}</Descriptions.Item>
<Descriptions.Item label={t('paymentRequest.applicationDate')}>{selectedRecord.application_date || selectedRecord.payment_date}</Descriptions.Item>
<Descriptions.Item label={t('paymentRequest.payeeType')}>{getPayeeTypeLabel(selectedRecord.payee_type)}</Descriptions.Item>
<Descriptions.Item label={t('paymentRequest.payee')}>{selectedRecord.payee}</Descriptions.Item>
<Descriptions.Item label={t('paymentRequest.accountName')}>{selectedRecord.account_name || '-'}</Descriptions.Item>
<Descriptions.Item label={t('paymentRequest.bankAccount')}>{selectedRecord.bank_account || '-'}</Descriptions.Item>
<Descriptions.Item label={t('paymentRequest.bankName')}>{selectedRecord.bank_name || '-'}</Descriptions.Item>
<Descriptions.Item label={t('paymentRequest.expenseType')}>
{selectedRecord.expense_type === 'company' ? t('paymentRequest.companyExpense') : t('paymentRequest.projectExpense')}
</Descriptions.Item>
{selectedRecord.expense_type === 'project' && (
<Descriptions.Item label="关联项目">
<Descriptions.Item label={t('paymentRequest.relatedProject')}>
{projects.find(p => p.id === selectedRecord.project_id)?.name || '-'}
</Descriptions.Item>
)}
<Descriptions.Item label="支出分类">
<Descriptions.Item label={t('paymentRequest.expenseCategory')}>
{getExpenseCategoryLabel(selectedRecord.expense_type, selectedRecord.expense_category)}
</Descriptions.Item>
<Descriptions.Item label="金额">
<Descriptions.Item label={t('common.amount')}>
{formatAmount(selectedRecord.amount, selectedRecord.currency)}
{selectedRecord.currency !== 'CNY' && selectedRecord.amount_cny && (
<span style={{ color: '#999', marginLeft: 8 }}> ¥{selectedRecord.amount_cny.toLocaleString('zh-CN', {minimumFractionDigits: 2})}</span>
)}
</Descriptions.Item>
<Descriptions.Item label="付款事由" span={2}>{selectedRecord.reason}</Descriptions.Item>
<Descriptions.Item label={t('paymentRequest.paymentReason')} span={2}>{selectedRecord.reason}</Descriptions.Item>
</Descriptions>
{selectedRecord.qr_code && (
<>
<Divider></Divider>
<Divider>{t('paymentRequest.qrCode')}</Divider>
<Image src={selectedRecord.qr_code} width={200} style={{ borderRadius: 4 }} />
</>
)}
{selectedRecord.attachments && selectedRecord.attachments.length > 0 && (
<>
<Divider></Divider>
<Divider>{t('paymentRequest.proofAttachment')}</Divider>
<Image.PreviewGroup>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{selectedRecord.attachments.map((url: string, index: number) => (
@@ -676,4 +708,4 @@ const PaymentRequestsPage: React.FC = () => {
);
};
export default PaymentRequestsPage;
export default PaymentRequestsPage;
+201 -196
View File
@@ -1,196 +1,201 @@
import React, { useCallback } from 'react';
import { Card, Typography, Button, Table, Tag, Space, Modal, Form, Input, Select, DatePicker, InputNumber, message, Row, Col, Statistic } from 'antd';
import { PlusOutlined, SearchOutlined, ShoppingOutlined } from '@ant-design/icons';
import useFormDraft from '../hooks/useFormDraft';
const { Title, Paragraph } = Typography;
const { RangePicker } = DatePicker;
const ProcurementPage: React.FC = () => {
const [loading, setLoading] = React.useState(false);
const [modalVisible, setModalVisible] = React.useState(false);
const [form] = Form.useForm();
// 表单草稿保护
const { save: saveDraft, restore: restoreDraft, clear: clearDraft, hasDraft } = useFormDraft({
form,
storageKey: 'procurement_create',
})
const handleFormChange = useCallback(() => {
saveDraft()
}, [saveDraft])
const columns = [
{ title: '采购单号', dataIndex: 'code', key: 'code', width: 140 },
{ title: '采购日期', dataIndex: 'date', key: 'date', width: 120 },
{ title: '供应商', dataIndex: 'supplier', key: 'supplier' },
{ title: '物料名称', dataIndex: 'material', key: 'material' },
{ title: '数量', dataIndex: 'quantity', key: 'quantity', width: 80 },
{ title: '单价', dataIndex: 'unitPrice', key: 'unitPrice', width: 100, render: (v: number) => `¥${v}` },
{ title: '总金额', dataIndex: 'amount', key: 'amount', width: 120, render: (v: number) => `¥${v?.toLocaleString()}` },
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: (v: string) => {
const colors: Record<string, string> = {
pending: 'default',
approved: 'processing',
received: 'success',
rejected: 'error'
};
const texts: Record<string, string> = {
pending: '待审批',
approved: '已批准',
received: '已入库',
rejected: '已拒绝'
};
return <Tag color={colors[v]}>{texts[v]}</Tag>;
}
},
{
title: '操作',
key: 'action',
width: 150,
render: () => (
<Space>
<Button size="small" type="link"></Button>
<Button size="small" type="link"></Button>
</Space>
)
}
];
const data = [
{ key: '1', code: 'PO20260318001', date: '2026-03-18', supplier: '老挝电力设备公司', material: '电缆 3x120', quantity: 1000, unitPrice: 45, amount: 45000, status: 'pending' },
{ key: '2', code: 'PO20260317002', date: '2026-03-17', supplier: '万象建材供应商', material: '钢管 DN50', quantity: 200, unitPrice: 120, amount: 24000, status: 'approved' },
{ key: '3', code: 'PO20260316003', date: '2026-03-16', supplier: '沙湾五金店', material: '螺栓 M12', quantity: 500, unitPrice: 5, amount: 2500, status: 'received' },
];
const handleSubmit = () => {
message.success('采购申请已提交');
clearDraft()
setModalVisible(false);
};
return (
<div style={{ padding: 24 }}>
<div style={{ marginBottom: 24, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<Title level={3} style={{ marginBottom: 0 }}></Title>
<Paragraph type="secondary"></Paragraph>
</div>
<Space>
<RangePicker placeholder={['开始日期', '结束日期']} />
<Input.Search placeholder="搜索采购单号" style={{ width: 200 }} />
<Button type="primary" icon={<PlusOutlined />} onClick={() => {
setModalVisible(true)
// 检查是否有草稿,提示用户是否恢复
setTimeout(() => {
if (hasDraft()) {
Modal.confirm({
title: '发现未完成的草稿',
content: '检测到上次未提交的采购信息,是否恢复?',
okText: '恢复草稿',
cancelText: '重新填写',
onOk: () => {
restoreDraft()
},
onCancel: () => {
clearDraft()
form.resetFields()
},
})
}
}, 0)
}}>
</Button>
</Space>
</div>
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col xs={24} sm={12} md={6}>
<Card>
<Statistic title="待审批" value={5} prefix={<ShoppingOutlined />} />
</Card>
</Col>
<Col xs={24} sm={12} md={6}>
<Card>
<Statistic title="已批准" value={12} valueStyle={{ color: '#1890ff' }} />
</Card>
</Col>
<Col xs={24} sm={12} md={6}>
<Card>
<Statistic title="已入库" value={28} valueStyle={{ color: '#52c41a' }} />
</Card>
</Col>
<Col xs={24} sm={12} md={6}>
<Card>
<Statistic title="本月采购额" value={156000} prefix="¥" />
</Card>
</Col>
</Row>
<Card>
<Table columns={columns} dataSource={data} loading={loading} pagination={{ pageSize: 10 }} scroll={{ x: 1200 }} />
</Card>
<Modal
title="新建采购申请"
open={modalVisible}
onCancel={() => {
if (form.isFieldsTouched()) {
Modal.confirm({
title: '确认关闭',
content: '表单数据尚未保存,关闭后可通过草稿恢复。确定关闭吗?',
okText: '关闭',
cancelText: '继续编辑',
onOk: () => {
saveDraft()
setModalVisible(false)
},
})
} else {
setModalVisible(false)
}
}}
onOk={handleSubmit}
width={600}
maskClosable={false}
>
<Form form={form} layout="vertical" onValuesChange={handleFormChange}>
<Form.Item label="供应商" name="supplier" rules={[{ required: true }]}>
<Select placeholder="选择供应商" options={[
{ value: 'supplier1', label: '老挝电力设备公司' },
{ value: 'supplier2', label: '万象建材供应商' },
{ value: 'supplier3', label: '沙湾五金店' }
]} />
</Form.Item>
<Form.Item label="物料名称" name="material" rules={[{ required: true }]}>
<Input placeholder="请输入物料名称" />
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item label="数量" name="quantity" rules={[{ required: true }]}>
<InputNumber style={{ width: '100%' }} min={1} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="单价" name="unitPrice" rules={[{ required: true }]}>
<InputNumber style={{ width: '100%' }} min={0} precision={2} prefix="¥" />
</Form.Item>
</Col>
</Row>
<Form.Item label="备注" name="remark">
<Input.TextArea rows={3} placeholder="请输入备注说明" />
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default ProcurementPage;
import React, { useCallback } from 'react';
import { Card, Typography, Button, Table, Tag, Space, Modal, Form, Input, Select, DatePicker, InputNumber, message, Row, Col, Statistic } from 'antd';
import { PlusOutlined, SearchOutlined, ShoppingOutlined } from '@ant-design/icons';
import useFormDraft from '../hooks/useFormDraft';
import { useLanguageStore } from '../store/languageStore';
const { Title, Paragraph } = Typography;
const { RangePicker } = DatePicker;
const ProcurementPage: React.FC = () => {
const { t, currentLanguage } = useLanguageStore();
const [loading, setLoading] = React.useState(false);
const [modalVisible, setModalVisible] = React.useState(false);
const [form] = Form.useForm();
// 表单草稿保护
const { save: saveDraft, restore: restoreDraft, clear: clearDraft, hasDraft } = useFormDraft({
form,
storageKey: 'procurement_create',
})
const handleFormChange = useCallback(() => {
saveDraft()
}, [saveDraft])
const columns = [
{ title: t('procurement.orderCode'), dataIndex: 'code', key: 'code', width: 140 },
{ title: t('procurement.purchaseDate'), dataIndex: 'date', key: 'date', width: 120 },
{ title: t('procurement.supplier'), dataIndex: 'supplier', key: 'supplier' },
{ title: t('procurement.materialName'), dataIndex: 'material', key: 'material' },
{ title: t('procurement.quantity'), dataIndex: 'quantity', key: 'quantity', width: 80 },
{ title: t('procurement.unitPrice'), dataIndex: 'unitPrice', key: 'unitPrice', width: 100, render: (v: number) => `¥${v}` },
{ title: t('procurement.totalAmount'), dataIndex: 'amount', key: 'amount', width: 120, render: (v: number) => `¥${v?.toLocaleString()}` },
{
title: t('procurement.status'),
dataIndex: 'status',
key: 'status',
width: 100,
render: (v: string) => {
const colors: Record<string, string> = {
pending: 'default',
approved: 'processing',
received: 'success',
rejected: 'error'
};
const texts: Record<string, string> = {
pending: t('procurement.pendingApproval'),
approved: t('procurement.approved'),
received: t('procurement.stocked'),
rejected: t('procurement.rejected')
};
return <Tag color={colors[v]}>{texts[v]}</Tag>;
}
},
{
title: t('procurement.action'),
key: 'action',
width: 150,
render: () => (
<Space>
<Button size="small" type="link">{t('procurement.view')}</Button>
<Button size="small" type="link">{t('procurement.approve')}</Button>
</Space>
)
}
];
const data = [
{ key: '1', code: 'PO20260318001', date: '2026-03-18', supplier: '老挝电力设备公司', material: '电缆 3x120', quantity: 1000, unitPrice: 45, amount: 45000, status: 'pending' },
{ key: '2', code: 'PO20260317002', date: '2026-03-17', supplier: '万象建材供应商', material: '钢管 DN50', quantity: 200, unitPrice: 120, amount: 24000, status: 'approved' },
{ key: '3', code: 'PO20260316003', date: '2026-03-16', supplier: '沙湾五金店', material: '螺栓 M12', quantity: 500, unitPrice: 5, amount: 2500, status: 'received' },
];
const handleSubmit = () => {
message.success(t('procurement.submitSuccess'));
clearDraft()
setModalVisible(false);
};
return (
<div style={{ padding: 24 }}>
<div style={{ marginBottom: 24, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<Title level={3} style={{ marginBottom: 0 }}>{t('procurement.title')}</Title>
<Paragraph type="secondary">{t('procurement.description')}</Paragraph>
</div>
<Space>
<RangePicker placeholder={[t('procurement.startDate'), t('procurement.endDate')]} />
<Input.Search placeholder={t('procurement.searchOrder')} style={{ width: 200 }} />
<Button type="primary" icon={<PlusOutlined />} onClick={() => {
setModalVisible(true)
// 检查是否有草稿,提示用户是否恢复
setTimeout(() => {
if (hasDraft()) {
Modal.confirm({
title: t('common.draftFound'),
content: t('common.draftRestore'),
okText: t('common.restoreDraft'),
cancelText: t('common.reFill'),
onOk: () => {
restoreDraft()
},
onCancel: () => {
clearDraft()
form.resetFields()
},
})
}
}, 0)
}}>
{t('procurement.newProcurement')}
</Button>
</Space>
</div>
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col xs={24} sm={12} md={6}>
<Card>
<Statistic title={t('procurement.pendingApproval')} value={5} prefix={<ShoppingOutlined />} />
</Card>
</Col>
<Col xs={24} sm={12} md={6}>
<Card>
<Statistic title={t('procurement.approved')} value={12} valueStyle={{ color: '#1890ff' }} />
</Card>
</Col>
<Col xs={24} sm={12} md={6}>
<Card>
<Statistic title={t('procurement.stocked')} value={28} valueStyle={{ color: '#52c41a' }} />
</Card>
</Col>
<Col xs={24} sm={12} md={6}>
<Card>
<Statistic title={t('procurement.monthPurchase')} value={156000} prefix="¥" />
</Card>
</Col>
</Row>
<Card>
<Table columns={columns} dataSource={data} loading={loading} pagination={{ pageSize: 10 }} scroll={{ x: 1200 }} />
</Card>
<Modal
title={t('procurement.newApplication')}
open={modalVisible}
onCancel={() => {
if (form.isFieldsTouched()) {
Modal.confirm({
title: t('common.closeConfirm'),
content: t('common.closeConfirmMsg'),
okText: t('common.close'),
cancelText: t('common.continueEdit'),
onOk: () => {
saveDraft()
form.resetFields()
setModalVisible(false)
},
})
} else {
form.resetFields()
setModalVisible(false)
}
}}
onOk={handleSubmit}
destroyOnClose
width={600}
maskClosable={false}
>
<Form form={form} layout="vertical" onValuesChange={handleFormChange}>
<Form.Item label={t('procurement.supplier')} name="supplier" rules={[{ required: true }]}>
<Select placeholder={t('procurement.selectSupplier')} options={[
{ value: 'supplier1', label: '老挝电力设备公司' },
{ value: 'supplier2', label: '万象建材供应商' },
{ value: 'supplier3', label: '沙湾五金店' }
]} />
</Form.Item>
<Form.Item label={t('procurement.materialName')} name="material" rules={[{ required: true }]}>
<Input placeholder={t('procurement.inputMaterialName')} />
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item label={t('procurement.quantity')} name="quantity" rules={[{ required: true }]}>
<InputNumber style={{ width: '100%' }} min={1} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label={t('procurement.unitPrice')} name="unitPrice" rules={[{ required: true }]}>
<InputNumber style={{ width: '100%' }} min={0} precision={2} prefix=" />
</Form.Item>
</Col>
</Row>
<Form.Item label={t('procurement.remark')} name="remark">
<Input.TextArea rows={3} placeholder={t('procurement.remarkPlaceholder')} />
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default ProcurementPage;
File diff suppressed because it is too large Load Diff
+189 -186
View File
@@ -1,14 +1,15 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Card, Typography, Form, Input, Button, Avatar, Space, Upload, message, Row, Col, Modal } from 'antd';
import { UserOutlined, LockOutlined, PhoneOutlined, MailOutlined, UploadOutlined } from '@ant-design/icons';
import { Card, Typography, Form, Input, Button, Avatar, Space, Upload, message, Row, Col, Modal, Image, Popconfirm } from 'antd';
import { UserOutlined, LockOutlined, PhoneOutlined, MailOutlined, UploadOutlined, EyeOutlined, DeleteOutlined, ReloadOutlined } from '@ant-design/icons';
import { useAuthStore } from '../store/authStore';
import useFormDraft from '../hooks/useFormDraft';
import { useLanguageStore } from '../store/languageStore';
import apiClient from '../utils/request';
const { Title, Paragraph } = Typography;
const { Title, Paragraph, Text } = Typography;
const ProfilePage: React.FC = () => {
const { user, setUser } = useAuthStore();
const { t, currentLanguage } = useLanguageStore();
const [form] = Form.useForm();
const [passwordForm] = Form.useForm();
const [loading, setLoading] = useState(false);
@@ -16,96 +17,79 @@ const ProfilePage: React.FC = () => {
const [avatarUrl, setAvatarUrl] = useState<string | undefined>(user?.avatar);
const [passportUrl, setPassportUrl] = useState<string | undefined>(user?.passport);
const [driverLicenseUrl, setDriverLicenseUrl] = useState<string | undefined>(user?.driverLicense);
const [uploading, setUploading] = useState<string | null>(null);
const [previewVisible, setPreviewVisible] = useState(false);
const [previewTitle, setPreviewTitle] = useState('');
const [previewUrl, setPreviewUrl] = useState('');
const [isMobile, setIsMobile] = useState(false);
// 表单草稿保护
const { save: saveDraft, restore: restoreDraft, clear: clearDraft, hasDraft } = useFormDraft({
form,
storageKey: 'profile_edit',
onRestore: (data) => {
if (data.avatarUrl !== undefined) setAvatarUrl(data.avatarUrl)
if (data.passportUrl !== undefined) setPassportUrl(data.passportUrl)
if (data.driverLicenseUrl !== undefined) setDriverLicenseUrl(data.driverLicenseUrl)
},
})
const handleFormChange = useCallback(() => {
saveDraft({ avatarUrl, passportUrl, driverLicenseUrl })
}, [saveDraft, avatarUrl, passportUrl, driverLicenseUrl])
// 页面级 beforeunload 保护
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (form.isFieldsTouched()) {
e.preventDefault()
}
}
window.addEventListener('beforeunload', handleBeforeUnload)
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
}, [form])
// 页面加载时检查草稿
useEffect(() => {
if (hasDraft()) {
Modal.confirm({
title: '发现未完成的草稿',
content: '检测到上次未保存的个人信息修改,是否恢复?',
okText: '恢复草稿',
cancelText: '放弃草稿',
onOk: () => {
restoreDraft()
},
onCancel: () => {
clearDraft()
},
})
}
}, [])
const checkMobile = () => setIsMobile(window.innerWidth <= 768);
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
useEffect(() => {
if (user) {
form.setFieldsValue({
name: user.name,
phone: user.phone,
email: user.email
});
form.setFieldsValue({ name: user.name, phone: user.phone, email: user.email });
setAvatarUrl(user.avatar);
setPassportUrl(user.passport);
setDriverLicenseUrl(user.driverLicense);
}
const fetchProfile = async () => {
try {
const res = await apiClient.get('/users/me');
if (res.data.success) {
const d = res.data.data;
form.setFieldsValue({ name: d.name, phone: d.phone, email: d.email });
setAvatarUrl(d.avatar);
setPassportUrl(d.passport);
setDriverLicenseUrl(d.driverLicense);
if (user) {
setUser({ ...user, name: d.name, email: d.email, phone: d.phone, avatar: d.avatar, passport: d.passport, driverLicense: d.driverLicense });
}
}
} catch (e) {}
};
fetchProfile();
}, [user, form]);
const uploadToCos = async (file: File): Promise<string | null> => {
try {
const formData = new FormData();
formData.append('file', file);
const res = await apiClient.post('/upload/single', formData);
if (res.data.success) return res.data.data.url;
message.error(t('user.uploadFailed'));
return null;
} catch (e) {
message.error(t('user.uploadFailed'));
return null;
}
};
const handleSubmit = async () => {
try {
const values = await form.validateFields();
setLoading(true);
const response = await apiClient.put(`/users/${user?.id}`, {
const response = await apiClient.put(`/users/${user?.id}/profile`, {
...values,
avatar: avatarUrl,
passport: passportUrl,
driverLicense: driverLicenseUrl
driver_license: driverLicenseUrl
});
const data = response.data;
if (data.success) {
message.success('个人信息已更新');
// 更新authStore中的用户信息
message.success(t('user.profileUpdated'));
if (user) {
const updatedUser = {
...user,
name: values.name,
email: values.email,
phone: values.phone,
avatar: avatarUrl
};
setUser(updatedUser);
setUser({ ...user, name: values.name, email: values.email, phone: values.phone, avatar: avatarUrl, passport: passportUrl, driverLicense: driverLicenseUrl });
}
} else {
message.error(data.message || '更新失败');
message.error(data.message || t('user.updateFailed'));
}
} catch (error) {
console.error('提交失败:', error);
message.error('更新失败,请重试');
message.error(t('user.updateRetry'));
} finally {
setLoading(false);
}
@@ -114,200 +98,219 @@ const ProfilePage: React.FC = () => {
const handlePasswordSubmit = async () => {
try {
const values = await passwordForm.validateFields();
const response = await apiClient.put(`/users/${user?.id}/password`, {
currentPassword: values.currentPassword,
newPassword: values.newPassword
});
const data = response.data;
if (data.success) {
message.success('密码已更新');
message.success(t('user.passwordUpdated'));
setPasswordModalVisible(false);
passwordForm.resetFields();
} else {
message.error(data.message || '密码更新失败');
message.error(data.message || t('user.passwordUpdateFailed'));
}
} catch (error) {
console.error('提交失败:', error);
message.error('密码更新失败,请重试');
message.error(t('user.passwordUpdateRetry'));
}
};
const handleAvatarChange = (info: any) => {
if (info.file.status === 'done') {
setAvatarUrl(URL.createObjectURL(info.file.originFileObj));
message.success('头像上传成功');
} else if (info.file.status === 'error') {
message.error('头像上传失败');
}
const handleAvatarUpload = async (options: any) => {
setUploading('avatar');
const url = await uploadToCos(options.file);
if (url) setAvatarUrl(url);
setUploading(null);
};
const handlePassportChange = (info: any) => {
if (info.file.status === 'done') {
setPassportUrl(URL.createObjectURL(info.file.originFileObj));
message.success('护照上传成功');
} else if (info.file.status === 'error') {
message.error('护照上传失败');
}
const handlePassportUpload = async (options: any) => {
setUploading('passport');
const url = await uploadToCos(options.file);
if (url) setPassportUrl(url);
setUploading(null);
};
const handleDriverLicenseChange = (info: any) => {
if (info.file.status === 'done') {
setDriverLicenseUrl(URL.createObjectURL(info.file.originFileObj));
message.success('驾照上传成功');
} else if (info.file.status === 'error') {
message.error('驾照上传失败');
}
const handleDriverLicenseUpload = async (options: any) => {
setUploading('driverLicense');
const url = await uploadToCos(options.file);
if (url) setDriverLicenseUrl(url);
setUploading(null);
};
const showPreview = (title: string, url: string) => {
setPreviewTitle(title);
setPreviewUrl(url);
setPreviewVisible(true);
};
const colSpan = isMobile ? 24 : 12;
return (
<div style={{ padding: 24 }}>
<div style={{ padding: isMobile ? 8 : 24 }}>
<div style={{ marginBottom: 24 }}>
<Title level={3}></Title>
<Paragraph type="secondary"></Paragraph>
<Title level={3}>{t('user.profile')}</Title>
<Paragraph type="secondary">{t('user.managingProfile')}</Paragraph>
</div>
<Card>
<div style={{ textAlign: 'center', marginBottom: 24 }}>
<Space direction="vertical" style={{ alignItems: 'center' }}>
<Upload
name="avatar"
listType="picture-circle"
customRequest={handleAvatarUpload}
showUploadList={false}
onChange={handleAvatarChange}
maxCount={1}
accept="image/*"
>
{avatarUrl ? (
<Avatar size={128} src={avatarUrl} />
<Avatar size={128} src={avatarUrl} style={{ cursor: 'pointer' }} />
) : (
<Avatar size={128} icon={<UserOutlined />} />
<Avatar size={128} icon={<UserOutlined />} style={{ cursor: 'pointer' }} />
)}
</Upload>
<Typography.Text></Typography.Text>
<Typography.Text strong>{user?.name || user?.username}</Typography.Text>
<Typography.Text type="secondary">{user?.role === 'admin' ? '管理员' : user?.role === 'manager' ? '经理' : '普通用户'}</Typography.Text>
<Text>{t('user.clickToChangeAvatar')}</Text>
<Text strong>{user?.name || user?.username}</Text>
<Text type="secondary">{user?.role === 'admin' ? t('user.admin') : user?.role === 'manager' ? t('user.manager') : t('user.employee')}</Text>
</Space>
</div>
<Form form={form} layout="vertical" onFinish={handleSubmit} onValuesChange={handleFormChange}>
<Form form={form} layout="vertical" onFinish={handleSubmit}>
<Row gutter={16}>
<Col span={12}>
<Form.Item label="姓名" name="name" rules={[{ required: true, message: '请输入姓名' }]}>
<Input placeholder="请输入姓名" />
<Col span={colSpan}>
<Form.Item label={t('user.name')} name="name" rules={[{ required: true, message: t('common.inputPlaceholder') }]}>
<Input placeholder={t('common.inputPlaceholder')} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="手机号" name="phone" rules={[{ required: true, message: '请输入手机号' }]}>
<Input placeholder="请输入手机号" prefix={<PhoneOutlined />} />
<Col span={colSpan}>
<Form.Item label={t('user.phone')} name="phone" rules={[{ required: true, message: t('common.inputPlaceholder') }]}>
<Input placeholder={t('common.inputPlaceholder')} prefix={<PhoneOutlined />} />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item label="邮箱" name="email" rules={[{ required: true, message: '请输入邮箱' }, { type: 'email', message: '请输入正确的邮箱地址' }]}>
<Input placeholder="请输入邮箱" prefix={<MailOutlined />} />
<Col span={colSpan}>
<Form.Item label={t('user.email')} name="email" rules={[{ required: true, message: t('common.inputPlaceholder') }, { type: 'email', message: 'Invalid email' }]}>
<Input placeholder="email@example.com" prefix={<MailOutlined />} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="用户名" disabled>
<Input value={user?.username} placeholder="用户名" />
<Col span={colSpan}>
<Form.Item label={t('user.username')} disabled>
<Input value={user?.username} placeholder={t('user.username')} />
</Form.Item>
</Col>
</Row>
<div style={{ marginBottom: 24 }}>
<Title level={4}></Title>
<div style={{ marginBottom: 16, marginTop: 8 }}>
<Title level={4}>{t('user.idDocument')}</Title>
<Text type="secondary">{t('user.idDocTip')}</Text>
</div>
<Row gutter={16}>
<Col span={12}>
<Form.Item label="护照">
<Upload
name="passport"
listType="picture"
showUploadList={false}
onChange={handlePassportChange}
maxCount={1}
>
<Card
style={{ textAlign: 'center', padding: 24, border: '1px dashed #d9d9d9' }}
>
{passportUrl ? (
<img src={passportUrl} alt="护照" style={{ maxWidth: '100%', maxHeight: 200 }} />
) : (
<Space direction="vertical" style={{ alignItems: 'center' }}>
<UploadOutlined style={{ fontSize: 32, color: '#1890ff' }} />
<Typography.Text></Typography.Text>
</Space>
)}
</Card>
</Upload>
</Form.Item>
<Col span={colSpan}>
<Card
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 ? (
<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>
) : (
<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' }} />
<div style={{ marginTop: 8 }}><Text type="secondary">{t('user.uploadPassport')}</Text></div>
</div>
</Upload>
)}
</Card>
</Col>
<Col span={12}>
<Form.Item label="驾照">
<Upload
name="driverLicense"
listType="picture"
showUploadList={false}
onChange={handleDriverLicenseChange}
maxCount={1}
>
<Card
style={{ textAlign: 'center', padding: 24, border: '1px dashed #d9d9d9' }}
>
{driverLicenseUrl ? (
<img src={driverLicenseUrl} alt="驾照" style={{ maxWidth: '100%', maxHeight: 200 }} />
) : (
<Space direction="vertical" style={{ alignItems: 'center' }}>
<UploadOutlined style={{ fontSize: 32, color: '#1890ff' }} />
<Typography.Text></Typography.Text>
</Space>
)}
</Card>
</Upload>
</Form.Item>
<Col span={colSpan}>
<Card
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 ? (
<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>
) : (
<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' }} />
<div style={{ marginTop: 8 }}><Text type="secondary">{t('user.uploadDriverLicense')}</Text></div>
</div>
</Upload>
)}
</Card>
</Col>
</Row>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 24 }}>
<Button type="primary" htmlType="submit" loading={loading}>
</Button>
<Button style={{ marginLeft: 16 }} onClick={() => setPasswordModalVisible(true)}>
</Button>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 24, gap: 8 }}>
<Button type="primary" htmlType="submit" loading={loading}>{t('user.saveProfile')}</Button>
<Button onClick={() => setPasswordModalVisible(true)}>{t('user.changePassword')}</Button>
</div>
</Form>
</Card>
<Modal
title="修改密码"
title={t('user.changePassword')}
open={passwordModalVisible}
onCancel={() => setPasswordModalVisible(false)}
onOk={handlePasswordSubmit}
width={400}
>
<Form form={passwordForm} layout="vertical">
<Form.Item label="当前密码" name="currentPassword" rules={[{ required: true, message: '请输入当前密码' }]}>
<Input.Password placeholder="请输入当前密码" prefix={<LockOutlined />} />
<Form.Item label={t('user.currentPassword')} name="currentPassword" rules={[{ required: true, message: t('user.currentPasswordPlaceholder') }]}>
<Input.Password placeholder={t('user.currentPasswordPlaceholder')} prefix={<LockOutlined />} />
</Form.Item>
<Form.Item label="新密码" name="newPassword" rules={[{ required: true, message: '请输入新密码' }, { min: 6, message: '密码长度至少为6位' }]}>
<Input.Password placeholder="请输入新密码" prefix={<LockOutlined />} />
<Form.Item label={t('user.newPassword')} name="newPassword" rules={[{ required: true, message: t('user.newPasswordPlaceholder') }, { min: 6, message: t('user.passwordMinLen') }]}>
<Input.Password placeholder={t('user.newPasswordPlaceholder')} prefix={<LockOutlined />} />
</Form.Item>
<Form.Item label="确认新密码" name="confirmPassword" rules={[{ required: true, message: '请确认新密码' }, ({ getFieldValue }) => ({
<Form.Item label={t('user.confirmPassword')} name="confirmPassword" rules={[{ required: true, message: t('user.confirmPasswordPlaceholder') }, ({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('newPassword') === value) {
return Promise.resolve();
}
return Promise.reject(new Error('两次输入的密码不一致'));
if (!value || getFieldValue('newPassword') === value) return Promise.resolve();
return Promise.reject(new Error(t('user.passwordMismatch')));
}
})]}>
<Input.Password placeholder="请确认新密码" prefix={<LockOutlined />} />
<Input.Password placeholder={t('user.confirmPasswordPlaceholder')} prefix={<LockOutlined />} />
</Form.Item>
</Form>
</Modal>
<Modal
title={previewTitle}
open={previewVisible}
footer={null}
onCancel={() => setPreviewVisible(false)}
width={isMobile ? '95%' : 600}
centered
>
<div style={{ textAlign: 'center' }}>
<img src={previewUrl} alt={previewTitle} style={{ maxWidth: '100%', maxHeight: '70vh', objectFit: 'contain' }} />
</div>
</Modal>
</div>
);
};
+383 -225
View File
@@ -1,249 +1,407 @@
import React, { useState, useEffect } from 'react'
import React, { useState, useEffect, useMemo } from 'react'
import {
Table, Button, Card, Row, Col, Statistic, Select, message, Spin, Progress
Card, Row, Col, Statistic, Select, message, Spin, Progress, Typography, Tabs, Table, Modal, Tag, DatePicker, Space
} from 'antd'
import { BarChartOutlined, DollarOutlined, ShoppingOutlined } from '@ant-design/icons'
import type { ColumnsType } from 'antd/es/table'
import { RiseOutlined, FallOutlined, UnorderedListOutlined, BarChartOutlined } from '@ant-design/icons'
import { useLanguageStore } from '../store/languageStore'
import apiClient from '../utils/request'
import dayjs from 'dayjs'
// ==================== 类型定义 ====================
interface Project {
id: number
name: string
code: string
contract_amount: number
}
const { Title } = Typography
const { RangePicker } = DatePicker
interface CostSummary {
project_name: string
contract_amount: number
purchase_cost: {
total: number
by_category: Record<string, number>
}
payment_cost: number
total_cost: number
profit: number
}
// ==================== 组件 ====================
const ProjectCostPage: React.FC = () => {
// 状态
const [projects, setProjects] = useState<Project[]>([])
const { t } = useLanguageStore()
const LEVEL2_LABELS = useMemo(() => ({
contract_payment: t('finance.projectRevenue'),
deposit_refund: t('finance.warrantyReturn'),
shareholder_investment: t('finance.shareholderInvestment'),
other_income: t('finance.otherIncome'),
customer_advance: t('cash.customerAdvance'),
bank_loan: t('cash.bankLoan'),
other_loan: t('cash.otherLoan'),
dividend_income: t('cash.dividendIncome'),
interest_income: t('cash.interestIncome'),
asset_disposal: t('cash.assetDisposal'),
tax_refund: t('cash.taxRefund'),
government_subsidy: t('cash.governmentSubsidy'),
material: t('finance.materialPurchase'),
equipment: t('finance.equipmentPurchase'),
subcontract: t('finance.constructionSubcontract'),
construction_subcontract: t('finance.constructionSubcontract'),
labor: t('finance.laborWage'),
travel: t('finance.travelTransport'),
accommodation: t('finance.accommodationFood'),
freight: t('finance.transportLogistics'),
transport_logistics: t('finance.transportLogistics'),
design: t('finance.surveyDesign'),
survey_design: t('finance.surveyDesign'),
tools: t('finance.smallTools'),
client_relations: t('finance.customerEDLRelation'),
customer_edl: t('finance.customerEDLRelation'),
other_project: t('finance.otherProjectExpense'),
salary: t('finance.salaryWelfare'),
rent: t('finance.rentProperty'),
office: t('finance.officeExpense'),
commute: t('finance.commute'),
vehicle_maintenance: t('finance.vehicleMaintenance'),
assets: t('finance.fixedAsset'),
marketing: t('finance.marketing'),
entertainment: t('finance.entertainment'),
welfare: t('finance.employeeBenefit'),
logistics: t('finance.expressLogistics'),
other_company: t('finance.otherCompanyExpense'),
loan_repayment: t('cash.loanRepayment'),
interest_expense: t('cash.interestExpense'),
dividend_payment: t('cash.dividendPayment'),
tax_payment: t('cash.taxPayment'),
deposit_payment: t('cash.depositPayment'),
owner_expense: t('cash.ownerExpense'),
other_finance: t('cash.otherFinance'),
}), [t])
const LEVEL1_LABELS = useMemo(() => ({
income: t('finance.incomeCategory'),
project: t('finance.projectExpense'),
company: t('finance.companyExpense'),
finance: t('cash.financeExpense'),
}), [t])
const COUNTERPARTY_LABELS = useMemo(() => ({
supplier: t('finance.counterpartySupplier'),
subcontractor: t('finance.counterpartySubcontractor'),
customer: t('finance.counterpartyCustomer'),
employee: t('finance.counterpartyEmployee'),
logistics: t('finance.counterpartyLogistics'),
shareholder: t('finance.counterpartyShareholder'),
bank: t('cash.counterpartyBank'),
other: t('finance.counterpartyOther'),
}), [t])
const SOURCE_LABELS = useMemo(() => ({
manual: t('finance.manual'),
cash_management: t('cash.sourceLabel'),
receipt: t('cash.receiptSource'),
advance: t('finance.advance'),
reimbursement: t('finance.reimbursement'),
material: t('finance.material'),
primary_freight: t('finance.freight'),
secondary_freight: t('finance.freight'),
}), [t])
const [projects, setProjects] = useState<any[]>([])
const [selectedProjectId, setSelectedProjectId] = useState<number | null>(null)
const [costSummary, setCostSummary] = useState<CostSummary | null>(null)
const [costSummary, setCostSummary] = useState<any>(null)
const [loading, setLoading] = useState(false)
// ==================== 数据加载 ====================
const fetchProjects = async () => {
try {
const response = await fetch('/api/projects')
const data = await response.json()
if (data.success) {
setProjects(data.data)
}
} catch (error) {
console.error('获取项目列表失败:', error)
}
}
const fetchCostSummary = async (projectId: number) => {
setLoading(true)
try {
const response = await fetch(`/api/projects/${projectId}/cost-summary`)
const data = await response.json()
if (data.success) {
setCostSummary(data.data)
} else {
message.error('获取成本统计失败')
setCostSummary(null)
}
} catch (error) {
console.error('获取成本统计失败:', error)
message.error('获取成本统计失败')
setCostSummary(null)
} finally {
setLoading(false)
}
}
const [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 [modalVisible, setModalVisible] = useState(false)
const [modalTitle, setModalTitle] = useState('')
const [modalRecords, setModalRecords] = useState<any[]>([])
const [modalPagination, setModalPagination] = useState({ page: 1, pageSize: 20, total: 0 })
const [modalLoading, setModalLoading] = useState(false)
const [modalFilter, setModalFilter] = useState<any>({})
useEffect(() => {
fetchProjects()
apiClient.get('/projects', { params: { pageSize: 200 } }).then(res => {
if (res.data.success) setProjects(res.data.data || res.data.projects || [])
}).catch(() => {})
}, [])
useEffect(() => {
if (selectedProjectId) {
fetchCostSummary(selectedProjectId)
setLoading(true)
apiClient.get(`/projects/${selectedProjectId}/cost-summary`).then(res => {
if (res.data.success) setCostSummary(res.data.data)
else { message.error(t('projectCost.getDataFailed')); setCostSummary(null) }
}).catch(() => { message.error(t('projectCost.getDataFailed')); setCostSummary(null) })
.finally(() => setLoading(false))
} else {
setCostSummary(null)
}
}, [selectedProjectId])
// ==================== 渲染 ====================
return (
<div style={{ padding: 24 }}>
<Card>
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={12}>
<Select
placeholder="请选择项目查看成本统计"
style={{ width: '100%' }}
value={selectedProjectId}
onChange={(value) => setSelectedProjectId(value)}
>
{projects.map(project => (
<Select.Option key={project.id} value={project.id}>
{project.name}
</Select.Option>
))}
</Select>
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>
{loading ? (
<div style={{ textAlign: 'center', padding: 40 }}>
<Spin size="large" />
<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>
) : 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>
</Col>
<Col span={6}>
<Card>
<Statistic
title="采购成本"
value={costSummary.purchase_cost.total}
precision={2}
prefix={<ShoppingOutlined />}
valueStyle={{ color: '#faad14' }}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="付款支出"
value={costSummary.payment_cost}
precision={2}
prefix={<DollarOutlined />}
valueStyle={{ color: '#fa8c16' }}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="利润"
value={costSummary.profit}
precision={2}
prefix={<BarChartOutlined />}
valueStyle={{ color: costSummary.profit >= 0 ? '#52c41a' : '#ff4d4f' }}
/>
</Card>
</Col>
<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>
<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>
)}
</>
)
}
const renderDetails = () => {
if (!selectedProjectId) return <div style={{ textAlign: 'center', padding: 40, color: '#999' }}>{t('projectCost.selectProject')}</div>
return (
<>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }}>
<Space size="small" wrap>
<RangePicker size="small" onChange={(dates) => setDetailDateRange(dates as any)} />
<Select size="small" allowClear placeholder={t('finance.incomeType')} style={{ width: 100 }}
onChange={(v) => { const f = { ...detailFilter, txn_type: v }; if (!v) delete f.txn_type; setDetailFilter(f); fetchDetails(1, f); }}
options={[{ value: 'income', label: t('finance.income') }, { value: 'expense', label: t('finance.expense') }]}
/>
<Select size="small" allowClear placeholder={t('finance.level1Category')} style={{ width: 120 }}
onChange={(v) => { const f = { ...detailFilter, category_level1: v }; if (!v) delete f.category_level1; setDetailFilter(f); fetchDetails(1, f); }}
options={[
{ value: 'income', label: t('finance.incomeCategory') },
{ value: 'project', label: t('finance.projectExpense') },
{ value: 'company', label: t('finance.companyExpense') },
{ value: 'finance', label: t('cash.financeExpense') },
]}
/>
</Space>
</div>
<Card size="small" styles={{ body: { padding: 0 } }}>
<Spin spinning={detailLoading}>
<Table
dataSource={details}
columns={detailColumns}
rowKey="id"
size="small"
scroll={{ x: 900 }}
pagination={{
current: detailPagination.page,
pageSize: detailPagination.pageSize,
total: detailPagination.total,
onChange: (page) => fetchDetails(page, detailFilter),
showTotal: (total) => t('finance.totalRecords', { total })
}}
/>
</Spin>
</Card>
</>
)
}
const tabItems = [
{
key: 'overview',
label: <span><BarChartOutlined /> {t('projectCost.overviewTab')}</span>,
children: renderOverview(),
},
{
key: 'details',
label: <span><UnorderedListOutlined /> {t('projectCost.detailsTab')}</span>,
children: renderDetails(),
},
]
return (
<div style={{ padding: 24 }}>
<Title level={3} style={{ marginBottom: 24 }}>{t('projectCost.title')}</Title>
<Card style={{ marginBottom: 16 }}>
<Select
placeholder={t('projectCost.selectProject')}
style={{ width: '100%', maxWidth: 500 }}
showSearch optionFilterProp="label"
value={selectedProjectId}
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
>
<Spin spinning={modalLoading}>
<Table
dataSource={modalRecords}
columns={modalColumns}
rowKey="id"
size="small"
scroll={{ x: 700 }}
pagination={{
current: modalPagination.page,
pageSize: modalPagination.pageSize,
total: modalPagination.total,
onChange: (page) => fetchModalRecords(page),
showTotal: (total) => t('finance.totalRecords', { total })
}}
/>
</Spin>
</Modal>
</div>
)
}
+140 -130
View File
@@ -18,6 +18,7 @@ import {
} from '@ant-design/icons'
import type { ColumnsType } from 'antd/es/table'
import dayjs from 'dayjs'
import { useLanguageStore } from '../store/languageStore'
interface PurchaseOrder {
id: number
@@ -94,6 +95,7 @@ interface Supplier { id: number; name: string; country: string }
interface Product { id: number; name: string; specification: string; unit: string }
const PurchaseOrdersPage: React.FC = () => {
const { t, currentLanguage } = useLanguageStore();
const [orders, setOrders] = useState<PurchaseOrder[]>([])
const [loading, setLoading] = useState(false)
const [projects, setProjects] = useState<Project[]>([])
@@ -128,11 +130,11 @@ const PurchaseOrdersPage: React.FC = () => {
if (data.success) {
setOrders(data.data)
} else {
message.error('获取采购订单列表失败')
message.error(t('purchaseOrder.getListFailed'))
}
} catch (error) {
console.error('获取采购订单列表失败:', error)
message.error('获取采购订单列表失败')
message.error(t('purchaseOrder.getListFailed'))
} finally {
setLoading(false)
}
@@ -177,11 +179,11 @@ const PurchaseOrdersPage: React.FC = () => {
setDetailModalVisible(true)
setActiveDetailTab('basic')
} else {
message.error('获取订单详情失败')
message.error(t('purchaseOrder.getDetailFailed'))
}
} catch (error) {
console.error('获取订单详情失败:', error)
message.error('获取订单详情失败')
message.error(t('purchaseOrder.getDetailFailed'))
}
}
@@ -204,14 +206,14 @@ const PurchaseOrdersPage: React.FC = () => {
})
const data = await response.json()
if (data.success) {
message.success('订单确认成功')
message.success(t('purchaseOrder.orderConfirmSuccess'))
fetchOrders()
} else {
message.error(data.message || '订单确认失败')
message.error(data.message || t('purchaseOrder.orderConfirmFailed'))
}
} catch (error) {
console.error('订单确认失败:', error)
message.error('订单确认失败')
message.error(t('purchaseOrder.orderConfirmFailed'))
}
}
@@ -220,14 +222,14 @@ const PurchaseOrdersPage: React.FC = () => {
const response = await fetch(`/api/purchase-orders/${id}/cancel`, { method: 'POST' })
const data = await response.json()
if (data.success) {
message.success('订单已取消')
message.success(t('purchaseOrder.orderCancelSuccess'))
fetchOrders()
} else {
message.error('取消订单失败')
message.error(t('purchaseOrder.orderCancelFailed'))
}
} catch (error) {
console.error('取消订单失败:', error)
message.error('取消订单失败')
message.error(t('purchaseOrder.orderCancelFailed'))
}
}
@@ -236,14 +238,14 @@ const PurchaseOrdersPage: React.FC = () => {
const response = await fetch(`/api/purchase-orders/${id}`, { method: 'DELETE' })
const data = await response.json()
if (data.success) {
message.success('订单删除成功')
message.success(t('purchaseOrder.orderDeleteSuccess'))
fetchOrders()
} else {
message.error('删除订单失败')
message.error(t('purchaseOrder.orderDeleteFailed'))
}
} catch (error) {
console.error('删除订单失败:', error)
message.error('删除订单失败')
message.error(t('purchaseOrder.orderDeleteFailed'))
}
}
@@ -275,11 +277,11 @@ const PurchaseOrdersPage: React.FC = () => {
const data = await response.json()
if (data.success) {
message.success(editingItem ? '商品更新成功' : '商品添加成功')
message.success(editingItem ? t('purchaseOrder.productUpdateSuccess') : t('purchaseOrder.productAddSuccess'))
setItemModalVisible(false)
fetchOrderDetail(currentOrder!.id)
} else {
message.error('操作失败')
message.error(t('common.operationFailed'))
}
} catch (error) {
console.error('保存商品失败:', error)
@@ -293,10 +295,10 @@ const PurchaseOrdersPage: React.FC = () => {
})
const data = await response.json()
if (data.success) {
message.success('商品删除成功')
message.success(t('purchaseOrder.productDeleteSuccess'))
fetchOrderDetail(currentOrder!.id)
} else {
message.error('删除失败')
message.error(t('common.deleteFailed'))
}
} catch (error) {
console.error('删除商品失败:', error)
@@ -338,11 +340,11 @@ const PurchaseOrdersPage: React.FC = () => {
const data = await response.json()
if (data.success) {
message.success(editingPayment ? '付款计划更新成功' : '付款计划添加成功')
message.success(editingPayment ? t('purchaseOrder.paymentPlanUpdateSuccess') : t('purchaseOrder.paymentPlanAddSuccess'))
setPaymentModalVisible(false)
fetchOrderDetail(currentOrder!.id)
} else {
message.error('操作失败')
message.error(t('common.operationFailed'))
}
} catch (error) {
console.error('保存付款计划失败:', error)
@@ -356,10 +358,10 @@ const PurchaseOrdersPage: React.FC = () => {
})
const data = await response.json()
if (data.success) {
message.success('付款计划删除成功')
message.success(t('purchaseOrder.paymentPlanDeleteSuccess'))
fetchOrderDetail(currentOrder!.id)
} else {
message.error('删除失败')
message.error(t('common.deleteFailed'))
}
} catch (error) {
console.error('删除付款计划失败:', error)
@@ -368,14 +370,14 @@ const PurchaseOrdersPage: React.FC = () => {
const getStatusTag = (status: string) => {
const statusMap: Record<string, { color: string; text: string }> = {
draft: { color: 'default', text: '草稿' },
confirmed: { color: 'blue', text: '已确认' },
partial_paid: { color: 'orange', text: '部分付款' },
paid: { color: 'green', text: '已付清' },
shipping: { color: 'cyan', text: '物流中' },
verified: { color: 'purple', text: '已验收' },
closed: { color: 'success', text: '已关闭' },
cancelled: { color: 'error', text: '已取消' }
draft: { color: 'default', text: t('purchaseOrder.draft') },
confirmed: { color: 'blue', text: t('purchaseOrder.confirmed') },
partial_paid: { color: 'orange', text: t('purchaseOrder.partialPayment') },
paid: { color: 'green', text: t('purchaseOrder.paidOff') },
shipping: { color: 'cyan', text: t('purchaseOrder.inTransit') },
verified: { color: 'purple', text: t('purchaseOrder.accepted') },
closed: { color: 'success', text: t('purchaseOrder.closed') },
cancelled: { color: 'error', text: t('purchaseOrder.cancelled') }
}
const info = statusMap[status] || { color: 'default', text: status }
return <Tag color={info.color}>{info.text}</Tag>
@@ -390,7 +392,7 @@ const PurchaseOrdersPage: React.FC = () => {
const columns: ColumnsType<PurchaseOrder> = [
{
title: '订单号',
title: t('purchaseOrder.orderCode'),
dataIndex: 'code',
key: 'code',
width: 150,
@@ -399,21 +401,21 @@ const PurchaseOrdersPage: React.FC = () => {
)
},
{
title: '供应商',
title: t('purchaseOrder.supplier'),
dataIndex: 'supplier_name',
key: 'supplier_name',
width: 140,
render: (v: string) => v || '-'
},
{
title: '项目',
title: t('purchaseOrder.relatedProject'),
dataIndex: 'project_name',
key: 'project_name',
width: 120,
render: (v: string) => v || '-'
},
{
title: '金额',
title: t('purchaseOrder.amount'),
dataIndex: 'total_amount',
key: 'total_amount',
width: 140,
@@ -428,7 +430,7 @@ const PurchaseOrdersPage: React.FC = () => {
}
},
{
title: '已付',
title: t('purchaseOrder.paid'),
dataIndex: 'paid_amount',
key: 'paid_amount',
width: 120,
@@ -440,7 +442,7 @@ const PurchaseOrdersPage: React.FC = () => {
)
},
{
title: '状态',
title: t('purchaseOrder.status'),
dataIndex: 'status',
key: 'status',
width: 100,
@@ -448,14 +450,14 @@ const PurchaseOrdersPage: React.FC = () => {
render: getStatusTag
},
{
title: '创建日期',
title: t('purchaseOrder.createDate'),
dataIndex: 'created_at',
key: 'created_at',
width: 100,
render: (v: string) => v ? dayjs(v).format('MM-DD') : '-'
},
{
title: '操作',
title: t('purchaseOrder.action'),
key: 'actions',
width: 180,
fixed: 'right',
@@ -464,14 +466,14 @@ const PurchaseOrdersPage: React.FC = () => {
<Button size="small" type="text" icon={<EyeOutlined />} onClick={() => fetchOrderDetail(record.id)} />
{record.status === 'draft' && (
<>
<Button size="small" type="text" icon={<CheckOutlined />} style={{ color: '#52c41a' }} onClick={() => handleConfirmOrder(record.id)}></Button>
<Popconfirm title="确定要取消吗?" onConfirm={() => handleCancelOrder(record.id)}>
<Button size="small" type="text" danger></Button>
<Button size="small" type="text" icon={<CheckOutlined />} style={{ color: '#52c41a' }} onClick={() => handleConfirmOrder(record.id)}>{t('purchaseOrder.confirm')}</Button>
<Popconfirm title={t('purchaseOrder.confirmCancel')} onConfirm={() => handleCancelOrder(record.id)}>
<Button size="small" type="text" danger>{t('purchaseOrder.cancel')}</Button>
</Popconfirm>
</>
)}
{record.status === 'cancelled' && (
<Popconfirm title="确定要删除吗?" onConfirm={() => handleDeleteOrder(record.id)}>
<Popconfirm title={t('purchaseOrder.confirmDelete')} onConfirm={() => handleDeleteOrder(record.id)}>
<Button size="small" type="text" danger icon={<DeleteOutlined />} />
</Popconfirm>
)}
@@ -481,20 +483,20 @@ const PurchaseOrdersPage: React.FC = () => {
]
const itemColumns: ColumnsType<OrderItem> = [
{ title: '商品名称', dataIndex: 'product_name', key: 'product_name', width: 150 },
{ title: '规格', dataIndex: 'specification', key: 'specification', width: 100 },
{ title: '单位', dataIndex: 'unit', key: 'unit', width: 60 },
{ title: '数量', dataIndex: 'quantity', key: 'quantity', width: 80, align: 'right' },
{ title: '单价', dataIndex: 'unit_price', key: 'unit_price', width: 100, align: 'right', render: (v: number) => v?.toFixed(2) },
{ title: '小计', dataIndex: 'total_price', key: 'total_price', width: 120, align: 'right', render: (v: number) => <strong>{v?.toFixed(2)}</strong> },
{ title: t('purchaseOrder.productName'), dataIndex: 'product_name', key: 'product_name', width: 150 },
{ title: t('purchaseOrder.spec'), dataIndex: 'specification', key: 'specification', width: 100 },
{ title: t('purchaseOrder.unit'), dataIndex: 'unit', key: 'unit', width: 60 },
{ title: t('purchaseOrder.quantity'), dataIndex: 'quantity', key: 'quantity', width: 80, align: 'right' },
{ title: t('purchaseOrder.unitPrice'), dataIndex: 'unit_price', key: 'unit_price', width: 100, align: 'right', render: (v: number) => v?.toFixed(2) },
{ title: t('purchaseOrder.subtotal'), dataIndex: 'total_price', key: 'total_price', width: 120, align: 'right', render: (v: number) => <strong>{v?.toFixed(2)}</strong> },
{
title: '操作',
title: t('purchaseOrder.action'),
key: 'actions',
width: 100,
render: (_, record) => currentOrder?.status === 'draft' && (
<Space size={4}>
<Button size="small" type="text" icon={<EditOutlined />} onClick={() => handleEditItem(record)} />
<Popconfirm title="确定删除?" onConfirm={() => handleDeleteItem(record.id)}>
<Popconfirm title={t('purchaseOrder.confirmDeleteShort')} onConfirm={() => handleDeleteItem(record.id)}>
<Button size="small" type="text" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
@@ -503,28 +505,28 @@ const PurchaseOrdersPage: React.FC = () => {
]
const paymentColumns: ColumnsType<PaymentPlan> = [
{ title: '阶段', dataIndex: 'stage', key: 'stage', width: 80 },
{ title: '计划日期', dataIndex: 'planned_date', key: 'planned_date', width: 100 },
{ title: '计划金额', dataIndex: 'planned_amount', key: 'planned_amount', width: 120, align: 'right', render: (v: number) => v?.toFixed(2) },
{ title: '比例%', dataIndex: 'planned_percentage', key: 'planned_percentage', width: 80, align: 'right' },
{ title: '实际金额', dataIndex: 'actual_amount', key: 'actual_amount', width: 120, align: 'right', render: (v: number) => v?.toFixed(2) || '-' },
{ title: '状态', dataIndex: 'status', key: 'status', width: 80, render: (s: string) => {
{ title: t('purchaseOrder.phase'), dataIndex: 'stage', key: 'stage', width: 80 },
{ title: t('purchaseOrder.plannedDate'), dataIndex: 'planned_date', key: 'planned_date', width: 100 },
{ title: t('purchaseOrder.plannedAmount'), dataIndex: 'planned_amount', key: 'planned_amount', width: 120, align: 'right', render: (v: number) => v?.toFixed(2) },
{ title: t('purchaseOrder.ratioPercent'), dataIndex: 'planned_percentage', key: 'planned_percentage', width: 80, align: 'right' },
{ title: t('purchaseOrder.actualAmount'), dataIndex: 'actual_amount', key: 'actual_amount', width: 120, align: 'right', render: (v: number) => v?.toFixed(2) || '-' },
{ title: t('purchaseOrder.status'), dataIndex: 'status', key: 'status', width: 80, render: (s: string) => {
const map: Record<string, { color: string; text: string }> = {
pending: { color: 'default', text: '待付款' },
requested: { color: 'blue', text: '已申请' },
paid: { color: 'green', text: '已支付' }
pending: { color: 'default', text: t('purchaseOrder.pendingPayment') },
requested: { color: 'blue', text: t('purchaseOrder.applied') },
paid: { color: 'green', text: t('purchaseOrder.paidStatus') }
}
const info = map[s] || { color: 'default', text: s }
return <Tag color={info.color}>{info.text}</Tag>
}},
{
title: '操作',
title: t('purchaseOrder.action'),
key: 'actions',
width: 100,
render: (_, record) => record.status === 'pending' && (
<Space size={4}>
<Button size="small" type="text" icon={<EditOutlined />} onClick={() => handleEditPayment(record)} />
<Popconfirm title="确定删除?" onConfirm={() => handleDeletePayment(record.id)}>
<Popconfirm title={t('purchaseOrder.confirmDeleteShort')} onConfirm={() => handleDeletePayment(record.id)}>
<Button size="small" type="text" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
@@ -533,46 +535,46 @@ const PurchaseOrdersPage: React.FC = () => {
]
const logisticsColumns: ColumnsType<LogisticsRecord> = [
{ title: '物流单号', dataIndex: 'code', key: 'code', width: 120 },
{ title: '发货地', dataIndex: 'ship_from', key: 'ship_from', width: 80, render: (v: string) => v === 'China' ? '中国' : '老挝' },
{ title: '物流公司', dataIndex: 'logistics_company_name', key: 'logistics_company_name', width: 120 },
{ title: '发货日期', dataIndex: 'ship_date', key: 'ship_date', width: 100 },
{ title: '一次运费', dataIndex: 'primary_freight', key: 'primary_freight', width: 100, align: 'right', render: (v: number) => v?.toFixed(2) || '-' },
{ title: '二次运费', dataIndex: 'secondary_freight', key: 'secondary_freight', width: 100, align: 'right', render: (v: number) => v?.toFixed(2) || '-' },
{ title: '状态', dataIndex: 'status', key: 'status', width: 80, render: getStatusTag }
{ title: t('purchaseOrder.trackingNumber'), dataIndex: 'code', key: 'code', width: 120 },
{ title: t('purchaseOrder.origin'), dataIndex: 'ship_from', key: 'ship_from', width: 80, render: (v: string) => v === 'China' ? t('purchaseOrder.china') : t('purchaseOrder.laos') },
{ title: t('purchaseOrder.logisticsCompany'), dataIndex: 'logistics_company_name', key: 'logistics_company_name', width: 120 },
{ title: t('purchaseOrder.deliveryDate'), dataIndex: 'ship_date', key: 'ship_date', width: 100 },
{ title: t('purchaseOrder.freight1'), dataIndex: 'primary_freight', key: 'primary_freight', width: 100, align: 'right', render: (v: number) => v?.toFixed(2) || '-' },
{ title: t('purchaseOrder.freight2'), dataIndex: 'secondary_freight', key: 'secondary_freight', width: 100, align: 'right', render: (v: number) => v?.toFixed(2) || '-' },
{ title: t('purchaseOrder.status'), dataIndex: 'status', key: 'status', width: 80, render: getStatusTag }
]
const verificationColumns: ColumnsType<VerificationRecord> = [
{ title: '验收单号', dataIndex: 'code', key: 'code', width: 120 },
{ title: '验收日期', dataIndex: 'verification_date', key: 'verification_date', width: 100 },
{ title: '验收人', dataIndex: 'verifier', key: 'verifier', width: 80 },
{ title: '验收数量', dataIndex: 'total_verified', key: 'total_verified', width: 100, align: 'right' },
{ title: '状态', dataIndex: 'status', key: 'status', width: 80, render: getStatusTag }
{ title: t('purchaseOrder.acceptanceCode'), dataIndex: 'code', key: 'code', width: 120 },
{ title: t('purchaseOrder.acceptanceDate'), dataIndex: 'verification_date', key: 'verification_date', width: 100 },
{ title: t('purchaseOrder.acceptor'), dataIndex: 'verifier', key: 'verifier', width: 80 },
{ title: t('purchaseOrder.acceptedQty'), dataIndex: 'total_verified', key: 'total_verified', width: 100, align: 'right' },
{ title: t('purchaseOrder.status'), dataIndex: 'status', key: 'status', width: 80, render: getStatusTag }
]
return (
<div style={{ padding: 24 }}>
<div style={{ marginBottom: 24 }}>
<h2 style={{ marginBottom: 8 }}></h2>
<p style={{ color: '#888', marginBottom: 0 }}>TAB</p>
<h2 style={{ marginBottom: 8 }}>{t('purchaseOrder.title')}</h2>
<p style={{ color: '#888', marginBottom: 0 }}>{t('purchaseOrder.description')}</p>
</div>
<Card>
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={6}>
<Select placeholder="选择项目筛选" allowClear style={{ width: '100%' }} onChange={(v) => setSelectedProjectId(v)}>
<Select placeholder={t('purchaseOrder.selectProject')} allowClear style={{ width: '100%' }} onChange={(v) => setSelectedProjectId(v)}>
{projects.map(p => <Select.Option key={p.id} value={p.id}>{p.name}</Select.Option>)}
</Select>
</Col>
<Col span={6}>
<Select placeholder="选择状态筛选" allowClear style={{ width: '100%' }} onChange={(v) => setSelectedStatus(v)}>
<Select.Option value="draft">稿</Select.Option>
<Select.Option value="confirmed"></Select.Option>
<Select.Option value="partial_paid"></Select.Option>
<Select.Option value="paid"></Select.Option>
<Select.Option value="shipping"></Select.Option>
<Select.Option value="verified"></Select.Option>
<Select.Option value="cancelled"></Select.Option>
<Select placeholder={t('purchaseOrder.selectStatus')} allowClear style={{ width: '100%' }} onChange={(v) => setSelectedStatus(v)}>
<Select.Option value="draft">{t('purchaseOrder.draft')}</Select.Option>
<Select.Option value="confirmed">{t('purchaseOrder.confirmed')}</Select.Option>
<Select.Option value="partial_paid">{t('purchaseOrder.partialPayment')}</Select.Option>
<Select.Option value="paid">{t('purchaseOrder.paidOff')}</Select.Option>
<Select.Option value="shipping">{t('purchaseOrder.inTransit')}</Select.Option>
<Select.Option value="verified">{t('purchaseOrder.accepted')}</Select.Option>
<Select.Option value="cancelled">{t('purchaseOrder.cancelled')}</Select.Option>
</Select>
</Col>
</Row>
@@ -581,7 +583,7 @@ const PurchaseOrdersPage: React.FC = () => {
{/* 订单详情弹窗 - 多TAB */}
<Modal
title={`采购订单详情 - ${currentOrder?.code || ''}`}
title={t('purchaseOrder.detailTitle').replace('{code}', currentOrder?.code || '')}
open={detailModalVisible}
onCancel={() => setDetailModalVisible(false)}
footer={null}
@@ -590,59 +592,59 @@ const PurchaseOrdersPage: React.FC = () => {
{currentOrder && (
<Tabs activeKey={activeDetailTab} onChange={setActiveDetailTab}>
{/* TAB1: 基本信息 */}
<Tabs.TabPane tab={<span><FileTextOutlined /> </span>} key="basic">
<Tabs.TabPane tab={<span><FileTextOutlined /> {t('purchaseOrder.basicInfo')}</span>} key="basic">
<Descriptions bordered column={2}>
<Descriptions.Item label="订单号">{currentOrder.code}</Descriptions.Item>
<Descriptions.Item label="状态">{getStatusTag(currentOrder.status)}</Descriptions.Item>
<Descriptions.Item label="供应商">{currentOrder.supplier_name || '-'}</Descriptions.Item>
<Descriptions.Item label="供应商国家">{currentOrder.supplier_country === 'China' ? '中国' : (currentOrder.supplier_country || '老挝')}</Descriptions.Item>
<Descriptions.Item label="项目">{currentOrder.project_name || '-'}</Descriptions.Item>
<Descriptions.Item label="币种">{currentOrder.currency}</Descriptions.Item>
<Descriptions.Item label="预计金额">
<Descriptions.Item label={t('purchaseOrder.orderCode')}>{currentOrder.code}</Descriptions.Item>
<Descriptions.Item label={t('purchaseOrder.status')}>{getStatusTag(currentOrder.status)}</Descriptions.Item>
<Descriptions.Item label={t('purchaseOrder.supplier')}>{currentOrder.supplier_name || '-'}</Descriptions.Item>
<Descriptions.Item label={t('purchaseOrder.supplierCountry')}>{currentOrder.supplier_country === 'China' ? t('purchaseOrder.china') : (currentOrder.supplier_country || t('purchaseOrder.laos'))}</Descriptions.Item>
<Descriptions.Item label={t('purchaseOrder.relatedProject')}>{currentOrder.project_name || '-'}</Descriptions.Item>
<Descriptions.Item label={t('common.currency')}>{currentOrder.currency}</Descriptions.Item>
<Descriptions.Item label={t('purchaseOrder.estimatedAmount')}>
<span style={{ color: '#999' }}>{currentOrder.estimated_amount?.toFixed(2) || '0.00'}</span>
</Descriptions.Item>
<Descriptions.Item label="订单金额">
<Descriptions.Item label={t('purchaseOrder.orderAmount')}>
<span style={{ fontWeight: 'bold', color: getAmountColor(currentOrder.status) }}>
{currentOrder.total_amount?.toFixed(2) || '0.00'}
</span>
</Descriptions.Item>
<Descriptions.Item label="已付金额">
<Descriptions.Item label={t('purchaseOrder.paidAmount')}>
<span style={{ color: '#52c41a' }}>{currentOrder.paid_amount?.toFixed(2) || '0.00'}</span>
</Descriptions.Item>
<Descriptions.Item label="创建时间">{currentOrder.created_at}</Descriptions.Item>
{currentOrder.remark && <Descriptions.Item label="备注" span={2}>{currentOrder.remark}</Descriptions.Item>}
<Descriptions.Item label={t('purchaseOrder.createdAt')}>{currentOrder.created_at}</Descriptions.Item>
{currentOrder.remark && <Descriptions.Item label={t('purchaseOrder.remark')} span={2}>{currentOrder.remark}</Descriptions.Item>}
</Descriptions>
</Tabs.TabPane>
{/* TAB2: 商品明细 */}
<Tabs.TabPane tab={<span><FileTextOutlined /> </span>} key="items">
<Tabs.TabPane tab={<span><FileTextOutlined /> {t('purchaseOrder.productDetails')}</span>} key="items">
{currentOrder.status === 'draft' && (
<Button type="dashed" icon={<PlusOutlined />} onClick={handleAddItem} style={{ marginBottom: 16 }}></Button>
<Button type="dashed" icon={<PlusOutlined />} onClick={handleAddItem} style={{ marginBottom: 16 }}>{t('purchaseOrder.addProduct')}</Button>
)}
<Table columns={itemColumns} dataSource={currentOrder.items || []} rowKey="id" pagination={false} size="small" />
<div style={{ marginTop: 16, textAlign: 'right' }}>
<strong>{currentOrder.currency} {(currentOrder.items || []).reduce((sum, item) => sum + (item.total_price || 0), 0).toFixed(2)}</strong>
<strong>{t('purchaseOrder.productTotal')}{currentOrder.currency} {(currentOrder.items || []).reduce((sum, item) => sum + (item.total_price || 0), 0).toFixed(2)}</strong>
</div>
</Tabs.TabPane>
{/* TAB3: 付款信息 */}
<Tabs.TabPane tab={<span><FileTextOutlined /> </span>} key="payment">
<Tabs.TabPane tab={<span><FileTextOutlined /> {t('purchaseOrder.paymentInfo')}</span>} key="payment">
{currentOrder.status !== 'draft' && currentOrder.status !== 'cancelled' && (
<Button type="dashed" icon={<PlusOutlined />} onClick={handleAddPayment} style={{ marginBottom: 16 }}></Button>
<Button type="dashed" icon={<PlusOutlined />} onClick={handleAddPayment} style={{ marginBottom: 16 }}>{t('purchaseOrder.addPaymentPlan')}</Button>
)}
<Table columns={paymentColumns} dataSource={currentOrder.payment_plans || []} rowKey="id" pagination={false} size="small" />
</Tabs.TabPane>
{/* TAB4: 物流信息 */}
<Tabs.TabPane tab={<span><CarOutlined /> </span>} key="logistics">
<Tabs.TabPane tab={<span><CarOutlined /> {t('purchaseOrder.logisticsInfo')}</span>} key="logistics">
<Table columns={logisticsColumns} dataSource={currentOrder.logistics || []} rowKey="id" pagination={false} size="small" />
{(currentOrder.logistics || []).length === 0 && <div style={{ textAlign: 'center', color: '#999', padding: 20 }}></div>}
{(currentOrder.logistics || []).length === 0 && <div style={{ textAlign: 'center', color: '#999', padding: 20 }}>{t('purchaseOrder.noLogistics')}</div>}
</Tabs.TabPane>
{/* TAB5: 验收记录 */}
<Tabs.TabPane tab={<span><SafetyCertificateOutlined /> </span>} key="verification">
<Tabs.TabPane tab={<span><SafetyCertificateOutlined /> {t('purchaseOrder.acceptanceRecord')}</span>} key="verification">
<Table columns={verificationColumns} dataSource={currentOrder.verifications || []} rowKey="id" pagination={false} size="small" />
{(currentOrder.verifications || []).length === 0 && <div style={{ textAlign: 'center', color: '#999', padding: 20 }}></div>}
{(currentOrder.verifications || []).length === 0 && <div style={{ textAlign: 'center', color: '#999', padding: 20 }}>{t('purchaseOrder.noAcceptance')}</div>}
</Tabs.TabPane>
</Tabs>
)}
@@ -650,17 +652,21 @@ const PurchaseOrdersPage: React.FC = () => {
{/* 商品明细编辑弹窗 */}
<Modal
title={editingItem ? '编辑商品' : '添加商品'}
title={editingItem ? t('purchaseOrder.editProduct') : t('purchaseOrder.addProductTitle')}
open={itemModalVisible}
onOk={handleSaveItem}
onCancel={() => setItemModalVisible(false)}
onCancel={() => {
itemForm.resetFields();
setItemModalVisible(false);
}}
destroyOnClose
width={600}
>
<Form form={itemForm} layout="vertical">
<Row gutter={16}>
<Col span={12}>
<Form.Item name="product_id" label="选择商品">
<Select placeholder="选择商品" allowClear showSearch optionFilterProp="children" onChange={(v) => {
<Form.Item name="product_id" label={t('purchaseOrder.selectProduct')}>
<Select placeholder={t('purchaseOrder.selectProduct')} allowClear showSearch optionFilterProp="children" onChange={(v) => {
const product = products.find(p => p.id === v)
if (product) {
itemForm.setFieldsValue({
@@ -675,29 +681,29 @@ const PurchaseOrdersPage: React.FC = () => {
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="product_name" label="商品名称" rules={[{ required: true }]}>
<Input placeholder="商品名称" />
<Form.Item name="product_name" label={t('purchaseOrder.productNameInput')} rules={[{ required: true }]}>
<Input placeholder={t('purchaseOrder.productNameInput')} />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={8}>
<Form.Item name="specification" label="规格">
<Input placeholder="规格" />
<Form.Item name="specification" label={t('purchaseOrder.specInput')}>
<Input placeholder={t('purchaseOrder.specInput')} />
</Form.Item>
</Col>
<Col span={4}>
<Form.Item name="unit" label="单位">
<Input placeholder="单位" />
<Form.Item name="unit" label={t('purchaseOrder.unitInput')}>
<Input placeholder={t('purchaseOrder.unitInput')} />
</Form.Item>
</Col>
<Col span={6}>
<Form.Item name="quantity" label="数量" rules={[{ required: true }]}>
<Form.Item name="quantity" label={t('purchaseOrder.quantityInput')} rules={[{ required: true }]}>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col span={6}>
<Form.Item name="unit_price" label="单价" rules={[{ required: true }]}>
<Form.Item name="unit_price" label={t('purchaseOrder.unitPriceInput')} rules={[{ required: true }]}>
<InputNumber min={0} precision={2} style={{ width: '100%' }} />
</Form.Item>
</Col>
@@ -707,43 +713,47 @@ const PurchaseOrdersPage: React.FC = () => {
{/* 付款计划编辑弹窗 */}
<Modal
title={editingPayment ? '编辑付款计划' : '添加付款计划'}
title={editingPayment ? t('purchaseOrder.editPaymentPlan') : t('purchaseOrder.addPaymentPlanTitle')}
open={paymentModalVisible}
onOk={handleSavePayment}
onCancel={() => setPaymentModalVisible(false)}
onCancel={() => {
paymentForm.resetFields();
setPaymentModalVisible(false);
}}
destroyOnClose
width={500}
>
<Form form={paymentForm} layout="vertical">
<Row gutter={16}>
<Col span={12}>
<Form.Item name="stage" label="阶段" rules={[{ required: true }]}>
<Select placeholder="选择阶段">
<Select.Option value="预付款"></Select.Option>
<Select.Option value="发货款"></Select.Option>
<Select.Option value="验收款"></Select.Option>
<Select.Option value="尾款"></Select.Option>
<Form.Item name="stage" label={t('purchaseOrder.phase')} rules={[{ required: true }]}>
<Select placeholder={t('purchaseOrder.selectPhase')}>
<Select.Option value="预付款">{t('purchaseOrder.advancePayment')}</Select.Option>
<Select.Option value="发货款">{t('purchaseOrder.deliveryPayment')}</Select.Option>
<Select.Option value="验收款">{t('purchaseOrder.acceptancePayment')}</Select.Option>
<Select.Option value="尾款">{t('purchaseOrder.finalPayment')}</Select.Option>
</Select>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="planned_date" label="计划日期">
<Form.Item name="planned_date" label={t('purchaseOrder.plannedDate')}>
<DatePicker style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="planned_amount" label="计划金额" rules={[{ required: true }]}>
<Form.Item name="planned_amount" label={t('purchaseOrder.plannedAmount')} rules={[{ required: true }]}>
<InputNumber min={0} precision={2} style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="planned_percentage" label="比例(%)">
<Form.Item name="planned_percentage" label={t('purchaseOrder.ratioPercent')}>
<InputNumber min={0} max={100} style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
<Form.Item name="remark" label="备注">
<Form.Item name="remark" label={t('purchaseOrder.remark')}>
<Input.TextArea rows={2} />
</Form.Item>
</Form>
@@ -752,4 +762,4 @@ const PurchaseOrdersPage: React.FC = () => {
)
}
export default PurchaseOrdersPage
export default PurchaseOrdersPage
+159 -142
View File
@@ -22,6 +22,7 @@ import {
import type { ColumnsType } from 'antd/es/table'
import dayjs from 'dayjs'
import useFormDraft from '../hooks/useFormDraft'
import { useLanguageStore } from '../store/languageStore'
interface PurchaseRequest {
id: number
@@ -50,6 +51,7 @@ interface Project {
}
const PurchaseRequestsPage: React.FC = () => {
const { t, currentLanguage } = useLanguageStore();
const [purchaseRequests, setPurchaseRequests] = useState<PurchaseRequest[]>([])
const [completedRequests, setCompletedRequests] = useState<PurchaseRequest[]>([])
const [activeTab, setActiveTab] = useState('active')
@@ -113,11 +115,11 @@ const PurchaseRequestsPage: React.FC = () => {
setPurchaseRequests(active)
setCompletedRequests(completed)
} else {
message.error('获取采购申请列表失败')
message.error(t('purchaseRequest.getListFailed'))
}
} catch (error) {
console.error('获取采购申请列表失败:', error)
message.error('获取采购申请列表失败')
message.error(t('purchaseRequest.getListFailed'))
} finally {
setLoading(false)
}
@@ -143,11 +145,11 @@ const PurchaseRequestsPage: React.FC = () => {
setViewingRequest(data.data)
setDetailModalVisible(true)
} else {
message.error('获取采购申请详情失败')
message.error(t('purchaseRequest.getDetailFailed'))
}
} catch (error) {
console.error('获取采购申请详情失败:', error)
message.error('获取采购申请详情失败')
message.error(t('purchaseRequest.getDetailFailed'))
}
}
@@ -196,7 +198,7 @@ const PurchaseRequestsPage: React.FC = () => {
expected_date: dayjs().add(7, 'day'),
currency: 'CNY',
expense_category: 'material',
applicant: '系统管理员',
applicant: t('common.systemAdmin'),
total_amount: 0,
attachments: []
})
@@ -207,10 +209,10 @@ const PurchaseRequestsPage: React.FC = () => {
setTimeout(() => {
if (hasDraft()) {
Modal.confirm({
title: '发现未完成的草稿',
content: '检测到上次未提交的采购申请,是否恢复?',
okText: '恢复草稿',
cancelText: '重新填写',
title: t('common.draftFound'),
content: t('common.draftRestore'),
okText: t('common.restoreDraft'),
cancelText: t('common.reFill'),
onOk: () => {
restoreDraft()
},
@@ -223,7 +225,7 @@ const PurchaseRequestsPage: React.FC = () => {
expected_date: dayjs().add(7, 'day'),
currency: 'CNY',
expense_category: 'material',
applicant: '系统管理员',
applicant: t('common.systemAdmin'),
total_amount: 0,
attachments: []
})
@@ -281,11 +283,11 @@ const PurchaseRequestsPage: React.FC = () => {
})
}, 100)
} else {
message.error('获取采购申请详情失败')
message.error(t('purchaseRequest.getDetailFailed'))
}
} catch (error) {
console.error('获取采购申请详情失败:', error)
message.error('获取采购申请详情失败')
message.error(t('purchaseRequest.getDetailFailed'))
}
}
@@ -295,14 +297,14 @@ const PurchaseRequestsPage: React.FC = () => {
const data = await response.json()
if (data.success) {
message.success('删除成功')
message.success(t('purchaseRequest.deleteSuccess'))
fetchPurchaseRequests()
} else {
message.error('删除失败')
message.error(t('purchaseRequest.deleteFailed'))
}
} catch (error) {
console.error('删除失败:', error)
message.error('删除失败')
message.error(t('purchaseRequest.deleteFailed'))
}
}
@@ -319,7 +321,7 @@ const PurchaseRequestsPage: React.FC = () => {
...values,
request_date: values.request_date.format('YYYY-MM-DD'),
expected_date: values.expected_date ? values.expected_date.format('YYYY-MM-DD') : null,
applicant: '系统管理员',
applicant: t('common.systemAdmin'),
status: saveStatus,
attachments: attachmentsUrl
}
@@ -342,7 +344,7 @@ const PurchaseRequestsPage: React.FC = () => {
const data = await response.json()
if (data.success) {
message.success(editingRequest ? '保存成功' : '创建成功')
message.success(editingRequest ? t('purchaseRequest.saveSuccess') : t('purchaseRequest.createSuccess'))
clearDraft()
if (!editingRequest) {
setEditingRequest(data.data)
@@ -351,7 +353,7 @@ const PurchaseRequestsPage: React.FC = () => {
fetchPurchaseRequests()
setModalVisible(false)
} else {
message.error(editingRequest ? '保存失败' : '创建失败')
message.error(editingRequest ? t('common.saveFailed') : t('common.operationFailed'))
}
} catch (error) {
console.error('保存失败:', error)
@@ -370,7 +372,7 @@ const PurchaseRequestsPage: React.FC = () => {
...values,
request_date: values.request_date.format('YYYY-MM-DD'),
expected_date: values.expected_date ? values.expected_date.format('YYYY-MM-DD') : null,
applicant: '系统管理员',
applicant: t('common.systemAdmin'),
status: 'pending_edit',
attachments: attachmentsUrl
}
@@ -406,20 +408,20 @@ const PurchaseRequestsPage: React.FC = () => {
const submitData = await submitResponse.json()
if (submitData.success) {
message.success(editingRequest ? '提交成功' : '创建并提交成功')
message.success(editingRequest ? t('common.submit') : t('purchaseRequest.submitSuccess'))
clearDraft()
setModalVisible(false)
setSelectedStatus(null)
fetchPurchaseRequests()
} else {
message.error('提交失败')
message.error(t('purchaseRequest.submitFailed'))
}
} else {
message.error(editingRequest ? '保存失败' : '创建失败')
message.error(editingRequest ? t('common.saveFailed') : t('common.operationFailed'))
}
} catch (error) {
console.error('提交失败:', error)
message.error('提交失败')
message.error(t('purchaseRequest.submitFailed'))
}
}
@@ -429,15 +431,15 @@ const PurchaseRequestsPage: React.FC = () => {
const data = await response.json()
if (data.success) {
message.success('撤回成功')
message.success(t('purchaseRequest.withdrawSuccess'))
setSelectedStatus(null)
fetchPurchaseRequests()
} else {
message.error('撤回失败')
message.error(t('purchaseRequest.withdrawFailed'))
}
} catch (error) {
console.error('撤回失败:', error)
message.error('撤回失败')
message.error(t('purchaseRequest.withdrawFailed'))
}
}
@@ -447,15 +449,15 @@ const PurchaseRequestsPage: React.FC = () => {
const data = await response.json()
if (data.success) {
message.success(data.message || '审批通过成功')
message.success(data.message || t('purchaseRequest.approveSuccess'))
setSelectedStatus(null)
fetchPurchaseRequests()
} else {
message.error('审批通过失败')
message.error(t('purchaseRequest.approveFailed'))
}
} catch (error) {
console.error('审批通过失败:', error)
message.error('审批通过失败')
message.error(t('purchaseRequest.approveFailed'))
}
}
@@ -465,25 +467,25 @@ const PurchaseRequestsPage: React.FC = () => {
const data = await response.json()
if (data.success) {
message.success('驳回成功')
message.success(t('purchaseRequest.rejectSuccess'))
setSelectedStatus(null)
fetchPurchaseRequests()
} else {
message.error('驳回失败')
message.error(t('purchaseRequest.rejectFailed'))
}
} catch (error) {
console.error('驳回失败:', error)
message.error('驳回失败')
message.error(t('purchaseRequest.rejectFailed'))
}
}
const getStatusTag = (status: string) => {
const statusMap: Record<string, { color: string; text: string }> = {
pending_edit: { color: 'default', text: '待编辑' },
pending: { color: 'blue', text: '待审批' },
approved: { color: 'green', text: '已审批' },
executed: { color: 'purple', text: '已执行' },
withdrawn: { color: 'orange', text: '已撤回' }
pending_edit: { color: 'default', text: t('purchaseRequest.pendingEdit') },
pending: { color: 'blue', text: t('purchaseRequest.pendingApproval') },
approved: { color: 'green', text: t('purchaseRequest.approved') },
executed: { color: 'purple', text: t('purchaseRequest.executed') },
withdrawn: { color: 'orange', text: t('purchaseRequest.withdrawn') }
}
const info = statusMap[status] || { color: 'default', text: status }
return <Tag color={info.color}>{info.text}</Tag>
@@ -491,7 +493,7 @@ const PurchaseRequestsPage: React.FC = () => {
const columns: ColumnsType<PurchaseRequest> = [
{
title: '事由',
title: t('purchaseRequest.subject'),
dataIndex: 'brief_description',
key: 'brief_description',
width: 180,
@@ -501,7 +503,7 @@ const PurchaseRequestsPage: React.FC = () => {
)
},
{
title: '项目',
title: t('purchaseRequest.project'),
dataIndex: 'project_name',
key: 'project_name',
width: 120,
@@ -509,22 +511,22 @@ const PurchaseRequestsPage: React.FC = () => {
render: (v: string) => v || '-'
},
{
title: '分类',
title: t('purchaseRequest.category'),
dataIndex: 'expense_category',
key: 'expense_category',
width: 80,
render: (category) => {
const categoryMap: Record<string, string> = {
material: '材料',
equipment: '设备',
pole: '电杆',
other: '其他'
material: t('purchaseRequest.material'),
equipment: t('purchaseRequest.equipment'),
pole: t('purchaseRequest.pole'),
other: t('purchaseRequest.other')
}
return <Tag size="small">{categoryMap[category] || category}</Tag>
}
},
{
title: '预计金额',
title: t('purchaseRequest.estimatedAmount'),
dataIndex: 'total_amount',
key: 'total_amount',
width: 130,
@@ -536,14 +538,14 @@ const PurchaseRequestsPage: React.FC = () => {
)
},
{
title: '需求日期',
title: t('purchaseRequest.demandDate'),
dataIndex: 'expected_date',
key: 'expected_date',
width: 100,
render: (date) => date ? dayjs(date).format('MM-DD') : '-'
},
{
title: '状态',
title: t('purchaseRequest.status'),
dataIndex: 'status',
key: 'status',
width: 90,
@@ -551,20 +553,20 @@ const PurchaseRequestsPage: React.FC = () => {
render: getStatusTag
},
{
title: '申请日期',
title: t('purchaseRequest.applicationDate'),
dataIndex: 'request_date',
key: 'request_date',
width: 100,
render: (date) => date ? dayjs(date).format('MM-DD') : '-'
},
{
title: '申请人',
title: t('purchaseRequest.applicant'),
dataIndex: 'applicant',
key: 'applicant',
width: 90
},
{
title: '编号',
title: t('purchaseRequest.code'),
dataIndex: 'request_code',
key: 'request_code',
width: 150,
@@ -572,7 +574,7 @@ const PurchaseRequestsPage: React.FC = () => {
render: (v: string) => <span style={{ fontSize: 12, color: '#999' }}>{v || '-'}</span>
},
{
title: '操作',
title: t('purchaseRequest.action'),
key: 'actions',
width: 200,
fixed: 'right',
@@ -594,7 +596,7 @@ const PurchaseRequestsPage: React.FC = () => {
onClick={() => handleApprove(record.id)}
style={{ color: '#52c41a' }}
>
{t('purchaseRequest.approve')}
</Button>
<Button
size="small"
@@ -603,7 +605,7 @@ const PurchaseRequestsPage: React.FC = () => {
onClick={() => handleReject(record.id)}
danger
>
{t('purchaseRequest.reject')}
</Button>
<Button
size="small"
@@ -611,7 +613,7 @@ const PurchaseRequestsPage: React.FC = () => {
icon={<UndoOutlined />}
onClick={() => handleWithdraw(record.id)}
>
{t('purchaseRequest.withdraw')}
</Button>
</>
)}
@@ -624,10 +626,10 @@ const PurchaseRequestsPage: React.FC = () => {
icon={<EditOutlined />}
onClick={() => handleEdit(record)}
>
{t('purchaseRequest.edit')}
</Button>
<Popconfirm
title="确定要删除吗?"
title={t('purchaseRequest.confirmDelete')}
onConfirm={() => handleDelete(record.id)}
>
<Button size="small" type="text" danger icon={<DeleteOutlined />} />
@@ -642,17 +644,17 @@ const PurchaseRequestsPage: React.FC = () => {
return (
<div style={{ padding: 24 }}>
<div style={{ marginBottom: 24 }}>
<h2 style={{ marginBottom: 8 }}></h2>
<p style={{ color: '#888', marginBottom: 0 }}></p>
<h2 style={{ marginBottom: 8 }}>{t('purchaseRequest.title')}</h2>
<p style={{ color: '#888', marginBottom: 0 }}>{t('purchaseRequest.description')}</p>
</div>
<Card extra={<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}></Button>}>
<Card extra={<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>{t('purchaseRequest.newRequest')}</Button>}>
<Tabs activeKey={activeTab} onChange={setActiveTab}>
<Tabs.TabPane tab="活跃申请" key="active">
<Tabs.TabPane tab={t('purchaseRequest.activeApplications')} key="active">
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={6}>
<Select
placeholder="选择项目筛选"
placeholder={t('purchaseRequest.selectProjectFilter')}
allowClear
style={{ width: '100%' }}
onChange={(value) => setSelectedProjectId(value)}
@@ -666,14 +668,14 @@ const PurchaseRequestsPage: React.FC = () => {
</Col>
<Col span={6}>
<Select
placeholder="选择状态筛选"
placeholder={t('purchaseRequest.selectStatusFilter')}
allowClear
style={{ width: '100%' }}
onChange={(value) => setSelectedStatus(value)}
>
<Select.Option value="pending"></Select.Option>
<Select.Option value="withdrawn"></Select.Option>
<Select.Option value="pending_edit"></Select.Option>
<Select.Option value="pending">{t('purchaseRequest.pendingApproval')}</Select.Option>
<Select.Option value="withdrawn">{t('purchaseRequest.withdrawn')}</Select.Option>
<Select.Option value="pending_edit">{t('purchaseRequest.pendingEdit')}</Select.Option>
</Select>
</Col>
</Row>
@@ -687,11 +689,11 @@ const PurchaseRequestsPage: React.FC = () => {
scroll={{ x: 1200 }}
/>
</Tabs.TabPane>
<Tabs.TabPane tab="已完成" key="completed">
<Tabs.TabPane tab={t('purchaseRequest.completed')} key="completed">
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={6}>
<Select
placeholder="选择项目筛选"
placeholder={t('purchaseRequest.selectProjectFilter')}
allowClear
style={{ width: '100%' }}
onChange={(value) => setSelectedProjectId(value)}
@@ -719,21 +721,23 @@ const PurchaseRequestsPage: React.FC = () => {
{/* 编辑/新建弹窗 - 简化版 */}
<Modal
title={editingRequest ? '编辑采购申请' : '新建采购申请'}
title={editingRequest ? t('purchaseRequest.editRequest') : t('purchaseRequest.newRequest')}
open={modalVisible}
onCancel={() => {
if (form.isFieldsTouched()) {
Modal.confirm({
title: '确认关闭',
content: '表单数据尚未保存,关闭后可通过草稿恢复。确定关闭吗?',
okText: '关闭',
cancelText: '继续编辑',
title: t('common.closeConfirm'),
content: t('common.closeConfirmMsg'),
okText: t('common.close'),
cancelText: t('common.continueEdit'),
onOk: () => {
saveDraft({ attachments, purchaseType, currency })
form.resetFields()
setModalVisible(false)
},
})
} else {
form.resetFields()
setModalVisible(false)
}
}}
@@ -741,39 +745,42 @@ const PurchaseRequestsPage: React.FC = () => {
<Button key="cancel" onClick={() => {
if (form.isFieldsTouched()) {
Modal.confirm({
title: '确认关闭',
content: '表单数据尚未保存,关闭后可通过草稿恢复。确定关闭吗?',
okText: '关闭',
cancelText: '继续编辑',
title: t('common.closeConfirm'),
content: t('common.closeConfirmMsg'),
okText: t('common.close'),
cancelText: t('common.continueEdit'),
onOk: () => {
saveDraft({ attachments, purchaseType, currency })
form.resetFields()
setModalVisible(false)
},
})
} else {
form.resetFields()
setModalVisible(false)
}
}}></Button>,
<Button key="save" onClick={handleSave}></Button>,
<Button key="submit" type="primary" onClick={handleFormSubmit}></Button>
}}>{t('common.cancel')}</Button>,
<Button key="save" onClick={handleSave}>{t('common.save')}</Button>,
<Button key="submit" type="primary" onClick={handleFormSubmit}>{t('purchaseRequest.submitApproval')}</Button>
]}
width={700}
maskClosable={false}
destroyOnClose
>
<Form form={form} layout="vertical" onValuesChange={handleFormChange}>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="purchase_type"
label="采购类型"
rules={[{ required: true, message: '请选择采购类型' }]}
label={t('purchaseRequest.purchaseType')}
rules={[{ required: true, message: t('purchaseRequest.purchaseTypeRequired') }]}
>
<Select
placeholder="请选择采购类型"
placeholder={t('purchaseRequest.selectPurchaseType')}
onChange={(value) => setPurchaseType(value as 'inventory' | 'project')}
>
<Select.Option value="inventory"></Select.Option>
<Select.Option value="project"></Select.Option>
<Select.Option value="inventory">{t('purchaseRequest.stockPurchase')}</Select.Option>
<Select.Option value="project">{t('purchaseRequest.projectPurchase')}</Select.Option>
</Select>
</Form.Item>
</Col>
@@ -781,10 +788,10 @@ const PurchaseRequestsPage: React.FC = () => {
{purchaseType === 'project' && (
<Form.Item
name="project_id"
label="关联项目"
rules={[{ required: true, message: '项目采购必须关联项目' }]}
label={t('purchaseRequest.relatedProject')}
rules={[{ required: true, message: t('purchaseRequest.projectRequired') }]}
>
<Select placeholder="请选择项目" allowClear>
<Select placeholder={t('purchaseRequest.selectProject')} allowClear>
{projects.map(project => (
<Select.Option key={project.id} value={project.id}>
{project.name}
@@ -800,18 +807,18 @@ const PurchaseRequestsPage: React.FC = () => {
<Col span={12}>
<Form.Item
name="applicant"
label="申请人"
initialValue="系统管理员"
rules={[{ required: true, message: '请输入申请人' }]}
label={t('purchaseRequest.applicantLabel')}
initialValue={t('common.systemAdmin')}
rules={[{ required: true, message: t('purchaseRequest.applicantRequired') }]}
>
<Input placeholder="请输入申请人" disabled />
<Input placeholder={t('purchaseRequest.applicantPlaceholder')} disabled />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="request_date"
label="申请日期"
rules={[{ required: true, message: '请选择申请日期' }]}
label={t('purchaseRequest.applicationDateLabel')}
rules={[{ required: true, message: t('purchaseRequest.dateRequired') }]}
>
<DatePicker style={{ width: '100%' }} />
</Form.Item>
@@ -820,15 +827,15 @@ const PurchaseRequestsPage: React.FC = () => {
<Form.Item
name="brief_description"
label="事由描述"
label={t('purchaseRequest.subjectDescription')}
rules={[
{ required: true, message: '请输入事由描述' },
{ max: 100, message: '事由描述不能超过100个字符' }
{ required: true, message: t('purchaseRequest.subjectRequired') },
{ max: 100, message: t('purchaseRequest.subjectMaxLength') }
]}
>
<Input.TextArea
rows={2}
placeholder="请简要描述采购需求(如:采购XX项目所需电缆、电杆等材料)"
placeholder={t('purchaseRequest.subjectPlaceholder')}
maxLength={100}
showCount
/>
@@ -838,26 +845,26 @@ const PurchaseRequestsPage: React.FC = () => {
<Col span={8}>
<Form.Item
name="expense_category"
label="支出分类"
rules={[{ required: true, message: '请选择支出分类' }]}
label={t('purchaseRequest.expenseCategory')}
rules={[{ required: true, message: t('purchaseRequest.categoryRequired') }]}
>
<Select placeholder="请选择支出分类">
<Select.Option value="material"></Select.Option>
<Select.Option value="equipment"></Select.Option>
<Select.Option value="pole"></Select.Option>
<Select.Option value="other"></Select.Option>
<Select placeholder={t('purchaseRequest.selectCategory')}>
<Select.Option value="material">{t('purchaseRequest.material')}</Select.Option>
<Select.Option value="equipment">{t('purchaseRequest.equipment')}</Select.Option>
<Select.Option value="pole">{t('purchaseRequest.pole')}</Select.Option>
<Select.Option value="other">{t('purchaseRequest.other')}</Select.Option>
</Select>
</Form.Item>
</Col>
<Col span={8}>
<Form.Item
name="total_amount"
label="预计金额"
rules={[{ required: true, message: '请输入预计金额' }]}
label={t('purchaseRequest.estimatedAmountLabel')}
rules={[{ required: true, message: t('purchaseRequest.amountRequired') }]}
>
<InputNumber
style={{ width: '100%' }}
placeholder="预计金额"
placeholder={t('purchaseRequest.estimatedAmountPlaceholder')}
min={0}
precision={2}
/>
@@ -866,14 +873,14 @@ const PurchaseRequestsPage: React.FC = () => {
<Col span={8}>
<Form.Item
name="currency"
label="币种"
rules={[{ required: true, message: '请选择币种' }]}
label={t('purchaseRequest.currency')}
rules={[{ required: true, message: t('purchaseRequest.currencyRequired') }]}
>
<Select placeholder="请选择币种" onChange={(value) => setCurrency(value)}>
<Select.Option value="CNY"></Select.Option>
<Select.Option value="USD"></Select.Option>
<Select.Option value="LAK"></Select.Option>
<Select.Option value="THB"></Select.Option>
<Select placeholder={t('purchaseRequest.selectCurrency')} onChange={(value) => setCurrency(value)}>
<Select.Option value="CNY">{t('purchaseRequest.currencyCNY')}</Select.Option>
<Select.Option value="USD">{t('purchaseRequest.currencyUSD')}</Select.Option>
<Select.Option value="LAK">{t('purchaseRequest.currencyLAK')}</Select.Option>
<Select.Option value="THB">{t('purchaseRequest.currencyTHB')}</Select.Option>
</Select>
</Form.Item>
</Col>
@@ -883,19 +890,19 @@ const PurchaseRequestsPage: React.FC = () => {
<Col span={12}>
<Form.Item
name="expected_date"
label="需求日期"
rules={[{ required: true, message: '请选择需求日期' }]}
label={t('purchaseRequest.demandDateLabel')}
rules={[{ required: true, message: t('purchaseRequest.dateRequired') }]}
>
<DatePicker style={{ width: '100%' }} placeholder="期望到货日期" />
<DatePicker style={{ width: '100%' }} placeholder={t('purchaseRequest.demandDatePlaceholder')} />
</Form.Item>
</Col>
</Row>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={2} placeholder="请输入备注(选填)" />
<Form.Item name="remark" label={t('purchaseRequest.remarkLabel')}>
<Input.TextArea rows={2} placeholder={t('purchaseRequest.remarkPlaceholder')} />
</Form.Item>
<Form.Item name="attachments" label="附件">
<Form.Item name="attachments" label={t('purchaseRequest.attachment')}>
<Upload
name="file"
listType="text"
@@ -915,9 +922,19 @@ const PurchaseRequestsPage: React.FC = () => {
formData.append('file', file as File)
try {
let authHeader = '';
try {
const authStorage = localStorage.getItem('auth-storage');
if (authStorage) {
const parsed = JSON.parse(authStorage);
const token = parsed?.state?.token;
if (token) authHeader = `Bearer ${token}`;
}
} catch (e) {}
const response = await fetch('/api/upload/single', {
method: 'POST',
body: formData
body: formData,
headers: authHeader ? { Authorization: authHeader } : {},
})
const data = await response.json()
@@ -943,7 +960,7 @@ const PurchaseRequestsPage: React.FC = () => {
window.open(file.url, '_blank')
}}
>
<Button icon={<UploadOutlined />}></Button>
<Button icon={<UploadOutlined />}>{t('purchaseRequest.selectFile')}</Button>
</Upload>
</Form.Item>
</Form>
@@ -951,46 +968,46 @@ const PurchaseRequestsPage: React.FC = () => {
{/* 详情弹窗 */}
<Modal
title="采购申请详情"
title={t('purchaseRequest.detailTitle')}
open={detailModalVisible}
onCancel={() => setDetailModalVisible(false)}
footer={[<Button key="close" onClick={() => setDetailModalVisible(false)}></Button>]}
footer={[<Button key="close" onClick={() => setDetailModalVisible(false)}>{t('common.close')}</Button>]}
width={700}
>
{viewingRequest && (
<div>
<Card style={{ marginBottom: 16 }}>
<Descriptions bordered column={2}>
<Descriptions.Item label="申请编号" span={1}>{viewingRequest.request_code || viewingRequest.code}</Descriptions.Item>
<Descriptions.Item label="状态" span={1}>{getStatusTag(viewingRequest.status)}</Descriptions.Item>
<Descriptions.Item label="采购类型" span={1}>
{viewingRequest.purchase_type === 'project' ? '项目采购' : '库存采购'}
<Descriptions.Item label={t('purchaseRequest.applicationCode')} span={1}>{viewingRequest.request_code || viewingRequest.code}</Descriptions.Item>
<Descriptions.Item label={t('purchaseRequest.status')} span={1}>{getStatusTag(viewingRequest.status)}</Descriptions.Item>
<Descriptions.Item label={t('purchaseRequest.purchaseType')} span={1}>
{viewingRequest.purchase_type === 'project' ? t('purchaseRequest.projectPurchase') : t('purchaseRequest.stockPurchase')}
</Descriptions.Item>
<Descriptions.Item label="项目" span={1}>{viewingRequest.project_name || '-'}</Descriptions.Item>
<Descriptions.Item label="申请人" span={1}>{viewingRequest.applicant}</Descriptions.Item>
<Descriptions.Item label="申请日期" span={1}>{viewingRequest.request_date}</Descriptions.Item>
<Descriptions.Item label="事由描述" span={2}>{viewingRequest.brief_description || '-'}</Descriptions.Item>
<Descriptions.Item label="支出分类" span={1}>
<Descriptions.Item label={t('purchaseRequest.project')} span={1}>{viewingRequest.project_name || '-'}</Descriptions.Item>
<Descriptions.Item label={t('purchaseRequest.applicant')} span={1}>{viewingRequest.applicant}</Descriptions.Item>
<Descriptions.Item label={t('purchaseRequest.applicationDate')} span={1}>{viewingRequest.request_date}</Descriptions.Item>
<Descriptions.Item label={t('purchaseRequest.subjectDescription')} span={2}>{viewingRequest.brief_description || '-'}</Descriptions.Item>
<Descriptions.Item label={t('purchaseRequest.expenseCategory')} span={1}>
{{
material: '材料',
equipment: '设备',
pole: '电杆',
other: '其他'
material: t('purchaseRequest.material'),
equipment: t('purchaseRequest.equipment'),
pole: t('purchaseRequest.pole'),
other: t('purchaseRequest.other')
}[viewingRequest.expense_category] || viewingRequest.expense_category}
</Descriptions.Item>
<Descriptions.Item label="预计金额" span={1}>
<Descriptions.Item label={t('purchaseRequest.estimatedAmount')} span={1}>
<strong style={{ fontSize: 16, color: '#999' }}>
{viewingRequest.currency} {viewingRequest.total_amount?.toFixed(2) || '0.00'}
</strong>
</Descriptions.Item>
<Descriptions.Item label="需求日期" span={1}>
<Descriptions.Item label={t('purchaseRequest.demandDate')} span={1}>
{viewingRequest.expected_date || '-'}
</Descriptions.Item>
<Descriptions.Item label="创建时间" span={1}>
<Descriptions.Item label={t('purchaseRequest.createdAt')} span={1}>
{viewingRequest.created_at}
</Descriptions.Item>
{viewingRequest.remark && (
<Descriptions.Item label="备注" span={2}>{viewingRequest.remark}</Descriptions.Item>
<Descriptions.Item label={t('purchaseRequest.remarkLabel')} span={2}>{viewingRequest.remark}</Descriptions.Item>
)}
</Descriptions>
</Card>
@@ -1001,4 +1018,4 @@ const PurchaseRequestsPage: React.FC = () => {
)
}
export default PurchaseRequestsPage
export default PurchaseRequestsPage
+140 -138
View File
@@ -1,138 +1,140 @@
import React from 'react';
import { Card, Typography, Button, Table, Tag, Space, Modal, Form, Input, Checkbox, message, Tree } from 'antd';
import { PlusOutlined, SafetyOutlined } from '@ant-design/icons';
const { Title, Paragraph } = Typography;
const RolesPage: React.FC = () => {
const [loading] = React.useState(false);
const [modalVisible, setModalVisible] = React.useState(false);
const [form] = Form.useForm();
const permissionTree = [
{
title: '项目管理',
key: 'project',
children: [
{ title: '查看项目', key: 'project:view' },
{ title: '创建项目', key: 'project:create' },
{ title: '编辑项目', key: 'project:edit' },
{ title: '删除项目', key: 'project:delete' },
],
},
{
title: '财务管理',
key: 'finance',
children: [
{ title: '查看财务', key: 'finance:view' },
{ title: '预支审批', key: 'finance:advance' },
{ title: '报销审批', key: 'finance:reimburse' },
{ title: '付款审批', key: 'finance:payment' },
],
},
{
title: '采购管理',
key: 'procurement',
children: [
{ title: '查看采购', key: 'procurement:view' },
{ title: '创建采购', key: 'procurement:create' },
{ title: '审批采购', key: 'procurement:approve' },
],
},
{
title: '系统设置',
key: 'system',
children: [
{ title: '用户管理', key: 'system:users' },
{ title: '角色管理', key: 'system:roles' },
{ title: '系统配置', key: 'system:config' },
],
},
];
const columns = [
{ title: '角色ID', dataIndex: 'id', key: 'id', width: 100 },
{ title: '角色名称', dataIndex: 'name', key: 'name', width: 150 },
{ title: '角色描述', dataIndex: 'description', key: 'description' },
{
title: '权限数量',
dataIndex: 'permissionCount',
key: 'permissionCount',
width: 100,
render: (v: number) => <Tag color="blue">{v} </Tag>
},
{ title: '创建时间', dataIndex: 'createdAt', key: 'createdAt', width: 150 },
{ title: '创建人', dataIndex: 'creator', key: 'creator', width: 120 },
{
title: '操作',
key: 'action',
width: 180,
render: () => (
<Space>
<Button size="small" type="link"></Button>
<Button size="small" type="link"></Button>
<Button size="small" type="link" danger></Button>
</Space>
)
}
];
const data = [
{ key: '1', id: 'R001', name: '超级管理员', description: '拥有系统所有权限', permissionCount: 50, createdAt: '2026-01-01', creator: '系统' },
{ key: '2', id: 'R002', name: '项目经理', description: '项目管理、施工管理权限', permissionCount: 25, createdAt: '2026-01-15', creator: 'admin' },
{ key: '3', id: 'R003', name: '财务经理', description: '财务管理、审批权限', permissionCount: 18, createdAt: '2026-02-01', creator: 'admin' },
{ key: '4', id: 'R004', name: '普通员工', description: '查看和申请权限', permissionCount: 10, createdAt: '2026-02-15', creator: 'admin' },
];
const handleSubmit = () => {
message.success('角色已创建');
setModalVisible(false);
};
return (
<div style={{ padding: 24 }}>
<div style={{ marginBottom: 24, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<Title level={3} style={{ marginBottom: 0 }}></Title>
<Paragraph type="secondary"></Paragraph>
</div>
<Space>
<Input.Search placeholder="搜索角色" style={{ width: 200 }} />
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalVisible(true)}>
</Button>
</Space>
</div>
<Card>
<Table columns={columns} dataSource={data} loading={loading} pagination={{ pageSize: 10 }} />
</Card>
<Modal
title="新增角色"
open={modalVisible}
onCancel={() => setModalVisible(false)}
onOk={handleSubmit}
width={600}
>
<Form form={form} layout="vertical">
<Form.Item label="角色名称" name="name" rules={[{ required: true }]}>
<Input placeholder="请输入角色名称" prefix={<SafetyOutlined />} />
</Form.Item>
<Form.Item label="角色描述" name="description">
<Input placeholder="请输入角色描述" />
</Form.Item>
<Form.Item label="权限配置" name="permissions">
<Tree
checkable
defaultExpandedKeys={['project', 'finance']}
treeData={permissionTree}
/>
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default RolesPage;
import React from 'react';
import { Card, Typography, Button, Table, Tag, Space, Modal, Form, Input, Checkbox, message, Tree } from 'antd';
import { PlusOutlined, SafetyOutlined } from '@ant-design/icons';
import { useLanguageStore } from '../store/languageStore';
const { Title, Paragraph } = Typography;
const RolesPage: React.FC = () => {
const { t, currentLanguage } = useLanguageStore();
const [loading] = React.useState(false);
const [modalVisible, setModalVisible] = React.useState(false);
const [form] = Form.useForm();
const permissionTree = [
{
title: t('roles.permProject'),
key: 'project',
children: [
{ title: t('roles.permViewProject'), key: 'project:view' },
{ title: t('roles.permCreateProject'), key: 'project:create' },
{ title: t('roles.permEditProject'), key: 'project:edit' },
{ title: t('roles.permDeleteProject'), key: 'project:delete' },
],
},
{
title: t('roles.permFinance'),
key: 'finance',
children: [
{ title: t('roles.permViewFinance'), key: 'finance:view' },
{ title: t('roles.permApproveAdvance'), key: 'finance:advance' },
{ title: t('roles.permApproveReimburse'), key: 'finance:reimburse' },
{ title: t('roles.permApprovePayment'), key: 'finance:payment' },
],
},
{
title: t('roles.permProcurement'),
key: 'procurement',
children: [
{ title: t('roles.permViewProcurement'), key: 'procurement:view' },
{ title: t('roles.permCreateProcurement'), key: 'procurement:create' },
{ title: t('roles.permApproveProcurement'), key: 'procurement:approve' },
],
},
{
title: t('roles.permSystem'),
key: 'system',
children: [
{ title: t('roles.permUserManagement'), key: 'system:users' },
{ title: t('roles.permRoleManagement'), key: 'system:roles' },
{ title: t('roles.permSystemConfig'), key: 'system:config' },
],
},
];
const columns = [
{ title: t('roles.roleId'), dataIndex: 'id', key: 'id', width: 100 },
{ title: t('roles.roleName'), dataIndex: 'name', key: 'name', width: 150 },
{ title: t('roles.roleDesc'), dataIndex: 'description', key: 'description' },
{
title: t('roles.permCount'),
dataIndex: 'permissionCount',
key: 'permissionCount',
width: 100,
render: (v: number) => <Tag color="blue">{v} {t('common.item')}</Tag>
},
{ title: t('roles.createdAt'), dataIndex: 'createdAt', key: 'createdAt', width: 150 },
{ title: t('roles.creator'), dataIndex: 'creator', key: 'creator', width: 120 },
{
title: t('roles.action'),
key: 'action',
width: 180,
render: () => (
<Space>
<Button size="small" type="link">{t('roles.viewPerm')}</Button>
<Button size="small" type="link">{t('roles.edit')}</Button>
<Button size="small" type="link" danger>{t('roles.delete')}</Button>
</Space>
)
}
];
const data = [
{ key: '1', id: 'R001', name: t('roles.superAdmin'), description: t('roles.superAdminDesc'), permissionCount: 50, createdAt: '2026-01-01', creator: t('roles.admin') },
{ key: '2', id: 'R002', name: t('user.manager'), description: t('roles.adminDesc'), permissionCount: 25, createdAt: '2026-01-15', creator: 'admin' },
{ key: '3', id: 'R003', name: t('roles.financeManager'), description: t('roles.financeManagerDesc'), permissionCount: 18, createdAt: '2026-02-01', creator: 'admin' },
{ key: '4', id: 'R004', name: t('roles.employee'), description: t('roles.employeeDesc'), permissionCount: 10, createdAt: '2026-02-15', creator: 'admin' },
];
const handleSubmit = () => {
message.success(t('roles.roleCreated'));
setModalVisible(false);
};
return (
<div style={{ padding: 24 }}>
<div style={{ marginBottom: 24, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<Title level={3} style={{ marginBottom: 0 }}>{t('roles.title')}</Title>
<Paragraph type="secondary">{t('roles.description')}</Paragraph>
</div>
<Space>
<Input.Search placeholder={t('roles.searchRole')} style={{ width: 200 }} />
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalVisible(true)}>
{t('roles.newRole')}
</Button>
</Space>
</div>
<Card>
<Table columns={columns} dataSource={data} loading={loading} pagination={{ pageSize: 10 }} />
</Card>
<Modal
title={t('roles.newRole')}
open={modalVisible}
onCancel={() => setModalVisible(false)}
onOk={handleSubmit}
width={600}
>
<Form form={form} layout="vertical">
<Form.Item label={t('roles.roleName')} name="name" rules={[{ required: true }]}>
<Input placeholder={t('roles.roleNameRequired')} prefix={<SafetyOutlined />} />
</Form.Item>
<Form.Item label={t('roles.roleDesc')} name="description">
<Input placeholder={t('roles.roleDescRequired')} />
</Form.Item>
<Form.Item label={t('roles.permConfig')} name="permissions">
<Tree
checkable
defaultExpandedKeys={['project', 'finance']}
treeData={permissionTree}
/>
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default RolesPage;
+25 -23
View File
@@ -8,6 +8,7 @@ import {
FileTextOutlined, DollarOutlined, BankOutlined
} from '@ant-design/icons'
import BusinessLedgerTab from '../components/BusinessLedgerTab'
import { useLanguageStore } from '../store/languageStore'
const { Title, Text } = Typography
@@ -68,6 +69,7 @@ interface Subcontractor {
const SubcontractorDetail: React.FC = () => {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const { t, currentLanguage } = useLanguageStore()
const [subcontractor, setSubcontractor] = useState<Subcontractor | null>(null)
const [loading, setLoading] = useState(true)
const [activeTab, setActiveTab] = useState('basic')
@@ -89,12 +91,12 @@ const SubcontractorDetail: React.FC = () => {
}
if (loading) return <Spin style={{ display: 'flex', justifyContent: 'center', padding: 50 }} />
if (!subcontractor) return <Empty description="分包商不存在" style={{ marginTop: 100 }} />
if (!subcontractor) return <Empty description={t('subcontractor.notFound')} style={{ marginTop: 100 }} />
return (
<div style={{ padding: '16px', maxWidth: 1200, margin: '0 auto' }}>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/subcontractors')} style={{ marginBottom: 16 }} type="text">
{t('subcontractor.returnToList')}
</Button>
<Title level={4} style={{ marginBottom: 24 }}>
@@ -105,65 +107,65 @@ const SubcontractorDetail: React.FC = () => {
<Card style={{ borderRadius: 8 }}>
<Tabs activeKey={activeTab} onChange={setActiveTab}>
{/* TAB1: 基本信息 */}
<Tabs.TabPane tab={<span><UserOutlined /> </span>} key="basic">
<Tabs.TabPane tab={<span><UserOutlined /> {t('subcontractor.basicInfo')}</span>} key="basic">
<Descriptions bordered column={{ xs: 1, sm: 2, md: 3 }} size="small">
<Descriptions.Item label="编号">{subcontractor.code}</Descriptions.Item>
<Descriptions.Item label="承包范围">{subcontractor.scope || '-'}</Descriptions.Item>
<Descriptions.Item label="国家"><Tag color="purple">{subcontractor.country || '-'}</Tag></Descriptions.Item>
<Descriptions.Item label={t('subcontractor.code')}>{subcontractor.code}</Descriptions.Item>
<Descriptions.Item label={t('subcontractor.scope')}>{subcontractor.scope || '-'}</Descriptions.Item>
<Descriptions.Item label={t('subcontractor.country')}><Tag color="purple">{subcontractor.country || '-'}</Tag></Descriptions.Item>
</Descriptions>
{subcontractor.features && (
<div style={{ marginTop: 16 }}>
<Text type="secondary"></Text>
<Text type="secondary">{t('subcontractor.featureLabel')}</Text>
<div style={{ padding: 12, background: '#f9f0ff', borderRadius: 4, border: '1px solid #d3adf7', marginTop: 8 }}>{subcontractor.features}</div>
</div>
)}
{subcontractor.remark && (
<div style={{ marginTop: 16 }}>
<Text type="secondary"></Text>
<Text type="secondary">{t('subcontractor.remarkLabel')}</Text>
<div style={{ padding: 12, background: '#fafafa', borderRadius: 4, marginTop: 8 }}>{subcontractor.remark}</div>
</div>
)}
</Tabs.TabPane>
{/* TAB2: 联系人 */}
<Tabs.TabPane tab={<span><PhoneOutlined /> </span>} key="contacts">
<Tabs.TabPane tab={<span><PhoneOutlined /> {t('subcontractor.contact')}</span>} key="contacts">
<Row gutter={[16, 16]}>
{(subcontractor.contacts || []).map((contact, i) => (
<Col key={i} xs={24} sm={12} lg={8}>
<Card size="small" style={{ borderLeft: contact.is_primary ? '3px solid #722ed1' : '3px solid #d9d9d9', background: contact.is_primary ? '#f9f0ff' : '#fff' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<Text strong>{contact.name || '未命名'}</Text>
{contact.is_primary && <Tag color="purple"></Tag>}
<Text strong>{contact.name || t('common.unnamed')}</Text>
{contact.is_primary && <Tag color="purple">{t('subcontractor.mainContactTag')}</Tag>}
</div>
<div style={{ color: '#666', fontSize: 13 }}>
{contact.position && <div>{contact.position}</div>}
{contact.phone && <div>{contact.phone}</div>}
{contact.position && <div>{t('subcontractor.positionLabel')}{contact.position}</div>}
{contact.phone && <div>{t('subcontractor.phoneLabel')}{contact.phone}</div>}
</div>
</Card>
</Col>
))}
</Row>
{(subcontractor.contacts || []).length === 0 && <Empty description="暂无联系人" image={Empty.PRESENTED_IMAGE_SIMPLE} />}
{(subcontractor.contacts || []).length === 0 && <Empty description={t('subcontractor.noContact')} image={Empty.PRESENTED_IMAGE_SIMPLE} />}
</Tabs.TabPane>
{/* TAB3: 收款信息 */}
<Tabs.TabPane tab={<span><BankOutlined /> </span>} key="payment">
<Tabs.TabPane tab={<span><BankOutlined /> {t('subcontractor.paymentInfo')}</span>} key="payment">
<Row gutter={[16, 16]}>
{(subcontractor.payment_infos || []).map((payment, i) => (
<Col key={i} xs={24} sm={12} lg={8}>
<Card size="small" style={{ borderLeft: payment.is_primary ? '3px solid #722ed1' : '3px solid #d9d9d9', background: payment.is_primary ? '#f9f0ff' : '#fff' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<Text strong>{payment.account_name}</Text>
{payment.is_primary && <Tag color="purple"></Tag>}
{payment.is_primary && <Tag color="purple">{t('subcontractor.defaultAccount')}</Tag>}
</div>
<div style={{ color: '#666', fontSize: 13 }}>
<div>{payment.bank_name}</div>
<div>{payment.bank_account}</div>
<div>{t('subcontractor.bankLabel')}{payment.bank_name}</div>
<div>{t('subcontractor.accountLabel')}{payment.bank_account}</div>
{payment.qr_code && (
<div style={{ marginTop: 8 }}>
<Text type="secondary"></Text>
<Text type="secondary">{t('subcontractor.qrCodeLabel')}</Text>
<div style={{ marginTop: 4 }}>
<img src={payment.qr_code} alt="二维码" style={{ maxWidth: '100%', maxHeight: 120, border: '1px solid #d9d9d9', borderRadius: 4 }} />
<img src={payment.qr_code} alt={t('subcontractor.qrCode')} style={{ maxWidth: '100%', maxHeight: 120, border: '1px solid #d9d9d9', borderRadius: 4 }} />
</div>
</div>
)}
@@ -172,11 +174,11 @@ const SubcontractorDetail: React.FC = () => {
</Col>
))}
</Row>
{(subcontractor.payment_infos || []).length === 0 && <Empty description="暂无收款信息" image={Empty.PRESENTED_IMAGE_SIMPLE} />}
{(subcontractor.payment_infos || []).length === 0 && <Empty description={t('subcontractor.noPayment')} image={Empty.PRESENTED_IMAGE_SIMPLE} />}
</Tabs.TabPane>
{/* TAB4: 业务台账 */}
<Tabs.TabPane tab={<span><DollarOutlined /> </span>} key="ledger">
<Tabs.TabPane tab={<span><DollarOutlined /> {t('subcontractor.ledger')}</span>} key="ledger">
<BusinessLedgerTab
partnerType="subcontractor"
summary={subcontractor.ledger?.summary || { item_count: 0, total_contract_amount: 0, total_paid_amount: 0, total_unpaid_amount: 0 }}
@@ -189,4 +191,4 @@ const SubcontractorDetail: React.FC = () => {
)
}
export default SubcontractorDetail
export default SubcontractorDetail
+68 -65
View File
@@ -5,6 +5,7 @@ import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, SolutionOut
import type { ColumnsType } from 'antd/es/table'
import FileUpload from '../components/FileUpload'
import useFormDraft from '../hooks/useFormDraft'
import { useLanguageStore } from '../store/languageStore'
interface Contact {
name: string
@@ -39,6 +40,7 @@ interface Subcontractor {
const SubcontractorPage: React.FC = () => {
const navigate = useNavigate()
const { t, currentLanguage } = useLanguageStore()
const [subcontractors, setSubcontractors] = useState<Subcontractor[]>([])
const [loading, setLoading] = useState(false)
const [modalVisible, setModalVisible] = useState(false)
@@ -63,7 +65,7 @@ const SubcontractorPage: React.FC = () => {
const data = await response.json()
if (data.success) setSubcontractors(data.data || [])
} catch (error) {
message.error('获取分包商列表失败')
message.error(t('subcontractor.getListFailed'))
} finally {
setLoading(false)
}
@@ -89,16 +91,16 @@ const SubcontractorPage: React.FC = () => {
const columns: ColumnsType<Subcontractor> = [
{
title: '名称', dataIndex: 'name', key: 'name',
title: t('subcontractor.name'), dataIndex: 'name', key: 'name',
render: (text, record) => (
<Button type="link" style={{ padding: 0, fontWeight: 'bold' }} onClick={() => navigate(`/subcontractors/${record.id}`)}>{text}</Button>
)
},
{ title: '承包范围', dataIndex: 'scope', key: 'scope', width: 120 },
{ title: '国家', dataIndex: 'country', key: 'country', width: 80, render: (c) => <Tag>{c || '-'}</Tag> },
{ title: '合同金额', dataIndex: 'total_contract_amount', key: 'total_contract_amount', width: 100, render: (v) => `¥${(v || 0).toLocaleString()}` },
{ title: '应付金额', dataIndex: 'total_payable', key: 'total_payable', width: 100, render: (v) => <span style={{ color: v > 0 ? '#ff4d4f' : '#52c41a' }}>¥{(v || 0).toLocaleString()}</span> },
{ title: '操作', key: 'actions', width: 100, render: (_, record) => (
{ title: t('subcontractor.scope'), dataIndex: 'scope', key: 'scope', width: 120 },
{ title: t('subcontractor.country'), dataIndex: 'country', key: 'country', width: 80, render: (c) => <Tag>{c || '-'}</Tag> },
{ title: t('subcontractor.contractAmount'), dataIndex: 'total_contract_amount', key: 'total_contract_amount', width: 100, render: (v) => `¥${(v || 0).toLocaleString()}` },
{ title: t('subcontractor.payableAmount'), dataIndex: 'total_payable', key: 'total_payable', width: 100, render: (v) => <span style={{ color: v > 0 ? '#ff4d4f' : '#52c41a' }}>¥{(v || 0).toLocaleString()}</span> },
{ title: t('common.action'), key: 'actions', width: 100, render: (_, record) => (
<Space>
<Button type="text" icon={<EditOutlined />} onClick={() => handleEdit(record)} size="small" />
<Button type="text" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.id)} size="small" />
@@ -156,17 +158,17 @@ const SubcontractorPage: React.FC = () => {
})
const data = await response.json()
if (data.success) {
message.success(editingSubcontractor ? '更新成功' : '创建成功')
message.success(editingSubcontractor ? t('common.updateSuccess') : t('common.createSuccess'))
clearDraft()
setModalVisible(false)
form.resetFields()
setEditingSubcontractor(null)
fetchSubcontractors()
} else {
message.error(data.message || '操作失败')
message.error(data.message || t('common.operationFailed'))
}
} catch (error) {
message.error('操作失败')
message.error(t('common.operationFailed'))
}
}
@@ -186,14 +188,14 @@ const SubcontractorPage: React.FC = () => {
const handleDelete = async (id: number) => {
Modal.confirm({
title: '确认删除', content: '确定要删除此分包商吗?', okText: '确定', cancelText: '取消',
title: t('common.confirmDelete'), content: t('subcontractor.confirmDeleteMsg'), okText: t('common.confirm'), cancelText: t('common.cancel'),
onOk: async () => {
try {
const response = await fetch(`/api/subcontractors/${id}`, { method: 'DELETE' })
const data = await response.json()
if (data.success) { message.success('删除成功'); fetchSubcontractors() }
else message.error(data.message || '删除失败')
} catch (error) { message.error('删除失败') }
if (data.success) { message.success(t('common.deleteSuccess')); fetchSubcontractors() }
else message.error(data.message || t('common.deleteFailed'))
} catch (error) { message.error(t('common.deleteFailed')) }
}
})
}
@@ -211,10 +213,10 @@ const SubcontractorPage: React.FC = () => {
setTimeout(() => {
if (hasDraft()) {
Modal.confirm({
title: '发现未完成的草稿',
content: '检测到上次未提交的分包商信息,是否恢复?',
okText: '恢复草稿',
cancelText: '重新填写',
title: t('common.draftFound'),
content: t('common.draftRestore'),
okText: t('common.restoreDraft'),
cancelText: t('common.reFill'),
onOk: () => {
restoreDraft()
},
@@ -235,89 +237,90 @@ const SubcontractorPage: React.FC = () => {
return (
<div style={{ padding: 24 }}>
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={8}><Card><Statistic title="分包商总数" value={stats.total} prefix={<SolutionOutlined />} /></Card></Col>
<Col span={8}><Card><Statistic title="合同总金额" value={stats.totalContract} prefix="¥" valueStyle={{ color: '#1890ff' }} /></Card></Col>
<Col span={8}><Card><Statistic title="应付总金额" value={stats.totalPayable} prefix="¥" valueStyle={{ color: stats.totalPayable > 0 ? '#ff4d4f' : '#52c41a' }} /></Card></Col>
<Col span={8}><Card><Statistic title={t('subcontractor.totalCount')} value={stats.total} prefix={<SolutionOutlined />} /></Card></Col>
<Col span={8}><Card><Statistic title={t('subcontractor.totalContract')} value={stats.totalContract} prefix="¥" valueStyle={{ color: '#1890ff' }} /></Card></Col>
<Col span={8}><Card><Statistic title={t('subcontractor.totalPayable')} value={stats.totalPayable} prefix="¥" valueStyle={{ color: stats.totalPayable > 0 ? '#ff4d4f' : '#52c41a' }} /></Card></Col>
</Row>
<Card style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Input placeholder="搜索分包商编号、名称或承包范围" prefix={<SearchOutlined />} value={searchText} onChange={(e) => setSearchText(e.target.value)} allowClear style={{ width: 350 }} />
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}></Button>
<Input placeholder={t('subcontractor.searchPlaceholder')} prefix={<SearchOutlined />} value={searchText} onChange={(e) => setSearchText(e.target.value)} allowClear style={{ width: 350 }} />
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>{t('subcontractor.newSubcontractor')}</Button>
</div>
</Card>
<Card>
<Table columns={columns} dataSource={filteredSubcontractors} rowKey="id" loading={loading} pagination={{ pageSize: 10, showTotal: (total) => `${total}` }} scroll={{ x: 1100 }} />
<Table columns={columns} dataSource={filteredSubcontractors} rowKey="id" loading={loading} pagination={{ pageSize: 10, showTotal: (total) => t('common.totalCount', { total }) }} scroll={{ x: 1100 }} />
</Card>
<Modal
title={editingSubcontractor ? '编辑分包商' : '新增分包商'}
open={modalVisible}
<Modal
title={editingSubcontractor ? t('subcontractor.editSubcontractor') : t('subcontractor.newSubcontractor')}
open={modalVisible}
onCancel={() => {
if (form.isFieldsTouched()) {
Modal.confirm({
title: '确认关闭',
content: '表单数据尚未保存,关闭后可通过草稿恢复。确定关闭吗?',
okText: '关闭',
cancelText: '继续编辑',
title: t('common.closeConfirm'),
content: t('common.closeConfirmMsg'),
okText: t('common.close'),
cancelText: t('common.continueEdit'),
onOk: () => {
saveDraft()
form.resetFields();
setModalVisible(false)
form.resetFields()
setEditingSubcontractor(null)
},
})
} else {
form.resetFields();
setModalVisible(false)
form.resetFields()
setEditingSubcontractor(null)
}
}}
onOk={() => form.submit()}
}}
onOk={() => form.submit()}
destroyOnClose
width={800}
maskClosable={false}
>
<Form form={form} layout="vertical" onFinish={handleSubmit} onValuesChange={handleFormChange}>
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请输入名称' }]}>
<Input placeholder="分包商名称" />
<Form.Item name="name" label={t('subcontractor.name')} rules={[{ required: true, message: t('subcontractor.nameRequired') }]}>
<Input placeholder={t('subcontractor.namePlaceholder')} />
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="scope" label="承包范围">
<Input placeholder="手填:如电力安装、土建工程" />
<Form.Item name="scope" label={t('subcontractor.scope')}>
<Input placeholder={t('subcontractor.scopePlaceholder')} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="country" label="国家" initialValue="Laos">
<Form.Item name="country" label={t('subcontractor.country')} initialValue="Laos">
<Select>
<Select.Option value="China"></Select.Option>
<Select.Option value="Laos"></Select.Option>
<Select.Option value="China">{t('subcontractor.china')}</Select.Option>
<Select.Option value="Laos">{t('subcontractor.laos')}</Select.Option>
</Select>
</Form.Item>
</Col>
</Row>
<Form.Item name="features" label="特点">
<Input.TextArea rows={2} placeholder="手填:如专业团队、设备齐全、价格合理等" />
<Form.Item name="features" label={t('subcontractor.feature')}>
<Input.TextArea rows={2} placeholder={t('subcontractor.featurePlaceholder')} />
</Form.Item>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={2} placeholder="备注信息" />
<Form.Item name="remark" label={t('common.remark')}>
<Input.TextArea rows={2} placeholder={t('subcontractor.remarkPlaceholder')} />
</Form.Item>
<h4></h4>
<h4>{t('subcontractor.contact')}</h4>
<Form.List name="contacts" initialValue={[{ name: '', position: '', phone: '', is_primary: true }]}>
{(fields, { add, remove }) => (
<div>
{fields.map(({ key, name, ...restField }) => (
<div key={key} style={{ display: 'flex', gap: 8, marginBottom: 8, alignItems: 'center' }}>
<Form.Item {...restField} name={[name, 'name']} style={{ marginBottom: 0, flex: 1 }}>
<Input placeholder="姓名" />
<Input placeholder={t('logistics.name')} />
</Form.Item>
<Form.Item {...restField} name={[name, 'position']} style={{ marginBottom: 0, flex: 1 }}>
<Input placeholder="职位" />
<Input placeholder={t('common.position')} />
</Form.Item>
<Form.Item {...restField} name={[name, 'phone']} style={{ marginBottom: 0, flex: 1 }}>
<Input placeholder="电话" />
<Input placeholder={t('common.phone')} />
</Form.Item>
<div style={{ display: 'flex', alignItems: 'center' }}>
<Form.Item {...restField} name={[name, 'is_primary']} valuePropName="checked" style={{ marginBottom: 0, marginRight: 8 }}>
@@ -326,33 +329,33 @@ const SubcontractorPage: React.FC = () => {
onChange={(e) => handleContactChange(name, 'is_primary', e.target.checked)}
/>
</Form.Item>
<span></span>
<span>{t('subcontractor.mainContact')}</span>
</div>
{fields.length > 1 && <Button type="link" danger onClick={() => remove(name)}></Button>}
{fields.length > 1 && <Button type="link" danger onClick={() => remove(name)}>{t('common.delete')}</Button>}
</div>
))}
<Button type="dashed" onClick={() => add()} style={{ width: '100%' }}>+ </Button>
<Button type="dashed" onClick={() => add()} style={{ width: '100%' }}>{t('subcontractor.addContact')}</Button>
</div>
)}
</Form.List>
<h4 style={{ marginTop: 24 }}></h4>
<h4 style={{ marginTop: 24 }}>{t('subcontractor.paymentInfo')}</h4>
<Form.List name="payment_infos" initialValue={[]}>
{(fields, { add, remove }) => (
<div>
{fields.map(({ key, name, ...restField }) => (
<div key={key} style={{ border: '1px solid #e8e8e8', padding: 16, marginBottom: 16, borderRadius: 4, backgroundColor: '#fafafa' }}>
<div style={{ display: 'flex', gap: 8, marginBottom: 8, alignItems: 'center' }}>
<Form.Item {...restField} name={[name, 'account_name']} label="收款户名" style={{ marginBottom: 0, flex: 1 }}>
<Input placeholder="收款户名" />
<Form.Item {...restField} name={[name, 'account_name']} label={t('subcontractor.accountName')} style={{ marginBottom: 0, flex: 1 }}>
<Input placeholder={t('subcontractor.accountName')} />
</Form.Item>
<Form.Item {...restField} name={[name, 'bank_name']} label="开户银行" style={{ marginBottom: 0, flex: 1 }}>
<Input placeholder="开户银行" />
<Form.Item {...restField} name={[name, 'bank_name']} label={t('subcontractor.bankName')} style={{ marginBottom: 0, flex: 1 }}>
<Input placeholder={t('subcontractor.bankName')} />
</Form.Item>
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 8, alignItems: 'center' }}>
<Form.Item {...restField} name={[name, 'bank_account']} label="银行账号" style={{ marginBottom: 0, flex: 1 }}>
<Input placeholder="银行账号" />
<Form.Item {...restField} name={[name, 'bank_account']} label={t('subcontractor.bankAccount')} style={{ marginBottom: 0, flex: 1 }}>
<Input placeholder={t('subcontractor.bankAccount')} />
</Form.Item>
<div style={{ display: 'flex', alignItems: 'center', marginTop: 30 }}>
<Form.Item {...restField} name={[name, 'is_primary']} valuePropName="checked" style={{ marginBottom: 0, marginRight: 8 }}>
@@ -361,19 +364,19 @@ const SubcontractorPage: React.FC = () => {
onChange={(e) => handlePaymentInfoChange(name, 'is_primary', e.target.checked)}
/>
</Form.Item>
<span></span>
<span>{t('subcontractor.mainAccount')}</span>
</div>
</div>
<Form.Item {...restField} name={[name, 'qr_code']} label="收款码" style={{ marginBottom: 0 }}>
<Form.Item {...restField} name={[name, 'qr_code']} label={t('subcontractor.qrCode')} style={{ marginBottom: 0 }}>
<FileUpload maxCount={1} accept="image/*" />
</Form.Item>
{fields.length > 0 && (
<Button type="link" danger onClick={() => remove(name)} style={{ marginTop: 8 }}></Button>
<Button type="link" danger onClick={() => remove(name)} style={{ marginTop: 8 }}>{t('subcontractor.deletePaymentInfo')}</Button>
)}
</div>
))}
<Button type="dashed" onClick={() => add({ account_name: '', bank_account: '', bank_name: '', is_primary: false })} style={{ width: '100%' }}>
+
{t('subcontractor.addPaymentInfo')}
</Button>
</div>
)}
@@ -384,4 +387,4 @@ const SubcontractorPage: React.FC = () => {
)
}
export default SubcontractorPage
export default SubcontractorPage
+25 -23
View File
@@ -7,6 +7,7 @@ import {
ArrowLeftOutlined, ShopOutlined, UserOutlined, PhoneOutlined, BankOutlined, DollarOutlined
} from '@ant-design/icons'
import BusinessLedgerTab from '../components/BusinessLedgerTab'
import { useLanguageStore } from '../store/languageStore'
const { Title, Text } = Typography
@@ -66,6 +67,7 @@ interface Supplier {
const SupplierDetail: React.FC = () => {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const { t, currentLanguage } = useLanguageStore()
const [supplier, setSupplier] = useState<Supplier | null>(null)
const [loading, setLoading] = useState(true)
const [activeTab, setActiveTab] = useState('basic')
@@ -87,22 +89,22 @@ const SupplierDetail: React.FC = () => {
}
if (loading) return <Spin style={{ display: 'flex', justifyContent: 'center', padding: 50 }} />
if (!supplier) return <Empty description="供应商不存在" style={{ marginTop: 100 }} />
if (!supplier) return <Empty description={t('supplier.notFound')} style={{ marginTop: 100 }} />
const tabItems = [
{
key: 'basic',
label: <span><UserOutlined /> </span>,
label: <span><UserOutlined /> {t('supplier.basicInfo')}</span>,
children: (
<>
<Descriptions bordered column={{ xs: 1, sm: 2, md: 3 }} size="small">
<Descriptions.Item label="编号">{supplier.code}</Descriptions.Item>
<Descriptions.Item label="供应类别">{supplier.supply_category || '-'}</Descriptions.Item>
<Descriptions.Item label="国家"><Tag color="blue">{supplier.country || '-'}</Tag></Descriptions.Item>
<Descriptions.Item label={t('supplier.code')}>{supplier.code}</Descriptions.Item>
<Descriptions.Item label={t('supplier.supplyCategory')}>{supplier.supply_category || '-'}</Descriptions.Item>
<Descriptions.Item label={t('supplier.country')}><Tag color="blue">{supplier.country || '-'}</Tag></Descriptions.Item>
</Descriptions>
{supplier.remark && (
<div style={{ marginTop: 16 }}>
<Text type="secondary"></Text>
<Text type="secondary">{t('supplier.remarkLabel')}</Text>
<div style={{ padding: 12, background: '#e6f7ff', borderRadius: 4, border: '1px solid #91d5ff', marginTop: 8 }}>{supplier.remark}</div>
</div>
)}
@@ -111,7 +113,7 @@ const SupplierDetail: React.FC = () => {
},
{
key: 'contacts',
label: <span><PhoneOutlined /> </span>,
label: <span><PhoneOutlined /> {t('supplier.contact')}</span>,
children: (
<>
<Row gutter={[16, 16]}>
@@ -119,24 +121,24 @@ const SupplierDetail: React.FC = () => {
<Col key={i} xs={24} sm={12} lg={8}>
<Card size="small" style={{ borderLeft: contact.is_primary ? '3px solid #1890ff' : '3px solid #d9d9d9', background: contact.is_primary ? '#f0f5ff' : '#fff' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<Text strong>{contact.name || '未命名'}</Text>
{contact.is_primary && <Tag color="blue"></Tag>}
<Text strong>{contact.name || t('common.unnamed')}</Text>
{contact.is_primary && <Tag color="blue">{t('supplier.mainContactTag')}</Tag>}
</div>
<div style={{ color: '#666', fontSize: 13 }}>
{contact.position && <div>{contact.position}</div>}
{contact.phone && <div>{contact.phone}</div>}
{contact.position && <div>{t('supplier.positionLabel')}{contact.position}</div>}
{contact.phone && <div>{t('supplier.phoneLabel')}{contact.phone}</div>}
</div>
</Card>
</Col>
))}
</Row>
{(supplier.contacts || []).length === 0 && <Empty description="暂无联系人" image={Empty.PRESENTED_IMAGE_SIMPLE} />}
{(supplier.contacts || []).length === 0 && <Empty description={t('supplier.noContact')} image={Empty.PRESENTED_IMAGE_SIMPLE} />}
</>
)
},
{
key: 'payment',
label: <span><BankOutlined /> </span>,
label: <span><BankOutlined /> {t('supplier.paymentInfo')}</span>,
children: (
<>
<Row gutter={[16, 16]}>
@@ -144,17 +146,17 @@ const SupplierDetail: React.FC = () => {
<Col key={i} xs={24} sm={12} lg={8}>
<Card size="small" style={{ borderLeft: payment.is_primary ? '3px solid #1890ff' : '3px solid #d9d9d9', background: payment.is_primary ? '#f0f5ff' : '#fff' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<Text strong>{payment.bank_name || '未命名'}</Text>
{payment.is_primary && <Tag color="blue"></Tag>}
<Text strong>{payment.bank_name || t('common.unnamed')}</Text>
{payment.is_primary && <Tag color="blue">{t('supplier.mainAccountTag')}</Tag>}
</div>
<div style={{ color: '#666', fontSize: 13 }}>
{payment.account_name && <div>{payment.account_name}</div>}
{payment.bank_account && <div>{payment.bank_account}</div>}
{payment.account_name && <div>{t('supplier.accountNameLabel')}{payment.account_name}</div>}
{payment.bank_account && <div>{t('supplier.accountNumLabel')}{payment.bank_account}</div>}
{payment.qr_code && (
<div style={{ marginTop: 8 }}>
<Text type="secondary"></Text>
<Text type="secondary">{t('supplier.qrCodeLabel')}</Text>
<div style={{ marginTop: 4 }}>
<img src={payment.qr_code} alt="收款码" style={{ maxWidth: '100px', maxHeight: '100px' }} />
<img src={payment.qr_code} alt={t('supplier.qrCode')} style={{ maxWidth: '100px', maxHeight: '100px' }} />
</div>
</div>
)}
@@ -163,13 +165,13 @@ const SupplierDetail: React.FC = () => {
</Col>
))}
</Row>
{(supplier.payment_infos || []).length === 0 && <Empty description="暂无收款信息" image={Empty.PRESENTED_IMAGE_SIMPLE} />}
{(supplier.payment_infos || []).length === 0 && <Empty description={t('supplier.noPayment')} image={Empty.PRESENTED_IMAGE_SIMPLE} />}
</>
)
},
{
key: 'ledger',
label: <span><DollarOutlined /> </span>,
label: <span><DollarOutlined /> {t('supplier.ledger')}</span>,
children: (
<BusinessLedgerTab
partnerType="supplier"
@@ -183,7 +185,7 @@ const SupplierDetail: React.FC = () => {
return (
<div style={{ padding: '16px', maxWidth: 1200, margin: '0 auto' }}>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/suppliers')} style={{ marginBottom: 16 }} type="text">
{t('supplier.returnToList')}
</Button>
<Title level={4} style={{ marginBottom: 24 }}>
@@ -198,4 +200,4 @@ const SupplierDetail: React.FC = () => {
)
}
export default SupplierDetail
export default SupplierDetail
+69 -66
View File
@@ -5,6 +5,7 @@ import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, ShopOutline
import type { ColumnsType } from 'antd/es/table'
import FileUpload from '../components/FileUpload'
import useFormDraft from '../hooks/useFormDraft'
import { useLanguageStore } from '../store/languageStore'
interface Contact {
name: string
@@ -39,6 +40,7 @@ interface Supplier {
const SupplierPage: React.FC = () => {
const navigate = useNavigate()
const location = useLocation()
const { t, currentLanguage } = useLanguageStore()
const [suppliers, setSuppliers] = useState<Supplier[]>([])
const [loading, setLoading] = useState(false)
const [modalVisible, setModalVisible] = useState(false)
@@ -66,7 +68,7 @@ const SupplierPage: React.FC = () => {
const data = await response.json()
if (data.success) setSuppliers(data.data || [])
} catch (error) {
message.error('获取供应商列表失败')
message.error(t('supplier.getListFailed'))
} finally {
setLoading(false)
}
@@ -92,7 +94,7 @@ const SupplierPage: React.FC = () => {
const columns: ColumnsType<Supplier> = [
{
title: '名称',
title: t('supplier.name'),
dataIndex: 'name',
key: 'name',
render: (text, record) => (
@@ -101,12 +103,12 @@ const SupplierPage: React.FC = () => {
</Button>
)
},
{ title: '供应类别', dataIndex: 'supply_category', key: 'supply_category', width: 120 },
{ title: '国家', dataIndex: 'country', key: 'country', width: 80, render: (country) => <Tag>{country || '-'}</Tag> },
{ title: '采购金额', dataIndex: 'total_purchase_amount', key: 'total_purchase_amount', width: 100, render: (amount) => `¥${(amount || 0).toLocaleString()}` },
{ title: '应付金额', dataIndex: 'total_payable', key: 'total_payable', width: 100, render: (amount) => <span style={{ color: amount > 0 ? '#ff4d4f' : '#52c41a' }}>¥{(amount || 0).toLocaleString()}</span> },
{ title: t('supplier.supplyCategory'), dataIndex: 'supply_category', key: 'supply_category', width: 120 },
{ title: t('supplier.country'), dataIndex: 'country', key: 'country', width: 80, render: (country) => <Tag>{country || '-'}</Tag> },
{ title: t('supplier.purchaseAmount'), dataIndex: 'total_purchase_amount', key: 'total_purchase_amount', width: 100, render: (amount) => `¥${(amount || 0).toLocaleString()}` },
{ title: t('supplier.payableAmount'), dataIndex: 'total_payable', key: 'total_payable', width: 100, render: (amount) => <span style={{ color: amount > 0 ? '#ff4d4f' : '#52c41a' }}>¥{(amount || 0).toLocaleString()}</span> },
{
title: '操作',
title: t('common.action'),
key: 'actions',
width: 100,
render: (_, record) => (
@@ -169,7 +171,7 @@ const SupplierPage: React.FC = () => {
const data = await response.json()
if (data.success) {
message.success(editingSupplier ? '更新成功' : '创建成功')
message.success(editingSupplier ? t('common.updateSuccess') : t('common.createSuccess'))
clearDraft()
setModalVisible(false)
form.resetFields()
@@ -181,10 +183,10 @@ const SupplierPage: React.FC = () => {
navigate(returnTo, { state: { supplierCreated: true } })
}
} else {
message.error(data.message || '操作失败')
message.error(data.message || t('common.operationFailed'))
}
} catch (error) {
message.error('操作失败')
message.error(t('common.operationFailed'))
}
}
@@ -203,18 +205,18 @@ const SupplierPage: React.FC = () => {
const handleDelete = async (id: number) => {
Modal.confirm({
title: '确认删除',
content: '确定要删除此供应商吗?',
okText: '确定',
cancelText: '取消',
title: t('common.confirmDelete'),
content: t('supplier.confirmDeleteMsg'),
okText: t('common.confirm'),
cancelText: t('common.cancel'),
onOk: async () => {
try {
const response = await fetch(`/api/suppliers/${id}`, { method: 'DELETE' })
const data = await response.json()
if (data.success) { message.success('删除成功'); fetchSuppliers() }
else message.error(data.message || '删除失败')
if (data.success) { message.success(t('common.deleteSuccess')); fetchSuppliers() }
else message.error(data.message || t('common.deleteFailed'))
} catch (error) {
message.error('删除失败')
message.error(t('common.deleteFailed'))
}
}
})
@@ -233,10 +235,10 @@ const SupplierPage: React.FC = () => {
setTimeout(() => {
if (hasDraft()) {
Modal.confirm({
title: '发现未完成的草稿',
content: '检测到上次未提交的供应商信息,是否恢复?',
okText: '恢复草稿',
cancelText: '重新填写',
title: t('common.draftFound'),
content: t('common.draftRestore'),
okText: t('common.restoreDraft'),
cancelText: t('common.reFill'),
onOk: () => {
restoreDraft()
},
@@ -264,86 +266,87 @@ const SupplierPage: React.FC = () => {
return (
<div style={{ padding: 24 }}>
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={8}><Card><Statistic title="供应商总数" value={stats.total} prefix={<ShopOutlined />} /></Card></Col>
<Col span={8}><Card><Statistic title="采购总金额" value={stats.totalPurchase} prefix="¥" valueStyle={{ color: '#1890ff' }} /></Card></Col>
<Col span={8}><Card><Statistic title="应付总金额" value={stats.totalPayable} prefix="¥" valueStyle={{ color: stats.totalPayable > 0 ? '#ff4d4f' : '#52c41a' }} /></Card></Col>
<Col span={8}><Card><Statistic title={t('supplier.totalCount')} value={stats.total} prefix={<ShopOutlined />} /></Card></Col>
<Col span={8}><Card><Statistic title={t('supplier.totalPurchase')} value={stats.totalPurchase} prefix="¥" valueStyle={{ color: '#1890ff' }} /></Card></Col>
<Col span={8}><Card><Statistic title={t('supplier.totalPayable')} value={stats.totalPayable} prefix="¥" valueStyle={{ color: stats.totalPayable > 0 ? '#ff4d4f' : '#52c41a' }} /></Card></Col>
</Row>
<Card style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Input placeholder="搜索供应商编号、名称或供应类别" prefix={<SearchOutlined />} value={searchText} onChange={(e) => setSearchText(e.target.value)} allowClear style={{ width: 350 }} />
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}></Button>
<Input placeholder={t('supplier.searchPlaceholder')} prefix={<SearchOutlined />} value={searchText} onChange={(e) => setSearchText(e.target.value)} allowClear style={{ width: 350 }} />
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>{t('supplier.newSupplier')}</Button>
</div>
</Card>
<Card>
<Table columns={columns} dataSource={filteredSuppliers} rowKey="id" loading={loading} pagination={{ pageSize: 10, showTotal: (total) => `${total}` }} scroll={{ x: 1100 }} />
<Table columns={columns} dataSource={filteredSuppliers} rowKey="id" loading={loading} pagination={{ pageSize: 10, showTotal: (total) => t('common.totalCount', { total }) }} scroll={{ x: 1100 }} />
</Card>
<Modal
title={editingSupplier ? '编辑供应商' : '新增供应商'}
open={modalVisible}
<Modal
title={editingSupplier ? t('supplier.editSupplier') : t('supplier.newSupplier')}
open={modalVisible}
onCancel={() => {
if (form.isFieldsTouched()) {
Modal.confirm({
title: '确认关闭',
content: '表单数据尚未保存,关闭后可通过草稿恢复。确定关闭吗?',
okText: '关闭',
cancelText: '继续编辑',
title: t('common.closeConfirm'),
content: t('common.closeConfirmMsg'),
okText: t('common.close'),
cancelText: t('common.continueEdit'),
onOk: () => {
saveDraft()
form.resetFields();
setModalVisible(false)
form.resetFields()
setEditingSupplier(null)
},
})
} else {
form.resetFields();
setModalVisible(false)
form.resetFields()
setEditingSupplier(null)
}
}}
onOk={() => form.submit()}
}}
onOk={() => form.submit()}
destroyOnClose
width={800}
maskClosable={false}
>
<Form form={form} layout="vertical" onFinish={handleSubmit} onValuesChange={handleFormChange}>
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请输入名称' }]}>
<Input placeholder="供应商名称" />
<Form.Item name="name" label={t('supplier.name')} rules={[{ required: true, message: t('supplier.nameRequired') }]}>
<Input placeholder={t('supplier.namePlaceholder')} />
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="supply_category" label="供应类别">
<Input placeholder="手填:如电力设备、建筑材料" />
<Form.Item name="supply_category" label={t('supplier.supplyCategory')}>
<Input placeholder={t('supplier.categoryPlaceholder')} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="country" label="国家" initialValue="Laos">
<Form.Item name="country" label={t('supplier.country')} initialValue="Laos">
<Select>
<Select.Option value="China"></Select.Option>
<Select.Option value="Laos"></Select.Option>
<Select.Option value="China">{t('supplier.china')}</Select.Option>
<Select.Option value="Laos">{t('supplier.laos')}</Select.Option>
</Select>
</Form.Item>
</Col>
</Row>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={2} placeholder="备注信息" />
<Form.Item name="remark" label={t('common.remark')}>
<Input.TextArea rows={2} placeholder={t('supplier.remarkPlaceholder')} />
</Form.Item>
<h4></h4>
<h4>{t('supplier.contact')}</h4>
<Form.List name="contacts" initialValue={[{ name: '', position: '', phone: '', is_primary: true }]}>
{(fields, { add, remove }) => (
<div>
{fields.map(({ key, name, ...restField }) => (
<div key={key} style={{ display: 'flex', gap: 8, marginBottom: 8, alignItems: 'center' }}>
<Form.Item {...restField} name={[name, 'name']} style={{ marginBottom: 0, flex: 1 }}>
<Input placeholder="姓名" />
<Input placeholder={t('logistics.name')} />
</Form.Item>
<Form.Item {...restField} name={[name, 'position']} style={{ marginBottom: 0, flex: 1 }}>
<Input placeholder="职位" />
<Input placeholder={t('common.position')} />
</Form.Item>
<Form.Item {...restField} name={[name, 'phone']} style={{ marginBottom: 0, flex: 1 }}>
<Input placeholder="电话" />
<Input placeholder={t('common.phone')} />
</Form.Item>
<div style={{ display: 'flex', alignItems: 'center' }}>
<Form.Item {...restField} name={[name, 'is_primary']} valuePropName="checked" style={{ marginBottom: 0, marginRight: 8 }}>
@@ -352,33 +355,33 @@ const SupplierPage: React.FC = () => {
onChange={(e) => handleContactChange(name, 'is_primary', e.target.checked)}
/>
</Form.Item>
<span></span>
<span>{t('supplier.mainContact')}</span>
</div>
{fields.length > 1 && <Button type="link" danger onClick={() => remove(name)}></Button>}
{fields.length > 1 && <Button type="link" danger onClick={() => remove(name)}>{t('common.delete')}</Button>}
</div>
))}
<Button type="dashed" onClick={() => add()} style={{ width: '100%' }}>+ </Button>
<Button type="dashed" onClick={() => add()} style={{ width: '100%' }}>{t('supplier.addContact')}</Button>
</div>
)}
</Form.List>
<h4 style={{ marginTop: 24 }}></h4>
<h4 style={{ marginTop: 24 }}>{t('supplier.paymentInfo')}</h4>
<Form.List name="payment_infos" initialValue={[]}>
{(fields, { add, remove }) => (
<div>
{fields.map(({ key, name, ...restField }) => (
<div key={key} style={{ border: '1px solid #e8e8e8', padding: 16, marginBottom: 16, borderRadius: 4, backgroundColor: '#fafafa' }}>
<div style={{ display: 'flex', gap: 8, marginBottom: 8, alignItems: 'center' }}>
<Form.Item {...restField} name={[name, 'account_name']} label="收款户名" style={{ marginBottom: 0, flex: 1 }}>
<Input placeholder="收款户名" />
<Form.Item {...restField} name={[name, 'account_name']} label={t('supplier.accountName')} style={{ marginBottom: 0, flex: 1 }}>
<Input placeholder={t('supplier.accountName')} />
</Form.Item>
<Form.Item {...restField} name={[name, 'bank_name']} label="开户银行" style={{ marginBottom: 0, flex: 1 }}>
<Input placeholder="开户银行" />
<Form.Item {...restField} name={[name, 'bank_name']} label={t('supplier.bankName')} style={{ marginBottom: 0, flex: 1 }}>
<Input placeholder={t('supplier.bankName')} />
</Form.Item>
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 8, alignItems: 'center' }}>
<Form.Item {...restField} name={[name, 'bank_account']} label="银行账号" style={{ marginBottom: 0, flex: 1 }}>
<Input placeholder="银行账号" />
<Form.Item {...restField} name={[name, 'bank_account']} label={t('supplier.bankAccount')} style={{ marginBottom: 0, flex: 1 }}>
<Input placeholder={t('supplier.bankAccount')} />
</Form.Item>
<div style={{ display: 'flex', alignItems: 'center', marginTop: 30 }}>
<Form.Item {...restField} name={[name, 'is_primary']} valuePropName="checked" style={{ marginBottom: 0, marginRight: 8 }}>
@@ -387,10 +390,10 @@ const SupplierPage: React.FC = () => {
onChange={(e) => handlePaymentInfoChange(name, 'is_primary', e.target.checked)}
/>
</Form.Item>
<span></span>
<span>{t('supplier.mainAccount')}</span>
</div>
</div>
<Form.Item {...restField} name={[name, 'qr_code']} label="收款码" style={{ marginBottom: 0 }}>
<Form.Item {...restField} name={[name, 'qr_code']} label={t('supplier.qrCode')} style={{ marginBottom: 0 }}>
<FileUpload
maxCount={1}
accept="image/*"
@@ -405,12 +408,12 @@ const SupplierPage: React.FC = () => {
/>
</Form.Item>
{fields.length > 0 && (
<Button type="link" danger onClick={() => remove(name)} style={{ marginTop: 8 }}></Button>
<Button type="link" danger onClick={() => remove(name)} style={{ marginTop: 8 }}>{t('supplier.deletePaymentInfo')}</Button>
)}
</div>
))}
<Button type="dashed" onClick={() => add({ account_name: '', bank_account: '', bank_name: '', is_primary: false })} style={{ width: '100%' }}>
+
{t('supplier.addPaymentInfo')}
</Button>
</div>
)}
@@ -421,4 +424,4 @@ const SupplierPage: React.FC = () => {
)
}
export default SupplierPage
export default SupplierPage
+77 -75
View File
@@ -1,75 +1,77 @@
import React from 'react';
import { Card, Typography, Button, Table, Tag, Space, Select, DatePicker, Input } from 'antd';
import { DownloadOutlined, DeleteOutlined } from '@ant-design/icons';
const { Title, Paragraph } = Typography;
const { RangePicker } = DatePicker;
const SystemLogsPage: React.FC = () => {
const [loading] = React.useState(false);
const columns = [
{ title: '日志ID', dataIndex: 'id', key: 'id', width: 80 },
{ title: '时间', dataIndex: 'timestamp', key: 'timestamp', width: 180 },
{
title: '级别',
dataIndex: 'level',
key: 'level',
width: 100,
render: (v: string) => {
const colors: Record<string, string> = { 'info': 'blue', 'warning': 'orange', 'error': 'red', 'success': 'green' };
return <Tag color={colors[v]}>{v.toUpperCase()}</Tag>;
}
},
{ title: '模块', dataIndex: 'module', key: 'module', width: 120 },
{ title: '操作人', dataIndex: 'operator', key: 'operator', width: 120 },
{ title: '操作', dataIndex: 'action', key: 'action' },
{ title: 'IP地址', dataIndex: 'ip', key: 'ip', width: 130 },
{ title: '详情', dataIndex: 'detail', key: 'detail', ellipsis: true },
];
const data = [
{ key: '1', id: 1001, timestamp: '2026-03-18 17:15:30', level: 'info', module: '用户管理', operator: 'admin', action: '用户登录', ip: '192.168.1.100', detail: '用户 admin 成功登录系统' },
{ key: '2', id: 1002, timestamp: '2026-03-18 17:14:25', level: 'info', module: '项目管理', operator: 'manager', action: '创建项目', ip: '192.168.1.101', detail: '创建新项目: 博纳斯线路改造' },
{ key: '3', id: 1003, timestamp: '2026-03-18 17:13:10', level: 'warning', module: '财务管理', operator: 'admin', action: '审批预支', ip: '192.168.1.100', detail: '预支申请单 ADV20260318001 审批通过' },
{ key: '4', id: 1004, timestamp: '2026-03-18 17:12:05', level: 'success', module: '系统', operator: 'system', action: '数据备份', ip: '127.0.0.1', detail: '自动备份完成,耗时 45 秒' },
{ key: '5', id: 1005, timestamp: '2026-03-18 17:10:00', level: 'error', module: 'API', operator: 'anonymous', action: '接口访问', ip: '10.0.0.55', detail: '无效的 API Token 访问尝试' },
{ key: '6', id: 1006, timestamp: '2026-03-18 17:09:30', level: 'info', module: '采购管理', operator: 'pm1', action: '创建采购', ip: '192.168.1.102', detail: '创建采购申请: PO20260318002' },
{ key: '7', id: 1007, timestamp: '2026-03-18 17:08:15', level: 'info', module: '用户管理', operator: 'admin', action: '修改角色', ip: '192.168.1.100', detail: '修改用户 zhang 的角色为项目经理' },
];
return (
<div style={{ padding: 24 }}>
<div style={{ marginBottom: 24, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<Title level={3} style={{ marginBottom: 0 }}></Title>
<Paragraph type="secondary"></Paragraph>
</div>
<Space>
<Select placeholder="日志级别" style={{ width: 120 }} allowClear options={[
{ value: 'info', label: 'Info' },
{ value: 'warning', label: 'Warning' },
{ value: 'error', label: 'Error' },
{ value: 'success', label: 'Success' }
]} />
<Select placeholder="模块" style={{ width: 150 }} allowClear options={[
{ value: 'user', label: '用户管理' },
{ value: 'project', label: '项目管理' },
{ value: 'finance', label: '财务管理' },
{ value: 'system', label: '系统' }
]} />
<RangePicker placeholder={['开始日期', '结束日期']} />
<Input.Search placeholder="搜索日志内容" style={{ width: 200 }} />
<Button icon={<DownloadOutlined />}></Button>
<Button icon={<DeleteOutlined />} danger></Button>
</Space>
</div>
<Card>
<Table columns={columns} dataSource={data} loading={loading} pagination={{ pageSize: 15 }} scroll={{ x: 1400 }} />
</Card>
</div>
);
};
export default SystemLogsPage;
import React from 'react';
import { Card, Typography, Button, Table, Tag, Space, Select, DatePicker, Input } from 'antd';
import { DownloadOutlined, DeleteOutlined } from '@ant-design/icons';
import { useLanguageStore } from '../store/languageStore';
const { Title, Paragraph } = Typography;
const { RangePicker } = DatePicker;
const SystemLogsPage: React.FC = () => {
const { t, currentLanguage } = useLanguageStore();
const [loading] = React.useState(false);
const columns = [
{ title: t('systemLogs.logId'), dataIndex: 'id', key: 'id', width: 80 },
{ title: t('systemLogs.time'), dataIndex: 'timestamp', key: 'timestamp', width: 180 },
{
title: t('systemLogs.level'),
dataIndex: 'level',
key: 'level',
width: 100,
render: (v: string) => {
const colors: Record<string, string> = { 'info': 'blue', 'warning': 'orange', 'error': 'red', 'success': 'green' };
return <Tag color={colors[v]}>{v.toUpperCase()}</Tag>;
}
},
{ title: t('systemLogs.module'), dataIndex: 'module', key: 'module', width: 120 },
{ title: t('systemLogs.operator'), dataIndex: 'operator', key: 'operator', width: 120 },
{ title: t('systemLogs.operation'), dataIndex: 'action', key: 'action' },
{ title: t('systemLogs.ipAddress'), dataIndex: 'ip', key: 'ip', width: 130 },
{ title: t('systemLogs.detail'), dataIndex: 'detail', key: 'detail', ellipsis: true },
];
const data = [
{ key: '1', id: 1001, timestamp: '2026-03-18 17:15:30', level: 'info', module: t('systemLogs.moduleUser'), operator: 'admin', action: t('systemLogs.login'), ip: '192.168.1.100', detail: '用户 admin 成功登录系统' },
{ key: '2', id: 1002, timestamp: '2026-03-18 17:14:25', level: 'info', module: t('systemLogs.moduleProject'), operator: 'manager', action: t('systemLogs.createProject'), ip: '192.168.1.101', detail: '创建新项目: 博纳斯线路改造' },
{ key: '3', id: 1003, timestamp: '2026-03-18 17:13:10', level: 'warning', module: t('systemLogs.moduleFinance'), operator: 'admin', action: t('systemLogs.approveAdvance'), ip: '192.168.1.100', detail: '预支申请单 ADV20260318001 审批通过' },
{ key: '4', id: 1004, timestamp: '2026-03-18 17:12:05', level: 'success', module: t('systemLogs.moduleSystem'), operator: 'system', action: t('systemLogs.dataBackup'), ip: '127.0.0.1', detail: '自动备份完成,耗时 45 秒' },
{ key: '5', id: 1005, timestamp: '2026-03-18 17:10:00', level: 'error', module: 'API', operator: 'anonymous', action: '接口访问', ip: '10.0.0.55', detail: '无效的 API Token 访问尝试' },
{ key: '6', id: 1006, timestamp: '2026-03-18 17:09:30', level: 'info', module: t('menu.procurement'), operator: 'pm1', action: '创建采购', ip: '192.168.1.102', detail: '创建采购申请: PO20260318002' },
{ key: '7', id: 1007, timestamp: '2026-03-18 17:08:15', level: 'info', module: t('systemLogs.moduleUser'), operator: 'admin', action: '修改角色', ip: '192.168.1.100', detail: '修改用户 zhang 的角色为项目经理' },
];
return (
<div style={{ padding: 24 }}>
<div style={{ marginBottom: 24, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<Title level={3} style={{ marginBottom: 0 }}>{t('systemLogs.title')}</Title>
<Paragraph type="secondary">{t('systemLogs.description')}</Paragraph>
</div>
<Space>
<Select placeholder={t('systemLogs.logLevel')} style={{ width: 120 }} allowClear options={[
{ value: 'info', label: 'Info' },
{ value: 'warning', label: 'Warning' },
{ value: 'error', label: 'Error' },
{ value: 'success', label: 'Success' }
]} />
<Select placeholder={t('systemLogs.module')} style={{ width: 150 }} allowClear options={[
{ value: 'user', label: t('systemLogs.moduleUser') },
{ value: 'project', label: t('systemLogs.moduleProject') },
{ value: 'finance', label: t('systemLogs.moduleFinance') },
{ value: 'system', label: t('systemLogs.moduleSystem') }
]} />
<RangePicker placeholder={['开始日期', '结束日期']} />
<Input.Search placeholder={t('systemLogs.searchPlaceholder')} style={{ width: 200 }} />
<Button icon={<DownloadOutlined />}>{t('systemLogs.export')}</Button>
<Button icon={<DeleteOutlined />} danger>{t('systemLogs.clear')}</Button>
</Space>
</div>
<Card>
<Table columns={columns} dataSource={data} loading={loading} pagination={{ pageSize: 15 }} scroll={{ x: 1400 }} />
</Card>
</div>
);
};
export default SystemLogsPage;
+11 -10
View File
@@ -1,12 +1,13 @@
import React, { useState, useEffect } from 'react';
import apiClient from '../utils/request';
import { useLanguageStore } from '../store/languageStore';
const UserManagement: React.FC = () => {
const { t, currentLanguage } = useLanguageStore();
const [users, setUsers] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// 从API获取用户数据
const fetchUsers = async () => {
setLoading(true);
setError(null);
@@ -15,11 +16,11 @@ const UserManagement: React.FC = () => {
if (response.data.success) {
setUsers(response.data.data || []);
} else {
throw new Error('API返回失败: ' + (response.data.message || '未知错误'));
throw new Error(t('userManagement.apiFailed') + (response.data.message || t('userManagement.unknownError')));
}
} catch (error) {
console.error('获取用户列表失败:', error);
setError(error instanceof Error ? error.message : '未知错误');
setError(error instanceof Error ? error.message : t('userManagement.unknownError'));
} finally {
setLoading(false);
}
@@ -31,23 +32,23 @@ const UserManagement: React.FC = () => {
return (
<div style={{ padding: 24 }}>
<h1></h1>
<p>API调用是否正常</p>
<h1>{t('userManagement.title')}</h1>
<p>{t('userManagement.testPage')}</p>
<button onClick={fetchUsers} disabled={loading}>
{loading ? '加载中...' : '刷新用户列表'}
{loading ? t('common.loading') : t('userManagement.refreshList')}
</button>
{error && (
<div style={{ marginTop: 10, color: 'red' }}>
: {error}
{t('userManagement.errorPrefix')}{error}
</div>
)}
<div style={{ marginTop: 20 }}>
<h2>API返回的数据:</h2>
<h2>{t('userManagement.apiResult')}</h2>
<pre>{JSON.stringify(users, null, 2)}</pre>
</div>
<div style={{ marginTop: 20 }}>
<h2>:</h2>
<p>{loading ? '加载中...' : '加载完成'}</p>
<h2>{t('userManagement.loadStatus')}</h2>
<p>{loading ? t('common.loading') : t('userManagement.loadComplete')}</p>
</div>
</div>
);
+67 -65
View File
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
import apiClient from '../utils/request';
import { Card, Button, Table, Tag, Space, Modal, Form, Input, Select, message, Row, Col, Avatar, Popconfirm } from 'antd';
import { PlusOutlined, UserOutlined, LockOutlined, EditOutlined, DeleteOutlined, ReloadOutlined, KeyOutlined } from '@ant-design/icons';
import { useLanguageStore } from '../store/languageStore';
interface User {
id: number;
@@ -16,6 +17,7 @@ interface User {
}
const UsersPage: React.FC = () => {
const { t, currentLanguage } = useLanguageStore();
const [loading, setLoading] = useState(false);
const [users, setUsers] = useState<User[]>([]);
const [addModalVisible, setAddModalVisible] = useState(false);
@@ -27,10 +29,10 @@ const UsersPage: React.FC = () => {
const [currentUser, setCurrentUser] = useState<User | null>(null);
const roles = [
{ value: 'admin', label: '系统管理员' },
{ value: 'finance', label: '财务专员' },
{ value: 'manager', label: '项目经理' },
{ value: 'employee', label: '普通员工' }
{ value: 'admin', label: t('user.admin') },
{ value: 'finance', label: t('user.finance') },
{ value: 'manager', label: t('user.manager') },
{ value: 'employee', label: t('user.employee') }
];
const fetchUsers = async () => {
@@ -40,14 +42,14 @@ const UsersPage: React.FC = () => {
if (response.data.success) {
setUsers(response.data.data || []);
} else {
message.error('获取用户列表失败: ' + (response.data.message || '未知错误'));
message.error(t('users.getListFailedLog') + (response.data.message || t('users.unknownError')));
}
} catch (error: any) {
console.error('获取用户列表失败:', error);
if (error.response?.status === 403) {
message.error('权限不足,仅管理员可访问');
message.error(t('users.notAdmin'));
} else {
message.error('获取用户列表失败');
message.error(t('users.getListFailed'));
}
} finally {
setLoading(false);
@@ -63,12 +65,12 @@ const UsersPage: React.FC = () => {
const values = await addForm.validateFields();
const response = await apiClient.post('/users', values);
if (response.data.success) {
message.success('用户已添加');
message.success(t('users.addSuccess'));
setAddModalVisible(false);
addForm.resetFields();
fetchUsers();
} else {
message.error(response.data.message || '添加失败');
message.error(response.data.message || t('common.failed'));
}
} catch (error: any) {
if (error.response?.data?.message) {
@@ -76,7 +78,7 @@ const UsersPage: React.FC = () => {
} else if (error.errorFields) {
return;
} else {
message.error('添加失败,请重试');
message.error(t('common.failed'));
}
}
};
@@ -98,13 +100,13 @@ const UsersPage: React.FC = () => {
if (currentUser) {
const response = await apiClient.put(`/users/${currentUser.id}`, values);
if (response.data.success) {
message.success('用户已更新');
message.success(t('common.updateSuccess'));
setEditModalVisible(false);
editForm.resetFields();
setCurrentUser(null);
fetchUsers();
} else {
message.error(response.data.message || '更新失败');
message.error(response.data.message || t('common.failed'));
}
}
} catch (error: any) {
@@ -113,7 +115,7 @@ const UsersPage: React.FC = () => {
} else if (error.errorFields) {
return;
} else {
message.error('更新失败,请重试');
message.error(t('common.failed'));
}
}
};
@@ -136,12 +138,12 @@ const UsersPage: React.FC = () => {
password: values.newPassword
});
if (response.data.success) {
message.success('密码已重置');
message.success(t('common.success'));
setResetPwdModalVisible(false);
resetPwdForm.resetFields();
setCurrentUser(null);
} else {
message.error(response.data.message || '重置失败');
message.error(response.data.message || t('common.failed'));
}
}
} catch (error: any) {
@@ -150,7 +152,7 @@ const UsersPage: React.FC = () => {
} else if (error.errorFields) {
return;
} else {
message.error('重置失败,请重试');
message.error(t('common.failed'));
}
}
};
@@ -159,64 +161,64 @@ const UsersPage: React.FC = () => {
try {
const response = await apiClient.delete(`/users/${user.id}`);
if (response.data.success) {
message.success('用户已删除');
message.success(t('common.deleteSuccess'));
fetchUsers();
} else {
message.error(response.data.message || '删除失败');
message.error(response.data.message || t('common.deleteFailed'));
}
} catch (error: any) {
if (error.response?.data?.message) {
message.error(error.response.data.message);
} else {
message.error('删除失败,请重试');
message.error(t('common.deleteFailed'));
}
}
};
const columns = [
{ title: 'ID', dataIndex: 'id', key: 'id', width: 60 },
{ title: t('users.id'), dataIndex: 'id', key: 'id', width: 60 },
{
title: '头像',
title: t('users.avatar'),
key: 'avatar',
width: 60,
render: () => <Avatar icon={<UserOutlined />} />
},
{ title: '用户名', dataIndex: 'username', key: 'username', width: 120 },
{ title: '姓名', dataIndex: 'name', key: 'name', width: 120 },
{ title: '邮箱', dataIndex: 'email', key: 'email', width: 180, render: (v: string) => v || '-' },
{ title: '手机号', dataIndex: 'phone', key: 'phone', width: 130, render: (v: string) => v || '-' },
{ title: t('users.username'), dataIndex: 'username', key: 'username', width: 120 },
{ title: t('users.name'), dataIndex: 'name', key: 'name', width: 120 },
{ title: t('users.email'), dataIndex: 'email', key: 'email', width: 180, render: (v: string) => v || '-' },
{ title: t('users.phone'), dataIndex: 'phone', key: 'phone', width: 130, render: (v: string) => v || '-' },
{
title: '角色',
title: t('users.role'),
dataIndex: 'role',
key: 'role',
width: 120,
render: (v: string) => {
const colors: Record<string, string> = { 'admin': 'red', 'manager': 'blue', 'finance': 'orange', 'employee': 'green' };
const roleMap = roles.find(role => role.value === v);
return <Tag color={colors[v] || 'green'}>{roleMap?.label || v || '用户'}</Tag>;
return <Tag color={colors[v] || 'green'}>{roleMap?.label || v || t('users.user')}</Tag>;
}
},
{
title: '操作',
title: t('users.action'),
key: 'action',
width: 240,
render: (_: any, record: User) => (
<Space>
<Button size="small" type="link" icon={<EditOutlined />} onClick={() => handleEdit(record)}>
{t('users.edit')}
</Button>
<Button size="small" type="link" icon={<KeyOutlined />} onClick={() => handleResetPassword(record)}>
{t('users.resetPassword')}
</Button>
<Popconfirm
title="确认删除"
description={`确定要删除用户 ${record.username} 吗?`}
title={t('users.confirmDelete')}
description={t('users.confirmDeleteMsg', { name: record.username })}
onConfirm={() => handleDelete(record)}
okText="确定"
cancelText="取消"
okText={t('common.confirm')}
cancelText={t('common.cancel')}
>
<Button size="small" type="link" danger icon={<DeleteOutlined />}>
{t('common.delete')}
</Button>
</Popconfirm>
</Space>
@@ -227,12 +229,12 @@ const UsersPage: React.FC = () => {
return (
<div style={{ padding: 24 }}>
<div style={{ marginBottom: 24, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2></h2>
<h2>{t('users.title')}</h2>
<Space>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setAddModalVisible(true)}>
{t('users.newUser')}
</Button>
<Button icon={<ReloadOutlined />} onClick={fetchUsers}></Button>
<Button icon={<ReloadOutlined />} onClick={fetchUsers}>{t('common.refresh')}</Button>
</Space>
</div>
@@ -247,7 +249,7 @@ const UsersPage: React.FC = () => {
</Card>
<Modal
title="新增用户"
title={t('users.newUser')}
open={addModalVisible}
onCancel={() => { setAddModalVisible(false); addForm.resetFields(); }}
onOk={handleAdd}
@@ -256,37 +258,37 @@ const UsersPage: React.FC = () => {
<Form form={addForm} layout="vertical">
<Row gutter={16}>
<Col span={12}>
<Form.Item label="用户名" name="username" rules={[{ required: true, message: '请输入用户名' }]}>
<Input placeholder="请输入用户名" prefix={<UserOutlined />} />
<Form.Item label={t('users.username')} name="username" rules={[{ required: true, message: t('users.usernamePlaceholder') }]}>
<Input placeholder={t('users.usernamePlaceholder')} prefix={<UserOutlined />} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="姓名" name="name" rules={[{ required: true, message: '请输入姓名' }]}>
<Input placeholder="请输入姓名" />
<Form.Item label={t('users.name')} name="name" rules={[{ required: true, message: t('users.namePlaceholder') }]}>
<Input placeholder={t('users.namePlaceholder')} />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item label="邮箱" name="email">
<Form.Item label={t('users.email')} name="email">
<Input placeholder="email@example.com" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="手机号" name="phone">
<Form.Item label={t('users.phone')} name="phone">
<Input placeholder="+856 20 xxxx xxxx" />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item label="角色" name="role" rules={[{ required: true, message: '请选择角色' }]} initialValue="employee">
<Select placeholder="选择角色" options={roles} />
<Form.Item label={t('users.role')} name="role" rules={[{ required: true, message: t('users.selectRolePlaceholder') }]} initialValue="employee">
<Select placeholder={t('users.selectRole')} options={roles} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="初始密码" name="password" rules={[{ required: true, message: '请输入初始密码' }, { min: 6, message: '密码至少6位' }]}>
<Input.Password placeholder="请输入初始密码" prefix={<LockOutlined />} />
<Form.Item label={t('users.initialPassword')} name="password" rules={[{ required: true, message: t('users.initialPasswordPlaceholder') }, { min: 6, message: t('users.passwordMinLen') }]}>
<Input.Password placeholder={t('users.initialPasswordPlaceholder')} prefix={<LockOutlined />} />
</Form.Item>
</Col>
</Row>
@@ -294,7 +296,7 @@ const UsersPage: React.FC = () => {
</Modal>
<Modal
title="编辑用户"
title={t('users.editUser')}
open={editModalVisible}
onCancel={() => {
setEditModalVisible(false);
@@ -307,32 +309,32 @@ const UsersPage: React.FC = () => {
<Form form={editForm} layout="vertical">
<Row gutter={16}>
<Col span={12}>
<Form.Item label="用户名">
<Form.Item label={t('users.username')}>
<Input value={currentUser?.username} disabled prefix={<UserOutlined />} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="姓名" name="name" rules={[{ required: true, message: '请输入姓名' }]}>
<Input placeholder="请输入姓名" />
<Form.Item label={t('users.name')} name="name" rules={[{ required: true, message: t('users.namePlaceholder') }]}>
<Input placeholder={t('users.namePlaceholder')} />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item label="邮箱" name="email">
<Form.Item label={t('users.email')} name="email">
<Input placeholder="email@example.com" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="手机号" name="phone">
<Form.Item label={t('users.phone')} name="phone">
<Input placeholder="+856 20 xxxx xxxx" />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item label="角色" name="role" rules={[{ required: true, message: '请选择角色' }]}>
<Select placeholder="选择角色" options={roles} />
<Form.Item label={t('users.role')} name="role" rules={[{ required: true, message: t('users.selectRolePlaceholder') }]}>
<Select placeholder={t('users.selectRole')} options={roles} />
</Form.Item>
</Col>
</Row>
@@ -340,7 +342,7 @@ const UsersPage: React.FC = () => {
</Modal>
<Modal
title={`重置密码 - ${currentUser?.username || ''}`}
title={`${t('users.resetPassword')} - ${currentUser?.username || ''}`}
open={resetPwdModalVisible}
onCancel={() => {
setResetPwdModalVisible(false);
@@ -351,21 +353,21 @@ const UsersPage: React.FC = () => {
width={400}
>
<Form form={resetPwdForm} layout="vertical">
<Form.Item label="新密码" name="newPassword" rules={[{ required: true, message: '请输入新密码' }, { min: 6, message: '密码至少6位' }]}>
<Input.Password placeholder="请输入新密码" prefix={<LockOutlined />} />
<Form.Item label={t('user.newPassword')} name="newPassword" rules={[{ required: true, message: t('users.newPasswordPlaceholder') }, { min: 6, message: t('users.passwordMinLen') }]}>
<Input.Password placeholder={t('users.newPasswordPlaceholder')} prefix={<LockOutlined />} />
</Form.Item>
<Form.Item label="确认密码" name="confirmPassword" rules={[
{ required: true, message: '请确认密码' },
<Form.Item label={t('users.confirmPassword')} name="confirmPassword" rules={[
{ required: true, message: t('users.confirmPasswordPlaceholder') },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('newPassword') === value) {
return Promise.resolve();
}
return Promise.reject(new Error('两次输入的密码不一致'));
return Promise.reject(new Error(t('users.passwordMismatch')));
},
}),
]}>
<Input.Password placeholder="请再次输入新密码" prefix={<LockOutlined />} />
<Input.Password placeholder={t('users.reEnterPassword')} prefix={<LockOutlined />} />
</Form.Item>
</Form>
</Modal>
@@ -373,4 +375,4 @@ const UsersPage: React.FC = () => {
);
};
export default UsersPage;
export default UsersPage;
File diff suppressed because it is too large Load Diff
+133 -130
View File
@@ -1,130 +1,133 @@
import React from 'react';
import { Card, Typography, Descriptions, Tag, Row, Col, Progress, Divider } from 'antd';
import {
CloudServerOutlined,
DatabaseOutlined,
NodeIndexOutlined,
CheckCircleOutlined,
InfoCircleOutlined
} from '@ant-design/icons';
const { Title, Paragraph, Text } = Typography;
const AboutPage: React.FC = () => {
return (
<div style={{ padding: 24 }}>
<div style={{ marginBottom: 24 }}>
<Title level={3} style={{ marginBottom: 8 }}></Title>
<Paragraph type="secondary"></Paragraph>
</div>
<Row gutter={24}>
<Col span={16}>
<Card title={<><InfoCircleOutlined /> </>}>
<Descriptions bordered column={2}>
<Descriptions.Item label="系统名称">ERP</Descriptions.Item>
<Descriptions.Item label="系统版本">V1.0.0</Descriptions.Item>
<Descriptions.Item label="开发团队"></Descriptions.Item>
<Descriptions.Item label="上线日期">20263</Descriptions.Item>
<Descriptions.Item label="技术架构">
<Tag color="blue">React 18</Tag>
<Tag color="green">Ant Design 5</Tag>
<Tag color="purple">Node.js</Tag>
<Tag color="orange">PostgreSQL</Tag>
</Descriptions.Item>
<Descriptions.Item label="部署环境">
<Tag color="cyan"></Tag>
</Descriptions.Item>
<Descriptions.Item label="前端框架">Vite + React + TypeScript</Descriptions.Item>
<Descriptions.Item label="后端框架">Express.js + PostgreSQL</Descriptions.Item>
</Descriptions>
</Card>
<Card title={<><CheckCircleOutlined /> </>} style={{ marginTop: 24 }}>
<Row gutter={[16, 16]}>
<Col span={8}>
<Card size="small" style={{ textAlign: 'center' }}>
<Title level={5}></Title>
<Text type="secondary"></Text>
</Card>
</Col>
<Col span={8}>
<Card size="small" style={{ textAlign: 'center' }}>
<Title level={5}></Title>
<Text type="secondary"></Text>
</Card>
</Col>
<Col span={8}>
<Card size="small" style={{ textAlign: 'center' }}>
<Title level={5}></Title>
<Text type="secondary"></Text>
</Card>
</Col>
<Col span={8}>
<Card size="small" style={{ textAlign: 'center' }}>
<Title level={5}></Title>
<Text type="secondary"></Text>
</Card>
</Col>
<Col span={8}>
<Card size="small" style={{ textAlign: 'center' }}>
<Title level={5}></Title>
<Text type="secondary"></Text>
</Card>
</Col>
<Col span={8}>
<Card size="small" style={{ textAlign: 'center' }}>
<Title level={5}></Title>
<Text type="secondary"></Text>
</Card>
</Col>
</Row>
</Card>
</Col>
<Col span={8}>
<Card title={<><CloudServerOutlined /> </>}>
<div style={{ marginBottom: 16 }}>
<Text type="secondary">CPU使用率</Text>
<Progress percent={45} status="active" />
</div>
<div style={{ marginBottom: 16 }}>
<Text type="secondary">使</Text>
<Progress percent={60} strokeColor="#52c41a" />
</div>
<div style={{ marginBottom: 16 }}>
<Text type="secondary"></Text>
<Progress percent={35} strokeColor="#1890ff" />
</div>
<Divider />
<Descriptions column={1} size="small">
<Descriptions.Item label="服务器IP">43.161.248.209</Descriptions.Item>
<Descriptions.Item label="操作系统">OpenCloudOS 9</Descriptions.Item>
<Descriptions.Item label="Node版本">v22.22.1</Descriptions.Item>
</Descriptions>
</Card>
<Card title={<><DatabaseOutlined /> </>} style={{ marginTop: 24 }}>
<div style={{ textAlign: 'center', padding: 20 }}>
<CheckCircleOutlined style={{ fontSize: 48, color: '#52c41a' }} />
<Title level={4} style={{ margin: '16px 0 8px' }}></Title>
<Text type="secondary">PostgreSQL 15</Text>
</div>
<Divider />
<Descriptions column={1} size="small">
<Descriptions.Item label="数据库名">company_finance_db</Descriptions.Item>
<Descriptions.Item label="连接状态"></Descriptions.Item>
<Descriptions.Item label="最近备份">2026-03-19 00:00</Descriptions.Item>
</Descriptions>
</Card>
</Col>
</Row>
<Card style={{ marginTop: 24, background: '#f6ffed', borderColor: '#b7eb8f' }}>
<Text>© 2026 ERP系统 - V1.0.0</Text>
</Card>
</div>
);
};
export default AboutPage;
import React from 'react';
import { Card, Typography, Descriptions, Tag, Row, Col, Progress, Divider } from 'antd';
import {
CloudServerOutlined,
DatabaseOutlined,
NodeIndexOutlined,
CheckCircleOutlined,
InfoCircleOutlined
} from '@ant-design/icons';
import { useLanguageStore } from '../../store/languageStore';
const { Title, Paragraph, Text } = Typography;
const AboutPage: React.FC = () => {
const { t, currentLanguage } = useLanguageStore();
return (
<div style={{ padding: 24 }}>
<div style={{ marginBottom: 24 }}>
<Title level={3} style={{ marginBottom: 8 }}>{t('about.title')}</Title>
<Paragraph type="secondary">{t('about.description')}</Paragraph>
</div>
<Row gutter={24}>
<Col span={16}>
<Card title={<><InfoCircleOutlined /> {t('about.systemInfo')}</>}>
<Descriptions bordered column={2}>
<Descriptions.Item label={t('about.systemName')}>{t('about.systemNameValue')}</Descriptions.Item>
<Descriptions.Item label={t('about.version')}>{t('about.versionValue')}</Descriptions.Item>
<Descriptions.Item label={t('about.devTeam')}>{t('about.devTeamValue')}</Descriptions.Item>
<Descriptions.Item label={t('about.onlineDate')}>{t('about.onlineDateValue')}</Descriptions.Item>
<Descriptions.Item label={t('about.techArchitecture')}>
<Tag color="blue">React 18</Tag>
<Tag color="green">Ant Design 5</Tag>
<Tag color="purple">Node.js</Tag>
<Tag color="orange">PostgreSQL</Tag>
</Descriptions.Item>
<Descriptions.Item label={t('about.deployEnv')}>
<Tag color="cyan">{t('about.deployEnvValue')}</Tag>
</Descriptions.Item>
<Descriptions.Item label={t('about.frontend')}>{t('about.frontendValue')}</Descriptions.Item>
<Descriptions.Item label={t('about.backend')}>{t('about.backendValue')}</Descriptions.Item>
</Descriptions>
</Card>
<Card title={<><CheckCircleOutlined /> {t('about.modules')}</>} style={{ marginTop: 24 }}>
<Row gutter={[16, 16]}>
<Col span={8}>
<Card size="small" style={{ textAlign: 'center' }}>
<Title level={5}>{t('menu.projects')}</Title>
<Text type="secondary"></Text>
</Card>
</Col>
<Col span={8}>
<Card size="small" style={{ textAlign: 'center' }}>
<Title level={5}>{t('menu.financeManagement')}</Title>
<Text type="secondary"></Text>
</Card>
</Col>
<Col span={8}>
<Card size="small" style={{ textAlign: 'center' }}>
<Title level={5}>{t('menu.procurement')}</Title>
<Text type="secondary"></Text>
</Card>
</Col>
<Col span={8}>
<Card size="small" style={{ textAlign: 'center' }}>
<Title level={5}>{t('menu.partners')}</Title>
<Text type="secondary"></Text>
</Card>
</Col>
<Col span={8}>
<Card size="small" style={{ textAlign: 'center' }}>
<Title level={5}>{t('menu.construction')}</Title>
<Text type="secondary"></Text>
</Card>
</Col>
<Col span={8}>
<Card size="small" style={{ textAlign: 'center' }}>
<Title level={5}>{t('menu.budgetQuotation')}</Title>
<Text type="secondary"></Text>
</Card>
</Col>
</Row>
</Card>
</Col>
<Col span={8}>
<Card title={<><CloudServerOutlined /> {t('about.serverStatus')}</>}>
<div style={{ marginBottom: 16 }}>
<Text type="secondary">{t('about.cpuUsage')}</Text>
<Progress percent={45} status="active" />
</div>
<div style={{ marginBottom: 16 }}>
<Text type="secondary">{t('about.memoryUsage')}</Text>
<Progress percent={60} strokeColor="#52c41a" />
</div>
<div style={{ marginBottom: 16 }}>
<Text type="secondary">{t('about.diskSpace')}</Text>
<Progress percent={35} strokeColor="#1890ff" />
</div>
<Divider />
<Descriptions column={1} size="small">
<Descriptions.Item label={t('about.serverIp')}>43.161.248.209</Descriptions.Item>
<Descriptions.Item label={t('about.os')}>{t('about.osValue')}</Descriptions.Item>
<Descriptions.Item label={t('about.nodeVersion')}>v22.22.1</Descriptions.Item>
</Descriptions>
</Card>
<Card title={<><DatabaseOutlined /> {t('about.databaseStatus')}</>} style={{ marginTop: 24 }}>
<div style={{ textAlign: 'center', padding: 20 }}>
<CheckCircleOutlined style={{ fontSize: 48, color: '#52c41a' }} />
<Title level={4} style={{ margin: '16px 0 8px' }}>{t('about.running')}</Title>
<Text type="secondary">PostgreSQL 15</Text>
</div>
<Divider />
<Descriptions column={1} size="small">
<Descriptions.Item label={t('about.dbName')}>company_finance_db</Descriptions.Item>
<Descriptions.Item label={t('about.connectionStatus')}>{t('about.normal')}</Descriptions.Item>
<Descriptions.Item label={t('about.lastBackup')}>2026-03-19 00:00</Descriptions.Item>
</Descriptions>
</Card>
</Col>
</Row>
<Card style={{ marginTop: 24, background: '#f6ffed', borderColor: '#b7eb8f' }}>
<Text>{t('about.footer')}</Text>
</Card>
</div>
);
};
export default AboutPage;
+99 -97
View File
@@ -1,97 +1,99 @@
import React from 'react';
import { Card, Typography, Button, Table, Tag, Space, Modal, Form, Input, DatePicker, message, Row, Col, Progress } from 'antd';
import { DownloadOutlined, UploadOutlined, DeleteOutlined, ClockCircleOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
const { Title, Paragraph, Text } = Typography;
const BackupPage: React.FC = () => {
const [loading, setLoading] = React.useState(false);
const [backuping, setBackuping] = React.useState(false);
const columns = [
{ title: '备份名称', dataIndex: 'name', key: 'name' },
{ title: '备份时间', dataIndex: 'time', key: 'time' },
{ title: '文件大小', dataIndex: 'size', key: 'size' },
{ title: '备份类型', dataIndex: 'type', key: 'type', render: (v: string) => <Tag color={v === 'auto' ? 'blue' : 'green'}>{v === 'auto' ? '自动' : '手动'}</Tag> },
{ title: '状态', dataIndex: 'status', key: 'status', render: (v: string) => <Tag color={v === 'success' ? 'success' : 'error'}>{v === 'success' ? '成功' : '失败'}</Tag> },
{
title: '操作',
key: 'action',
render: () => (
<Space>
<Button size="small" type="link" icon={<DownloadOutlined />}></Button>
<Button size="small" type="link" icon={<UploadOutlined />}></Button>
<Button size="small" danger type="link" icon={<DeleteOutlined />}></Button>
</Space>
)
}
];
const data = [
{ key: '1', name: 'backup-20260319.sql', time: '2026-03-19 00:00', size: '15.2 MB', type: 'auto', status: 'success' },
{ key: '2', name: 'backup-20260318.sql', time: '2026-03-18 00:00', size: '14.8 MB', type: 'auto', status: 'success' },
{ key: '3', name: 'backup-manual-20260317.sql', time: '2026-03-17 15:30', size: '14.5 MB', type: 'manual', status: 'success' },
];
const handleBackup = () => {
setBackuping(true);
setTimeout(() => {
message.success('备份创建成功');
setBackuping(false);
}, 2000);
};
return (
<div style={{ padding: 24 }}>
<div style={{ marginBottom: 24 }}>
<Title level={3} style={{ marginBottom: 8 }}></Title>
<Paragraph type="secondary"></Paragraph>
</div>
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={6}>
<Card>
<Text type="secondary"></Text>
<Title level={2} style={{ margin: '8px 0 0' }}>3</Title>
</Card>
</Col>
<Col span={6}>
<Card>
<Text type="secondary"></Text>
<Title level={2} style={{ margin: '8px 0 0' }}>44.5 MB</Title>
</Card>
</Col>
<Col span={6}>
<Card>
<Text type="secondary"></Text>
<Title level={4} style={{ margin: '8px 0 0' }}>2026-03-19 00:00</Title>
</Card>
</Col>
<Col span={6}>
<Card>
<Text type="secondary"></Text>
<Progress percent={30} size="small" style={{ marginTop: 8 }} />
<Text type="secondary">300 MB / 1 GB</Text>
</Card>
</Col>
</Row>
<Card
title="备份列表"
extra={
<Space>
<Button icon={<ClockCircleOutlined />}></Button>
<Button type="primary" icon={<DownloadOutlined />} loading={backuping} onClick={handleBackup}>
</Button>
</Space>
}
>
<Table columns={columns} dataSource={data} loading={loading} pagination={{ pageSize: 10 }} />
</Card>
</div>
);
};
export default BackupPage;
import React from 'react';
import { Card, Typography, Button, Table, Tag, Space, Modal, Form, Input, DatePicker, message, Row, Col, Progress } from 'antd';
import { DownloadOutlined, UploadOutlined, DeleteOutlined, ClockCircleOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { useLanguageStore } from '../../store/languageStore';
const { Title, Paragraph, Text } = Typography;
const BackupPage: React.FC = () => {
const { t, currentLanguage } = useLanguageStore();
const [loading, setLoading] = React.useState(false);
const [backuping, setBackuping] = React.useState(false);
const columns = [
{ title: t('backup.backupName'), dataIndex: 'name', key: 'name' },
{ title: t('backup.backupTime'), dataIndex: 'time', key: 'time' },
{ title: t('backup.fileSize'), dataIndex: 'size', key: 'size' },
{ title: t('backup.backupType'), dataIndex: 'type', key: 'type', render: (v: string) => <Tag color={v === 'auto' ? 'blue' : 'green'}>{v === 'auto' ? t('backup.auto') : t('backup.manual')}</Tag> },
{ title: t('backup.status'), dataIndex: 'status', key: 'status', render: (v: string) => <Tag color={v === 'success' ? 'success' : 'error'}>{v === 'success' ? t('backup.success') : t('backup.failed')}</Tag> },
{
title: t('backup.action'),
key: 'action',
render: () => (
<Space>
<Button size="small" type="link" icon={<DownloadOutlined />}>{t('backup.download')}</Button>
<Button size="small" type="link" icon={<UploadOutlined />}>{t('backup.restore')}</Button>
<Button size="small" danger type="link" icon={<DeleteOutlined />}>{t('backup.delete')}</Button>
</Space>
)
}
];
const data = [
{ key: '1', name: 'backup-20260319.sql', time: '2026-03-19 00:00', size: '15.2 MB', type: 'auto', status: 'success' },
{ key: '2', name: 'backup-20260318.sql', time: '2026-03-18 00:00', size: '14.8 MB', type: 'auto', status: 'success' },
{ key: '3', name: 'backup-manual-20260317.sql', time: '2026-03-17 15:30', size: '14.5 MB', type: 'manual', status: 'success' },
];
const handleBackup = () => {
setBackuping(true);
setTimeout(() => {
message.success(t('backup.backupCreated'));
setBackuping(false);
}, 2000);
};
return (
<div style={{ padding: 24 }}>
<div style={{ marginBottom: 24 }}>
<Title level={3} style={{ marginBottom: 8 }}>{t('backup.title')}</Title>
<Paragraph type="secondary">{t('backup.description')}</Paragraph>
</div>
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={6}>
<Card>
<Text type="secondary">{t('backup.totalBackups')}</Text>
<Title level={2} style={{ margin: '8px 0 0' }}>3</Title>
</Card>
</Col>
<Col span={6}>
<Card>
<Text type="secondary">{t('backup.totalSize')}</Text>
<Title level={2} style={{ margin: '8px 0 0' }}>44.5 MB</Title>
</Card>
</Col>
<Col span={6}>
<Card>
<Text type="secondary">{t('backup.lastBackup')}</Text>
<Title level={4} style={{ margin: '8px 0 0' }}>2026-03-19 00:00</Title>
</Card>
</Col>
<Col span={6}>
<Card>
<Text type="secondary">{t('backup.storageSpace')}</Text>
<Progress percent={30} size="small" style={{ marginTop: 8 }} />
<Text type="secondary">300 MB / 1 GB</Text>
</Card>
</Col>
</Row>
<Card
title={t('backup.backupList')}
extra={
<Space>
<Button icon={<ClockCircleOutlined />}>{t('backup.autoBackupSetting')}</Button>
<Button type="primary" icon={<DownloadOutlined />} loading={backuping} onClick={handleBackup}>
{t('backup.immediateBackup')}
</Button>
</Space>
}
>
<Table columns={columns} dataSource={data} loading={loading} pagination={{ pageSize: 10 }} />
</Card>
</div>
);
};
export default BackupPage;
+73 -45
View File
@@ -4,6 +4,7 @@ import { UploadOutlined, FileExcelOutlined, CheckCircleOutlined, CloseCircleOutl
import * as XLSX from 'xlsx';
import apiClient from '../../utils/request';
import dayjs from 'dayjs';
import { useLanguageStore } from '../../store/languageStore';
const { Title } = Typography;
@@ -34,6 +35,7 @@ interface ParsedRow {
}
const ExcelImport: React.FC = () => {
const { t, currentLanguage } = useLanguageStore();
const [parsedData, setParsedData] = useState<ParsedRow[]>([]);
const [categories, setCategories] = useState<any>({});
const [importing, setImporting] = useState(false);
@@ -42,7 +44,10 @@ const ExcelImport: React.FC = () => {
useEffect(() => {
apiClient.get('/expense-categories/grouped').then(res => {
if (res.data.success) setCategories(res.data.data);
}).catch(() => {});
}).catch((err) => {
console.error('加载支出分类失败:', err);
message.error('加载支出分类失败,请刷新页面重试');
});
}, []);
const getCategoryMap = () => {
@@ -63,16 +68,29 @@ const ExcelImport: React.FC = () => {
const json = XLSX.utils.sheet_to_json(sheet, { header: 1 });
if (json.length < 2) {
message.error('Excel文件没有数据行');
message.error(t('excelImport.noData'));
return;
}
const categoryMap = getCategoryMap();
if (Object.keys(categoryMap).length === 0) {
message.error('支出分类未加载,请刷新页面后重试');
return;
}
console.log('Excel导入 - 分类映射表:', categoryMap);
console.log('Excel导入 - 原始数据行数:', json.length - 1);
const rows: ParsedRow[] = [];
for (let i = 1; i < json.length; i++) {
const r: any[] = json[i];
if (!r || r.length === 0 || !r[0]) continue;
if (!r || r.length === 0) continue;
// 跳过完全空行(所有列都为空)
const hasData = r.some((cell: any) => cell !== null && cell !== undefined && cell !== '');
if (!hasData) continue;
if (i <= 5) {
console.log(`Excel导入 - 第${i+1}行原始数据:`, JSON.stringify(r));
}
const errors: string[] = [];
const dateVal = r[0];
@@ -98,17 +116,27 @@ const ExcelImport: React.FC = () => {
const description = String(r[12] || '').trim();
const voucherNo = String(r[13] || '').trim();
const txnType = TXN_TYPE_MAP[txnTypeLabel] || '';
const level1 = LEVEL1_MAP[level1Label] || '';
const level2 = categoryMap[level2Label] || '';
const txnType = TXN_TYPE_MAP[txnTypeLabel] || TXN_TYPE_MAP[txnTypeLabel.replace(/\s/g, '')] || '';
const level1 = LEVEL1_MAP[level1Label] || LEVEL1_MAP[level1Label.replace(/\s/g, '')] || '';
let level2 = categoryMap[level2Label] || categoryMap[level2Label.replace(/\s/g, '')] || '';
// 如果精确匹配失败,尝试模糊匹配(包含关系)
if (!level2 && level2Label) {
for (const [label, value] of Object.entries(categoryMap)) {
if (label.includes(level2Label) || level2Label.includes(label)) {
level2 = value;
break;
}
}
}
if (!recordDate) errors.push('日期为空');
if (!txnType) errors.push(`收支类型无效: ${txnTypeLabel}`);
if (!level1) errors.push(`一级分类无效: ${level1Label}`);
if (!level2) errors.push(`二级分类无效: ${level2Label}`);
if (amountOriginal <= 0) errors.push('金额必须大于0');
if (!recordDate) errors.push(t('excelImport.invalidDate'));
if (!txnType) errors.push(`无效的收支类型: "${txnTypeLabel}" (应为: 收入/支出)`);
if (!level1) errors.push(`无效的一级分类: "${level1Label}" (应为: 收入/项目支出/公司支出)`);
if (!level2) errors.push(`无效的二级分类: "${level2Label}" (请参考模板中的分类下拉列表)`);
if (amountOriginal <= 0) errors.push(t('excelImport.amountPositive'));
if ((level1 === 'project' || (level1 === 'income' && level2 !== 'shareholder_investment')) && !projectName) {
errors.push('项目支出/项目收入必须填写项目名称');
errors.push(t('excelImport.projectRequired'));
}
rows.push({
@@ -133,9 +161,9 @@ const ExcelImport: React.FC = () => {
setParsedData(rows);
setImportResult(null);
message.success(`解析完成,共 ${rows.length} 条记录`);
message.success(t('excelImport.parseComplete', { count: rows.length }));
} catch (err: any) {
message.error('解析Excel失败: ' + err.message);
message.error(t('excelImport.parseFailed') + err.message);
}
};
reader.readAsArrayBuffer(file);
@@ -145,7 +173,7 @@ const ExcelImport: React.FC = () => {
const handleImport = async () => {
const validRows = parsedData.filter(r => r.errors.length === 0);
if (validRows.length === 0) {
message.error('没有有效的数据可导入');
message.error(t('excelImport.noValidData'));
return;
}
@@ -172,10 +200,10 @@ const ExcelImport: React.FC = () => {
const res = await apiClient.post('/financial-records/batch', { records });
if (res.data.success) {
setImportResult(res.data.data);
message.success(`导入完成:成功 ${res.data.data.imported} 条,失败 ${res.data.data.failed}`);
message.success(t('excelImport.importComplete', { success: res.data.data.imported, fail: res.data.data.failed }));
}
} catch (e: any) {
message.error('导入失败: ' + (e.response?.data?.message || e.message));
message.error(t('excelImport.importFailed') + (e.response?.data?.message || e.message));
}
setImporting(false);
};
@@ -184,22 +212,22 @@ const ExcelImport: React.FC = () => {
const validCount = parsedData.filter(r => r.errors.length === 0).length;
const columns = [
{ title: '行号', dataIndex: 'row', width: 50 },
{ title: '日期', dataIndex: 'record_date', width: 100 },
{ title: '收支', dataIndex: 'txn_type', width: 60, render: (v: string) => <Tag color={v === 'income' ? 'green' : 'red'}>{v === 'income' ? '收入' : '支出'}</Tag> },
{ title: '一级', dataIndex: 'category_level1', width: 80, render: (v: string) => {
const m: Record<string, string> = { income: '收入', project: '项目', company: '公司' };
{ title: t('excelImport.rowNum'), dataIndex: 'row', width: 50 },
{ title: t('excelImport.date'), dataIndex: 'record_date', width: 100 },
{ title: t('excelImport.incomeExpense'), dataIndex: 'txn_type', width: 60, render: (v: string) => <Tag color={v === 'income' ? 'green' : 'red'}>{v === 'income' ? t('excelImport.income') : t('excelImport.expense')}</Tag> },
{ title: t('excelImport.level1'), dataIndex: 'category_level1', width: 80, render: (v: string) => {
const m: Record<string, string> = { income: t('excelImport.income'), project: t('excelImport.projectLabel'), company: t('excelImport.companyLabel') };
return m[v] || v;
}},
{ title: '二级', dataIndex: 'category_level2', width: 100 },
{ title: '项目', dataIndex: 'project_name', width: 120, ellipsis: true },
{ title: '金额', dataIndex: 'amount_original', width: 90, render: (v: number) => v?.toLocaleString() },
{ title: '币种', dataIndex: 'currency', width: 50 },
{ title: '汇率', dataIndex: 'exchange_rate', width: 60 },
{ title: '等效人民币', dataIndex: 'amount_cny', width: 100, render: (v: number) => v?.toLocaleString() },
{ title: '描述', dataIndex: 'description', width: 150, ellipsis: true },
{ title: t('excelImport.level2'), dataIndex: 'category_level2', width: 100 },
{ title: t('excelImport.projectLabel'), dataIndex: 'project_name', width: 120, ellipsis: true },
{ title: t('excelImport.amount'), dataIndex: 'amount_original', width: 90, render: (v: number) => v?.toLocaleString() },
{ title: t('excelImport.currency'), dataIndex: 'currency', width: 50 },
{ title: t('excelImport.rate'), dataIndex: 'exchange_rate', width: 60 },
{ title: t('excelImport.equivalentCNY'), dataIndex: 'amount_cny', width: 100, render: (v: number) => v?.toLocaleString() },
{ title: t('excelImport.desc'), dataIndex: 'description', width: 150, ellipsis: true },
{
title: '校验', width: 80,
title: t('excelImport.validation'), width: 80,
render: (_: unknown, r: ParsedRow) => r.errors.length === 0
? <CheckCircleOutlined style={{ color: '#52c41a' }} />
: <CloseCircleOutlined style={{ color: '#ff4d4f' }} title={r.errors.join('; ')} />
@@ -209,18 +237,18 @@ const ExcelImport: React.FC = () => {
return (
<div style={{ padding: 24 }}>
<Card>
<Title level={4}>Excel </Title>
<Title level={4}>{t('excelImport.title')}</Title>
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message="Excel 模板格式要求"
message={t('excelImport.templateRequirements')}
description={
<div>
<p> | | | | | | | | | | | | | </p>
<p> / / / CNY / USD / LAK / THB</p>
<p>使</p>
<p>{t('excelImport.columnOrder')}</p>
<p>{t('excelImport.formatRequirements')}</p>
<p>{t('excelImport.categoryRequirements')}</p>
</div>
}
/>
@@ -230,20 +258,20 @@ const ExcelImport: React.FC = () => {
showUploadList={false}
beforeUpload={handleFileUpload}
>
<Button icon={<FileExcelOutlined />} type="primary"> Excel </Button>
<Button icon={<FileExcelOutlined />} type="primary">{t('excelImport.selectFile')}</Button>
</Upload>
<a href="/templates/财务记账导入模板.xlsx" download="财务记账导入模板.xlsx" style={{ marginLeft: 12 }}>
<Button icon={<DownloadOutlined />}></Button>
<a href="/templates/财务记账导入模板.xlsx" download={t('excelImport.templateFileName')} style={{ marginLeft: 12 }}>
<Button icon={<DownloadOutlined />}>{t('excelImport.downloadTemplate')}</Button>
</a>
{parsedData.length > 0 && (
<>
<div style={{ margin: '16px 0', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Space>
<span> {parsedData.length} </span>
<Tag color="green"> {validCount}</Tag>
{errorCount > 0 && <Tag color="red"> {errorCount}</Tag>}
<span>{t('excelImport.countPrefix')} {parsedData.length} {t('excelImport.countSuffix')}</span>
<Tag color="green">{t('excelImport.validPrefix')} {validCount}</Tag>
{errorCount > 0 && <Tag color="red">{t('excelImport.errorPrefix')} {errorCount}</Tag>}
</Space>
<Button
type="primary"
@@ -251,7 +279,7 @@ const ExcelImport: React.FC = () => {
loading={importing}
disabled={validCount === 0}
>
{validCount}
{t('excelImport.importPrefix')} {validCount} {t('excelImport.importSuffix')}
</Button>
</div>
@@ -270,10 +298,10 @@ const ExcelImport: React.FC = () => {
type={importResult.failed > 0 ? 'warning' : 'success'}
showIcon
style={{ marginTop: 16 }}
message={`导入完成:成功 ${importResult.imported} 条,失败 ${importResult.failed}`}
message={t('excelImport.importComplete', { success: importResult.imported, fail: importResult.failed })}
description={
importResult.errors?.length > 0
? importResult.errors.map((e: any, i: number) => <div key={i}> {e.row} {e.message}</div>)
? importResult.errors.map((e: any, i: number) => <div key={i}>{t('excelImport.rowPrefix')}{e.row}{t('excelImport.rowSuffix')}{e.message}</div>)
: undefined
}
/>
@@ -287,4 +315,4 @@ const ExcelImport: React.FC = () => {
);
};
export default ExcelImport;
export default ExcelImport;
+28 -26
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 { PlusOutlined, EditOutlined, ReloadOutlined } from '@ant-design/icons';
import apiClient from '../../utils/request';
import { useLanguageStore } from '../../store/languageStore';
const { Title } = Typography;
@@ -12,6 +13,7 @@ const LEVEL1_OPTIONS = [
];
const ExpenseCategories: React.FC = () => {
const { t, currentLanguage } = useLanguageStore();
const [categories, setCategories] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [modalVisible, setModalVisible] = useState(false);
@@ -26,7 +28,7 @@ const ExpenseCategories: React.FC = () => {
setCategories(res.data.data);
}
} catch (e) {
message.error('获取分类失败');
message.error(t('expenseCategory.getFailed'));
}
setLoading(false);
};
@@ -50,10 +52,10 @@ const ExpenseCategories: React.FC = () => {
const values = await form.validateFields();
if (editingId) {
await apiClient.put(`/expense-categories/${editingId}`, values);
message.success('更新成功');
message.success(t('common.updateSuccess'));
} else {
await apiClient.post('/expense-categories', values);
message.success('创建成功');
message.success(t('common.createSuccess'));
}
setModalVisible(false);
fetchCategories();
@@ -65,10 +67,10 @@ const ExpenseCategories: React.FC = () => {
const handleToggleActive = async (id: number, isActive: boolean) => {
try {
await apiClient.put(`/expense-categories/${id}`, { is_active: isActive });
message.success(isActive ? '已启用' : '已禁用');
message.success(isActive ? t('expenseCategory.enabled') : t('expenseCategory.disabled'));
fetchCategories();
} catch (e) {
message.error('操作失败');
message.error(t('common.operationFailed'));
}
};
@@ -78,22 +80,22 @@ const ExpenseCategories: React.FC = () => {
};
const columns = [
{ title: 'ID', dataIndex: 'id', width: 60 },
{ title: '一级分类', dataIndex: 'category_level1', width: 120, render: getLevel1Tag },
{ title: '二级编码', dataIndex: 'category_level2', width: 160 },
{ title: '显示名称', dataIndex: 'label', width: 140 },
{ title: '说明', dataIndex: 'description', ellipsis: true },
{ title: '排序', dataIndex: 'sort_order', width: 70 },
{ title: t('expenseCategory.id'), dataIndex: 'id', width: 60 },
{ title: t('expenseCategory.level1'), dataIndex: 'category_level1', width: 120, render: getLevel1Tag },
{ title: t('expenseCategory.level2Code'), dataIndex: 'category_level2', width: 160 },
{ title: t('expenseCategory.displayName'), dataIndex: 'label', width: 140 },
{ title: t('expenseCategory.desc'), dataIndex: 'description', ellipsis: true },
{ title: t('expenseCategory.order'), dataIndex: 'sort_order', width: 70 },
{
title: '状态', dataIndex: 'is_active', width: 90,
title: t('expenseCategory.status'), dataIndex: 'is_active', width: 90,
render: (v: boolean, r: any) => (
<Switch size="small" checked={v} onChange={(checked) => handleToggleActive(r.id, checked)} />
)
},
{
title: '操作', width: 100,
title: t('expenseCategory.action'), width: 100,
render: (_: unknown, r: any) => (
<Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(r)}></Button>
<Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(r)}>{t('common.edit')}</Button>
)
}
];
@@ -102,10 +104,10 @@ const ExpenseCategories: React.FC = () => {
<div style={{ padding: 24 }}>
<Card>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Title level={4} style={{ margin: 0 }}></Title>
<Title level={4} style={{ margin: 0 }}>{t('expenseCategory.title')}</Title>
<Space>
<Button icon={<ReloadOutlined />} onClick={fetchCategories}></Button>
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}></Button>
<Button icon={<ReloadOutlined />} onClick={fetchCategories}>{t('expenseCategory.refresh')}</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>{t('expenseCategory.add')}</Button>
</Space>
</div>
<Table
@@ -119,26 +121,26 @@ const ExpenseCategories: React.FC = () => {
</Card>
<Modal
title={editingId ? '编辑分类' : '新增分类'}
title={editingId ? t('expenseCategory.editCategory') : t('expenseCategory.addCategory')}
open={modalVisible}
onOk={handleSave}
onCancel={() => setModalVisible(false)}
destroyOnClose
>
<Form form={form} layout="vertical">
<Form.Item name="category_level1" label="一级分类" rules={[{ required: true, message: '请选择' }]}>
<Form.Item name="category_level1" label={t('expenseCategory.level1')} rules={[{ required: true, message: t('expenseCategory.selectLevel1') }]}>
<Select options={LEVEL1_OPTIONS.map(o => ({ value: o.value, label: o.label }))} />
</Form.Item>
<Form.Item name="category_level2" label="二级编码" rules={[{ required: true, message: '请输入' }]}>
<Input placeholder="如 material, salary 等" />
<Form.Item name="category_level2" label={t('expenseCategory.level2Code')} rules={[{ required: true, message: t('expenseCategory.inputLevel2') }]}>
<Input placeholder={t('expenseCategory.codePlaceholder')} />
</Form.Item>
<Form.Item name="label" label="显示名称" rules={[{ required: true, message: '请输入' }]}>
<Input placeholder="如 材料采购" />
<Form.Item name="label" label={t('expenseCategory.displayName')} rules={[{ required: true, message: t('expenseCategory.inputLevel2') }]}>
<Input placeholder={t('expenseCategory.namePlaceholder')} />
</Form.Item>
<Form.Item name="description" label="说明">
<Form.Item name="description" label={t('expenseCategory.desc')}>
<Input.TextArea rows={2} />
</Form.Item>
<Form.Item name="sort_order" label="排序" initialValue={0}>
<Form.Item name="sort_order" label={t('expenseCategory.order')} initialValue={0}>
<Input type="number" />
</Form.Item>
</Form>
@@ -147,4 +149,4 @@ const ExpenseCategories: React.FC = () => {
);
};
export default ExpenseCategories;
export default ExpenseCategories;
+216 -221
View File
@@ -1,221 +1,216 @@
import React, { useState } from 'react';
import { Card, Typography, Table, Button, Space, Modal, Form, Select, Input, message, Tag, Steps, Divider, Switch, Badge } from 'antd';
import { EditOutlined, PlusOutlined, SettingOutlined, CheckCircleOutlined, ClockCircleOutlined, SyncOutlined } from '@ant-design/icons';
const { Title, Paragraph, Text } = Typography;
const { Option } = Select;
interface ProcessNode {
id: string;
name: string;
role: string;
roleName: string;
order: number;
enabled: boolean;
}
const ProcessManagement: React.FC = () => {
const [loading, setLoading] = useState(false);
const [modalVisible, setModalVisible] = useState(false);
const [editingNode, setEditingNode] = useState<ProcessNode | null>(null);
const [form] = Form.useForm();
// 流程节点数据
const [nodes, setNodes] = useState<ProcessNode[]>([
{ id: '1', name: '发起申请', role: 'applicant', roleName: '申请人(任意角色)', order: 1, enabled: true },
{ id: '2', name: '审批', role: 'admin', roleName: '管理员', order: 2, enabled: true },
{ id: '3', name: '执行付款', role: 'admin', roleName: '管理员', order: 3, enabled: true },
]);
// 角色选项
const roleOptions = [
{ value: 'applicant', label: '申请人(任意角色)' },
{ value: 'admin', label: '管理员' },
{ value: 'finance', label: '财务专员' },
{ value: 'manager', label: '项目经理' },
];
// 流程类型
const processTypes = [
{ key: 'advance', name: '预支申请', description: '员工预支款项申请流程' },
{ key: 'reimbursement', name: '报销申请', description: '费用报销申请流程' },
{ key: 'payment', name: '付款申请', description: '供应商付款申请流程' },
{ key: 'verification', name: '核销申请', description: '单据核销申请流程' },
];
const handleEdit = (node: ProcessNode) => {
setEditingNode(node);
form.setFieldsValue({
role: node.role
});
setModalVisible(true);
};
const handleSave = () => {
form.validateFields().then(values => {
if (editingNode) {
const updatedNodes = nodes.map(n => {
if (n.id === editingNode.id) {
const roleOption = roleOptions.find(r => r.value === values.role);
return { ...n, role: values.role, roleName: roleOption?.label || values.role };
}
return n;
});
setNodes(updatedNodes);
message.success('节点配置已保存');
}
setModalVisible(false);
});
};
const getStatusTag = (enabled: boolean) => {
return enabled ? <Tag color="success"></Tag> : <Tag color="default"></Tag>;
};
const getStepStatus = (order: number) => {
if (order === 1) return 'finish';
if (order === 2) return 'process';
return 'wait';
};
const columns = [
{
title: '顺序',
dataIndex: 'order',
key: 'order',
width: 80,
render: (v: number) => <Badge count={v} style={{ backgroundColor: '#1890ff' }} />
},
{ title: '节点名称', dataIndex: 'name', key: 'name', width: 150 },
{
title: '执行角色',
dataIndex: 'roleName',
key: 'roleName',
render: (v: string, r: ProcessNode) => (
<Space>
<Tag color={r.role === 'admin' ? 'blue' : r.role === 'finance' ? 'green' : 'default'}>
{v}
</Tag>
</Space>
)
},
{
title: '状态',
dataIndex: 'enabled',
key: 'enabled',
width: 100,
render: (v: boolean) => getStatusTag(v)
},
{
title: '操作',
key: 'action',
width: 120,
render: (_: any, record: ProcessNode) => (
<Space>
<Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)}>
</Button>
</Space>
)
}
];
return (
<div style={{ padding: 24 }}>
<div style={{ marginBottom: 24 }}>
<Title level={3} style={{ marginBottom: 8 }}></Title>
<Paragraph type="secondary">
</Paragraph>
</div>
{/* 流程图示 */}
<Card title="当前流程图" style={{ marginBottom: 24 }}>
<Steps current={1} style={{ marginTop: 16 }}>
{nodes.filter(n => n.enabled).map((node, index) => (
<Steps.Step
key={node.id}
title={node.name}
description={node.roleName}
status={getStepStatus(node.order)}
icon={
node.order === 1 ? <PlusOutlined /> :
node.order === 2 ? <CheckCircleOutlined /> :
<SyncOutlined />
}
/>
))}
</Steps>
<Divider />
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
<Text strong></Text>
</Paragraph>
</Card>
{/* 节点配置表 */}
<Card title="节点配置">
<Table
columns={columns}
dataSource={nodes}
rowKey="id"
pagination={false}
size="middle"
/>
</Card>
{/* 流程类型说明 */}
<Card title="适用流程" style={{ marginTop: 24 }}>
<Table
columns={[
{ title: '流程类型', dataIndex: 'name', key: 'name', width: 150 },
{ title: '说明', dataIndex: 'description', key: 'description' },
{
title: '状态',
key: 'status',
width: 100,
render: () => <Tag color="success"></Tag>
}
]}
dataSource={processTypes}
rowKey="key"
pagination={false}
size="middle"
/>
</Card>
{/* 编辑节点弹窗 */}
<Modal
title={`编辑节点:${editingNode?.name}`}
open={modalVisible}
onCancel={() => setModalVisible(false)}
onOk={handleSave}
width={500}
>
<Form form={form} layout="vertical">
<Form.Item label="节点名称">
<Input value={editingNode?.name} disabled />
</Form.Item>
<Form.Item
name="role"
label="执行角色"
rules={[{ required: true, message: '请选择执行角色' }]}
>
<Select placeholder="选择执行角色">
{roleOptions.map(opt => (
<Option key={opt.value} value={opt.value}>{opt.label}</Option>
))}
</Select>
</Form.Item>
</Form>
<div style={{ padding: 12, background: '#fffbe6', borderRadius: 6, marginTop: 16 }}>
<Text type="warning">
使
</Text>
</div>
</Modal>
</div>
);
};
export default ProcessManagement;
import React, { useState } from 'react';
import { Card, Typography, Table, Button, Space, Modal, Form, Select, Input, message, Tag, Steps, Divider, Switch, Badge } from 'antd';
import { EditOutlined, PlusOutlined, SettingOutlined, CheckCircleOutlined, ClockCircleOutlined, SyncOutlined } from '@ant-design/icons';
import { useLanguageStore } from '../../store/languageStore';
const { Title, Paragraph, Text } = Typography;
const { Option } = Select;
interface ProcessNode {
id: string;
name: string;
role: string;
roleName: string;
order: number;
enabled: boolean;
}
const ProcessManagement: React.FC = () => {
const { t, currentLanguage } = useLanguageStore();
const [loading, setLoading] = useState(false);
const [modalVisible, setModalVisible] = useState(false);
const [editingNode, setEditingNode] = useState<ProcessNode | null>(null);
const [form] = Form.useForm();
const [nodes, setNodes] = useState<ProcessNode[]>([
{ id: '1', name: t('processManagement.submitApplication'), role: 'applicant', roleName: t('processManagement.roleApplicant'), order: 1, enabled: true },
{ id: '2', name: t('processManagement.approvalNode'), role: 'admin', roleName: t('processManagement.roleAdmin'), order: 2, enabled: true },
{ id: '3', name: t('processManagement.executePayment'), role: 'admin', roleName: t('processManagement.roleAdmin'), order: 3, enabled: true },
]);
const roleOptions = [
{ value: 'applicant', label: t('processManagement.roleApplicant') },
{ value: 'admin', label: t('processManagement.roleAdmin') },
{ value: 'finance', label: t('processManagement.roleFinance') },
{ value: 'manager', label: t('processManagement.roleManager') },
];
const processTypes = [
{ key: 'advance', name: t('processManagement.advanceProcess'), description: t('processManagement.advanceProcessDesc') },
{ key: 'reimbursement', name: t('processManagement.reimburseProcess'), description: t('processManagement.reimburseProcessDesc') },
{ key: 'payment', name: t('processManagement.paymentProcess'), description: t('processManagement.paymentProcessDesc') },
{ key: 'verification', name: t('processManagement.verificationProcess'), description: t('processManagement.verificationProcessDesc') },
];
const handleEdit = (node: ProcessNode) => {
setEditingNode(node);
form.setFieldsValue({
role: node.role
});
setModalVisible(true);
};
const handleSave = () => {
form.validateFields().then(values => {
if (editingNode) {
const updatedNodes = nodes.map(n => {
if (n.id === editingNode.id) {
const roleOption = roleOptions.find(r => r.value === values.role);
return { ...n, role: values.role, roleName: roleOption?.label || values.role };
}
return n;
});
setNodes(updatedNodes);
message.success(t('processManagement.nodeSaved'));
}
setModalVisible(false);
});
};
const getStatusTag = (enabled: boolean) => {
return enabled ? <Tag color="success">{t('processManagement.enabled')}</Tag> : <Tag color="default">{t('processManagement.disabled')}</Tag>;
};
const getStepStatus = (order: number) => {
if (order === 1) return 'finish';
if (order === 2) return 'process';
return 'wait';
};
const columns = [
{
title: t('processManagement.sequence'),
dataIndex: 'order',
key: 'order',
width: 80,
render: (v: number) => <Badge count={v} style={{ backgroundColor: '#1890ff' }} />
},
{ title: t('processManagement.nodeName'), dataIndex: 'name', key: 'name', width: 150 },
{
title: t('processManagement.executeRole'),
dataIndex: 'roleName',
key: 'roleName',
render: (v: string, r: ProcessNode) => (
<Space>
<Tag color={r.role === 'admin' ? 'blue' : r.role === 'finance' ? 'green' : 'default'}>
{v}
</Tag>
</Space>
)
},
{
title: t('processManagement.status'),
dataIndex: 'enabled',
key: 'enabled',
width: 100,
render: (v: boolean) => getStatusTag(v)
},
{
title: t('processManagement.action'),
key: 'action',
width: 120,
render: (_: any, record: ProcessNode) => (
<Space>
<Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)}>
{t('processManagement.edit')}
</Button>
</Space>
)
}
];
return (
<div style={{ padding: 24 }}>
<div style={{ marginBottom: 24 }}>
<Title level={3} style={{ marginBottom: 8 }}>{t('processManagement.title')}</Title>
<Paragraph type="secondary">
{t('processManagement.description')}
</Paragraph>
</div>
<Card title={t('processManagement.flowchart')} style={{ marginBottom: 24 }}>
<Steps current={1} style={{ marginTop: 16 }}>
{nodes.filter(n => n.enabled).map((node, index) => (
<Steps.Step
key={node.id}
title={node.name}
description={node.roleName}
status={getStepStatus(node.order)}
icon={
node.order === 1 ? <PlusOutlined /> :
node.order === 2 ? <CheckCircleOutlined /> :
<SyncOutlined />
}
/>
))}
</Steps>
<Divider />
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
<Text strong>{t('processManagement.tipTitle')}</Text>
{t('processManagement.tipContent')}
</Paragraph>
</Card>
<Card title={t('processManagement.nodeConfig')}>
<Table
columns={columns}
dataSource={nodes}
rowKey="id"
pagination={false}
size="middle"
/>
</Card>
<Card title={t('processManagement.applicableProcess')} style={{ marginTop: 24 }}>
<Table
columns={[
{ title: t('processManagement.processType'), dataIndex: 'name', key: 'name', width: 150 },
{ title: t('processManagement.desc'), dataIndex: 'description', key: 'description' },
{
title: t('processManagement.status'),
key: 'status',
width: 100,
render: () => <Tag color="success">{t('processManagement.enabled')}</Tag>
}
]}
dataSource={processTypes}
rowKey="key"
pagination={false}
size="middle"
/>
</Card>
<Modal
title={t('processManagement.editNode', { name: editingNode?.name || '' })}
open={modalVisible}
onCancel={() => setModalVisible(false)}
onOk={handleSave}
width={500}
>
<Form form={form} layout="vertical">
<Form.Item label={t('processManagement.nodeName')}>
<Input value={editingNode?.name} disabled />
</Form.Item>
<Form.Item
name="role"
label={t('processManagement.executeRole')}
rules={[{ required: true, message: t('processManagement.selectRolePlaceholder') }]}
>
<Select placeholder={t('processManagement.selectRole')}>
{roleOptions.map(opt => (
<Option key={opt.value} value={opt.value}>{opt.label}</Option>
))}
</Select>
</Form.Item>
</Form>
<div style={{ padding: 12, background: '#fffbe6', borderRadius: 6, marginTop: 16 }}>
<Text type="warning">
{t('processManagement.warning')}
</Text>
</div>
</Modal>
</div>
);
};
export default ProcessManagement;
+64 -62
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 { PlusOutlined, CopyOutlined, EditOutlined, DeleteOutlined, EyeOutlined, ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons';
import apiClient from '../../utils/request';
import { useLanguageStore } from '../../store/languageStore';
const { Title, Text, Paragraph } = Typography;
const { Option } = Select;
@@ -26,6 +27,7 @@ interface Template {
}
const ProcessTemplates: React.FC = () => {
const { t, currentLanguage } = useLanguageStore();
const [templates, setTemplates] = useState<Template[]>([]);
const [loading, setLoading] = useState(false);
const [createModalVisible, setCreateModalVisible] = useState(false);
@@ -46,7 +48,7 @@ const ProcessTemplates: React.FC = () => {
try {
const res = await apiClient.get('/process-templates');
if (res.data.success) setTemplates(res.data.data);
} catch (e) { message.error('获取模板列表失败'); }
} catch (e) { message.error(t('processTemplate.getListFailed')); }
finally { setLoading(false); }
};
@@ -58,32 +60,32 @@ const ProcessTemplates: React.FC = () => {
setCreateModalVisible(true);
};
const handleEdit = (t: Template) => {
form.setFieldsValue({ name: t.name, description: t.description });
setPhases(t.phases || []);
setCurrentStep(1);
setEditingTemplateId(t.id);
const handleEdit = (template: Template) => {
form.setFieldsValue({ name: template.name, description: template.description });
setPhases(template.phases || []);
setCurrentStep(0);
setEditingTemplateId(template.id);
setCreateModalVisible(true);
};
const handleCopy = async (t: Template) => {
const handleCopy = async (template: Template) => {
try {
await apiClient.post(`/process-templates/${t.id}/copy`, { name: `${t.name} (副本)` });
message.success('复制成功');
await apiClient.post(`/process-templates/${template.id}/copy`, { name: `${template.name} (副本)` });
message.success(t('processTemplate.copySuccess'));
fetchTemplates();
} catch (e) { message.error('复制失败'); }
} catch (e) { message.error(t('processTemplate.copyFailed')); }
};
const handleDelete = async (id: number) => {
try {
await apiClient.delete(`/process-templates/${id}`);
message.success('删除成功');
message.success(t('processTemplate.deleteSuccess'));
fetchTemplates();
} catch (e) { message.error('删除失败'); }
} catch (e) { message.error(t('processTemplate.deleteFailed')); }
};
const handleView = (t: Template) => {
setCurrentTemplate(t);
const handleView = (template: Template) => {
setCurrentTemplate(template);
setViewDrawerVisible(true);
};
@@ -91,19 +93,19 @@ const ProcessTemplates: React.FC = () => {
try {
const name = form.getFieldValue('name');
const description = form.getFieldValue('description');
if (!name) { message.warning('请输入模板名称'); setCurrentStep(0); return; }
if (phases.length === 0) { message.warning('请至少添加一个阶段'); setCurrentStep(1); return; }
if (!name) { message.warning(t('processTemplate.nameRequired')); setCurrentStep(0); return; }
if (phases.length === 0) { message.warning(t('processTemplate.phaseRequired')); setCurrentStep(1); return; }
if (editingTemplateId) {
await apiClient.put(`/process-templates/${editingTemplateId}`, { name, description, phases });
message.success('模板更新成功');
message.success(t('processTemplate.updateSuccess'));
} else {
await apiClient.post('/process-templates', { name, description, phases });
message.success('模板创建成功');
message.success(t('processTemplate.createSuccess'));
}
setCreateModalVisible(false);
setEditingTemplateId(null);
fetchTemplates();
} catch (e) { message.error('保存失败'); }
} catch (e) { message.error(t('common.saveFailed')); }
};
const addPhase = () => {
@@ -135,7 +137,7 @@ const ProcessTemplates: React.FC = () => {
depends: values.depends || [],
sub_items: subItems,
};
if (!phaseData.name) { message.warning('阶段名称不能为空'); return; }
if (!phaseData.name) { message.warning(t('processTemplate.phaseNameEmpty')); return; }
const newPhases = [...phases];
if (editingPhaseIndex >= 0) {
phaseData.order = newPhases[editingPhaseIndex].order;
@@ -162,7 +164,7 @@ const ProcessTemplates: React.FC = () => {
setPhases(newPhases);
};
const getTypeTag = (type: string) => type === 'parallel' ? <Tag color="blue"></Tag> : <Tag color="green"></Tag>;
const getTypeTag = (type: string) => type === 'parallel' ? <Tag color="blue">{t('processTemplate.parallelLabel')}</Tag> : <Tag color="green">{t('processTemplate.serialLabel')}</Tag>;
const getDependsNames = (depends: number[]) => {
return depends.map(d => {
@@ -172,69 +174,69 @@ const ProcessTemplates: React.FC = () => {
};
const columns = [
{ title: '模板名称', dataIndex: 'name', key: 'name', render: (text: string, r: Template) => <Space>{text}{r.is_system && <Tag color="gold"></Tag>}</Space> },
{ title: '阶段数', key: 'phases', render: (_: unknown, r: Template) => r.phases?.length || 0 },
{ title: '描述', dataIndex: 'description', key: 'description', ellipsis: true },
{ title: '操作', key: 'action', render: (_: unknown, r: Template) => (
{ title: t('processTemplate.templateName'), dataIndex: 'name', key: 'name', render: (text: string, r: Template) => <Space>{text}{r.is_system && <Tag color="gold">{t('processTemplate.systemPreset')}</Tag>}</Space> },
{ title: t('processTemplate.phaseCount'), key: 'phases', render: (_: unknown, r: Template) => r.phases?.length || 0 },
{ title: t('processTemplate.desc'), dataIndex: 'description', key: 'description', ellipsis: true },
{ title: t('processTemplate.action'), key: 'action', render: (_: unknown, r: Template) => (
<Space>
<Button size="small" icon={<EyeOutlined />} onClick={() => handleView(r)}></Button>
<Button size="small" type="primary" icon={<EditOutlined />} onClick={() => handleEdit(r)}></Button>
<Button size="small" icon={<CopyOutlined />} onClick={() => handleCopy(r)}></Button>
{!r.is_system && <Popconfirm title="确定删除?" onConfirm={() => handleDelete(r.id)}><Button size="small" danger icon={<DeleteOutlined />}></Button></Popconfirm>}
<Button size="small" icon={<EyeOutlined />} onClick={() => handleView(r)}>{t('common.view')}</Button>
<Button size="small" type="primary" icon={<EditOutlined />} onClick={() => handleEdit(r)}>{t('common.edit')}</Button>
<Button size="small" icon={<CopyOutlined />} onClick={() => handleCopy(r)}>{t('processTemplate.copy')}</Button>
{!r.is_system && <Popconfirm title={t('processTemplate.confirmDelete')} onConfirm={() => handleDelete(r.id)}><Button size="small" danger icon={<DeleteOutlined />}>{t('processTemplate.delete')}</Button></Popconfirm>}
</Space>
)},
];
return (
<div style={{ padding: 24 }}>
<Card title="工程模板管理" extra={<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}></Button>}>
<Card title={t('processTemplate.title')} extra={<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>{t('processTemplate.newTemplate')}</Button>}>
<Table columns={columns} dataSource={templates} rowKey="id" loading={loading} pagination={false} />
</Card>
<Modal title={editingTemplateId ? "编辑工程模板" : "新建工程模板"} open={createModalVisible} onCancel={() => setCreateModalVisible(false)} width={800} footer={[
<Button key="cancel" onClick={() => setCreateModalVisible(false)}></Button>,
currentStep > 0 && <Button key="prev" onClick={() => setCurrentStep(currentStep - 1)}></Button>,
<Modal title={editingTemplateId ? t('processTemplate.editTemplate') : t('processTemplate.newTemplate')} open={createModalVisible} onCancel={() => setCreateModalVisible(false)} width={800} footer={[
<Button key="cancel" onClick={() => setCreateModalVisible(false)}>{t('processTemplate.cancel')}</Button>,
currentStep > 0 && <Button key="prev" onClick={() => setCurrentStep(currentStep - 1)}>{t('processTemplate.prev')}</Button>,
currentStep < 2 && <Button key="next" type="primary" onClick={() => {
if (currentStep === 0 && !form.getFieldValue('name')) { message.warning('请输入模板名称'); return; }
if (currentStep === 0 && !form.getFieldValue('name')) { message.warning(t('processTemplate.nameRequired')); return; }
setCurrentStep(currentStep + 1);
}}></Button>,
currentStep === 2 && <Button key="save" type="primary" onClick={handleSaveTemplate}>{editingTemplateId ? '保存修改' : '确认创建'}</Button>,
}}>{t('processTemplate.next')}</Button>,
currentStep === 2 && <Button key="save" type="primary" onClick={handleSaveTemplate}>{editingTemplateId ? t('processTemplate.saveEdit') : t('processTemplate.confirmCreate')}</Button>,
]}>
<Steps current={currentStep} size="small" style={{ marginBottom: 24 }}>
<Steps.Step title="基本信息" />
<Steps.Step title="设计阶段" />
<Steps.Step title="预览确认" />
<Steps.Step title={t('processTemplate.basicInfo')} />
<Steps.Step title={t('processTemplate.designPhase')} />
<Steps.Step title={t('processTemplate.preview')} />
</Steps>
{currentStep === 0 && (
<Form form={form} layout="vertical">
<Form.Item name="name" label="模板名称" rules={[{ required: true }]}>
<Input placeholder="例如:配电安装工程" />
<Form.Item name="name" label={t('processTemplate.templateName')} rules={[{ required: true }]}>
<Input placeholder={t('processTemplate.namePlaceholder')} />
</Form.Item>
<Form.Item name="description" label="模板描述">
<TextArea rows={3} placeholder="描述该模板适用的工程类型" />
<Form.Item name="description" label={t('processTemplate.templateDescription')}>
<TextArea rows={3} placeholder={t('processTemplate.descPlaceholder')} />
</Form.Item>
</Form>
)}
{currentStep === 1 && (
<div>
{phases.length === 0 ? <Empty description="暂无阶段,请点击下方添加" /> : (
{phases.length === 0 ? <Empty description={t('processTemplate.noPhase')} /> : (
<List bordered dataSource={phases} renderItem={(phase, index) => (
<List.Item actions={[
<Button size="small" icon={<EditOutlined />} onClick={() => editPhase(index)}></Button>,
<Button size="small" icon={<EditOutlined />} onClick={() => editPhase(index)}>{t('common.edit')}</Button>,
<Button size="small" icon={<ArrowUpOutlined />} onClick={() => movePhase(index, 'up')} disabled={index === 0} />,
<Button size="small" icon={<ArrowDownOutlined />} onClick={() => movePhase(index, 'down')} disabled={index === phases.length - 1} />,
<Popconfirm title="确定删除?" onConfirm={() => removePhase(index)}><Button size="small" danger icon={<DeleteOutlined />} /></Popconfirm>,
<Popconfirm title={t('processTemplate.confirmDelete')} onConfirm={() => removePhase(index)}><Button size="small" danger icon={<DeleteOutlined />} /></Popconfirm>,
]}>
<List.Item.Meta
title={<Space>{phase.order}. {phase.name} {getTypeTag(phase.type)} <Text type="secondary">{getDependsNames(phase.depends)}</Text></Space>}
description={phase.sub_items?.length > 0 ? `子项:${phase.sub_items.join('、')}` : '无子项'}
title={<Space>{phase.order}. {phase.name} {getTypeTag(phase.type)} <Text type="secondary">{t('processTemplate.dependencyLabel')}{getDependsNames(phase.depends)}</Text></Space>}
description={phase.sub_items?.length > 0 ? `子项:${phase.sub_items.join('、')}` : t('processTemplate.emptySubItems')}
/>
</List.Item>
)} />
)}
<Button type="dashed" block icon={<PlusOutlined />} style={{ marginTop: 16 }} onClick={addPhase}></Button>
<Button type="dashed" block icon={<PlusOutlined />} style={{ marginTop: 16 }} onClick={addPhase}>{t('processTemplate.addPhase')}</Button>
</div>
)}
@@ -247,7 +249,7 @@ const ProcessTemplates: React.FC = () => {
<List.Item.Meta
title={<Space>{phase.order}. {phase.name} {getTypeTag(phase.type)}</Space>}
description={<>
<Text type="secondary">{getDependsNames(phase.depends)}</Text><br />
<Text type="secondary">{t('processTemplate.dependencyLabel')}{getDependsNames(phase.depends)}</Text><br />
{phase.sub_items?.length > 0 && <Text>{phase.sub_items.join('、')}</Text>}
</>}
/>
@@ -257,26 +259,26 @@ const ProcessTemplates: React.FC = () => {
)}
</Modal>
<Drawer title="阶段编辑" open={phaseDrawerVisible} onClose={() => setPhaseDrawerVisible(false)} width={400} extra={<Button type="primary" onClick={savePhase}></Button>}>
<Drawer title={t('processTemplate.phaseEdit')} open={phaseDrawerVisible} onClose={() => setPhaseDrawerVisible(false)} width={400} extra={<Button type="primary" onClick={savePhase}>{t('processTemplate.save')}</Button>}>
<Form form={phaseForm} layout="vertical">
<Form.Item name="name" label="阶段名称" rules={[{ required: true }]}>
<Input placeholder="例如:物资采购" />
<Form.Item name="name" label={t('processTemplate.phaseName')} rules={[{ required: true }]}>
<Input placeholder={t('processTemplate.phaseNamePlaceholder')} />
</Form.Item>
<Form.Item name="type" label="阶段类型">
<Form.Item name="type" label={t('processTemplate.phaseType')}>
<Radio.Group>
<Radio value="serial"></Radio>
<Radio value="parallel"></Radio>
<Radio value="serial">{t('processTemplate.serial')}</Radio>
<Radio value="parallel">{t('processTemplate.parallel')}</Radio>
</Radio.Group>
</Form.Item>
<Form.Item name="depends" label="依赖关系(哪些阶段完成后才能开始)">
<Form.Item name="depends" label={t('processTemplate.dependency')}>
<Checkbox.Group>
{phases.filter((_, i) => i !== editingPhaseIndex).map(p => (
<Checkbox key={p.order} value={p.order} style={{ display: 'block' }}>{p.order}. {p.name}</Checkbox>
))}
</Checkbox.Group>
</Form.Item>
<Form.Item name="sub_items_text" label="子项列表(每行一个)">
<TextArea rows={6} placeholder={"电杆采购\n变压器采购\n电缆采购"} />
<Form.Item name="sub_items_text" label={t('processTemplate.subItems')}>
<TextArea rows={6} placeholder={t('processTemplate.subItemsPlaceholder')} />
</Form.Item>
</Form>
</Drawer>
@@ -288,9 +290,9 @@ const ProcessTemplates: React.FC = () => {
<List bordered dataSource={currentTemplate.phases || []} renderItem={(phase: PhaseItem) => (
<List.Item>
<List.Item.Meta
title={<Space>{phase.order}. {phase.name} {phase.type === 'parallel' ? <Tag color="blue"></Tag> : <Tag color="green"></Tag>}</Space>}
title={<Space>{phase.order}. {phase.name} {phase.type === 'parallel' ? <Tag color="blue">{t('processTemplate.parallelLabel')}</Tag> : <Tag color="green">{t('processTemplate.serialLabel')}</Tag>}</Space>}
description={<>
<Text type="secondary">{phase.depends?.join('、') || '无'}</Text><br />
<Text type="secondary">{t('processTemplate.dependencyLabelShort')}{'阶段'}{phase.depends?.join('、') || '无'}</Text><br />
{phase.sub_items?.length > 0 && <Text>{phase.sub_items.join('、')}</Text>}
</>}
/>
@@ -303,4 +305,4 @@ const ProcessTemplates: React.FC = () => {
);
};
export default ProcessTemplates;
export default ProcessTemplates;
@@ -1,106 +1,67 @@
import React, { useState, useEffect } from 'react';
import { Table, Card, Tag, Button, Space, Input, DatePicker, Select, message, Descriptions, Divider, Tabs, Modal, Result } from 'antd';
import { EyeOutlined, CalendarOutlined, UserOutlined, FilterOutlined } from '@ant-design/icons';
import { Card, Table, Tag, Button, Space, Modal, Form, Input, Select, DatePicker, message, Descriptions, Divider, List } from 'antd';
import { SearchOutlined, EyeOutlined, CheckOutlined, CloseOutlined, DollarOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { useAuthStore } from '../../store/authStore';
import { useLanguageStore } from '../../store/languageStore';
const { Option } = Select;
const { RangePicker } = DatePicker;
const { TextArea } = Input;
const AdvanceVerificationStatusPage: React.FC = () => {
const { user } = useAuthStore();
const { t, currentLanguage } = useLanguageStore();
const [loading, setLoading] = useState(false);
const [modalVisible, setModalVisible] = useState(false);
const [detailModalVisible, setDetailModalVisible] = useState(false);
const [selectedAdvance, setSelectedAdvance] = useState<any>(null);
const [unsettledAdvances, setUnsettledAdvances] = useState<any[]>([]);
const [settledAdvances, setSettledAdvances] = useState<any[]>([]);
const [searchName, setSearchName] = useState('');
const [dateRange, setDateRange] = useState<[dayjs.Dayjs, dayjs.Dayjs] | null>(null);
const [sortField, setSortField] = useState('');
const [sortOrder, setSortOrder] = useState('');
const [selectedRecord, setSelectedRecord] = useState<any>(null);
const [fullDetail, setFullDetail] = useState<any>(null);
const [form] = Form.useForm();
const [projects, setProjects] = useState<any[]>([]);
// 检查权限
const hasPermission = user?.role === 'admin' || user?.department === '财务部';
if (!hasPermission) {
return (
<div style={{ padding: 24, textAlign: 'center' }}>
<Result
status="403"
title="无权限访问"
subTitle="您没有权限访问此页面,只有管理员和财务人员可以查看预支核销状态。"
/>
</div>
);
}
// 所有核销记录
const [verifications, setVerifications] = useState<any[]>([]);
// 获取未核销和已核销的预支单
const fetchAdvances = async () => {
// 筛选条件
const [searchCode, setSearchCode] = useState('');
const [searchApplicant, setSearchApplicant] = useState('');
const [filterStatus, setFilterStatus] = useState<string>('all');
const [filterSettlement, setFilterSettlement] = useState<string>('all');
// 加载数据
useEffect(() => {
fetchVerifications();
fetchProjects();
}, []);
const fetchVerifications = 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);
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('获取预支单失败');
message.error(t('advanceVerification.getListFailed'));
} finally {
setLoading(false);
}
};
// 获取预支单详情,包括关联的核销单
const fetchAdvanceDetail = async (advanceId: number) => {
setLoading(true);
const fetchProjects = async () => {
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);
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) {
message.error('获取预支单详情失败');
} finally {
setLoading(false);
console.error('获取项目列表失败:', error);
}
};
useEffect(() => {
fetchAdvances();
}, []);
const formatAmount = (amount: number, currency: string = 'CNY') => {
const symbols: Record<string, string> = { CNY: '¥', USD: '$', LAK: '₭', THB: '฿' };
return (symbols[currency] || '¥') + (amount || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
@@ -108,295 +69,393 @@ const AdvanceVerificationStatusPage: React.FC = () => {
const getStatusTag = (status: string) => {
const statusMap: Record<string, { color: string; text: string }> = {
pending: { color: 'processing', text: '待审批' },
approved: { color: 'success', text: '已批准' },
rejected: { color: 'error', text: '已退回' },
withdrawn: { color: 'default', text: '已撤回' },
settled: { color: 'blue', text: '已核销' },
pending_edit: { color: 'warning', text: '待编辑' },
partial_verification: { color: 'orange', text: '部分核销' },
completed: { color: 'green', text: '已完成' },
pending: { color: 'processing', text: t('advanceVerification.pendingApproval') },
approved: { color: 'success', text: t('advanceVerification.approved') },
rejected: { color: 'error', text: t('advanceVerification.rejected') },
pending_edit: { color: 'warning', text: t('advanceVerification.pendingEdit') },
executed: { color: 'blue', text: t('advanceVerification.verified') },
};
const config = statusMap[status] || { color: 'default', text: status };
return <Tag color={config.color}>{config.text}</Tag>;
};
const handleSearch = () => {
// 这里可以添加搜索逻辑
fetchAdvances();
const getSettlementTag = (settlement: boolean) => {
return settlement ?
<Tag color="green">{t('advanceVerification.settlement')}</Tag> :
<Tag color="orange">{t('advanceVerification.nonSettlement')}</Tag>;
};
// 根据项目ID获取项目名称
const getProjectName = (projectId: any) => {
if (!projectId) return '-';
const project = projects.find(p => p.id === projectId);
return project ? project.name : `项目ID: ${projectId}`;
};
const handleSort = (field: string, order: string) => {
setSortField(field);
setSortOrder(order);
// 这里可以添加排序逻辑
fetchAdvances();
// 查看详情
const handleViewDetail = async (record: any) => {
try {
const response = await fetch(`/api/verifications/${record.id}`);
const data = await response.json();
if (data.success) {
setFullDetail(data.data);
setSelectedRecord(record);
form.resetFields();
setDetailModalVisible(true);
}
} catch (error) {
message.error(t('advanceVerification.getDetailFailed'));
}
};
// 查看已执行核销的详情
const handleViewExecutedDetail = async (record: any) => {
try {
const response = await fetch(`/api/verifications/${record.id}`);
const data = await response.json();
if (data.success) {
setFullDetail(data.data);
setSelectedRecord(record);
setDetailModalVisible(true);
}
} catch (error) {
message.error(t('advanceVerification.getDetailFailed'));
}
};
// 处理执行通过
const handleApprove = async () => {
try {
const values = await form.validateFields();
// 调用执行API
const executeResponse = await fetch('/api/executions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
apply_id: selectedRecord.id,
apply_type: 'verification',
action: 'execute',
execute_method: 'bank',
voucher_files: [],
remark: values.remark
})
});
if (executeResponse.ok) {
// 更新本地状态
setVerifications(verifications.map(v =>
v.id === selectedRecord.id ? { ...v, status: 'executed' } : v
));
message.success(t('advanceVerification.executeSuccess', { code: selectedRecord.verification_code }));
setDetailModalVisible(false);
} else {
message.error(t('advanceVerification.executeFailed'));
}
} catch (error) {
console.error('审批操作失败:', error);
}
};
// 处理执行退回
const handleReject = async () => {
try {
const values = await form.validateFields();
// 调用退回API
const rejectResponse = await fetch('/api/executions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
apply_id: selectedRecord.id,
apply_type: 'verification',
action: 'reject',
reject_reason: values.remark
})
});
if (rejectResponse.ok) {
setVerifications(verifications.map(v =>
v.id === selectedRecord.id ? { ...v, status: 'rejected' } : v
));
message.success(t('advanceVerification.rejectSuccess', { code: selectedRecord.verification_code }));
setDetailModalVisible(false);
} else {
message.error(t('advanceVerification.rejectFailed'));
}
} catch (error) {
console.error('审批操作失败:', error);
}
};
// 筛选数据
const getFilteredData = () => {
let data = [...verifications];
if (searchCode) {
data = data.filter(v => v.verification_code && v.verification_code.toLowerCase().includes(searchCode.toLowerCase()));
}
if (searchApplicant) {
data = data.filter(v => v.applicant && v.applicant.toLowerCase().includes(searchApplicant.toLowerCase()));
}
if (filterStatus !== 'all') {
data = data.filter(v => v.status === filterStatus);
}
if (filterSettlement !== 'all') {
const isSettlement = filterSettlement === 'yes';
data = data.filter(v => v.settlement === isSettlement);
}
return data;
};
const columns = [
{ title: t('advanceVerification.subject'), dataIndex: 'reason', key: 'reason', ellipsis: true },
{ title: t('advanceVerification.applicant'), dataIndex: 'applicant', key: 'applicant', width: 80 },
{ title: t('advanceVerification.verificationCode'), dataIndex: 'verification_code', key: 'verification_code', width: 120 },
{ title: t('advanceVerification.advanceCode'), dataIndex: 'advance_code', key: 'advance_code', width: 120 },
{ title: t('advanceVerification.advanceAmount'), dataIndex: 'advance_amount', key: 'advance_amount', width: 120, render: (v: number, r: any) => formatAmount(v, r.currency) },
{ title: t('advanceVerification.verificationAmount'), dataIndex: 'amount', key: 'amount', width: 120, render: (v: number, r: any) => formatAmount(v, r.currency) },
{ title: t('advanceVerification.verificationDate'), dataIndex: 'verification_date', key: 'verification_date', width: 110 },
{ title: t('advanceVerification.settlementType'), dataIndex: 'settlement', key: 'settlement', width: 100, render: (settlement: boolean) => getSettlementTag(settlement) },
{ title: t('advanceVerification.status'), dataIndex: 'status', key: 'status', width: 80, render: (status: string) => getStatusTag(status) },
{
title: '事由',
dataIndex: 'reason',
key: 'reason',
ellipsis: true,
render: (v: string, r: any) => (
<a onClick={() => fetchAdvanceDetail(r.id)}>{v}</a>
)
},
{
title: '申请人',
dataIndex: 'applicant',
key: 'applicant',
width: 100,
sorter: (a: any, b: any) => a.applicant.localeCompare(b.applicant),
onHeaderCell: (column: any) => ({
onClick: () => handleSort('applicant', sortOrder === 'ascend' ? 'descend' : 'ascend')
})
},
{
title: '预支金额',
dataIndex: 'amount',
key: 'amount',
width: 140,
render: (v: number, r: any) => formatAmount(v, r.currency),
sorter: (a: any, b: any) => a.amount - b.amount,
onHeaderCell: (column: any) => ({
onClick: () => handleSort('amount', sortOrder === 'ascend' ? 'descend' : 'ascend')
})
},
{
title: '已核销金额',
dataIndex: 'total_reimbursed',
key: 'total_reimbursed',
width: 140,
render: (v: number, r: any) => formatAmount(v || 0, r.currency),
sorter: (a: any, b: any) => (a.total_reimbursed || 0) - (b.total_reimbursed || 0),
onHeaderCell: (column: any) => ({
onClick: () => handleSort('total_reimbursed', sortOrder === 'ascend' ? 'descend' : 'ascend')
})
},
{
title: '剩余金额',
dataIndex: 'remaining',
key: 'remaining',
width: 140,
render: (_, r: any) => formatAmount((r.amount || 0) - (r.total_reimbursed || 0), r.currency),
sorter: (a: any, b: any) => ((a.amount || 0) - (a.total_reimbursed || 0)) - ((b.amount || 0) - (b.total_reimbursed || 0)),
onHeaderCell: (column: any) => ({
onClick: () => handleSort('remaining', sortOrder === 'ascend' ? 'descend' : 'ascend')
})
},
{
title: '预支日期',
dataIndex: 'advance_date',
key: 'advance_date',
width: 120,
sorter: (a: any, b: any) => new Date(a.advance_date).getTime() - new Date(b.advance_date).getTime(),
onHeaderCell: (column: any) => ({
onClick: () => handleSort('advance_date', sortOrder === 'ascend' ? 'descend' : 'ascend')
})
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: (status: string) => getStatusTag(status)
},
{
title: '编号',
dataIndex: 'advance_code',
key: 'advance_code',
width: 120
},
{
title: '操作',
key: 'action',
width: 80,
title: t('advanceVerification.action'), key: 'action', width: 140,
render: (_: any, record: any) => (
<Space wrap>
<Button size="small" icon={<EyeOutlined />} onClick={() => fetchAdvanceDetail(record.id)}></Button>
<Space>
<Button size="small" type="primary" icon={<EyeOutlined />} onClick={() => {
if (record.status === 'approved') {
handleViewDetail(record);
} else if (record.status === 'executed') {
handleViewExecutedDetail(record);
} else {
// 其他状态也可以查看详情
handleViewExecutedDetail(record);
}
}}>
{t('advanceVerification.view')}
</Button>
</Space>
)
}
];
// 渲染附件列表
const renderAttachments = (attachments: any) => {
if (!attachments || !Array.isArray(attachments) || attachments.length === 0) return null;
return (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{attachments.map((url: string, index: number) => (
<img
key={index}
src={url}
alt={`附件${index + 1}`}
style={{ width: 120, height: 120, objectFit: 'cover', borderRadius: 4, border: '1px solid #f0f0f0', cursor: 'pointer' }}
onClick={() => window.open(url, '_blank')}
/>
))}
</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 }}>
<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}>
<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>
</Card>
<p style={{ color: '#888', marginBottom: 0 }}>{t('advanceVerification.description')}</p>
</div>
{/* 预支单列表 */}
<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 }}
/>
)
}
]}
<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 }}
/>
</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>
<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>
<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)
}
]}
/>
</>
)}
{/* 事由 */}
<Descriptions.Item label={t('advanceVerification.subject')} span={2}>{fullDetail.reason}</Descriptions.Item>
</Descriptions>
{/* 附件 */}
{selectedAdvance.attachments && selectedAdvance.attachments.length > 0 && (
<>
<Divider></Divider>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{selectedAdvance.attachments.map((url: string, index: number) => (
<div key={index} style={{ position: 'relative' }}>
{url && url.match(/\.(jpg|jpeg|png|gif|webp)$/i) ? (
<img
src={url}
alt={`附件${index + 1}`}
style={{ width: 120, height: 120, objectFit: 'cover', borderRadius: 4, border: '1px solid #f0f0f0', cursor: 'pointer' }}
onClick={() => window.open(url, '_blank')}
/>
) : (
<a href={url} target="_blank" rel="noopener noreferrer">
<div style={{ width: 120, height: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', border: '1px solid #f0f0f0', borderRadius: 4, background: '#f5f5f5' }}>
<span style={{ color: '#666' }}> {index + 1}</span>
</div>
</a>
)}
</div>
))}
</div>
</>
)}
</>
)}
</Modal>
{/* 明细清单 */}
{fullDetail.detail_items && fullDetail.detail_items.length > 0 && (
<>
<Divider>{t('advanceVerification.expenseDetail')}</Divider>
{renderDetailItems(fullDetail.detail_items)}
</>
)}
{/* 凭证附件 */}
{fullDetail.attachments && fullDetail.attachments.length > 0 && (
<>
<Divider>{t('advanceVerification.proofAttachment')}</Divider>
{renderAttachments(fullDetail.attachments)}
</>
)}
{/* 执行表单 - 已审批待执行的核销 */}
{fullDetail.status === 'approved' && (
<>
<Divider>{t('advanceVerification.executionOpinion')}</Divider>
<Form form={form} layout="vertical">
{fullDetail.settlement && fullDetail.settlement_amount && (
<div style={{ marginBottom: 16, padding: '12px', backgroundColor: '#f6ffed', border: '1px solid #b7eb8f', borderRadius: '4px' }}>
<p style={{ margin: 0, color: '#389e0d' }}>
{t('advanceVerification.settlementInfo', {
type: fullDetail.settlement_amount > 0 ? t('advanceVerification.refund') : t('advanceVerification.supplement'),
amount: formatAmount(Math.abs(fullDetail.settlement_amount), fullDetail.currency)
})}
</p>
</div>
)}
<Form.Item
name="remark"
label={t('advanceVerification.executionNote')}
>
<TextArea rows={3} placeholder={t('advanceVerification.executionNotePlaceholder')} />
</Form.Item>
</Form>
</>
)}
</>
)}
</Modal>
</Card>
</div>
);
};
File diff suppressed because it is too large Load Diff
+168 -156
View File
@@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react';
import { Card, Table, Tag, Button, Space, Modal, Form, Input, message, Tabs, Badge, Descriptions, Divider, List, Upload } from 'antd';
import { CheckOutlined, CloseOutlined, EyeOutlined, EditOutlined, UndoOutlined, FileImageOutlined, DeleteOutlined } from '@ant-design/icons';
import { useLanguageStore } from '../../store/languageStore';
const { TextArea } = Input;
@@ -25,7 +26,17 @@ const COMPANY_EXPENSE_CATEGORIES = [
{ value: 'other', label: '其他支出' }
];
// Type name to i18n key mapping
const typeKeyMap: Record<string, string> = {
'预支申请': 'approval.advanceApply',
'报销申请': 'approval.reimburseApply',
'付款申请': 'approval.paymentApply',
'核销申请': 'approval.verificationApply',
'采购申请': 'approval.purchaseApply',
};
const ApprovalManagement: React.FC = () => {
const { t, currentLanguage } = useLanguageStore();
const [loading, setLoading] = useState(false);
const [detailModalVisible, setDetailModalVisible] = useState(false);
const [editModalVisible, setEditModalVisible] = useState(false);
@@ -79,56 +90,56 @@ const ApprovalManagement: React.FC = () => {
switch (type) {
case 'advances':
typeText = '预支申请';
typeText = t('approval.advanceApply');
code = item.advance_code;
date = item.advance_date;
// 根据预支申请的状态设置操作文本
switch (item.status) {
case 'pending':
action = '待审批';
action = t('approval.pendingApproval');
break;
case 'approved':
action = '通过';
action = t('approval.approved');
break;
case 'rejected':
action = '退回';
action = t('approval.rejected');
break;
case 'executed':
action = '已执行';
action = t('approval.executed');
break;
case 'partial_verification':
action = '部分核销';
action = t('approval.partialVerified');
break;
case 'completed':
action = '已完结';
action = t('approval.completed');
break;
default:
action = item.status;
}
break;
case 'reimbursements':
typeText = '报销申请';
typeText = t('approval.reimburseApply');
code = item.reimbursement_code;
date = item.reimbursement_date;
action = item.status === 'approved' ? '通过' : '退回';
action = item.status === 'approved' ? t('approval.approved') : t('approval.rejected');
break;
case 'payment-requests':
typeText = '付款申请';
typeText = t('approval.paymentApply');
code = item.request_code;
date = item.payment_date;
action = item.status === 'approved' ? '通过' : '退回';
action = item.status === 'approved' ? t('approval.approved') : t('approval.rejected');
break;
case 'verifications':
typeText = '核销申请';
typeText = t('approval.verificationApply');
code = item.verification_code;
date = item.verification_date;
action = item.status === 'approved' ? '通过' : item.status === 'rejected' ? '退回' : '待编辑';
action = item.status === 'approved' ? t('approval.approved') : item.status === 'rejected' ? t('approval.rejected') : t('advance.pendingEdit');
break;
case 'purchase-requests':
typeText = '采购申请';
typeText = t('approval.purchaseApply');
code = item.request_code;
date = item.request_date;
action = item.status === 'approved' ? '通过' : item.status === 'pending_edit' ? '待编辑' : item.status;
action = item.status === 'approved' ? t('approval.approved') : item.status === 'pending_edit' ? t('advance.pendingEdit') : item.status;
break;
}
@@ -140,7 +151,7 @@ const ApprovalManagement: React.FC = () => {
amount: type === 'purchase-requests' ? item.total_amount : item.amount,
currency: item.currency,
action: action,
operator: '系统管理员', // 实际应该从数据库中获取
operator: t('common.systemAdmin'), // 实际应该从数据库中获取
remark: item.approval_remark || '',
timestamp: date
});
@@ -152,7 +163,7 @@ const ApprovalManagement: React.FC = () => {
setApprovalHistory(historyData);
} catch (error) {
console.error('获取审批历史记录失败:', error);
message.error('获取审批历史记录失败');
message.error(t('approval.getHistoryFailed'));
} finally {
setLoading(false);
}
@@ -207,7 +218,7 @@ const ApprovalManagement: React.FC = () => {
allPendingData.push({
key: `adv-${item.id}`,
id: item.id,
type: '预支申请',
type: t('approval.advanceApply'),
code: item.advance_code,
applicant: item.applicant,
amount: item.amount,
@@ -228,7 +239,7 @@ const ApprovalManagement: React.FC = () => {
allPendingData.push({
key: `reimb-${item.id}`,
id: item.id,
type: '报销申请',
type: t('approval.reimburseApply'),
code: item.reimbursement_code,
applicant: item.applicant,
amount: item.amount,
@@ -249,7 +260,7 @@ const ApprovalManagement: React.FC = () => {
allPendingData.push({
key: `pay-${item.id}`,
id: item.id,
type: '付款申请',
type: t('approval.paymentApply'),
code: item.request_code,
applicant: item.applicant,
amount: item.amount,
@@ -270,7 +281,7 @@ const ApprovalManagement: React.FC = () => {
allPendingData.push({
key: `ver-${item.id}`,
id: item.id,
type: '核销申请',
type: t('approval.verificationApply'),
code: item.verification_code,
applicant: item.applicant,
amount: item.amount,
@@ -291,13 +302,13 @@ const ApprovalManagement: React.FC = () => {
allPendingData.push({
key: `pur-${item.id}`,
id: item.id,
type: '采购申请',
type: t('approval.purchaseApply'),
code: item.request_code,
applicant: item.applicant,
amount: item.total_amount,
currency: item.currency,
date: item.request_date,
reason: item.brief_description || item.remark || '采购申请',
reason: item.brief_description || item.remark || t('approval.purchaseApply'),
status: item.status,
rawData: item
});
@@ -308,7 +319,7 @@ const ApprovalManagement: React.FC = () => {
setPendingData(allPendingData);
} catch (error) {
console.error('获取待审批数据失败:', error);
message.error('获取待审批数据失败');
message.error(t('approval.getPendingFailed'));
} finally {
setLoading(false);
}
@@ -323,16 +334,17 @@ const ApprovalManagement: React.FC = () => {
// 获取类型标签
const getTypeTag = (type: string) => {
const colors: Record<string, string> = { '预支申请': 'blue', '报销申请': 'green', '付款申请': 'orange', '核销申请': 'purple', '采购申请': 'cyan' };
return <Tag color={colors[type] || 'default'}>{type}</Tag>;
const key = typeKeyMap[type] || type;
return <Tag color={colors[type] || 'default'}>{t(key)}</Tag>;
};
// 获取状态标签
const getStatusTag = (status: string) => {
const statusMap: Record<string, { color: string; text: string }> = {
pending: { color: 'processing', text: '待审批' },
approved: { color: 'success', text: '已通过' },
rejected: { color: 'error', text: '已退回' },
withdrawn: { color: 'default', text: '已撤回' }
pending: { color: 'processing', text: t('approval.pendingApproval') },
approved: { color: 'success', text: t('approval.approved') },
rejected: { color: 'error', text: t('approval.rejected') },
withdrawn: { color: 'default', text: t('approval.withdrawn') }
};
const config = statusMap[status] || { color: 'default', text: status };
return <Tag color={config.color}>{config.text}</Tag>;
@@ -352,7 +364,7 @@ const ApprovalManagement: React.FC = () => {
form.resetFields();
// 获取完整详情
if (record.type === '采购申请') {
if (record.type === t('approval.purchaseApply')) {
try {
const response = await fetch(`/api/purchase-requests/${record.id}`);
const data = await response.json();
@@ -401,14 +413,14 @@ const ApprovalManagement: React.FC = () => {
if (result.success) {
// 从待审批列表中移除该申请
setPendingData(pendingData.filter(item => item.key !== selectedRecord.key));
message.success(`审批通过:${selectedRecord.code}`);
message.success(t('approval.approveSuccess', { code: selectedRecord.code }));
setDetailModalVisible(false);
} else {
message.error(result.message || '操作失败');
message.error(result.message || t('common.operationFailed'));
}
} catch (error) {
console.error('审批操作失败:', error);
message.error('操作失败');
message.error(t('common.operationFailed'));
}
};
@@ -443,27 +455,27 @@ const ApprovalManagement: React.FC = () => {
if (result.success) {
// 从待审批列表中移除该申请
setPendingData(pendingData.filter(item => item.key !== selectedRecord.key));
message.success(`已退回:${selectedRecord.code}`);
message.success(t('approval.rejectSuccess', { code: selectedRecord.code }));
setDetailModalVisible(false);
} else {
message.error(result.message || '操作失败');
message.error(result.message || t('common.operationFailed'));
}
} catch (error) {
console.error('审批操作失败:', error);
message.error('操作失败');
message.error(t('common.operationFailed'));
}
};
// 处理撤回申请
const handleWithdraw = (record: any) => {
Modal.confirm({
title: '撤回申请',
content: `确认撤回申请 ${record.code} 吗?`,
okText: '确认撤回',
cancelText: '取消',
title: t('approval.withdrawConfirm'),
content: t('approval.withdrawConfirmMsg', { code: record.code }),
okText: t('approval.withdrawConfirmBtn'),
cancelText: t('common.cancel'),
onOk: () => {
setPendingData(pendingData.filter(item => item.key !== record.key));
message.success('申请已撤回');
message.success(t('approval.withdrawSuccess'));
}
});
};
@@ -478,7 +490,7 @@ const ApprovalManagement: React.FC = () => {
// 处理编辑提交
const handleEditSubmit = () => {
editForm.validateFields().then(values => {
message.success('修改成功,已重新提交审批');
message.success(t('approval.editResubmit'));
setEditModalVisible(false);
});
};
@@ -544,22 +556,22 @@ const ApprovalManagement: React.FC = () => {
// 特殊分类映射
const specialCategories: Record<string, string> = {
// Project expense categories
accommodation: '住宿',
food: '餐饮',
fuel: '加油',
materials: '零散材料',
customer_relations: '客户关系',
subcontract_relations: '分包关系',
edl_relations: 'EDL关系',
extra_construction: '额外施工',
accommodation: t('approval.accommodation'),
food: t('approval.catering'),
fuel: t('approval.fuel'),
materials: t('approval.scatteredMaterial'),
customer_relations: t('approval.customerRelation'),
subcontract_relations: t('approval.subcontractorRelation'),
edl_relations: t('approval.EDLRelation'),
extra_construction: t('approval.extraConstruction'),
// Company expense categories
general_operations: '通用运营(房租/耗材)',
transportation: '交通通勤',
business_expansion: '业扩营销',
power_system_relations: '电力系统关系',
employee_benefits: '员工福利',
express_logistics: '快递物流',
other: '其他'
general_operations: t('approval.generalOperation'),
transportation: t('approval.commute'),
business_expansion: t('approval.marketing'),
power_system_relations: t('approval.powerSystem'),
employee_benefits: t('approval.employeeBenefit'),
express_logistics: t('approval.expressLogistics'),
other: t('approval.other')
};
// 先检查特殊分类
if (specialCategories[category]) return specialCategories[category];
@@ -597,17 +609,17 @@ const ApprovalManagement: React.FC = () => {
<List.Item>
<div style={{ width: '100%' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
<span><strong> {index + 1}:</strong> {item.description || getCategoryName(item.category) || '-'}</span>
<span><strong>{t('approval.detailLabel', { index: index + 1 })}</strong> {item.description || getCategoryName(item.category) || '-'}</span>
<span style={{ fontWeight: 'bold', color: '#1890ff' }}>{formatAmount(item.amount, item.currency)}</span>
</div>
{item.category && (
<div style={{ marginBottom: 8, fontSize: 13, color: '#666' }}>
<strong></strong>{getCategoryName(item.category)}
<strong>{t('approval.categoryLabel')}</strong>{getCategoryName(item.category)}
</div>
)}
{item.attachments && (
<div style={{ marginTop: 8 }}>
<span style={{ color: '#666', fontSize: 12 }}></span>
<span style={{ color: '#666', fontSize: 12 }}>{t('approval.detailAttachment')}</span>
{renderAttachments(item.attachments)}
</div>
)}
@@ -620,18 +632,18 @@ const ApprovalManagement: React.FC = () => {
// 待审批列
const pendingColumns = [
{ title: '事由', dataIndex: 'reason', key: 'reason', ellipsis: true, render: (v: string, r: any) => <a onClick={() => handleViewDetail(r)}>{v}</a> },
{ title: '类型', dataIndex: 'type', key: 'type', width: 100, render: (v: string) => getTypeTag(v) },
{ title: '申请人', dataIndex: 'applicant', key: 'applicant', width: 80 },
{ title: '金额', dataIndex: 'amount', key: 'amount', width: 140, render: (v: number, r: any) => formatAmount(v, r.currency) },
{ title: '申请日期', dataIndex: 'date', key: 'date', width: 100 },
{ title: '状态', dataIndex: 'status', key: 'status', width: 100, render: (v: string) => getStatusTag(v) },
{ title: '编号', dataIndex: 'code', key: 'code', width: 140 },
{ title: t('approval.subject'), dataIndex: 'reason', key: 'reason', ellipsis: true, render: (v: string, r: any) => <a onClick={() => handleViewDetail(r)}>{v}</a> },
{ title: t('approval.type'), dataIndex: 'type', key: 'type', width: 100, render: (v: string) => getTypeTag(v) },
{ title: t('approval.applicant'), dataIndex: 'applicant', key: 'applicant', width: 80 },
{ title: t('approval.amount'), dataIndex: 'amount', key: 'amount', width: 140, render: (v: number, r: any) => formatAmount(v, r.currency) },
{ title: t('approval.applicationDate'), dataIndex: 'date', key: 'date', width: 100 },
{ title: t('approval.status'), dataIndex: 'status', key: 'status', width: 100, render: (v: string) => getStatusTag(v) },
{ title: t('approval.code'), dataIndex: 'code', key: 'code', width: 140 },
{
title: '操作', key: 'action', width: 100,
title: t('approval.action'), key: 'action', width: 100,
render: (_: any, record: any) => (
<Space>
<Button size="small" type="primary" icon={<EyeOutlined />} onClick={() => handleViewDetail(record)}></Button>
<Button size="small" type="primary" icon={<EyeOutlined />} onClick={() => handleViewDetail(record)}>{t('approval.approve')}</Button>
</Space>
)
}
@@ -641,49 +653,49 @@ const ApprovalManagement: React.FC = () => {
// 审批记录列
const historyColumns = [
{ title: '时间', dataIndex: 'timestamp', key: 'timestamp', width: 140 },
{ title: '操作', dataIndex: 'action', key: 'action', width: 100 },
{ title: '申请编号', dataIndex: 'applyCode', key: 'applyCode', width: 140 },
{ title: '类型', dataIndex: 'applyType', key: 'applyType', width: 100, render: (v: string) => getTypeTag(v) },
{ title: '申请人', dataIndex: 'applicant', key: 'applicant', width: 80 },
{ title: '金额', dataIndex: 'amount', key: 'amount', width: 140, render: (v: number, r: any) => formatAmount(v, r.currency) },
{ title: '操作人', dataIndex: 'operator', key: 'operator', width: 100 },
{ title: '备注/原因', dataIndex: 'remark', key: 'remark', ellipsis: true }
{ title: t('approval.time'), dataIndex: 'timestamp', key: 'timestamp', width: 140 },
{ title: t('approval.operation'), dataIndex: 'action', key: 'action', width: 100 },
{ title: t('approval.applicationCode'), dataIndex: 'applyCode', key: 'applyCode', width: 140 },
{ title: t('approval.type'), dataIndex: 'applyType', key: 'applyType', width: 100, render: (v: string) => getTypeTag(v) },
{ title: t('approval.applicant'), dataIndex: 'applicant', key: 'applicant', width: 80 },
{ title: t('approval.amount'), dataIndex: 'amount', key: 'amount', width: 140, render: (v: number, r: any) => formatAmount(v, r.currency) },
{ title: t('approval.operator'), dataIndex: 'operator', key: 'operator', width: 100 },
{ title: t('approval.note'), dataIndex: 'remark', key: 'remark', ellipsis: true }
];
const tabItems = [
{ key: 'pending', label: <span> <Badge count={pendingData.length} style={{ marginLeft: 8 }} /></span>, children: <Table columns={pendingColumns} dataSource={pendingData} rowKey="key" loading={loading} pagination={{ pageSize: 10 }} scroll={{ x: 1200 }} /> },
{ key: 'history', label: <span> <Badge count={approvalHistory.length} style={{ marginLeft: 8 }} /></span>, children: <Table columns={historyColumns} dataSource={approvalHistory} rowKey="id" loading={loading} pagination={{ pageSize: 10 }} scroll={{ x: 1400 }} /> },
{ key: 'pending', label: <span>{t('approval.pendingTab')} <Badge count={pendingData.length} style={{ marginLeft: 8 }} /></span>, children: <Table columns={pendingColumns} dataSource={pendingData} rowKey="key" loading={loading} pagination={{ pageSize: 10 }} scroll={{ x: 1200 }} /> },
{ key: 'history', label: <span>{t('approval.historyTab')} <Badge count={approvalHistory.length} style={{ marginLeft: 8 }} /></span>, children: <Table columns={historyColumns} dataSource={approvalHistory} rowKey="id" loading={loading} pagination={{ pageSize: 10 }} scroll={{ x: 1400 }} /> },
];
return (
<div style={{ padding: 24 }}>
<div style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<h2></h2>
<h2>{t('approval.title')}</h2>
<Button type="primary" onClick={fetchPendingData} loading={loading}>
{t('approval.refresh')}
</Button>
</div>
<p style={{ color: '#888', marginBottom: 0 }}></p>
<p style={{ color: '#888', marginBottom: 0 }}>{t('approval.description')}</p>
</div>
<Card><Tabs items={tabItems} /></Card>
{/* 详情模态框 */}
<Modal
title={`${selectedRecord?.type}详情:${selectedRecord?.code}`}
title={t('approval.detailTitle', { type: selectedRecord?.type || '', code: selectedRecord?.code || '' })}
open={detailModalVisible}
onCancel={() => setDetailModalVisible(false)}
width={900}
footer={
selectedRecord?.status === 'pending' ? (
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<Button onClick={() => setDetailModalVisible(false)}></Button>
<Button danger icon={<CloseOutlined />} onClick={handleReject}>退</Button>
<Button type="primary" icon={<CheckOutlined />} onClick={handleApprove}></Button>
<Button onClick={() => setDetailModalVisible(false)}>{t('common.cancel')}</Button>
<Button danger icon={<CloseOutlined />} onClick={handleReject}>{t('approval.reject')}</Button>
<Button type="primary" icon={<CheckOutlined />} onClick={handleApprove}>{t('approval.pass')}</Button>
</div>
) : (
<Button onClick={() => setDetailModalVisible(false)}></Button>
<Button onClick={() => setDetailModalVisible(false)}>{t('approval.close')}</Button>
)
}
>
@@ -691,37 +703,37 @@ const ApprovalManagement: React.FC = () => {
<>
{/* 基本信息 */}
<Descriptions bordered column={2} size="small">
<Descriptions.Item label="申请类型">{getTypeTag(selectedRecord.type)}</Descriptions.Item>
<Descriptions.Item label="申请编号">{selectedRecord.code}</Descriptions.Item>
<Descriptions.Item label="申请人">{selectedRecord.applicant}</Descriptions.Item>
<Descriptions.Item label="申请日期">{selectedRecord.date}</Descriptions.Item>
<Descriptions.Item label="金额">
<Descriptions.Item label={t('approval.applicationType')}>{getTypeTag(selectedRecord.type)}</Descriptions.Item>
<Descriptions.Item label={t('approval.applicationCode')}>{selectedRecord.code}</Descriptions.Item>
<Descriptions.Item label={t('approval.applicant')}>{selectedRecord.applicant}</Descriptions.Item>
<Descriptions.Item label={t('approval.applicationDate')}>{selectedRecord.date}</Descriptions.Item>
<Descriptions.Item label={t('approval.amount')}>
{formatAmount(selectedRecord.amount, selectedRecord.currency)}
{selectedRecord.currency !== 'CNY' && fullDetail.amount_cny > 0 && (
<span style={{ color: '#999', marginLeft: 8 }}> ¥{fullDetail.amount_cny.toLocaleString('zh-CN', {minimumFractionDigits: 2})}</span>
)}
</Descriptions.Item>
<Descriptions.Item label="状态">{getStatusTag(selectedRecord.status)}</Descriptions.Item>
<Descriptions.Item label="事由" span={2}>{selectedRecord.reason}</Descriptions.Item>
<Descriptions.Item label={t('approval.status')}>{getStatusTag(selectedRecord.status)}</Descriptions.Item>
<Descriptions.Item label={t('approval.subject')} span={2}>{selectedRecord.reason}</Descriptions.Item>
{/* 付款申请特有字段 */}
{selectedRecord.type === '付款申请' && (
{selectedRecord.type === t('approval.paymentApply') && (
<>
<Descriptions.Item label="收款单位类型">
{fullDetail.payee_type === 'subcontractor' ? '分包商' :
fullDetail.payee_type === 'supplier' ? '供应商' :
fullDetail.payee_type === 'customer' ? '客户' : '其他'}
<Descriptions.Item label={t('approval.payeeType')}>
{fullDetail.payee_type === 'subcontractor' ? t('approval.counterpartySubcontractor') :
fullDetail.payee_type === 'supplier' ? t('approval.counterpartySupplier') :
fullDetail.payee_type === 'customer' ? t('approval.counterpartyCustomer') : t('approval.counterpartyOther')}
</Descriptions.Item>
<Descriptions.Item label="收款方">{fullDetail.payee || '-'}</Descriptions.Item>
<Descriptions.Item label="银行名称">{fullDetail.bank_name || '-'}</Descriptions.Item>
<Descriptions.Item label="银行账号">{fullDetail.bank_account || '-'}</Descriptions.Item>
<Descriptions.Item label="支出类型">
{fullDetail.expense_type === 'company' ? '公司支出' : '项目支出'}
<Descriptions.Item label={t('approval.payee')}>{fullDetail.payee || '-'}</Descriptions.Item>
<Descriptions.Item label={t('approval.bankName')}>{fullDetail.bank_name || '-'}</Descriptions.Item>
<Descriptions.Item label={t('approval.bankAccount')}>{fullDetail.bank_account || '-'}</Descriptions.Item>
<Descriptions.Item label={t('approval.expenseType')}>
{fullDetail.expense_type === 'company' ? t('approval.companyExpense') : t('approval.projectExpense')}
</Descriptions.Item>
{fullDetail.expense_type === 'project' && fullDetail.project_id && (
<Descriptions.Item label="关联项目">{getProjectName(fullDetail.project_id)}</Descriptions.Item>
<Descriptions.Item label={t('approval.relatedProject')}>{getProjectName(fullDetail.project_id)}</Descriptions.Item>
)}
<Descriptions.Item label="支出分类">
<Descriptions.Item label={t('approval.expenseCategory')}>
{fullDetail.expense_type === 'project'
? (PROJECT_EXPENSE_CATEGORIES.find(c => c.value === fullDetail.expense_category)?.label || fullDetail.expense_category)
: (COMPANY_EXPENSE_CATEGORIES.find(c => c.value === fullDetail.expense_category)?.label || fullDetail.expense_category)
@@ -731,66 +743,66 @@ const ApprovalManagement: React.FC = () => {
)}
{/* 报销申请特有字段 */}
{selectedRecord.type === '报销申请' && fullDetail.expense_type && (
{selectedRecord.type === t('approval.reimburseApply') && fullDetail.expense_type && (
<>
<Descriptions.Item label="支出类型">
{fullDetail.expense_type === 'company' ? '公司支出' : '项目支出'}
<Descriptions.Item label={t('approval.expenseType')}>
{fullDetail.expense_type === 'company' ? t('approval.companyExpense') : t('approval.projectExpense')}
</Descriptions.Item>
{fullDetail.project_id && (
<Descriptions.Item label="关联项目">{getProjectName(fullDetail.project_id)}</Descriptions.Item>
<Descriptions.Item label={t('approval.relatedProject')}>{getProjectName(fullDetail.project_id)}</Descriptions.Item>
)}
</>
)}
{/* 核销申请特有字段 */}
{selectedRecord.type === '核销申请' && fullDetail.advance_code && (
{selectedRecord.type === t('approval.verificationApply') && fullDetail.advance_code && (
<>
<Descriptions.Item label="关联预支单">{fullDetail.advance_code}</Descriptions.Item>
<Descriptions.Item label="预支金额">{formatAmount(fullDetail.advance_amount, fullDetail.currency)}</Descriptions.Item>
<Descriptions.Item label="结算核销">
<Descriptions.Item label={t('approval.relatedAdvance')}>{fullDetail.advance_code}</Descriptions.Item>
<Descriptions.Item label={t('approval.advanceAmount')}>{formatAmount(fullDetail.advance_amount, fullDetail.currency)}</Descriptions.Item>
<Descriptions.Item label={t('approval.settlement')}>
<span style={{ fontWeight: 'bold', color: fullDetail.settlement ? '#52c41a' : '#fa8c16' }}>
{fullDetail.settlement ? '是' : '否'}
{fullDetail.settlement ? t('common.is') : t('common.no')}
</span>
</Descriptions.Item>
<Descriptions.Item label="已核销金额">{formatAmount(fullDetail.total_reimbursed || 0, fullDetail.currency)}</Descriptions.Item>
<Descriptions.Item label="剩余核销金额">{formatAmount((fullDetail.advance_amount || 0) - (fullDetail.total_reimbursed || 0), fullDetail.currency)}</Descriptions.Item>
<Descriptions.Item label={t('approval.verifiedAmount')}>{formatAmount(fullDetail.total_reimbursed || 0, fullDetail.currency)}</Descriptions.Item>
<Descriptions.Item label={t('approval.remainingAmount')}>{formatAmount((fullDetail.advance_amount || 0) - (fullDetail.total_reimbursed || 0), fullDetail.currency)}</Descriptions.Item>
{fullDetail.settlement && fullDetail.settlement_amount && (
<Descriptions.Item label="核销结算金额" span={2}>
{fullDetail.settlement_amount > 0 ? `退款 ${formatAmount(fullDetail.settlement_amount, fullDetail.currency)}` : `补款 ${formatAmount(Math.abs(fullDetail.settlement_amount), fullDetail.currency)}`}
<Descriptions.Item label={t('approval.settlementAmount')} span={2}>
{fullDetail.settlement_amount > 0 ? `${t('approval.refundText')}${formatAmount(fullDetail.settlement_amount, fullDetail.currency)}` : `${t('approval.supplementText')}${formatAmount(Math.abs(fullDetail.settlement_amount), fullDetail.currency)}`}
</Descriptions.Item>
)}
</>
)}
{/* 采购申请特有字段 */}
{selectedRecord.type === '采购申请' && (
{selectedRecord.type === t('approval.purchaseApply') && (
<>
<Descriptions.Item label="采购类型">
{fullDetail.purchase_type === 'project' ? '项目采购' : '库存采购'}
<Descriptions.Item label={t('approval.purchaseType')}>
{fullDetail.purchase_type === 'project' ? t('approval.projectPurchase') : t('approval.stockPurchase')}
</Descriptions.Item>
{fullDetail.purchase_type === 'project' && fullDetail.project_id && (
<Descriptions.Item label="关联项目">{getProjectName(fullDetail.project_id)}</Descriptions.Item>
<Descriptions.Item label={t('approval.relatedProject')}>{getProjectName(fullDetail.project_id)}</Descriptions.Item>
)}
<Descriptions.Item label="供应商">{fullDetail.supplier_name || '-'}</Descriptions.Item>
<Descriptions.Item label="支出分类">
{fullDetail.expense_category === 'material' ? '材料' :
fullDetail.expense_category === 'equipment' ? '设备' :
fullDetail.expense_category === 'pole' ? '电杆' : '其他'}
<Descriptions.Item label={t('approval.supplier')}>{fullDetail.supplier_name || '-'}</Descriptions.Item>
<Descriptions.Item label={t('approval.expenseCategory')}>
{fullDetail.expense_category === 'material' ? t('approval.material') :
fullDetail.expense_category === 'equipment' ? t('approval.equipment') :
fullDetail.expense_category === 'pole' ? t('approval.pole') : t('approval.other')}
</Descriptions.Item>
<Descriptions.Item label="币种">{fullDetail.currency}</Descriptions.Item>
<Descriptions.Item label="申请日期">{fullDetail.request_date}</Descriptions.Item>
<Descriptions.Item label="事由" span={2}>{fullDetail.brief_description || '-'}</Descriptions.Item>
<Descriptions.Item label={t('approval.currency')}>{fullDetail.currency}</Descriptions.Item>
<Descriptions.Item label={t('approval.applicationDate')}>{fullDetail.request_date}</Descriptions.Item>
<Descriptions.Item label={t('approval.subject')} span={2}>{fullDetail.brief_description || '-'}</Descriptions.Item>
{fullDetail.remark && (
<Descriptions.Item label="备注" span={2}>{fullDetail.remark}</Descriptions.Item>
<Descriptions.Item label={t('approval.remark')} span={2}>{fullDetail.remark}</Descriptions.Item>
)}
</>
)}
</Descriptions>
{/* 采购申请商品明细 */}
{selectedRecord.type === '采购申请' && fullDetail.items && fullDetail.items.length > 0 && (
{selectedRecord.type === t('approval.purchaseApply') && fullDetail.items && fullDetail.items.length > 0 && (
<>
<Divider></Divider>
<Divider>{t('approval.productDetail')}</Divider>
<List
size="small"
bordered
@@ -805,8 +817,8 @@ const ApprovalManagement: React.FC = () => {
</span>
</div>
<div style={{ fontSize: 13, color: '#666' }}>
: {item.specification || '-'} | : {item.unit || '-'} |
: {item.quantity} | : {fullDetail.currency} {item.unit_price?.toLocaleString('zh-CN', {minimumFractionDigits: 2})}
{t('approval.specLabel')}{item.specification || '-'} | {t('approval.unitLabel')}{item.unit || '-'} |
{t('approval.qtyLabel')}{item.quantity} | {t('approval.priceLabel')}{fullDetail.currency} {item.unit_price?.toLocaleString('zh-CN', {minimumFractionDigits: 2})}
</div>
</div>
</List.Item>
@@ -818,7 +830,7 @@ const ApprovalManagement: React.FC = () => {
{/* 明细清单 */}
{fullDetail.detail_items && fullDetail.detail_items.length > 0 && (
<>
<Divider></Divider>
<Divider>{t('approval.detailList')}</Divider>
{renderDetailItems(fullDetail.detail_items)}
</>
)}
@@ -826,7 +838,7 @@ const ApprovalManagement: React.FC = () => {
{/* 凭证附件或退款凭证 */}
{fullDetail.attachments && fullDetail.attachments.length > 0 && (
<>
<Divider>{fullDetail.settlement && fullDetail.settlement_amount > 0 ? '退款凭证' : '凭证附件'}</Divider>
<Divider>{fullDetail.settlement && fullDetail.settlement_amount > 0 ? t('approval.refundProof') : t('approval.proofAttachment')}</Divider>
{renderAttachments(fullDetail.attachments)}
</>
)}
@@ -834,13 +846,13 @@ const ApprovalManagement: React.FC = () => {
{/* 审批备注表单 */}
{selectedRecord.status === 'pending' && (
<>
<Divider></Divider>
<Divider>{t('approval.approvalOpinion')}</Divider>
<Form form={form} layout="vertical">
<Form.Item name="remark" label="审批备注">
<TextArea rows={3} placeholder="可选:填写审批备注" />
<Form.Item name="remark" label={t('approval.approvalNote')}>
<TextArea rows={3} placeholder={t('approval.approvalNotePlaceholder')} />
</Form.Item>
<Form.Item name="rejectReason" label="退回原因" style={{ display: 'none' }}>
<TextArea rows={3} placeholder="请填写退回原因" />
<Form.Item name="rejectReason" label={t('approval.rejectReason')} style={{ display: 'none' }}>
<TextArea rows={3} placeholder={t('approval.rejectReasonPlaceholder')} />
</Form.Item>
</Form>
</>
@@ -849,35 +861,35 @@ const ApprovalManagement: React.FC = () => {
)}
</Modal>
<Modal title={`编辑申请:${selectedRecord?.code}`} open={editModalVisible} onCancel={() => setEditModalVisible(false)} onOk={handleEditSubmit} width={600}>
<Modal title={t('approval.editTitle', { code: selectedRecord?.code || '' })} open={editModalVisible} onCancel={() => setEditModalVisible(false)} onOk={handleEditSubmit} width={600}>
<Form form={editForm} layout="vertical">
<Form.Item label="申请类型"><Input value={selectedRecord?.type} disabled /></Form.Item>
<Form.Item label="申请人"><Input value={selectedRecord?.applicant} disabled /></Form.Item>
<Form.Item name="amount" label="金额" rules={[{ required: true }]}><Input type="number" style={{ width: '100%' }} /></Form.Item>
<Form.Item name="reason" label="事由" rules={[{ required: true }]}><TextArea rows={3} /></Form.Item>
<Form.Item label={t('approval.applicationType')}><Input value={selectedRecord?.type} disabled /></Form.Item>
<Form.Item label={t('approval.applicant')}><Input value={selectedRecord?.applicant} disabled /></Form.Item>
<Form.Item name="amount" label={t('approval.amount')} rules={[{ required: true }]}><Input type="number" style={{ width: '100%' }} /></Form.Item>
<Form.Item name="reason" label={t('approval.subject')} rules={[{ required: true }]}><TextArea rows={3} /></Form.Item>
</Form>
</Modal>
<Modal title={`${selectedRecord?.advance_code ? '预支申请' : '报销申请'}详情:${selectedRecord?.advance_code || selectedRecord?.reimbursement_code}`} open={historyModalVisible} onCancel={() => setHistoryModalVisible(false)} footer={null} width={800}>
<Modal title={`${selectedRecord?.advance_code ? t('approval.advanceApply') : t('approval.reimburseApply')}${t('approval.detailTitle', { type: '', code: selectedRecord?.advance_code || selectedRecord?.reimbursement_code || '' })}`} open={historyModalVisible} onCancel={() => setHistoryModalVisible(false)} footer={null} width={800}>
{selectedRecord && (
<>
<Descriptions bordered column={2} size="small">
<Descriptions.Item label="申请编号">{selectedRecord.advance_code || selectedRecord.reimbursement_code}</Descriptions.Item>
<Descriptions.Item label="状态">{getStatusTag(selectedRecord.status)}</Descriptions.Item>
<Descriptions.Item label="申请人">{selectedRecord.applicant}</Descriptions.Item>
<Descriptions.Item label="申请日期">{selectedRecord.advance_date || selectedRecord.reimbursement_date}</Descriptions.Item>
<Descriptions.Item label="金额">
<Descriptions.Item label={t('approval.applicationCode')}>{selectedRecord.advance_code || selectedRecord.reimbursement_code}</Descriptions.Item>
<Descriptions.Item label={t('approval.status')}>{getStatusTag(selectedRecord.status)}</Descriptions.Item>
<Descriptions.Item label={t('approval.applicant')}>{selectedRecord.applicant}</Descriptions.Item>
<Descriptions.Item label={t('approval.applicationDate')}>{selectedRecord.advance_date || selectedRecord.reimbursement_date}</Descriptions.Item>
<Descriptions.Item label={t('approval.amount')}>
{formatAmount(selectedRecord.amount, selectedRecord.currency)}
{selectedRecord.currency !== 'CNY' && selectedRecord.amount_cny && (
<span style={{ color: '#999', marginLeft: 8 }}> ¥{selectedRecord.amount_cny.toLocaleString('zh-CN', {minimumFractionDigits: 2})}</span>
)}
</Descriptions.Item>
<Descriptions.Item label="事由" span={2}>{selectedRecord.reason}</Descriptions.Item>
<Descriptions.Item label={t('approval.subject')} span={2}>{selectedRecord.reason}</Descriptions.Item>
</Descriptions>
{Array.isArray(selectedRecord.attachments) && selectedRecord.attachments.length > 0 && (
<>
<Divider></Divider>
<Divider>{t('approval.proofAttachment')}</Divider>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{selectedRecord.attachments.map((url: string, index: number) => (
<img key={index} src={url} width={100} height={100} style={{ objectFit: 'cover', borderRadius: 4, border: '1px solid #f0f0f0' }} />
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
import { Card, Table, Tag, Button, Space, Modal, Form, Input, Select, DatePicker, message, Tabs, Badge, Descriptions, Divider, List, Upload } from 'antd';
import { CheckOutlined, CloseOutlined, EyeOutlined, DollarOutlined, EditOutlined, UndoOutlined, ClockCircleOutlined, FileImageOutlined, UploadOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { useLanguageStore } from '../../store/languageStore';
const { TextArea } = Input;
@@ -26,6 +27,15 @@ const COMPANY_EXPENSE_CATEGORIES = [
{ value: 'other', label: '其他支出' }
];
// Type name to i18n key mapping
const typeKeyMap: Record<string, string> = {
'预支申请': 'execution.advanceApply',
'报销申请': 'execution.reimburseApply',
'付款申请': 'execution.paymentApply',
'核销申请': 'execution.verificationApply',
'采购申请': 'execution.purchaseApply',
};
// 执行记录类型
interface ExecutionRecord {
id: string;
@@ -45,6 +55,7 @@ interface ExecutionRecord {
}
const ExecutionManagement: React.FC = () => {
const { t, currentLanguage } = useLanguageStore();
const [loading, setLoading] = useState(false);
const [detailModalVisible, setDetailModalVisible] = useState(false);
const [editModalVisible, setEditModalVisible] = useState(false);
@@ -85,14 +96,14 @@ const ExecutionManagement: React.FC = () => {
rawData: item
})));
} else {
message.error('获取待执行数据失败:数据格式错误');
message.error(t('execution.getPendingFailedFormat'));
}
} else {
message.error('获取待执行数据失败:' + response.statusText);
message.error(t('execution.getPendingFailed') + response.statusText);
}
} catch (error) {
console.error('获取待执行数据错误:', error);
message.error('网络错误,获取待执行数据失败');
message.error(t('execution.getPendingNetworkError'));
} finally {
setLoading(false);
}
@@ -135,14 +146,14 @@ const ExecutionManagement: React.FC = () => {
rawData: item
})));
} else {
message.error('获取已执行数据失败:数据格式错误');
message.error(t('execution.getExecutedFailedFormat'));
}
} else {
message.error('获取已执行数据失败:' + response.statusText);
message.error(t('execution.getExecutedFailed') + response.statusText);
}
} catch (error) {
console.error('获取已执行数据错误:', error);
message.error('网络错误,获取已执行数据失败');
message.error(t('execution.getExecutedNetworkError'));
} finally {
setLoading(false);
}
@@ -169,15 +180,16 @@ const ExecutionManagement: React.FC = () => {
const getTypeTag = (type: string) => {
const colors: Record<string, string> = { '预支申请': 'blue', '报销申请': 'green', '付款申请': 'orange', '核销申请': 'purple', '采购申请': 'cyan' };
return <Tag color={colors[type] || 'default'}>{type}</Tag>;
const key = typeKeyMap[type] || type;
return <Tag color={colors[type] || 'default'}>{t(key)}</Tag>;
};
const getStatusTag = (status: string) => {
const statusMap: Record<string, { color: string; text: string }> = {
pending: { color: 'processing', text: '待执行' },
executed: { color: 'success', text: '已执行' },
rejected: { color: 'error', text: '已退回' },
approved: { color: 'success', text: '已批准' },
pending: { color: 'processing', text: t('execution.pendingExecution') },
executed: { color: 'success', text: t('execution.executed') },
rejected: { color: 'error', text: t('execution.rejected') },
approved: { color: 'success', text: t('execution.approved') },
};
const config = statusMap[status] || { color: 'default', text: status };
return <Tag color={config.color}>{config.text}</Tag>;
@@ -201,7 +213,7 @@ const ExecutionManagement: React.FC = () => {
setIsRejecting(false);
// 获取完整详情
if (record.type === '采购申请') {
if (record.type === t('execution.purchaseApply')) {
try {
const response = await fetch(`/api/purchase-requests/${record.id}`);
const data = await response.json();
@@ -225,7 +237,7 @@ const ExecutionManagement: React.FC = () => {
const handleExecute = async () => {
try {
// 检查是否为核销申请
const isVerification = selectedRecord.type === '核销申请';
const isVerification = selectedRecord.type === t('execution.verificationApply');
// 检查是否为退款类型的核销申请
const isRefundVerification = isVerification && fullDetail.settlement && fullDetail.settlement_amount > 0;
// 检查是否为非结算核销
@@ -271,10 +283,10 @@ const ExecutionManagement: React.FC = () => {
}
};
fetchExecutedData();
message.success(`执行成功:${selectedRecord.code}`);
message.success(t('execution.executeSuccess', { code: selectedRecord.code }));
setDetailModalVisible(false);
} else {
message.error('执行操作失败,请重试');
message.error(t('execution.executeFailed'));
}
} else {
// 其他类型的申请需要验证表单
@@ -282,7 +294,7 @@ const ExecutionManagement: React.FC = () => {
// 检查是否需要上传付款凭证
if (!isRefundVerification && (!voucherFiles || voucherFiles.length === 0)) {
message.error('请上传付款凭证');
message.error(t('execution.proofRequired'));
return;
}
@@ -303,7 +315,7 @@ const ExecutionManagement: React.FC = () => {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
apply_id: selectedRecord.id,
apply_type: selectedRecord.type === '预支申请' ? 'advance' : selectedRecord.type === '报销申请' ? 'reimbursement' : selectedRecord.type === '付款申请' ? 'payment' : selectedRecord.type === '采购申请' ? 'purchase' : 'verification',
apply_type: selectedRecord.type === t('execution.advanceApply') ? 'advance' : selectedRecord.type === t('execution.reimburseApply') ? 'reimbursement' : selectedRecord.type === t('execution.paymentApply') ? 'payment' : selectedRecord.type === t('execution.purchaseApply') ? 'purchase' : 'verification',
action: 'execute',
execute_method: isRefundVerification ? 'refund' : values.execute_method,
voucher_files: voucherFileUrls,
@@ -332,15 +344,15 @@ const ExecutionManagement: React.FC = () => {
}
};
fetchExecutedData();
message.success(`执行成功:${selectedRecord.code}`);
message.success(t('execution.executeSuccess', { code: selectedRecord.code }));
setDetailModalVisible(false);
} else {
message.error('执行操作失败,请重试');
message.error(t('execution.executeFailed'));
}
}
} catch (error) {
console.error('执行操作失败:', error);
message.error('网络错误,操作失败');
message.error(t('common.networkError'));
} finally {
setLoading(false);
}
@@ -361,7 +373,7 @@ const ExecutionManagement: React.FC = () => {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
apply_id: selectedRecord.id,
apply_type: selectedRecord.type === '预支申请' ? 'advance' : selectedRecord.type === '报销申请' ? 'reimbursement' : selectedRecord.type === '付款申请' ? 'payment' : selectedRecord.type === '采购申请' ? 'purchase' : 'verification',
apply_type: selectedRecord.type === t('execution.advanceApply') ? 'advance' : selectedRecord.type === t('execution.reimburseApply') ? 'reimbursement' : selectedRecord.type === t('execution.paymentApply') ? 'payment' : selectedRecord.type === t('execution.purchaseApply') ? 'purchase' : 'verification',
action: 'reject',
reject_reason: values.rejectReason
})
@@ -369,14 +381,14 @@ const ExecutionManagement: React.FC = () => {
if (rejectResponse.ok) {
setPendingData(pendingData.filter(item => item.key !== selectedRecord.key));
message.success(`已退回:${selectedRecord.code},申请人可编辑后重新提交`);
message.success(t('execution.rejectSuccess', { code: selectedRecord.code }));
setDetailModalVisible(false);
} else {
message.error('退回操作失败,请重试');
message.error(t('execution.rejectFailed'));
}
} catch (error) {
console.error('退回操作失败:', error);
message.error('网络错误,操作失败');
message.error(t('common.networkError'));
} finally {
setLoading(false);
}
@@ -392,7 +404,7 @@ const ExecutionManagement: React.FC = () => {
const handleEditSubmit = () => {
editForm.validateFields().then(values => {
message.success('修改成功,已重新提交审批');
message.success(t('execution.editSuccess'));
setEditModalVisible(false);
});
};
@@ -448,22 +460,22 @@ const ExecutionManagement: React.FC = () => {
// 特殊分类映射
const specialCategories: Record<string, string> = {
// Project expense categories
accommodation: '住宿',
food: '餐饮',
fuel: '加油',
materials: '零散材料',
customer_relations: '客户关系',
subcontract_relations: '分包关系',
edl_relations: 'EDL关系',
extra_construction: '额外施工',
accommodation: t('execution.accommodation'),
food: t('execution.catering'),
fuel: t('execution.fuel'),
materials: t('execution.scatteredMaterial'),
customer_relations: t('execution.customerRelation'),
subcontract_relations: t('execution.subcontractorRelation'),
edl_relations: t('execution.EDLRelation'),
extra_construction: t('execution.extraConstruction'),
// Company expense categories
general_operations: '通用运营(房租/耗材)',
transportation: '交通通勤',
business_expansion: '业扩营销',
power_system_relations: '电力系统关系',
employee_benefits: '员工福利',
express_logistics: '快递物流',
other: '其他'
general_operations: t('execution.generalOperation'),
transportation: t('execution.commute'),
business_expansion: t('execution.marketing'),
power_system_relations: t('execution.powerSystem'),
employee_benefits: t('execution.employeeBenefit'),
express_logistics: t('execution.expressLogistics'),
other: t('execution.otherCategory')
};
// 先检查特殊分类
if (specialCategories[category]) return specialCategories[category];
@@ -501,17 +513,17 @@ const ExecutionManagement: React.FC = () => {
<List.Item>
<div style={{ width: '100%' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
<span><strong> {index + 1}:</strong> {item.description || getCategoryName(item.category) || '-'}</span>
<span><strong>{t('execution.detailLabel', { index: index + 1 })}</strong> {item.description || getCategoryName(item.category) || '-'}</span>
<span style={{ fontWeight: 'bold', color: '#1890ff' }}>{formatAmount(item.amount, item.currency)}</span>
</div>
{item.category && (
<div style={{ marginBottom: 8, fontSize: 13, color: '#666' }}>
<strong></strong>{getCategoryName(item.category)}
<strong>{t('execution.categoryLabel')}</strong>{getCategoryName(item.category)}
</div>
)}
{item.attachments && (
<div style={{ marginTop: 8 }}>
<span style={{ color: '#666', fontSize: 12 }}></span>
<span style={{ color: '#666', fontSize: 12 }}>{t('execution.detailAttachment')}</span>
{renderAttachments(item.attachments)}
</div>
)}
@@ -525,19 +537,19 @@ const ExecutionManagement: React.FC = () => {
const pendingColumns = [
{ title: '事由', dataIndex: 'reason', key: 'reason', ellipsis: true, render: (v: string, r: any) => <a onClick={() => handleViewDetail(r)}>{v}</a> },
{ title: '类型', dataIndex: 'type', key: 'type', width: 100, render: (v: string) => getTypeTag(v) },
{ title: '申请人', dataIndex: 'applicant', key: 'applicant', width: 80 },
{ title: '金额', dataIndex: 'amount', key: 'amount', width: 120, render: (v: number, r: any) => <span style={{ fontWeight: 'bold', color: '#1890ff' }}>{formatAmount(v, r.currency)}</span> },
{ title: '收款方', dataIndex: 'payee', key: 'payee', ellipsis: true, render: (v: string, r: any) => v || r.applicant },
{ title: '审批日期', dataIndex: 'approveDate', key: 'approveDate', width: 100 },
{ title: '编号', dataIndex: 'code', key: 'code', width: 140 },
{ title: t('execution.subject'), dataIndex: 'reason', key: 'reason', ellipsis: true, render: (v: string, r: any) => <a onClick={() => handleViewDetail(r)}>{v}</a> },
{ title: t('execution.type'), dataIndex: 'type', key: 'type', width: 100, render: (v: string) => getTypeTag(v) },
{ title: t('execution.applicant'), dataIndex: 'applicant', key: 'applicant', width: 80 },
{ title: t('execution.amount'), dataIndex: 'amount', key: 'amount', width: 120, render: (v: number, r: any) => <span style={{ fontWeight: 'bold', color: '#1890ff' }}>{formatAmount(v, r.currency)}</span> },
{ title: t('execution.payee'), dataIndex: 'payee', key: 'payee', ellipsis: true, render: (v: string, r: any) => v || r.applicant },
{ title: t('execution.approvalDate'), dataIndex: 'approveDate', key: 'approveDate', width: 100 },
{ title: t('execution.code'), dataIndex: 'code', key: 'code', width: 140 },
{
title: '操作', key: 'action', width: 200,
title: t('execution.action'), key: 'action', width: 200,
render: (_: any, record: any) => (
<Space wrap>
<Button size="small" type="primary" icon={<DollarOutlined />} onClick={() => handleViewDetail(record)}></Button>
<Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)}></Button>
<Button size="small" type="primary" icon={<DollarOutlined />} onClick={() => handleViewDetail(record)}>{t('execution.execute')}</Button>
<Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)}>{t('execution.edit')}</Button>
</Space>
)
}
@@ -583,26 +595,26 @@ const ExecutionManagement: React.FC = () => {
};
const executedColumns = [
{ title: '事由', dataIndex: 'reason', key: 'reason', ellipsis: true, render: (v: string, r: any) => <a onClick={() => handleViewDetail(r)}>{v || '-'}</a> },
{ title: '类型', dataIndex: 'type', key: 'type', width: 100, render: (v: string) => getTypeTag(v) },
{ title: '申请人', dataIndex: 'applicant', key: 'applicant', width: 80 },
{ title: '金额', dataIndex: 'amount', key: 'amount', width: 140, render: (v: number, r: any) => (
{ title: t('execution.subject'), dataIndex: 'reason', key: 'reason', ellipsis: true, render: (v: string, r: any) => <a onClick={() => handleViewDetail(r)}>{v || '-'}</a> },
{ title: t('execution.type'), dataIndex: 'type', key: 'type', width: 100, render: (v: string) => getTypeTag(v) },
{ title: t('execution.applicant'), dataIndex: 'applicant', key: 'applicant', width: 80 },
{ title: t('execution.amount'), dataIndex: 'amount', key: 'amount', width: 140, render: (v: number, r: any) => (
<>
<div>{formatAmount(v, r.currency)}</div>
{r.currency !== 'CNY' && r.amount_cny && <div style={{ color: '#999', fontSize: 12 }}> ¥{r.amount_cny.toLocaleString('zh-CN', {minimumFractionDigits: 2})}</div>}
</>
) },
{ title: '执行日期', dataIndex: 'executeDate', key: 'executeDate', width: 100, sorter: true, render: (v: string) => v || '-' },
{ title: '执行方式', dataIndex: 'executeMethod', key: 'executeMethod', width: 100, render: (v: string) => v || '-' },
{ title: '状态', dataIndex: 'status', key: 'status', width: 100, render: (v: string) => getStatusTag(v) },
{ title: '编号', dataIndex: 'code', key: 'code', width: 140 },
{ title: t('execution.executionDate'), dataIndex: 'executeDate', key: 'executeDate', width: 100, sorter: true, render: (v: string) => v || '-' },
{ title: t('execution.executionMethod'), dataIndex: 'executeMethod', key: 'executeMethod', width: 100, render: (v: string) => v || '-' },
{ title: t('execution.status'), dataIndex: 'status', key: 'status', width: 100, render: (v: string) => getStatusTag(v) },
{ title: t('execution.code'), dataIndex: 'code', key: 'code', width: 140 },
];
// 已执行列表的筛选和排序控件
const ExecutedListControls = () => (
<div style={{ marginBottom: 16, display: 'flex', gap: 16, flexWrap: 'wrap' }}>
<Input.Search
placeholder="搜索事由、编号或申请人"
placeholder={t('execution.searchPlaceholder')}
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
onSearch={(value) => setSearchKeyword(value)}
@@ -610,20 +622,20 @@ const ExecutionManagement: React.FC = () => {
allowClear
/>
<Select
placeholder="筛选类型"
placeholder={t('execution.filterType')}
value={filterType}
onChange={(value) => setFilterType(value)}
style={{ width: 150 }}
allowClear
>
<Select.Option value="预支申请"></Select.Option>
<Select.Option value="报销申请"></Select.Option>
<Select.Option value="付款申请"></Select.Option>
<Select.Option value="核销申请"></Select.Option>
<Select.Option value="采购申请"></Select.Option>
<Select.Option value={t('execution.advanceApply')}>{t('execution.advanceApply')}</Select.Option>
<Select.Option value={t('execution.reimburseApply')}>{t('execution.reimburseApply')}</Select.Option>
<Select.Option value={t('execution.paymentApply')}>{t('execution.paymentApply')}</Select.Option>
<Select.Option value={t('execution.verificationApply')}>{t('execution.verificationApply')}</Select.Option>
<Select.Option value={t('execution.purchaseApply')}>{t('execution.purchaseApply')}</Select.Option>
</Select>
<Select
placeholder="排序方式"
placeholder={t('execution.sortBy')}
value={`${sortField}_${sortOrder}`}
onChange={(value) => {
const [field, order] = (value as string).split('_');
@@ -632,17 +644,17 @@ const ExecutionManagement: React.FC = () => {
}}
style={{ width: 180 }}
>
<Select.Option value="executeDate_descend"></Select.Option>
<Select.Option value="executeDate_ascend"></Select.Option>
<Select.Option value="amount_descend"></Select.Option>
<Select.Option value="amount_ascend"></Select.Option>
<Select.Option value="executeDate_descend">{t('execution.sortDateNew')}</Select.Option>
<Select.Option value="executeDate_ascend">{t('execution.sortDateOld')}</Select.Option>
<Select.Option value="amount_descend">{t('execution.sortAmountHigh')}</Select.Option>
<Select.Option value="amount_ascend">{t('execution.sortAmountLow')}</Select.Option>
</Select>
</div>
);
const tabItems = [
{ key: 'pending', label: <span> <Badge count={pendingData.length} style={{ marginLeft: 8 }} /></span>, children: <Table columns={pendingColumns} dataSource={pendingData} loading={loading} pagination={{ pageSize: 10 }} scroll={{ x: 1300 }} /> },
{ key: 'executed', label: '已执行', children: (
{ key: 'pending', label: <span>{t('execution.pendingTab')} <Badge count={pendingData.length} style={{ marginLeft: 8 }} /></span>, children: <Table columns={pendingColumns} dataSource={pendingData} loading={loading} pagination={{ pageSize: 10 }} scroll={{ x: 1300 }} /> },
{ key: 'executed', label: t('execution.executedTab'), children: (
<>
<ExecutedListControls />
<Table
@@ -663,18 +675,28 @@ const ExecutionManagement: React.FC = () => {
];
// 上传配置
const getUploadHeaders = () => {
try {
const authStorage = localStorage.getItem('auth-storage');
if (authStorage) {
const parsed = JSON.parse(authStorage);
const token = parsed?.state?.token;
if (token) return { authorization: `Bearer ${token}` };
}
} catch (e) {}
return {};
};
const uploadProps = {
name: 'file',
action: '/api/upload/single',
headers: {
authorization: 'authorization-text',
},
headers: getUploadHeaders(),
onChange(info: any) {
// 更新文件列表状态
setVoucherFiles(info.fileList);
if (info.file.status === 'done') {
message.success(`${info.file.name} 上传成功`);
message.success(t('execution.uploadSuccess', { name: info.file.name }));
// 如果上传成功,将返回的URL添加到文件对象中
const updatedFileList = info.fileList.map((file: any) => {
if (file.uid === info.file.uid && file.response) {
@@ -687,7 +709,7 @@ const ExecutionManagement: React.FC = () => {
});
setVoucherFiles(updatedFileList);
} else if (info.file.status === 'error') {
message.error(`${info.file.name} 上传失败`);
message.error(t('execution.uploadFailed', { name: info.file.name }));
}
},
fileList: voucherFiles,
@@ -695,24 +717,24 @@ const ExecutionManagement: React.FC = () => {
return (
<div style={{ padding: 24 }}>
<div style={{ marginBottom: 24 }}><h2 style={{ marginBottom: 8 }}></h2><p style={{ color: '#888', marginBottom: 0 }}></p></div>
<div style={{ marginBottom: 24 }}><h2 style={{ marginBottom: 8 }}>{t('execution.title')}</h2><p style={{ color: '#888', marginBottom: 0 }}>{t('execution.description')}</p></div>
<Card><Tabs items={tabItems} /></Card>
{/* 详情模态框 */}
<Modal
title={`${selectedRecord?.type}详情`}
title={`${selectedRecord?.type}${t('common.detail')}`}
open={detailModalVisible}
onCancel={() => setDetailModalVisible(false)}
width={900}
footer={
selectedRecord?.status === 'approved' || selectedRecord?.status === 'pending' ? (
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<Button onClick={() => setDetailModalVisible(false)}></Button>
<Button danger icon={<CloseOutlined />} onClick={handleReject}>退</Button>
<Button type="primary" icon={<CheckOutlined />} onClick={handleExecute}></Button>
<Button onClick={() => setDetailModalVisible(false)}>{t('execution.cancel')}</Button>
<Button danger icon={<CloseOutlined />} onClick={handleReject}>{t('execution.pass')}</Button>
<Button type="primary" icon={<CheckOutlined />} onClick={handleExecute}>{t('execution.execute')}</Button>
</div>
) : (
<Button onClick={() => setDetailModalVisible(false)}></Button>
<Button onClick={() => setDetailModalVisible(false)}>{t('execution.close')}</Button>
)
}
>
@@ -720,37 +742,37 @@ const ExecutionManagement: React.FC = () => {
<>
{/* 基本信息 */}
<Descriptions bordered column={2} size="small">
<Descriptions.Item label="申请类型">{getTypeTag(selectedRecord.type)}</Descriptions.Item>
<Descriptions.Item label="申请编号">{selectedRecord.code}</Descriptions.Item>
<Descriptions.Item label="申请人">{selectedRecord.applicant}</Descriptions.Item>
<Descriptions.Item label="申请日期">{selectedRecord.date || fullDetail.advance_date || fullDetail.reimbursement_date || fullDetail.payment_date || fullDetail.verification_date}</Descriptions.Item>
<Descriptions.Item label="金额">
<Descriptions.Item label={t('execution.applicationType')}>{getTypeTag(selectedRecord.type)}</Descriptions.Item>
<Descriptions.Item label={t('execution.applicationCode')}>{selectedRecord.code}</Descriptions.Item>
<Descriptions.Item label={t('execution.applicant')}>{selectedRecord.applicant}</Descriptions.Item>
<Descriptions.Item label={t('execution.approvalDate')}>{selectedRecord.date || fullDetail.advance_date || fullDetail.reimbursement_date || fullDetail.payment_date || fullDetail.verification_date}</Descriptions.Item>
<Descriptions.Item label={t('execution.amount')}>
{formatAmount(selectedRecord.amount, selectedRecord.currency)}
{selectedRecord.currency !== 'CNY' && fullDetail.amount_cny > 0 && (
<span style={{ color: '#999', marginLeft: 8 }}> ¥{fullDetail.amount_cny.toLocaleString('zh-CN', {minimumFractionDigits: 2})}</span>
)}
</Descriptions.Item>
<Descriptions.Item label="状态">{getStatusTag(selectedRecord.status)}</Descriptions.Item>
<Descriptions.Item label="事由" span={2}>{selectedRecord.reason}</Descriptions.Item>
<Descriptions.Item label={t('execution.status')}>{getStatusTag(selectedRecord.status)}</Descriptions.Item>
<Descriptions.Item label={t('execution.subject')} span={2}>{selectedRecord.reason}</Descriptions.Item>
{/* 付款申请特有字段 */}
{selectedRecord.type === '付款申请' && (
{selectedRecord.type === t('execution.paymentApply') && (
<>
<Descriptions.Item label="收款单位类型">
{fullDetail.payee_type === 'subcontractor' ? '分包商' :
fullDetail.payee_type === 'supplier' ? '供应商' :
fullDetail.payee_type === 'customer' ? '客户' : '其他'}
<Descriptions.Item label={t('execution.payeeType')}>
{fullDetail.payee_type === 'subcontractor' ? t('execution.counterpartySubcontractor') :
fullDetail.payee_type === 'supplier' ? t('execution.counterpartySupplier') :
fullDetail.payee_type === 'customer' ? t('execution.counterpartyCustomer') : t('execution.counterpartyOther')}
</Descriptions.Item>
<Descriptions.Item label="收款方">{fullDetail.payee || '-'}</Descriptions.Item>
<Descriptions.Item label="银行名称">{fullDetail.bank_name || '-'}</Descriptions.Item>
<Descriptions.Item label="银行账号">{fullDetail.bank_account || '-'}</Descriptions.Item>
<Descriptions.Item label="支出类型">
{fullDetail.expense_type === 'company' ? '公司支出' : '项目支出'}
<Descriptions.Item label={t('execution.payee')}>{fullDetail.payee || '-'}</Descriptions.Item>
<Descriptions.Item label={t('execution.bankName')}>{fullDetail.bank_name || '-'}</Descriptions.Item>
<Descriptions.Item label={t('execution.bankAccount')}>{fullDetail.bank_account || '-'}</Descriptions.Item>
<Descriptions.Item label={t('execution.expenseType')}>
{fullDetail.expense_type === 'company' ? t('execution.companyExpense') : t('execution.projectExpense')}
</Descriptions.Item>
{fullDetail.expense_type === 'project' && fullDetail.project_id && (
<Descriptions.Item label="关联项目">{getProjectName(fullDetail.project_id)}</Descriptions.Item>
<Descriptions.Item label={t('execution.relatedProject')}>{getProjectName(fullDetail.project_id)}</Descriptions.Item>
)}
<Descriptions.Item label="支出分类">
<Descriptions.Item label={t('execution.expenseCategory')}>
{fullDetail.expense_type === 'project'
? (PROJECT_EXPENSE_CATEGORIES.find(c => c.value === fullDetail.expense_category)?.label || fullDetail.expense_category)
: (COMPANY_EXPENSE_CATEGORIES.find(c => c.value === fullDetail.expense_category)?.label || fullDetail.expense_category)
@@ -760,75 +782,75 @@ const ExecutionManagement: React.FC = () => {
)}
{/* 报销申请特有字段 */}
{selectedRecord.type === '报销申请' && fullDetail.expense_type && (
{selectedRecord.type === t('execution.reimburseApply') && fullDetail.expense_type && (
<>
<Descriptions.Item label="支出类型">
{fullDetail.expense_type === 'company' ? '公司支出' : '项目支出'}
<Descriptions.Item label={t('execution.expenseType')}>
{fullDetail.expense_type === 'company' ? t('execution.companyExpense') : t('execution.projectExpense')}
</Descriptions.Item>
{fullDetail.project_id && (
<Descriptions.Item label="关联项目">{getProjectName(fullDetail.project_id)}</Descriptions.Item>
<Descriptions.Item label={t('execution.relatedProject')}>{getProjectName(fullDetail.project_id)}</Descriptions.Item>
)}
</>
)}
{/* 核销申请特有字段 */}
{selectedRecord.type === '核销申请' && fullDetail.advance_code && (
{selectedRecord.type === t('execution.verificationApply') && fullDetail.advance_code && (
<>
<Descriptions.Item label="关联预支单">{fullDetail.advance_code}</Descriptions.Item>
<Descriptions.Item label="预支金额">{formatAmount(fullDetail.advance_amount, fullDetail.currency)}</Descriptions.Item>
<Descriptions.Item label="结算核销">
<Descriptions.Item label={t('execution.relatedAdvance')}>{fullDetail.advance_code}</Descriptions.Item>
<Descriptions.Item label={t('execution.advanceAmount')}>{formatAmount(fullDetail.advance_amount, fullDetail.currency)}</Descriptions.Item>
<Descriptions.Item label={t('execution.settlement')}>
<span style={{ fontWeight: 'bold', color: fullDetail.settlement ? '#52c41a' : '#fa8c16' }}>
{fullDetail.settlement ? '是' : '否'}
{fullDetail.settlement ? t('common.is') : t('common.no')}
</span>
</Descriptions.Item>
<Descriptions.Item label="已核销金额">{formatAmount(fullDetail.total_reimbursed || 0, fullDetail.currency)}</Descriptions.Item>
<Descriptions.Item label="剩余核销金额">{formatAmount((fullDetail.advance_amount || 0) - (fullDetail.total_reimbursed || 0), fullDetail.currency)}</Descriptions.Item>
<Descriptions.Item label={t('execution.verifiedAmount')}>{formatAmount(fullDetail.total_reimbursed || 0, fullDetail.currency)}</Descriptions.Item>
<Descriptions.Item label={t('execution.remainingAmount')}>{formatAmount((fullDetail.advance_amount || 0) - (fullDetail.total_reimbursed || 0), fullDetail.currency)}</Descriptions.Item>
{fullDetail.settlement && fullDetail.settlement_amount && (
<Descriptions.Item label="核销结算金额" span={2}>
{fullDetail.settlement_amount > 0 ? `退款 ${formatAmount(fullDetail.settlement_amount, fullDetail.currency)}` : `补款 ${formatAmount(Math.abs(fullDetail.settlement_amount), fullDetail.currency)}`}
<Descriptions.Item label={t('execution.settlementAmount')} span={2}>
{fullDetail.settlement_amount > 0 ? `${t('execution.refundText')}${formatAmount(fullDetail.settlement_amount, fullDetail.currency)}` : `${t('execution.supplementText')}${formatAmount(Math.abs(fullDetail.settlement_amount), fullDetail.currency)}`}
</Descriptions.Item>
)}
</>
)}
{/* 采购申请特有字段 */}
{selectedRecord.type === '采购申请' && (
{selectedRecord.type === t('execution.purchaseApply') && (
<>
<Descriptions.Item label="采购类型">
{fullDetail.purchase_type === 'project' ? '项目采购' : '库存采购'}
<Descriptions.Item label={t('execution.purchaseType')}>
{fullDetail.purchase_type === 'project' ? t('execution.projectPurchase') : t('execution.stockPurchase')}
</Descriptions.Item>
{fullDetail.purchase_type === 'project' && fullDetail.project_id && (
<Descriptions.Item label="关联项目">{getProjectName(fullDetail.project_id)}</Descriptions.Item>
<Descriptions.Item label={t('execution.relatedProject')}>{getProjectName(fullDetail.project_id)}</Descriptions.Item>
)}
<Descriptions.Item label="供应商">{fullDetail.supplier_name || '-'}</Descriptions.Item>
<Descriptions.Item label="支出分类">
{fullDetail.expense_category === 'material' ? '材料' :
fullDetail.expense_category === 'equipment' ? '设备' :
fullDetail.expense_category === 'pole' ? '电杆' : '其他'}
<Descriptions.Item label={t('execution.supplier')}>{fullDetail.supplier_name || '-'}</Descriptions.Item>
<Descriptions.Item label={t('execution.expenseCategory')}>
{fullDetail.expense_category === 'material' ? t('execution.material') :
fullDetail.expense_category === 'equipment' ? t('execution.equipment') :
fullDetail.expense_category === 'pole' ? t('execution.pole') : t('execution.otherCategory')}
</Descriptions.Item>
<Descriptions.Item label="币种">{fullDetail.currency}</Descriptions.Item>
<Descriptions.Item label="申请日期">{fullDetail.request_date}</Descriptions.Item>
<Descriptions.Item label="事由" span={2}>{fullDetail.brief_description || '-'}</Descriptions.Item>
<Descriptions.Item label={t('execution.currency')}>{fullDetail.currency}</Descriptions.Item>
<Descriptions.Item label={t('execution.approvalDate')}>{fullDetail.request_date}</Descriptions.Item>
<Descriptions.Item label={t('execution.subject')} span={2}>{fullDetail.brief_description || '-'}</Descriptions.Item>
{fullDetail.remark && (
<Descriptions.Item label="备注" span={2}>{fullDetail.remark}</Descriptions.Item>
<Descriptions.Item label={t('execution.remark')} span={2}>{fullDetail.remark}</Descriptions.Item>
)}
</>
)}
</Descriptions>
{/* 采购申请供应商收款信息 */}
{selectedRecord.type === '采购申请' && fullDetail.supplier_payment_infos && fullDetail.supplier_payment_infos.length > 0 && (
{selectedRecord.type === t('execution.purchaseApply') && fullDetail.supplier_payment_infos && fullDetail.supplier_payment_infos.length > 0 && (
<>
<Divider></Divider>
<Divider>{t('execution.supplierPaymentInfo')}</Divider>
<Descriptions bordered column={2} size="small">
{fullDetail.supplier_payment_infos.filter((p: any) => p.is_primary).map((payment: any, index: number) => (
<React.Fragment key={index}>
<Descriptions.Item label="收款户名">{payment.account_name || '-'}</Descriptions.Item>
<Descriptions.Item label="银行账号">{payment.bank_account || '-'}</Descriptions.Item>
<Descriptions.Item label="开户银行">{payment.bank_name || '-'}</Descriptions.Item>
<Descriptions.Item label={t('execution.accountName')}>{payment.account_name || '-'}</Descriptions.Item>
<Descriptions.Item label={t('execution.bankAccount')}>{payment.bank_account || '-'}</Descriptions.Item>
<Descriptions.Item label={t('execution.bankName')}>{payment.bank_name || '-'}</Descriptions.Item>
{payment.qr_code && (
<Descriptions.Item label="收款码">
<img src={payment.qr_code} alt="收款码" style={{ width: 100, height: 100, objectFit: 'contain' }} />
<Descriptions.Item label={t('execution.qrCode')}>
<img src={payment.qr_code} alt={t('execution.qrCode')} style={{ width: 100, height: 100, objectFit: 'contain' }} />
</Descriptions.Item>
)}
</React.Fragment>
@@ -838,9 +860,9 @@ const ExecutionManagement: React.FC = () => {
)}
{/* 采购申请商品明细 */}
{selectedRecord.type === '采购申请' && fullDetail.items && fullDetail.items.length > 0 && (
{selectedRecord.type === t('execution.purchaseApply') && fullDetail.items && fullDetail.items.length > 0 && (
<>
<Divider></Divider>
<Divider>{t('execution.purchaseDetail')}</Divider>
<List
size="small"
bordered
@@ -855,8 +877,8 @@ const ExecutionManagement: React.FC = () => {
</span>
</div>
<div style={{ fontSize: 13, color: '#666' }}>
: {item.specification || '-'} | : {item.unit || '-'} |
: {item.quantity} | : {fullDetail.currency} {item.unit_price?.toLocaleString('zh-CN', {minimumFractionDigits: 2})}
{t('execution.specLabel')}{item.specification || '-'} | {t('execution.unitLabel')}{item.unit || '-'} |
{t('execution.qtyLabel')}{item.quantity} | {t('execution.priceLabel')}{fullDetail.currency} {item.unit_price?.toLocaleString('zh-CN', {minimumFractionDigits: 2})}
</div>
</div>
</List.Item>
@@ -868,7 +890,7 @@ const ExecutionManagement: React.FC = () => {
{/* 明细清单 */}
{fullDetail.detail_items && fullDetail.detail_items.length > 0 && (
<>
<Divider></Divider>
<Divider>{t('execution.detailList')}</Divider>
{renderDetailItems(fullDetail.detail_items)}
</>
)}
@@ -876,7 +898,7 @@ const ExecutionManagement: React.FC = () => {
{/* 审批意见 */}
{fullDetail.approval_remark && (
<>
<Divider></Divider>
<Divider>{t('execution.approvalOpinion')}</Divider>
<div style={{ padding: '12px', background: '#f5f5f5', borderRadius: '4px' }}>
<p style={{ margin: 0, color: '#666' }}>{fullDetail.approval_remark}</p>
</div>
@@ -886,7 +908,7 @@ const ExecutionManagement: React.FC = () => {
{/* 申请凭证附件或退款凭证 */}
{fullDetail.attachments && fullDetail.attachments.length > 0 && (
<>
<Divider>{fullDetail.settlement && fullDetail.settlement_amount > 0 ? '退款凭证' : '申请凭证附件'}</Divider>
<Divider>{fullDetail.settlement && fullDetail.settlement_amount > 0 ? t('execution.refundProof') : t('execution.applicationAttachment')}</Divider>
{renderAttachments(fullDetail.attachments)}
</>
)}
@@ -894,49 +916,49 @@ const ExecutionManagement: React.FC = () => {
{/* 执行表单 */}
{(selectedRecord.status === 'approved' || selectedRecord.status === 'pending') && (
<>
<Divider></Divider>
<Divider>{t('execution.executionInfo')}</Divider>
<Form form={form} layout="vertical">
{/* 执行/确认日期 */}
<Form.Item name="execute_date" label={selectedRecord.type === '核销申请' && fullDetail.settlement && fullDetail.settlement_amount > 0 ? "确认日期" : "执行日期"} rules={[{ required: true }]}>
<Form.Item name="execute_date" label={selectedRecord.type === t('execution.verificationApply') && fullDetail.settlement && fullDetail.settlement_amount > 0 ? t('execution.confirmationDate') : t('execution.executionDate')} rules={[{ required: true }]}>
<DatePicker style={{ width: '100%' }} />
</Form.Item>
{/* 执行方式或收款方式 - 非结算核销不需要 */}
{!(selectedRecord.type === '核销申请' && !fullDetail.settlement) && (
selectedRecord.type === '核销申请' && fullDetail.settlement && fullDetail.settlement_amount > 0 ? (
<Form.Item name="execute_method" label="收款方式" rules={[{ required: true }]}>
<Select options={[{ value: 'bank', label: '银行转账' }, { value: 'cash', label: '现金' }, { value: 'wechat', label: '微信' }, { value: 'other', label: '其他' }]} />
{!(selectedRecord.type === t('execution.verificationApply') && !fullDetail.settlement) && (
selectedRecord.type === t('execution.verificationApply') && fullDetail.settlement && fullDetail.settlement_amount > 0 ? (
<Form.Item name="execute_method" label={t('execution.paymentMethod')} rules={[{ required: true }]}>
<Select options={[{ value: 'bank', label: t('execution.bankTransfer') }, { value: 'cash', label: t('execution.cash') }, { value: 'wechat', label: t('execution.wechat') }, { value: 'other', label: t('execution.other') }]} />
</Form.Item>
) : (
<Form.Item name="execute_method" label="执行方式" rules={[{ required: true }]}>
<Select options={[{ value: 'bank', label: '银行转账' }, { value: 'cash', label: '现金' }, { value: 'wechat', label: '微信' }, { value: 'other', label: '其他' }]} />
<Form.Item name="execute_method" label={t('execution.executionMethodLabel')} rules={[{ required: true }]}>
<Select options={[{ value: 'bank', label: t('execution.bankTransfer') }, { value: 'cash', label: t('execution.cash') }, { value: 'wechat', label: t('execution.wechat') }, { value: 'other', label: t('execution.other') }]} />
</Form.Item>
)
)}
{/* 只有退款类型的核销申请和非结算核销不需要上传付款凭证,其他类型的申请都需要 */}
{!(selectedRecord.type === '核销申请' && (fullDetail.settlement && fullDetail.settlement_amount > 0 || !fullDetail.settlement)) && (
<Form.Item label="付款凭证" required>
{!(selectedRecord.type === t('execution.verificationApply') && (fullDetail.settlement && fullDetail.settlement_amount > 0 || !fullDetail.settlement)) && (
<Form.Item label={t('execution.proofOfPayment')} required>
<Upload {...uploadProps}>
<Button icon={<UploadOutlined />}></Button>
<Button icon={<UploadOutlined />}>{t('execution.uploadProof')}</Button>
</Upload>
<div style={{ marginTop: 8, color: '#666', fontSize: 12 }}>
PDF格式
{t('execution.proofUploadTip')}
</div>
</Form.Item>
)}
{/* 退款的核销申请显示提示 */}
{selectedRecord.type === '核销申请' && fullDetail.settlement && fullDetail.settlement_amount > 0 && (
{selectedRecord.type === t('execution.verificationApply') && fullDetail.settlement && fullDetail.settlement_amount > 0 && (
<div style={{ margin: '16px 0', padding: '12px', backgroundColor: '#f6ffed', border: '1px solid #b7eb8f', borderRadius: '4px' }}>
<p style={{ margin: 0, color: '#389e0d' }}>退</p>
<p style={{ margin: 0, color: '#389e0d' }}>{t('execution.noProofRefund')}</p>
</div>
)}
{/* 非结算核销的核销申请显示提示 */}
{selectedRecord.type === '核销申请' && !fullDetail.settlement && (
{selectedRecord.type === t('execution.verificationApply') && !fullDetail.settlement && (
<div style={{ margin: '16px 0', padding: '12px', backgroundColor: '#e6f7ff', border: '1px solid #91d5ff', borderRadius: '4px' }}>
<p style={{ margin: 0, color: '#1890ff' }}></p>
<p style={{ margin: 0, color: '#1890ff' }}>{t('execution.noProofNonSettlement')}</p>
</div>
)}
@@ -945,20 +967,20 @@ const ExecutionManagement: React.FC = () => {
// 执行操作时显示的字段
<>
{/* 收款确认信息(仅退款类型)或备注 */}
{selectedRecord.type === '核销申请' && fullDetail.settlement && fullDetail.settlement_amount > 0 ? (
<Form.Item name="remark" label="收款确认信息" rules={[{ required: true, message: '请填写收款确认信息' }]}>
<TextArea rows={3} placeholder="请填写收款确认信息,如收款账号、收款时间等" />
{selectedRecord.type === t('execution.verificationApply') && fullDetail.settlement && fullDetail.settlement_amount > 0 ? (
<Form.Item name="remark" label={t('execution.paymentConfirmation')} rules={[{ required: true, message: t('execution.confirmRequired') }]}>
<TextArea rows={3} placeholder={t('execution.confirmationPlaceholder')} />
</Form.Item>
) : (
<Form.Item name="remark" label="备注">
<TextArea rows={2} placeholder="可选:填写执行备注" />
<Form.Item name="remark" label={t('execution.remark')}>
<TextArea rows={2} placeholder={t('execution.remarkPlaceholder')} />
</Form.Item>
)}
</>
) : (
// 退回操作时显示的字段
<Form.Item name="rejectReason" label="退回原因" rules={[{ required: true, message: '请填写退回原因' }]}>
<TextArea rows={3} placeholder="请填写退回原因" />
<Form.Item name="rejectReason" label={t('execution.returnReason')} rules={[{ required: true, message: t('execution.rejectReasonRequired') }]}>
<TextArea rows={3} placeholder={t('execution.returnReason')} />
</Form.Item>
)}
</Form>
@@ -968,12 +990,12 @@ const ExecutionManagement: React.FC = () => {
)}
</Modal>
<Modal title={`编辑申请${selectedRecord?.code}`} open={editModalVisible} onCancel={() => setEditModalVisible(false)} onOk={handleEditSubmit} width={600}>
<Modal title={`${t('execution.edit')}${selectedRecord?.code}`} open={editModalVisible} onCancel={() => setEditModalVisible(false)} onOk={handleEditSubmit} width={600}>
<Form form={editForm} layout="vertical">
<Form.Item label="申请类型"><Input value={selectedRecord?.type} disabled /></Form.Item>
<Form.Item label="申请人"><Input value={selectedRecord?.applicant} disabled /></Form.Item>
<Form.Item name="amount" label="金额" rules={[{ required: true }]}><Input type="number" style={{ width: '100%' }} /></Form.Item>
<Form.Item name="reason" label="事由" rules={[{ required: true }]}><TextArea rows={3} /></Form.Item>
<Form.Item label={t('execution.applicationType')}><Input value={selectedRecord?.type} disabled /></Form.Item>
<Form.Item label={t('execution.applicant')}><Input value={selectedRecord?.applicant} disabled /></Form.Item>
<Form.Item name="amount" label={t('execution.amount')} rules={[{ required: true }]}><Input type="number" style={{ width: '100%' }} /></Form.Item>
<Form.Item name="reason" label={t('execution.subject')} rules={[{ required: true }]}><TextArea rows={3} /></Form.Item>
</Form>
</Modal>
+7 -7
View File
@@ -34,7 +34,7 @@ const LoginPage: React.FC = () => {
const [error, setError] = useState<string | null>(null)
const { login } = useAuthStore()
const { t } = useLanguageStore()
const { t, currentLanguage } = useLanguageStore()
const handleSubmit = async (values: { username: string; password: string }) => {
setLoading(true)
@@ -51,10 +51,10 @@ const LoginPage: React.FC = () => {
}
const testAccounts = [
{ username: 'admin', role: t('user.admin'), name: '系统管理员' },
{ username: 'finance', role: t('user.finance'), name: '财务专员' },
{ username: 'manager', role: t('user.manager'), name: '项目经理' },
{ username: 'employee', role: t('user.employee'), name: '普通员工' }
{ username: 'admin', role: t('user.admin'), name: t('common.systemAdmin') },
{ username: 'finance', role: t('user.finance'), name: t('user.finance') },
{ username: 'manager', role: t('user.manager'), name: t('user.manager') },
{ username: 'employee', role: t('user.employee'), name: t('user.employee') }
]
const handleTestLogin = async (username: string) => {
@@ -65,7 +65,7 @@ const LoginPage: React.FC = () => {
await login(username, QUICK_PASSWORD)
navigate('/dashboard')
} catch (err) {
setError(err instanceof Error ? err.message : '登录失败')
setError(err instanceof Error ? err.message : t('login.loginFailed'))
} finally {
setLoading(false)
}
@@ -107,7 +107,7 @@ const LoginPage: React.FC = () => {
border: '2px solid #1890ff'
}}>
<div style={{ marginBottom: 8, color: '#1890ff', fontWeight: 'bold' }}>
🌍 / Select Language
{t('login.selectLanguage')}
</div>
<LanguageSelector size="large" style={{ width: '200px' }} />
</div>
+378 -390
View File
@@ -1,390 +1,378 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Card, Typography, Button, Form, Input, Select, DatePicker, InputNumber, Radio, Space, message, Divider, Row, Col, Modal } from 'antd';
import { SaveOutlined, ArrowLeftOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import apiClient from '../../utils/request';
import dayjs from 'dayjs';
import FileUpload from '../../components/FileUpload';
import { useAuthStore } from '../../store/authStore';
import useFormDraft from '../../hooks/useFormDraft';
const { Title, Paragraph } = Typography;
const { Option } = Select;
const { TextArea } = Input;
interface Customer {
id: number;
name: string;
}
interface User {
id: number;
name: string;
department?: string;
}
const BudgetProjectCreate: React.FC = () => {
const [isMobile, setIsMobile] = useState(false);
const [loading, setLoading] = useState(false);
const [customers, setCustomers] = useState<Customer[]>([]);
const [users, setUsers] = useState<User[]>([]);
const [form] = Form.useForm();
const [attachments, setAttachments] = useState<string[]>([]);
const [surveyPhotos, setSurveyPhotos] = useState<string[]>([]);
const navigate = useNavigate();
const { user: currentUser } = useAuthStore();
const isAdmin = currentUser?.role === 'admin';
// 表单草稿保护
const { save: saveDraft, restore: restoreDraft, clear: clearDraft, hasDraft } = useFormDraft({
form,
storageKey: 'budget_project_create',
onRestore: (data) => {
if (data.attachments) setAttachments(data.attachments);
if (data.surveyPhotos) setSurveyPhotos(data.surveyPhotos);
},
});
// 保存草稿(包含外部状态)
const handleFormChange = useCallback(() => {
saveDraft({ attachments, surveyPhotos });
}, [saveDraft, attachments, surveyPhotos]);
// 浏览器刷新/关闭拦截
useEffect(() => {
const handler = (e: BeforeUnloadEvent) => {
if (form.isFieldsTouched()) {
e.preventDefault();
}
};
window.addEventListener('beforeunload', handler);
return () => window.removeEventListener('beforeunload', handler);
}, [form]);
// 移动端:页面切到后台时保存草稿
useEffect(() => {
const handler = () => {
if (document.visibilityState === 'hidden' && form.isFieldsTouched()) {
saveDraft({ attachments, surveyPhotos });
}
};
document.addEventListener('visibilitychange', handler);
const pageHideHandler = () => {
if (form.isFieldsTouched()) saveDraft({ attachments, surveyPhotos });
};
window.addEventListener('pagehide', pageHideHandler);
return () => {
document.removeEventListener('visibilitychange', handler);
window.removeEventListener('pagehide', pageHideHandler);
};
}, [form, saveDraft, attachments, surveyPhotos]);
// 页面加载时检查草稿
useEffect(() => {
if (hasDraft()) {
Modal.confirm({
title: '发现未完成的草稿',
content: '检测到上次未提交的商谈项目,是否恢复?',
okText: '恢复草稿',
cancelText: '重新填写',
onOk: () => {
restoreDraft();
},
onCancel: () => {
clearDraft();
},
});
}
}, []);
// 检查权限,如果不是管理员,重定向到列表页面
useEffect(() => {
if (!isAdmin) {
message.error('您没有权限访问此页面');
navigate('/budget-projects');
}
}, [isAdmin, navigate]);
// const { user: currentUser } = useAuthStore();
// 表单监听值
const intermediaryFeeType = Form.useWatch('intermediary_fee_type', form);
useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth <= 768);
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
useEffect(() => {
fetchCustomers();
fetchUsers();
}, []);
const fetchCustomers = async () => {
try {
const res = await apiClient.get('/customers');
if (res.data.success) setCustomers(res.data.data);
} catch (error) {
console.error('获取客户列表失败:', error);
}
};
const fetchUsers = async () => {
try {
const res = await apiClient.get('/users');
if (res.data.success) setUsers(res.data.data);
} catch (error) {
console.error('获取用户列表失败:', error);
}
};
const handleSubmit = async () => {
try {
const values = await form.validateFields();
setLoading(true);
const projectData = {
...values,
attachments,
survey_photos: surveyPhotos,
survey_date: values.survey_date?.format('YYYY-MM-DD'),
status: 'negotiating',
};
const res = await apiClient.post('/budget-projects', projectData, {
headers: {
'x-user-role': 'admin'
}
});
if (res.data.success) {
message.success('创建成功');
clearDraft();
navigate('/budget-projects');
}
} catch (error: any) {
if (error.response?.data?.error) {
message.error(error.response.data.error);
} else {
message.error('创建失败');
}
} finally {
setLoading(false);
}
};
return (
<div style={{ padding: isMobile ? 8 : 24 }}>
<div style={{ marginBottom: isMobile ? 12 : 24 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 8 }}>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => {
if (form.isFieldsTouched()) {
Modal.confirm({
title: '确认离开',
content: '表单数据尚未保存,离开后可通过草稿恢复。确定离开吗?',
okText: '离开',
cancelText: '继续编辑',
onOk: () => {
saveDraft({ attachments, surveyPhotos });
navigate('/budget-projects');
},
});
} else {
navigate('/budget-projects');
}
}}
>
</Button>
<Title level={isMobile ? 3 : 2} style={{ marginBottom: 0 }}></Title>
</div>
<Paragraph type="secondary"></Paragraph>
</div>
<Card>
<Form
form={form}
layout="vertical"
onValuesChange={handleFormChange}
initialValues={{
intermediary_fee_type: 'fixed',
survey_date: dayjs(), // 勘察日期默认为当天
attachments: [],
survey_photos: []
}}
>
{/* 基本信息 */}
<Divider orientation="left"></Divider>
<Row gutter={16}>
<Col xs={24} md={12}>
<Form.Item
name="name"
label="项目名称"
rules={[{ required: true, message: '请输入项目名称' }]}
>
<Input placeholder="请输入项目名称" size="large" />
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item
name="customer_id"
label="客户"
rules={[{ required: true, message: '请选择客户' }]}
>
<Select
placeholder="请选择客户"
showSearch
optionFilterProp="children"
size="large"
>
{customers.map((c) => (
<Option key={c.id} value={c.id}>{c.name}</Option>
))}
</Select>
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} md={12}>
<Form.Item
name="manager_id"
label="业务经理"
rules={[{ required: true, message: '请选择业务经理' }]}
>
<Select
placeholder="请选择业务经理"
showSearch
optionFilterProp="children"
size="large"
>
{users.map((u) => (
<Option key={u.id} value={u.id}>{u.name} ({u.department || '未知部门'})</Option>
))}
</Select>
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item name="location" label="项目地点">
<Input placeholder="请输入项目地点" size="large" />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} md={12}>
<Form.Item name="survey_date" label="勘察日期">
<DatePicker style={{ width: '100%' }} size="large" />
</Form.Item>
</Col>
</Row>
{/* 居间人信息 */}
<Divider orientation="left"></Divider>
<Row gutter={16}>
<Col xs={24} md={8}>
<Form.Item name="intermediary" label="居间人">
<Input placeholder="请输入居间人姓名" size="large" />
</Form.Item>
</Col>
<Col xs={24} md={8}>
<Form.Item name="intermediary_fee_type" label="居间费类型">
<Radio.Group>
<Radio value="fixed"></Radio>
<Radio value="percentage"></Radio>
</Radio.Group>
</Form.Item>
</Col>
<Col xs={24} md={8}>
<Form.Item
name="intermediary_fee_value"
label={intermediaryFeeType === 'percentage' ? '居间费比例(%)' : '居间费金额'}
>
<InputNumber
style={{ width: '100%' }}
size="large"
min={0}
precision={intermediaryFeeType === 'percentage' ? 2 : 0}
placeholder={intermediaryFeeType === 'percentage' ? '输入比例,如:5' : '输入金额'}
/>
</Form.Item>
</Col>
</Row>
{/* 项目详情 */}
<Divider orientation="left"></Divider>
<Form.Item name="customer_requirements" label="客户要求">
<TextArea rows={4} placeholder="请输入客户的具体要求" />
</Form.Item>
<Form.Item name="project_overview" label="工程概况">
<TextArea rows={4} placeholder="请输入工程概况描述" />
</Form.Item>
{/* 附件上传 */}
<Divider orientation="left"></Divider>
<Row gutter={16}>
<Col xs={24} md={12}>
<Form.Item label="附件上传">
<FileUpload
value={attachments}
onChange={(urls) => { setAttachments(urls); saveDraft({ attachments: urls, surveyPhotos }); }}
accept=".pdf,.doc,.docx,.jpg,.jpeg,.png,.xlsx,.xls"
/>
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item label="勘察照片">
<FileUpload
value={surveyPhotos}
onChange={(urls) => { setSurveyPhotos(urls); saveDraft({ attachments, surveyPhotos: urls }); }}
accept="image/*"
/>
</Form.Item>
</Col>
</Row>
{/* 提交按钮 */}
<div style={{ marginTop: 24, textAlign: 'right' }}>
<Space>
<Button onClick={() => {
if (form.isFieldsTouched()) {
Modal.confirm({
title: '确认离开',
content: '表单数据尚未保存,离开后可通过草稿恢复。确定离开吗?',
okText: '离开',
cancelText: '继续编辑',
onOk: () => {
saveDraft({ attachments, surveyPhotos });
navigate('/budget-projects');
},
});
} else {
navigate('/budget-projects');
}
}}></Button>
<Button
type="primary"
icon={<SaveOutlined />}
loading={loading}
onClick={handleSubmit}
>
</Button>
</Space>
</div>
</Form>
</Card>
</div>
);
};
export default BudgetProjectCreate;
import React, { useState, useEffect, useCallback } from 'react';
import { Card, Typography, Button, Form, Input, Select, DatePicker, InputNumber, Radio, Space, message, Divider, Row, Col, Modal } from 'antd';
import { SaveOutlined, ArrowLeftOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import apiClient from '../../utils/request';
import dayjs from 'dayjs';
import FileUpload from '../../components/FileUpload';
import { useAuthStore } from '../../store/authStore';
import { useLanguageStore } from '../../store/languageStore';
import useFormDraft from '../../hooks/useFormDraft';
const { Title, Paragraph } = Typography;
const { Option } = Select;
const { TextArea } = Input;
interface Customer {
id: number;
name: string;
}
interface User {
id: number;
name: string;
department?: string;
}
const BudgetProjectCreate: React.FC = () => {
const [isMobile, setIsMobile] = useState(false);
const [loading, setLoading] = useState(false);
const [customers, setCustomers] = useState<Customer[]>([]);
const [users, setUsers] = useState<User[]>([]);
const [form] = Form.useForm();
const [attachments, setAttachments] = useState<string[]>([]);
const [surveyPhotos, setSurveyPhotos] = useState<string[]>([]);
const navigate = useNavigate();
const { user: currentUser } = useAuthStore();
const { t, currentLanguage } = useLanguageStore();
const isAdmin = currentUser?.role === 'admin';
const { save: saveDraft, restore: restoreDraft, clear: clearDraft, hasDraft } = useFormDraft({
form,
storageKey: 'budget_project_create',
onRestore: (data) => {
if (data.attachments) setAttachments(data.attachments);
if (data.surveyPhotos) setSurveyPhotos(data.surveyPhotos);
},
});
const handleFormChange = useCallback(() => {
saveDraft({ attachments, surveyPhotos });
}, [saveDraft, attachments, surveyPhotos]);
useEffect(() => {
const handler = (e: BeforeUnloadEvent) => {
if (form.isFieldsTouched()) {
e.preventDefault();
}
};
window.addEventListener('beforeunload', handler);
return () => window.removeEventListener('beforeunload', handler);
}, [form]);
useEffect(() => {
const handler = () => {
if (document.visibilityState === 'hidden' && form.isFieldsTouched()) {
saveDraft({ attachments, surveyPhotos });
}
};
document.addEventListener('visibilitychange', handler);
const pageHideHandler = () => {
if (form.isFieldsTouched()) saveDraft({ attachments, surveyPhotos });
};
window.addEventListener('pagehide', pageHideHandler);
return () => {
document.removeEventListener('visibilitychange', handler);
window.removeEventListener('pagehide', pageHideHandler);
};
}, [form, saveDraft, attachments, surveyPhotos]);
useEffect(() => {
if (hasDraft()) {
Modal.confirm({
title: t('common.draftFound'),
content: t('common.draftRestore'),
okText: t('common.restoreDraft'),
cancelText: t('common.reFill'),
onOk: () => {
restoreDraft();
},
onCancel: () => {
clearDraft();
},
});
}
}, []);
useEffect(() => {
if (!isAdmin) {
message.error(t('budget.noAccess'));
navigate('/budget-projects');
}
}, [isAdmin, navigate]);
const intermediaryFeeType = Form.useWatch('intermediary_fee_type', form);
useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth <= 768);
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
useEffect(() => {
fetchCustomers();
fetchUsers();
}, []);
const fetchCustomers = async () => {
try {
const res = await apiClient.get('/customers');
if (res.data.success) setCustomers(res.data.data);
} catch (error) {
console.error('获取客户列表失败:', error);
}
};
const fetchUsers = async () => {
try {
const res = await apiClient.get('/users');
if (res.data.success) setUsers(res.data.data);
} catch (error) {
console.error('获取用户列表失败:', error);
}
};
const handleSubmit = async () => {
try {
const values = await form.validateFields();
setLoading(true);
const projectData = {
...values,
attachments,
survey_photos: surveyPhotos,
survey_date: values.survey_date?.format('YYYY-MM-DD'),
status: 'negotiating',
};
const res = await apiClient.post('/budget-projects', projectData, {
headers: {
'x-user-role': 'admin'
}
});
if (res.data.success) {
message.success(t('budget.createSuccess'));
clearDraft();
navigate('/budget-projects');
}
} catch (error: any) {
if (error.response?.data?.error) {
message.error(error.response.data.error);
} else {
message.error(t('budget.createFailed'));
}
} finally {
setLoading(false);
}
};
return (
<div style={{ padding: isMobile ? 8 : 24 }}>
<div style={{ marginBottom: isMobile ? 12 : 24 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 8 }}>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => {
if (form.isFieldsTouched()) {
Modal.confirm({
title: t('budget.leaveConfirm'),
content: t('budget.leaveConfirmMsg'),
okText: t('budget.leave'),
cancelText: t('budget.continueEdit'),
onOk: () => {
saveDraft({ attachments, surveyPhotos });
navigate('/budget-projects');
},
});
} else {
navigate('/budget-projects');
}
}}
>
{t('budget.return')}
</Button>
<Title level={isMobile ? 3 : 2} style={{ marginBottom: 0 }}>{t('budget.createTitle')}</Title>
</div>
<Paragraph type="secondary">{t('budget.createDesc')}</Paragraph>
</div>
<Card>
<Form
form={form}
layout="vertical"
onValuesChange={handleFormChange}
initialValues={{
intermediary_fee_type: 'fixed',
survey_date: dayjs(),
attachments: [],
survey_photos: []
}}
>
<Divider orientation="left">{t('budget.basicInfo')}</Divider>
<Row gutter={16}>
<Col xs={24} md={12}>
<Form.Item
name="name"
label={t('budget.projectName')}
rules={[{ required: true, message: t('budget.projectNamePlaceholder') }]}
>
<Input placeholder={t('budget.projectNamePlaceholder')} size="large" />
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item
name="customer_id"
label={t('budget.customer')}
rules={[{ required: true, message: t('budget.selectCustomer') }]}
>
<Select
placeholder={t('budget.selectCustomer')}
showSearch
optionFilterProp="children"
size="large"
>
{customers.map((c) => (
<Option key={c.id} value={c.id}>{c.name}</Option>
))}
</Select>
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} md={12}>
<Form.Item
name="manager_id"
label={t('budget.businessManager')}
rules={[{ required: true, message: t('budget.selectManager') }]}
>
<Select
placeholder={t('budget.selectManager')}
showSearch
optionFilterProp="children"
size="large"
>
{users.map((u) => (
<Option key={u.id} value={u.id}>{u.name} ({u.department || t('budget.unknownDept')})</Option>
))}
</Select>
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item name="location" label={t('budget.projectLocation')}>
<Input placeholder={t('budget.locationPlaceholder')} size="large" />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} md={12}>
<Form.Item name="survey_date" label={t('budget.surveyDate')}>
<DatePicker style={{ width: '100%' }} size="large" />
</Form.Item>
</Col>
</Row>
<Divider orientation="left">{t('budget.intermediary')}</Divider>
<Row gutter={16}>
<Col xs={24} md={8}>
<Form.Item name="intermediary" label={t('budget.intermediaryName')}>
<Input placeholder={t('budget.intermediaryNamePlaceholder')} size="large" />
</Form.Item>
</Col>
<Col xs={24} md={8}>
<Form.Item name="intermediary_fee_type" label={t('budget.intermediaryType')}>
<Radio.Group>
<Radio value="fixed">{t('budget.fixedAmount')}</Radio>
<Radio value="percentage">{t('budget.percentage')}</Radio>
</Radio.Group>
</Form.Item>
</Col>
<Col xs={24} md={8}>
<Form.Item
name="intermediary_fee_value"
label={intermediaryFeeType === 'percentage' ? t('budget.intermediaryRatio') : t('budget.intermediaryAmount')}
>
<InputNumber
style={{ width: '100%' }}
size="large"
min={0}
precision={intermediaryFeeType === 'percentage' ? 2 : 0}
placeholder={intermediaryFeeType === 'percentage' ? t('budget.intermediaryRatioPlaceholder') : t('budget.intermediaryAmountPlaceholder')}
/>
</Form.Item>
</Col>
</Row>
<Divider orientation="left">{t('budget.projectDetail')}</Divider>
<Form.Item name="customer_requirements" label={t('budget.customerRequirement')}>
<TextArea rows={4} placeholder={t('budget.requirementPlaceholder')} />
</Form.Item>
<Form.Item name="project_overview" label={t('budget.overview')}>
<TextArea rows={4} placeholder={t('budget.overviewPlaceholder')} />
</Form.Item>
<Divider orientation="left">{t('budget.attachment')}</Divider>
<Row gutter={16}>
<Col xs={24} md={12}>
<Form.Item label={t('budget.attachmentUpload')}>
<FileUpload
value={attachments}
onChange={(urls) => { setAttachments(urls); saveDraft({ attachments: urls, surveyPhotos }); }}
accept=".pdf,.doc,.docx,.jpg,.jpeg,.png,.xlsx,.xls"
/>
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item label={t('budget.surveyPhoto')}>
<FileUpload
value={surveyPhotos}
onChange={(urls) => { setSurveyPhotos(urls); saveDraft({ attachments, surveyPhotos: urls }); }}
accept="image/*"
/>
</Form.Item>
</Col>
</Row>
<div style={{ marginTop: 24, textAlign: 'right' }}>
<Space>
<Button onClick={() => {
if (form.isFieldsTouched()) {
Modal.confirm({
title: t('budget.leaveConfirm'),
content: t('budget.leaveConfirmMsg'),
okText: t('budget.leave'),
cancelText: t('budget.continueEdit'),
onOk: () => {
saveDraft({ attachments, surveyPhotos });
navigate('/budget-projects');
},
});
} else {
navigate('/budget-projects');
}
}}>{t('common.cancel')}</Button>
<Button
type="primary"
icon={<SaveOutlined />}
loading={loading}
onClick={handleSubmit}
>
{t('common.save')}
</Button>
</Space>
</div>
</Form>
</Card>
</div>
);
};
export default BudgetProjectCreate;
@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { Card, Typography, Button, Space, Tag, message, Empty, Divider, Row, Col, List, Descriptions, Avatar, Badge, Modal, Input } from 'antd';
import { Card, Typography, Button, Space, Tag, message, Empty, Divider, Row, Col, List, Descriptions, Modal, Input } from 'antd';
import { ArrowLeftOutlined, EyeOutlined, FileAddOutlined, FileOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
import { useNavigate, useParams } from 'react-router-dom';
import apiClient from '../../utils/request';
@@ -7,6 +7,7 @@ import dayjs from 'dayjs';
import QuotationCreateModal from './QuotationCreateModal';
import ContractCreateModal from './ContractCreateModal';
import { useAuthStore } from '../../store/authStore';
import { useLanguageStore } from '../../store/languageStore';
const { Title, Paragraph, Text } = Typography;
@@ -65,6 +66,7 @@ const BudgetProjectDetail: React.FC = () => {
const { id } = useParams<{ id: string }>();
const { user: currentUser } = useAuthStore();
const { t, currentLanguage } = useLanguageStore();
const isAdmin = currentUser?.role === 'admin' || false;
useEffect(() => {
@@ -81,7 +83,6 @@ const BudgetProjectDetail: React.FC = () => {
const res = await apiClient.get(`/budget-projects/${id}`);
if (res.data.success) {
const projectData = res.data.data;
// 后端已经解析了数据,直接使用
projectData.quotations = Array.isArray(projectData.quotations) ? projectData.quotations : [];
projectData.attachments = Array.isArray(projectData.attachments) ? projectData.attachments : [];
projectData.survey_photos = Array.isArray(projectData.survey_photos) ? projectData.survey_photos : [];
@@ -89,7 +90,7 @@ const BudgetProjectDetail: React.FC = () => {
}
} catch (error) {
console.error('获取项目详情失败:', error);
message.error('获取数据失败');
message.error(t('budget.getDataFailed'));
} finally {
setLoading(false);
}
@@ -97,9 +98,9 @@ const BudgetProjectDetail: React.FC = () => {
const getStatusTag = (status: string) => {
const statusMap: Record<string, { color: string; text: string }> = {
negotiating: { color: 'processing', text: '商谈中' },
signed: { color: 'success', text: '已签约' },
unsigned: { color: 'error', text: '未签约' },
negotiating: { color: 'processing', text: t('budget.inNegotiation') },
signed: { color: 'success', text: t('budget.signed') },
unsigned: { color: 'error', text: t('budget.unsigned') },
};
const config = statusMap[status] || { color: 'default', text: status };
return <Tag color={config.color}>{config.text}</Tag>;
@@ -107,10 +108,10 @@ const BudgetProjectDetail: React.FC = () => {
const getQuotationStatusTag = (status: string) => {
const statusMap: Record<string, { color: string; text: string }> = {
draft: { color: 'default', text: '草稿' },
sent: { color: 'processing', text: '已发送' },
approved: { color: 'success', text: '已通过' },
rejected: { color: 'error', text: '已拒绝' },
draft: { color: 'default', text: t('budget.draft') },
sent: { color: 'processing', text: t('budget.sent') },
approved: { color: 'success', text: t('budget.approved') },
rejected: { color: 'error', text: t('budget.rejected') },
};
const config = statusMap[status] || { color: 'default', text: status };
return <Tag color={config.color}>{config.text}</Tag>;
@@ -142,11 +143,11 @@ const BudgetProjectDetail: React.FC = () => {
}
});
if (res.data.success) {
message.success('标记未签约成功');
message.success(t('budget.signedSuccess'));
fetchProjectDetail();
}
} catch (error) {
message.error('操作失败');
message.error(t('common.operationFailed'));
}
};
@@ -159,9 +160,8 @@ const BudgetProjectDetail: React.FC = () => {
const handleQuotationDeleteConfirm = async () => {
if (!project || !quotationDeleteId) return;
// 验证密码(这里简单验证,实际项目中应该使用更安全的验证方式)
if (deletePassword !== 'X123c321@') {
message.error('密码错误');
message.error(t('budget.passwordError'));
return;
}
@@ -173,12 +173,12 @@ const BudgetProjectDetail: React.FC = () => {
}
});
if (res.data.success) {
message.success('删除成功');
message.success(t('budget.deleteSuccess'));
setQuotationDeleteModalVisible(false);
fetchProjectDetail();
}
} catch (error) {
message.error('删除失败');
message.error(t('common.deleteFailed'));
} finally {
setDeleteLoading(false);
}
@@ -210,9 +210,8 @@ const BudgetProjectDetail: React.FC = () => {
const handleProjectDeleteConfirm = async () => {
if (!project) return;
// 验证密码(这里简单验证,实际项目中应该使用更安全的验证方式)
if (deletePassword !== 'X123c321@') {
message.error('密码错误');
message.error(t('budget.passwordError'));
return;
}
@@ -224,12 +223,12 @@ const BudgetProjectDetail: React.FC = () => {
}
});
if (res.data.success) {
message.success('删除成功');
message.success(t('budget.deleteSuccess'));
setDeleteModalVisible(false);
navigate('/budget-projects');
}
} catch (error) {
message.error('删除失败');
message.error(t('common.deleteFailed'));
} finally {
setDeleteLoading(false);
}
@@ -247,9 +246,9 @@ const BudgetProjectDetail: React.FC = () => {
return (
<div style={{ padding: 24 }}>
<Card>
<Empty description="项目不存在" />
<Empty description={t('budget.notFound')} />
<Button type="primary" onClick={() => navigate('/budget-projects')} style={{ marginTop: 16 }}>
{t('budget.return')}
</Button>
</Card>
</div>
@@ -264,60 +263,58 @@ const BudgetProjectDetail: React.FC = () => {
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/budget-projects')}
>
{t('budget.return')}
</Button>
<Title level={2} style={{ marginBottom: 0 }}></Title>
<Title level={2} style={{ marginBottom: 0 }}>{t('budget.detailTitle')}</Title>
</div>
<Paragraph type="secondary"></Paragraph>
<Paragraph type="secondary">{t('budget.detailDesc')}</Paragraph>
</div>
{/* 项目基本信息 */}
<Card style={{ marginBottom: 24 }}>
<Title level={4}></Title>
<Title level={4}>{t('budget.projectInfo')}</Title>
<Divider />
<Row gutter={16}>
<Col xs={24} md={12}>
<Descriptions column={1} bordered>
<Descriptions.Item label="项目名称">{project.name}</Descriptions.Item>
<Descriptions.Item label="客户">{project.customer_name}</Descriptions.Item>
<Descriptions.Item label="业务经理">{project.manager_name}</Descriptions.Item>
<Descriptions.Item label="项目地点">{project.location || '-'}</Descriptions.Item>
<Descriptions.Item label="勘察日期">{project.survey_date || '-'}</Descriptions.Item>
<Descriptions.Item label="状态">{getStatusTag(project.status)}</Descriptions.Item>
<Descriptions.Item label="创建时间">{dayjs(project.created_at).format('YYYY-MM-DD HH:mm:ss')}</Descriptions.Item>
<Descriptions.Item label={t('budget.projectName')}>{project.name}</Descriptions.Item>
<Descriptions.Item label={t('budget.customer')}>{project.customer_name}</Descriptions.Item>
<Descriptions.Item label={t('budget.businessManager')}>{project.manager_name}</Descriptions.Item>
<Descriptions.Item label={t('budget.projectLocation')}>{project.location || '-'}</Descriptions.Item>
<Descriptions.Item label={t('budget.surveyDate')}>{project.survey_date || '-'}</Descriptions.Item>
<Descriptions.Item label={t('common.status')}>{getStatusTag(project.status)}</Descriptions.Item>
<Descriptions.Item label={t('common.create')}>{dayjs(project.created_at).format('YYYY-MM-DD HH:mm:ss')}</Descriptions.Item>
</Descriptions>
</Col>
<Col xs={24} md={12}>
<Descriptions column={1} bordered>
<Descriptions.Item label="居间人">{project.intermediary || '-'}</Descriptions.Item>
<Descriptions.Item label="居间费类型">
{project.intermediary_fee_type === 'fixed' ? '固定金额' : project.intermediary_fee_type === 'percentage' ? '百分比' : '-'}
<Descriptions.Item label={t('budget.intermediaryName')}>{project.intermediary || '-'}</Descriptions.Item>
<Descriptions.Item label={t('budget.intermediaryType')}>
{project.intermediary_fee_type === 'fixed' ? t('budget.fixedAmount') : project.intermediary_fee_type === 'percentage' ? t('budget.percentage') : '-'}
</Descriptions.Item>
<Descriptions.Item label="居间费">
<Descriptions.Item label={t('budget.intermediaryAmount')}>
{project.intermediary_fee_value ?
project.intermediary_fee_type === 'percentage' ?
`${project.intermediary_fee_value}%` :
formatAmount(project.intermediary_fee_value, 'CNY')
: '-'}
</Descriptions.Item>
<Descriptions.Item label="客户要求">{project.customer_requirements || '-'}</Descriptions.Item>
<Descriptions.Item label="工程概况">{project.project_overview || '-'}</Descriptions.Item>
<Descriptions.Item label={t('budget.customerRequirement')}>{project.customer_requirements || '-'}</Descriptions.Item>
<Descriptions.Item label={t('budget.overview')}>{project.project_overview || '-'}</Descriptions.Item>
</Descriptions>
</Col>
</Row>
</Card>
{/* 附件和照片 */}
<Card style={{ marginBottom: 24 }}>
<Title level={4}></Title>
<Title level={4}>{t('budget.attachment')}</Title>
<Divider />
<Row gutter={16}>
<Col xs={24} md={12}>
<div style={{ marginBottom: 16 }}>
<Text strong>:</Text>
<Text strong>{t('budget.attachmentUpload')}:</Text>
{project.attachments && project.attachments.length > 0 ? (
<List
style={{ marginTop: 8 }}
@@ -342,7 +339,7 @@ const BudgetProjectDetail: React.FC = () => {
icon={<EyeOutlined />}
onClick={handleView}
>
{t('common.view')}
</Button>
</Space>
</List.Item>
@@ -350,14 +347,14 @@ const BudgetProjectDetail: React.FC = () => {
}}
/>
) : (
<Text type="secondary" style={{ display: 'block', marginTop: 8 }}></Text>
<Text type="secondary" style={{ display: 'block', marginTop: 8 }}>{t('budget.noAttachment')}</Text>
)}
</div>
</Col>
<Col xs={24} md={12}>
<div style={{ marginBottom: 16 }}>
<Text strong>:</Text>
<Text strong>{t('budget.surveyPhoto')}:</Text>
{project.survey_photos && project.survey_photos.length > 0 ? (
<div style={{ marginTop: 8, display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{project.survey_photos.map((url, index) => (
@@ -369,30 +366,29 @@ const BudgetProjectDetail: React.FC = () => {
onClick={() => window.open(url, '_blank')}
/>
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, background: 'rgba(0, 0, 0, 0.5)', color: '#fff', padding: 4, fontSize: 12, textAlign: 'center' }}>
{index + 1}
{t('budget.photos')}{index + 1}
</div>
</div>
))}
</div>
) : (
<Text type="secondary" style={{ display: 'block', marginTop: 8 }}></Text>
<Text type="secondary" style={{ display: 'block', marginTop: 8 }}>{t('budget.noPhotos')}</Text>
)}
</div>
</Col>
</Row>
</Card>
{/* 报价版本列表 */}
<Card style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<Title level={4}></Title>
<Title level={4}>{t('budget.quotationVersions')}</Title>
{isAdmin && project.status === 'negotiating' && (
<Button
type="primary"
icon={<FileAddOutlined />}
onClick={openQuotationModal}
>
{t('budget.addVersion')}
</Button>
)}
</div>
@@ -424,7 +420,7 @@ const BudgetProjectDetail: React.FC = () => {
onClick={handleViewFile}
disabled={!quotation.file_url}
>
{t('common.view')}
</Button>,
isAdmin && (
<Button
@@ -432,24 +428,24 @@ const BudgetProjectDetail: React.FC = () => {
danger
onClick={() => handleDeleteQuotation(quotation.id)}
>
{t('common.delete')}
</Button>
)
].filter(Boolean)}
>
<List.Item.Meta
avatar={<Avatar style={{ backgroundColor: '#1890ff' }}>V{quotation.version}</Avatar>}
avatar={<div style={{ width: 40, height: 40, borderRadius: '50%', backgroundColor: '#1890ff', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', fontWeight: 'bold' }}>V{quotation.version}</div>}
title={
<Space>
<Text strong>V{quotation.version}</Text>
<Text strong>{t('budget.version')}V{quotation.version}</Text>
{getQuotationStatusTag(quotation.status)}
</Space>
}
description={
<Space direction="vertical">
<Text>: {dayjs(quotation.quotation_date).format('YYYY-MM-DD')}</Text>
<Text>: {formatAmount(quotation.amount, quotation.currency)}</Text>
{quotation.remark && <Text>: {quotation.remark}</Text>}
<Text>{t('budget.versionDate')}{dayjs(quotation.quotation_date).format('YYYY-MM-DD')}</Text>
<Text>{t('budget.versionAmount')}{formatAmount(quotation.amount, quotation.currency)}</Text>
{quotation.remark && <Text>{t('budget.versionRemark')}{quotation.remark}</Text>}
</Space>
}
/>
@@ -458,13 +454,12 @@ const BudgetProjectDetail: React.FC = () => {
}}
/>
) : (
<Empty description="暂无报价版本" image={Empty.PRESENTED_IMAGE_SIMPLE} />
<Empty description={t('budget.noVersion')} image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</Card>
{/* 操作按钮 */}
<Card>
<Title level={4}></Title>
<Title level={4}>{t('common.action')}</Title>
<Divider />
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
@@ -475,14 +470,14 @@ const BudgetProjectDetail: React.FC = () => {
icon={<CheckCircleOutlined />}
onClick={handleSign}
>
{t('budget.markSigned')}
</Button>
<Button
danger
icon={<CloseCircleOutlined />}
onClick={handleUnsigned}
>
{t('budget.markUnsigned')}
</Button>
</>
)}
@@ -492,7 +487,7 @@ const BudgetProjectDetail: React.FC = () => {
type="primary"
onClick={goToProjectManagement}
>
{t('budget.enterProject')}
</Button>
)}
@@ -501,13 +496,12 @@ const BudgetProjectDetail: React.FC = () => {
danger
onClick={handleDeleteProject}
>
{t('budget.deleteProject')}
</Button>
)}
</div>
</Card>
{/* 新增报价版本弹窗 */}
<QuotationCreateModal
visible={quotationModalVisible}
project={project}
@@ -515,7 +509,6 @@ const BudgetProjectDetail: React.FC = () => {
onSuccess={handleQuotationSuccess}
/>
{/* 合同信息录入弹窗 */}
<ContractCreateModal
visible={contractModalVisible}
projectId={project?.id || 0}
@@ -524,44 +517,42 @@ const BudgetProjectDetail: React.FC = () => {
onSuccess={handleContractSuccess}
/>
{/* 删除项目确认模态框 */}
<Modal
title="删除确认"
title={t('budget.deleteConfirm')}
open={deleteModalVisible}
onOk={handleProjectDeleteConfirm}
onCancel={() => setDeleteModalVisible(false)}
confirmLoading={deleteLoading}
okText="确认删除"
cancelText="取消"
okText={t('common.deleteConfirm')}
cancelText={t('common.cancel')}
>
<div style={{ marginBottom: 16 }}>
<p></p>
<p></p>
<p>{t('budget.deleteConfirmMsg')}</p>
<p>{t('budget.deletePassMsg')}</p>
</div>
<Input.Password
placeholder="请输入管理员密码"
placeholder={t('budget.deletePass')}
value={deletePassword}
onChange={(e) => setDeletePassword(e.target.value)}
size="large"
/>
</Modal>
{/* 删除报价版本确认模态框 */}
<Modal
title="删除确认"
title={t('budget.deleteConfirm')}
open={quotationDeleteModalVisible}
onOk={handleQuotationDeleteConfirm}
onCancel={() => setQuotationDeleteModalVisible(false)}
confirmLoading={deleteLoading}
okText="确认删除"
cancelText="取消"
okText={t('common.deleteConfirm')}
cancelText={t('common.cancel')}
>
<div style={{ marginBottom: 16 }}>
<p></p>
<p></p>
<p>{t('budget.versionDeleteConfirm')}</p>
<p>{t('budget.deletePassMsg')}</p>
</div>
<Input.Password
placeholder="请输入管理员密码"
placeholder={t('budget.deletePass')}
value={deletePassword}
onChange={(e) => setDeletePassword(e.target.value)}
size="large"
+299 -303
View File
@@ -1,303 +1,299 @@
import React, { useState, useEffect } from 'react';
import { Card, Typography, Button, Space, Tag, message, Empty, Radio, Modal, Input } from 'antd';
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import apiClient from '../../utils/request';
import dayjs from 'dayjs';
import { useAuthStore } from '../../store/authStore';
const { Title, Paragraph, Text } = Typography;
interface Quotation {
id: number;
version: number;
quotation_date: string;
amount: number;
currency: string;
status: 'draft' | 'sent' | 'approved' | 'rejected';
file_url?: string;
remark?: string;
created_at: string;
}
interface BudgetProject {
id: number;
name: string;
customer_id: number;
customer_name: string;
manager_id: number;
manager_name: string;
location?: string;
survey_date?: string;
intermediary?: string;
intermediary_fee_type?: 'fixed' | 'percentage';
intermediary_fee_value?: number;
customer_requirements?: string;
project_overview?: string;
attachments?: string[];
survey_photos?: string[];
status: 'negotiating' | 'signed' | 'unsigned';
days_in_status: number;
created_at: string;
quotations: Quotation[];
}
type StatusFilter = 'all' | 'negotiating' | 'signed' | 'unsigned';
const CURRENCIES: Record<string, { label: string; symbol: string }> = {
CNY: { label: '人民币', symbol: '¥' },
USD: { label: '美元', symbol: '$' },
LAK: { label: '老挝基普', symbol: '' },
THB: { label: '泰铢', symbol: '฿' },
};
const BudgetProjectList: React.FC = () => {
const [isMobile, setIsMobile] = useState(false);
const [projects, setProjects] = useState<BudgetProject[]>([]);
const [loading, setLoading] = useState(false);
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
const [deleteProjectId, setDeleteProjectId] = useState<number | null>(null);
const [deletePassword, setDeletePassword] = useState('');
const [deleteLoading, setDeleteLoading] = useState(false);
const navigate = useNavigate();
const { user: currentUser } = useAuthStore();
const isAdmin = currentUser?.role === 'admin' || false;
useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth <= 768);
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
useEffect(() => {
fetchProjects();
}, []);
const fetchProjects = async () => {
setLoading(true);
try {
const res = await apiClient.get('/budget-projects');
if (res.data.success) {
// 后端已经解析了数据,直接使用
const projectsWithParsedData = res.data.data.map((project: any) => {
return {
...project,
quotations: Array.isArray(project.quotations) ? project.quotations : [],
attachments: Array.isArray(project.attachments) ? project.attachments : [],
survey_photos: Array.isArray(project.survey_photos) ? project.survey_photos : []
};
});
setProjects(projectsWithParsedData);
}
} catch (error) {
console.error('获取预算项目失败:', error);
message.error('获取数据失败');
} finally {
setLoading(false);
}
};
const filteredProjects = projects.filter(p =>
statusFilter === 'all' || p.status === statusFilter
);
const getStatusTag = (status: string) => {
const statusMap: Record<string, { color: string; text: string }> = {
negotiating: { color: 'processing', text: '商谈中' },
signed: { color: 'success', text: '已签约' },
unsigned: { color: 'error', text: '未签约' },
};
const config = statusMap[status] || { color: 'default', text: status };
return <Tag color={config.color}>{config.text}</Tag>;
};
const getQuotationStatusTag = (status: string) => {
const statusMap: Record<string, { color: string; text: string }> = {
draft: { color: 'default', text: '草稿' },
sent: { color: 'processing', text: '已发送' },
approved: { color: 'success', text: '已通过' },
rejected: { color: 'error', text: '已拒绝' },
};
const config = statusMap[status] || { color: 'default', text: status };
return <Tag color={config.color}>{config.text}</Tag>;
};
const formatAmount = (amount: number, currency: string = 'CNY') => {
const c = CURRENCIES[currency];
const symbol = c?.symbol || '¥';
return `${symbol}${amount.toLocaleString('zh-CN')}`;
};
const handleDeleteProject = (projectId: number) => {
setDeleteProjectId(projectId);
setDeletePassword('');
setDeleteModalVisible(true);
};
const handleDeleteConfirm = async () => {
if (!deleteProjectId) return;
// 验证密码(这里简单验证,实际项目中应该使用更安全的验证方式)
if (deletePassword !== 'X123c321@') {
message.error('密码错误');
return;
}
setDeleteLoading(true);
try {
const res = await apiClient.delete(`/budget-projects/${deleteProjectId}`, {
headers: {
'x-user-role': currentUser?.role || 'employee'
}
});
if (res.data.success) {
message.success('删除成功');
setDeleteModalVisible(false);
fetchProjects();
}
} catch (error) {
message.error('删除失败');
} finally {
setDeleteLoading(false);
}
};
return (
<div style={{ padding: isMobile ? 8 : 24 }}>
<div style={{ marginBottom: isMobile ? 12 : 24 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 12 }}>
<div>
<Title level={isMobile ? 3 : 2} style={{ marginBottom: 8 }}></Title>
<Paragraph type="secondary" style={{ marginBottom: 0 }}></Paragraph>
</div>
{isAdmin && (
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => navigate('/budget-projects/create')}
size={isMobile ? 'middle' : 'large'}
>
</Button>
)}
</div>
</div>
{/* 状态筛选 */}
<Card style={{ marginBottom: 16 }}>
<Space>
<Text strong>:</Text>
<Radio.Group
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
optionType="button"
buttonStyle="solid"
>
<Radio.Button value="all"></Radio.Button>
<Radio.Button value="negotiating"></Radio.Button>
<Radio.Button value="signed"></Radio.Button>
<Radio.Button value="unsigned"></Radio.Button>
</Radio.Group>
</Space>
</Card>
{/* 项目列表 */}
<Card loading={loading}>
{filteredProjects.length === 0 ? (
<Empty description="暂无数据" />
) : (
<div>
{filteredProjects.map((project) => (
<div
key={project.id}
style={{
border: '1px solid #f0f0f0',
borderRadius: 8,
marginBottom: 16,
overflow: 'hidden'
}}
>
{/* 项目头部 */}
<div
style={{
padding: '16px 20px',
background: '#fafafa',
borderBottom: '1px solid #f0f0f0',
cursor: 'pointer'
}}
onClick={() => navigate(`/budget-projects/${project.id}`)}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: 12 }}>
<Space size="middle">
<Text strong style={{ fontSize: 16 }}>{project.name}</Text>
</Space>
<Space>
{getStatusTag(project.status)}
<Text type="secondary">{project.days_in_status}</Text>
{isAdmin && (
<Button
danger
size="small"
onClick={(e) => {
e.stopPropagation();
handleDeleteProject(project.id);
}}
>
</Button>
)}
</Space>
</div>
<div style={{ marginTop: 12 }}>
<Space direction="vertical" size={4} style={{ width: '100%' }}>
<Text type="secondary">: {project.customer_name}</Text>
<Text type="secondary">: {project.manager_name}</Text>
{project.intermediary && (
<Text type="secondary">
: {project.intermediary}
{project.intermediary_fee_value && (
<span> : {formatAmount(project.intermediary_fee_value, 'CNY')}</span>
)}
</Text>
)}
</Space>
</div>
</div>
</div>
))}
</div>
)}
</Card>
{/* 删除确认模态框 */}
<Modal
title="删除确认"
open={deleteModalVisible}
onOk={handleDeleteConfirm}
onCancel={() => setDeleteModalVisible(false)}
confirmLoading={deleteLoading}
okText="确认删除"
cancelText="取消"
>
<div style={{ marginBottom: 16 }}>
<p></p>
<p></p>
</div>
<Input.Password
placeholder="请输入管理员密码"
value={deletePassword}
onChange={(e) => setDeletePassword(e.target.value)}
size="large"
/>
</Modal>
</div>
);
};
export default BudgetProjectList;
import React, { useState, useEffect } from 'react';
import { Card, Typography, Button, Space, Tag, message, Empty, Radio, Modal, Input } from 'antd';
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import apiClient from '../../utils/request';
import dayjs from 'dayjs';
import { useAuthStore } from '../../store/authStore';
import { useLanguageStore } from '../../store/languageStore';
const { Title, Paragraph, Text } = Typography;
interface Quotation {
id: number;
version: number;
quotation_date: string;
amount: number;
currency: string;
status: 'draft' | 'sent' | 'approved' | 'rejected';
file_url?: string;
remark?: string;
created_at: string;
}
interface BudgetProject {
id: number;
name: string;
customer_id: number;
customer_name: string;
manager_id: number;
manager_name: string;
location?: string;
survey_date?: string;
intermediary?: string;
intermediary_fee_type?: 'fixed' | 'percentage';
intermediary_fee_value?: number;
customer_requirements?: string;
project_overview?: string;
attachments?: string[];
survey_photos?: string[];
status: 'negotiating' | 'signed' | 'unsigned';
days_in_status: number;
created_at: string;
quotations: Quotation[];
}
type StatusFilter = 'all' | 'negotiating' | 'signed' | 'unsigned';
const CURRENCIES: Record<string, { label: string; symbol: string }> = {
CNY: { label: '人民币', symbol: '¥' },
USD: { label: '美元', symbol: '$' },
LAK: { label: '老挝基普', symbol: '' },
THB: { label: '泰铢', symbol: '฿' },
};
const BudgetProjectList: React.FC = () => {
const [isMobile, setIsMobile] = useState(false);
const [projects, setProjects] = useState<BudgetProject[]>([]);
const [loading, setLoading] = useState(false);
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
const [deleteProjectId, setDeleteProjectId] = useState<number | null>(null);
const [deletePassword, setDeletePassword] = useState('');
const [deleteLoading, setDeleteLoading] = useState(false);
const navigate = useNavigate();
const { user: currentUser } = useAuthStore();
const { t, currentLanguage } = useLanguageStore();
const isAdmin = currentUser?.role === 'admin' || false;
useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth <= 768);
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
useEffect(() => {
fetchProjects();
}, []);
const fetchProjects = async () => {
setLoading(true);
try {
const res = await apiClient.get('/budget-projects');
if (res.data.success) {
const projectsWithParsedData = res.data.data.map((project: any) => {
return {
...project,
quotations: Array.isArray(project.quotations) ? project.quotations : [],
attachments: Array.isArray(project.attachments) ? project.attachments : [],
survey_photos: Array.isArray(project.survey_photos) ? project.survey_photos : []
};
});
setProjects(projectsWithParsedData);
}
} catch (error) {
console.error('获取预算项目失败:', error);
message.error(t('budget.getDataFailed'));
} finally {
setLoading(false);
}
};
const filteredProjects = projects.filter(p =>
statusFilter === 'all' || p.status === statusFilter
);
const getStatusTag = (status: string) => {
const statusMap: Record<string, { color: string; text: string }> = {
negotiating: { color: 'processing', text: t('budget.inNegotiation') },
signed: { color: 'success', text: t('budget.signed') },
unsigned: { color: 'error', text: t('budget.unsigned') },
};
const config = statusMap[status] || { color: 'default', text: status };
return <Tag color={config.color}>{config.text}</Tag>;
};
const getQuotationStatusTag = (status: string) => {
const statusMap: Record<string, { color: string; text: string }> = {
draft: { color: 'default', text: t('budget.draft') },
sent: { color: 'processing', text: t('budget.sent') },
approved: { color: 'success', text: t('budget.approved') },
rejected: { color: 'error', text: t('budget.rejected') },
};
const config = statusMap[status] || { color: 'default', text: status };
return <Tag color={config.color}>{config.text}</Tag>;
};
const formatAmount = (amount: number, currency: string = 'CNY') => {
const c = CURRENCIES[currency];
const symbol = c?.symbol || '¥';
return `${symbol}${amount.toLocaleString('zh-CN')}`;
};
const handleDeleteProject = (projectId: number) => {
setDeleteProjectId(projectId);
setDeletePassword('');
setDeleteModalVisible(true);
};
const handleDeleteConfirm = async () => {
if (!deleteProjectId) return;
if (deletePassword !== 'X123c321@') {
message.error(t('budget.passwordError'));
return;
}
setDeleteLoading(true);
try {
const res = await apiClient.delete(`/budget-projects/${deleteProjectId}`, {
headers: {
'x-user-role': currentUser?.role || 'employee'
}
});
if (res.data.success) {
message.success(t('budget.deleteSuccess'));
setDeleteModalVisible(false);
fetchProjects();
}
} catch (error) {
message.error(t('common.deleteFailed'));
} finally {
setDeleteLoading(false);
}
};
return (
<div style={{ padding: isMobile ? 8 : 24 }}>
<div style={{ marginBottom: isMobile ? 12 : 24 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 12 }}>
<div>
<Title level={isMobile ? 3 : 2} style={{ marginBottom: 8 }}>{t('budget.title')}</Title>
<Paragraph type="secondary" style={{ marginBottom: 0 }}>{t('budget.description')}</Paragraph>
</div>
{isAdmin && (
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => navigate('/budget-projects/create')}
size={isMobile ? 'middle' : 'large'}
>
{t('budget.newProject')}
</Button>
)}
</div>
</div>
<Card style={{ marginBottom: 16 }}>
<Space>
<Text strong>{t('budget.statusFilter')}</Text>
<Radio.Group
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
optionType="button"
buttonStyle="solid"
>
<Radio.Button value="all">{t('common.all')}</Radio.Button>
<Radio.Button value="negotiating">{t('budget.inNegotiation')}</Radio.Button>
<Radio.Button value="signed">{t('budget.signed')}</Radio.Button>
<Radio.Button value="unsigned">{t('budget.unsigned')}</Radio.Button>
</Radio.Group>
</Space>
</Card>
<Card loading={loading}>
{filteredProjects.length === 0 ? (
<Empty description={t('common.noData')} />
) : (
<div>
{filteredProjects.map((project) => (
<div
key={project.id}
style={{
border: '1px solid #f0f0f0',
borderRadius: 8,
marginBottom: 16,
overflow: 'hidden'
}}
>
<div
style={{
padding: '16px 20px',
background: '#fafafa',
borderBottom: '1px solid #f0f0f0',
cursor: 'pointer'
}}
onClick={() => navigate(`/budget-projects/${project.id}`)}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: 12 }}>
<Space size="middle">
<Text strong style={{ fontSize: 16 }}>{project.name}</Text>
</Space>
<Space>
{getStatusTag(project.status)}
<Text type="secondary">{project.days_in_status}{t('common.days')}</Text>
{isAdmin && (
<Button
danger
size="small"
onClick={(e) => {
e.stopPropagation();
handleDeleteProject(project.id);
}}
>
{t('common.delete')}
</Button>
)}
</Space>
</div>
<div style={{ marginTop: 12 }}>
<Space direction="vertical" size={4} style={{ width: '100%' }}>
<Text type="secondary">{t('budget.customerLabel')}{project.customer_name}</Text>
<Text type="secondary">{t('budget.managerLabel')}{project.manager_name}</Text>
{project.intermediary && (
<Text type="secondary">
{t('budget.intermediaryLabel')}{project.intermediary}
{project.intermediary_fee_value && (
<span> {t('budget.intermediaryFee')}{formatAmount(project.intermediary_fee_value, 'CNY')}</span>
)}
</Text>
)}
</Space>
</div>
</div>
</div>
))}
</div>
)}
</Card>
<Modal
title={t('budget.deleteConfirm')}
open={deleteModalVisible}
onOk={handleDeleteConfirm}
onCancel={() => setDeleteModalVisible(false)}
confirmLoading={deleteLoading}
okText={t('common.deleteConfirm')}
cancelText={t('common.cancel')}
>
<div style={{ marginBottom: 16 }}>
<p>{t('budget.deleteConfirmMsg')}</p>
<p>{t('budget.deletePassMsg')}</p>
</div>
<Input.Password
placeholder={t('budget.deletePass')}
value={deletePassword}
onChange={(e) => setDeletePassword(e.target.value)}
size="large"
/>
</Modal>
</div>
);
};
export default BudgetProjectList;
@@ -2,6 +2,7 @@ import React, { useState, useEffect, useCallback } from 'react';
import { Modal, Form, Input, DatePicker, InputNumber, Select, Space, message } from 'antd';
import dayjs from 'dayjs';
import apiClient from '../../utils/request';
import { useLanguageStore } from '../../store/languageStore';
import useFormDraft from '../../hooks/useFormDraft';
interface ContractCreateModalProps {
@@ -21,8 +22,8 @@ const ContractCreateModal: React.FC<ContractCreateModalProps> = ({
}) => {
const [form] = Form.useForm();
const [contractAmount, setContractAmount] = useState(0);
const { t, currentLanguage } = useLanguageStore();
// 表单草稿保护
const { save: saveDraft, restore: restoreDraft, clear: clearDraft, hasDraft } = useFormDraft({
form,
storageKey: 'contract_create',
@@ -32,7 +33,6 @@ const ContractCreateModal: React.FC<ContractCreateModalProps> = ({
saveDraft()
}, [saveDraft])
// 生成默认的合同编号(包含时间戳确保唯一性)
const today = dayjs();
const dateStr = today.format('YYYYMMDD');
const timeStr = today.format('HHmmss');
@@ -52,14 +52,10 @@ const ContractCreateModal: React.FC<ContractCreateModalProps> = ({
}
}, [visible, form, projectName]);
// 处理工期变化
const handlePeriodChange = (value: number) => {
// 只需要设置工期天数,不需要计算开始和结束日期
};
// 提交表单
const handleSubmit = async (values: any) => {
// 构建提交数据(简化版)
const submitData = {
contract_code: values.contract_code,
project_name: values.project_name,
@@ -67,9 +63,8 @@ const ContractCreateModal: React.FC<ContractCreateModalProps> = ({
currency: values.currency || 'CNY',
contract_amount: values.contract_amount || 0,
contract_period: values.contract_period || 180,
warranty_deposit_percentage: 5, // 默认5%
warranty_period: 12, // 默认12个月
// 其他字段留空,后续在项目管理中补充
warranty_deposit_percentage: 5,
warranty_period: 12,
project_overview: '',
other_requirements: '',
contract_file: null,
@@ -81,39 +76,39 @@ const ContractCreateModal: React.FC<ContractCreateModalProps> = ({
try {
const res = await apiClient.put(`/budget-projects/${projectId}/sign`, submitData, {
headers: {
'x-user-role': 'admin' // 签约操作需要管理员权限
'x-user-role': 'admin'
}
});
if (res.data.success) {
message.success('签约成功,项目已自动创建');
message.success(t('budget.quickSignSuccess'));
clearDraft()
onSuccess();
onCancel();
} else {
message.error(res.data.message || '操作失败');
message.error(res.data.message || t('common.operationFailed'));
}
} catch (error: any) {
console.error('签约失败:', error);
console.error('错误响应:', error.response);
const errorMessage = error.response?.data?.message || error.message || '操作失败';
const errorMessage = error.response?.data?.message || error.message || t('common.operationFailed');
message.error(errorMessage);
}
};
return (
<Modal
title="快速签约"
title={t('budget.quickSign')}
open={visible}
onOk={() => form.submit()}
onCancel={() => {
if (form.isFieldsTouched()) {
Modal.confirm({
title: '确认关闭',
content: '表单数据尚未保存,关闭后可通过草稿恢复。确定关闭吗?',
okText: '关闭',
cancelText: '继续编辑',
title: t('common.closeConfirm'),
content: t('common.closeConfirmMsg'),
okText: t('common.close'),
cancelText: t('common.continueEdit'),
onOk: () => {
saveDraft()
onCancel()
@@ -124,8 +119,8 @@ const ContractCreateModal: React.FC<ContractCreateModalProps> = ({
}
}}
width={600}
okText="确认签约"
cancelText="取消"
okText={t('budget.markSigned')}
cancelText={t('common.cancel')}
maskClosable={false}
>
<Form
@@ -134,44 +129,43 @@ const ContractCreateModal: React.FC<ContractCreateModalProps> = ({
onFinish={handleSubmit}
onValuesChange={handleFormChange}
>
{/* 基本信息 */}
<Form.Item
name="contract_code"
label="合同编号"
rules={[{ required: true, message: '请输入合同编号' }]}
label={t('budget.contractNo')}
rules={[{ required: true, message: t('budget.contractNoPlaceholder') }]}
>
<Input placeholder="请输入合同编号" />
<Input placeholder={t('budget.contractNoPlaceholder')} />
</Form.Item>
<Form.Item
name="project_name"
label="项目名称"
rules={[{ required: true, message: '请输入项目名称' }]}
label={t('budget.projectName')}
rules={[{ required: true, message: t('budget.projectNamePlaceholder') }]}
>
<Input placeholder="请输入项目名称" />
<Input placeholder={t('budget.projectNamePlaceholder')} />
</Form.Item>
<Form.Item
name="contract_method"
label="承包方式"
rules={[{ required: true, message: '请选择承包方式' }]}
label={t('budget.contractType')}
rules={[{ required: true, message: t('budget.selectContractType') }]}
>
<Select
placeholder="请选择承包方式"
placeholder={t('budget.selectContractType')}
options={[
{ value: 'lump_sum', label: '总价包干' },
{ value: 'unit_price', label: '单价结算' }
{ value: 'lump_sum', label: t('budget.fixedAmount') },
{ value: 'unit_price', label: t('budget.percentage') }
]}
/>
</Form.Item>
<Form.Item
name="currency"
label="币种"
rules={[{ required: true, message: '请选择币种' }]}
label={t('common.currency')}
rules={[{ required: true, message: t('common.pleaseSelect') }]}
>
<Select
placeholder="请选择币种"
placeholder={t('common.pleaseSelect')}
options={[
{ value: 'CNY', label: '人民币' },
{ value: 'USD', label: '美元' },
@@ -183,41 +177,40 @@ const ContractCreateModal: React.FC<ContractCreateModalProps> = ({
<Form.Item
name="contract_amount"
label="总价"
label={t('budget.totalPrice')}
rules={[
{
required: true,
message: '请输入总价'
message: t('budget.totalPricePlaceholder')
}
]}
>
<InputNumber
style={{ width: '100%' }}
min={0}
placeholder="请输入总价"
placeholder={t('budget.totalPricePlaceholder')}
formatter={(value) => `¥ ${value}`}
parser={(value) => value.replace(/¥\s?|(,*)/g, '')}
onChange={(value) => setContractAmount(value || 0)}
/>
</Form.Item>
{/* 工期 */}
<Form.Item
name="contract_period"
label="工期(天)"
rules={[{ required: true, message: '请输入工期' }]}
label={t('budget.durationDays')}
rules={[{ required: true, message: t('budget.durationPlaceholder') }]}
>
<InputNumber
style={{ width: '100%' }}
min={1}
placeholder="请输入工期(天)"
placeholder={t('budget.durationDaysPlaceholder')}
onChange={handlePeriodChange}
/>
</Form.Item>
<div style={{ marginTop: 16, padding: 16, background: '#f5f5f5', borderRadius: 8 }}>
<p style={{ margin: 0, fontSize: 14, color: '#666' }}>
{t('budget.quickSignNote')}
</p>
</div>
</Form>
@@ -225,4 +218,4 @@ const ContractCreateModal: React.FC<ContractCreateModalProps> = ({
);
};
export default ContractCreateModal;
export default ContractCreateModal;
+297 -288
View File
@@ -1,288 +1,297 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Modal, Form, Input, DatePicker, InputNumber, Select, Upload, Button, message } from 'antd';
import { UploadOutlined, DeleteOutlined, FileOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import apiClient from '../../utils/request';
import useFormDraft from '../../hooks/useFormDraft';
const { Option } = Select;
interface Quotation {
id: number;
version: number;
quotation_date: string;
amount: number;
currency: string;
status: 'draft' | 'sent' | 'approved' | 'rejected';
file_url?: string;
remark?: string;
}
interface BudgetProject {
id: number;
name: string;
quotations: Quotation[];
}
interface QuotationCreateModalProps {
visible: boolean;
project: BudgetProject | null;
onCancel: () => void;
onSuccess: () => void;
}
const CURRENCIES = [
{ value: 'CNY', label: '人民币', symbol: '¥' },
{ value: 'USD', label: '美元', symbol: '$' },
{ value: 'LAK', label: '老挝基普', symbol: '' },
{ value: 'THB', label: '泰铢', symbol: '฿' },
];
const QuotationCreateModal: React.FC<QuotationCreateModalProps> = ({
visible,
project,
onCancel,
onSuccess,
}) => {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [uploadedFile, setUploadedFile] = useState<{ url: string; name: string } | null>(null);
// 表单草稿保护
const { save: saveDraft, restore: restoreDraft, clear: clearDraft, hasDraft } = useFormDraft({
form,
storageKey: 'quotation_create',
onRestore: (data) => {
if (data.uploadedFile) setUploadedFile(data.uploadedFile)
},
})
const handleFormChange = useCallback(() => {
saveDraft({ uploadedFile })
}, [saveDraft, uploadedFile])
// 计算下一个版本号
const nextVersion = project?.quotations && Array.isArray(project.quotations) && project.quotations.length > 0
? Math.max(...project.quotations.map(q => q.version || 0)) + 1
: 1;
useEffect(() => {
if (visible) {
form.resetFields();
form.setFieldsValue({
quotation_date: dayjs(),
currency: 'CNY',
version: nextVersion,
});
setUploadedFile(null);
}
}, [visible, nextVersion, form]);
const handleUpload = async (options: any) => {
const { file, onSuccess: onUploadSuccess, onError } = options;
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch('/api/upload/single', {
method: 'POST',
body: formData,
});
const result = await response.json();
if (result.success) {
message.success('上传成功');
setUploadedFile({ url: result.data.url, name: file.name });
onUploadSuccess(result.data, file);
} else {
message.error(result.error || '上传失败');
onError?.(new Error(result.error));
}
} catch (error: any) {
message.error('上传失败');
onError?.(error);
}
};
const handleRemoveFile = () => {
setUploadedFile(null);
};
const handleSubmit = async () => {
if (!project) return;
try {
const values = await form.validateFields();
setLoading(true);
const quotationData = {
...values,
quotation_date: values.quotation_date.format('YYYY-MM-DD'),
file_url: uploadedFile?.url,
version: nextVersion,
};
const res = await apiClient.post(`/budget-projects/${project.id}/quotations`, quotationData, {
headers: {
'x-user-role': 'admin' // 创建报价版本需要管理员权限
}
});
if (res.data.success) {
message.success('新增报价版本成功');
clearDraft()
onSuccess();
}
} catch (error: any) {
if (error.response?.data?.error) {
message.error(error.response.data.error);
} else {
message.error('创建失败');
}
} finally {
setLoading(false);
}
};
const getFileIcon = () => (
<div
style={{
width: 60,
height: 60,
border: '1px solid #d9d9d9',
borderRadius: 4,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#fafafa',
}}
>
<FileOutlined style={{ fontSize: 24, color: '#1890ff' }} />
</div>
);
return (
<Modal
title="新增报价版本"
open={visible}
onOk={handleSubmit}
onCancel={() => {
if (form.isFieldsTouched()) {
Modal.confirm({
title: '确认关闭',
content: '表单数据尚未保存,关闭后可通过草稿恢复。确定关闭吗?',
okText: '关闭',
cancelText: '继续编辑',
onOk: () => {
saveDraft({ uploadedFile })
onCancel()
},
})
} else {
onCancel()
}
}}
width={600}
confirmLoading={loading}
okText="保存"
cancelText="取消"
maskClosable={false}
>
<Form form={form} layout="vertical" onValuesChange={handleFormChange}>
{/* 项目信息展示 */}
<div style={{
padding: 16,
background: '#f5f5f5',
borderRadius: 8,
marginBottom: 24
}}>
<div style={{ marginBottom: 8 }}>
<span style={{ color: '#666' }}>: </span>
<span style={{ fontWeight: 500 }}>{project?.name}</span>
</div>
<div>
<span style={{ color: '#666' }}>: </span>
<span style={{ fontWeight: 500 }}>V{nextVersion - 1}</span>
<span style={{ color: '#999', marginLeft: 8 }}>
( V{nextVersion})
</span>
</div>
</div>
<Form.Item
name="quotation_date"
label="报价日期"
rules={[{ required: true, message: '请选择报价日期' }]}
>
<DatePicker style={{ width: '100%' }} />
</Form.Item>
<Form.Item
name="amount"
label="报价金额"
rules={[{ required: true, message: '请输入报价金额' }]}
>
<InputNumber
style={{ width: '100%' }}
min={0}
precision={2}
placeholder="请输入报价金额"
addonAfter=""
/>
</Form.Item>
<Form.Item
name="currency"
label="币种"
rules={[{ required: true, message: '请选择币种' }]}
>
<Select placeholder="请选择币种">
{CURRENCIES.map((c) => (
<Option key={c.value} value={c.value}>
{c.label} ({c.symbol})
</Option>
))}
</Select>
</Form.Item>
<Form.Item label="报价文件">
{uploadedFile ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
{getFileIcon()}
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 500 }}>{uploadedFile.name}</div>
<a href={uploadedFile.url} target="_blank" rel="noopener noreferrer">
</a>
</div>
<Button
danger
icon={<DeleteOutlined />}
onClick={handleRemoveFile}
size="small"
>
</Button>
</div>
) : (
<Upload
accept=".pdf,.doc,.docx,.xlsx,.xls,.jpg,.jpeg,.png"
customRequest={handleUpload}
showUploadList={false}
maxCount={1}
>
<Button icon={<UploadOutlined />}></Button>
</Upload>
)}
</Form.Item>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={3} placeholder="请输入备注信息" />
</Form.Item>
</Form>
</Modal>
);
};
export default QuotationCreateModal;
import React, { useState, useEffect, useCallback } from 'react';
import { Modal, Form, Input, DatePicker, InputNumber, Select, Upload, Button, message } from 'antd';
import { UploadOutlined, DeleteOutlined, FileOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import apiClient from '../../utils/request';
import { useLanguageStore } from '../../store/languageStore';
import useFormDraft from '../../hooks/useFormDraft';
const { Option } = Select;
interface Quotation {
id: number;
version: number;
quotation_date: string;
amount: number;
currency: string;
status: 'draft' | 'sent' | 'approved' | 'rejected';
file_url?: string;
remark?: string;
}
interface BudgetProject {
id: number;
name: string;
quotations: Quotation[];
}
interface QuotationCreateModalProps {
visible: boolean;
project: BudgetProject | null;
onCancel: () => void;
onSuccess: () => void;
}
const CURRENCIES = [
{ value: 'CNY', label: '人民币', symbol: '¥' },
{ value: 'USD', label: '美元', symbol: '$' },
{ value: 'LAK', label: '老挝基普', symbol: '' },
{ value: 'THB', label: '泰铢', symbol: '฿' },
];
const QuotationCreateModal: React.FC<QuotationCreateModalProps> = ({
visible,
project,
onCancel,
onSuccess,
}) => {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [uploadedFile, setUploadedFile] = useState<{ url: string; name: string } | null>(null);
const { t, currentLanguage } = useLanguageStore();
const { save: saveDraft, restore: restoreDraft, clear: clearDraft, hasDraft } = useFormDraft({
form,
storageKey: 'quotation_create',
onRestore: (data) => {
if (data.uploadedFile) setUploadedFile(data.uploadedFile)
},
})
const handleFormChange = useCallback(() => {
saveDraft({ uploadedFile })
}, [saveDraft, uploadedFile])
const nextVersion = project?.quotations && Array.isArray(project.quotations) && project.quotations.length > 0
? Math.max(...project.quotations.map(q => q.version || 0)) + 1
: 1;
useEffect(() => {
if (visible) {
form.resetFields();
form.setFieldsValue({
quotation_date: dayjs(),
currency: 'CNY',
version: nextVersion,
});
setUploadedFile(null);
}
}, [visible, nextVersion, form]);
const handleUpload = async (options: any) => {
const { file, onSuccess: onUploadSuccess, onError } = options;
const formData = new FormData();
formData.append('file', file);
try {
let authHeader = '';
try {
const authStorage = localStorage.getItem('auth-storage');
if (authStorage) {
const parsed = JSON.parse(authStorage);
const token = parsed?.state?.token;
if (token) authHeader = `Bearer ${token}`;
}
} catch (e) {}
const response = await fetch('/api/upload/single', {
method: 'POST',
body: formData,
headers: authHeader ? { Authorization: authHeader } : {},
});
const result = await response.json();
if (result.success) {
message.success(t('budget.uploadSuccess'));
setUploadedFile({ url: result.data.url, name: file.name });
onUploadSuccess(result.data, file);
} else {
message.error(result.error || t('common.operationFailed'));
onError?.(new Error(result.error));
}
} catch (error: any) {
message.error(t('common.operationFailed'));
onError?.(error);
}
};
const handleRemoveFile = () => {
setUploadedFile(null);
};
const handleSubmit = async () => {
if (!project) return;
try {
const values = await form.validateFields();
setLoading(true);
const quotationData = {
...values,
quotation_date: values.quotation_date.format('YYYY-MM-DD'),
file_url: uploadedFile?.url,
version: nextVersion,
};
const res = await apiClient.post(`/budget-projects/${project.id}/quotations`, quotationData, {
headers: {
'x-user-role': 'admin'
}
});
if (res.data.success) {
message.success(t('budget.addVersion'));
clearDraft()
onSuccess();
}
} catch (error: any) {
if (error.response?.data?.error) {
message.error(error.response.data.error);
} else {
message.error(t('budget.createFailed'));
}
} finally {
setLoading(false);
}
};
const getFileIcon = () => (
<div
style={{
width: 60,
height: 60,
border: '1px solid #d9d9d9',
borderRadius: 4,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#fafafa',
}}
>
<FileOutlined style={{ fontSize: 24, color: '#1890ff' }} />
</div>
);
return (
<Modal
title={t('budget.newQuotation')}
open={visible}
onOk={handleSubmit}
onCancel={() => {
if (form.isFieldsTouched()) {
Modal.confirm({
title: t('common.closeConfirm'),
content: t('common.closeConfirmMsg'),
okText: t('common.close'),
cancelText: t('common.continueEdit'),
onOk: () => {
saveDraft({ uploadedFile })
onCancel()
},
})
} else {
onCancel()
}
}}
width={600}
confirmLoading={loading}
okText={t('common.save')}
cancelText={t('common.cancel')}
maskClosable={false}
>
<Form form={form} layout="vertical" onValuesChange={handleFormChange}>
<div style={{
padding: 16,
background: '#f5f5f5',
borderRadius: 8,
marginBottom: 24
}}>
<div style={{ marginBottom: 8 }}>
<span style={{ color: '#666' }}>{t('budget.projectLabel')}</span>
<span style={{ fontWeight: 500 }}>{project?.name}</span>
</div>
<div>
<span style={{ color: '#666' }}>{t('budget.version')}</span>
<span style={{ fontWeight: 500 }}>V{nextVersion - 1}</span>
<span style={{ color: '#999', marginLeft: 8 }}>
({t('budget.newQuotation')} V{nextVersion})
</span>
</div>
</div>
<Form.Item
name="quotation_date"
label={t('budget.quotationDate')}
rules={[{ required: true, message: t('budget.selectDate') }]}
>
<DatePicker style={{ width: '100%' }} />
</Form.Item>
<Form.Item
name="amount"
label={t('budget.quotationAmount')}
rules={[{ required: true, message: t('budget.amountPlaceholder') }]}
>
<InputNumber
style={{ width: '100%' }}
min={0}
precision={2}
placeholder={t('budget.amountPlaceholder')}
addonAfter={t('common.yuan')}
/>
</Form.Item>
<Form.Item
name="currency"
label={t('common.currency')}
rules={[{ required: true, message: t('common.pleaseSelect') }]}
>
<Select placeholder={t('common.pleaseSelect')}>
{CURRENCIES.map((c) => (
<Option key={c.value} value={c.value}>
{c.label} ({c.symbol})
</Option>
))}
</Select>
</Form.Item>
<Form.Item label={t('budget.quotationFile')}>
{uploadedFile ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
{getFileIcon()}
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 500 }}>{uploadedFile.name}</div>
<a href={uploadedFile.url} target="_blank" rel="noopener noreferrer">
{t('budget.viewFile')}
</a>
</div>
<Button
danger
icon={<DeleteOutlined />}
onClick={handleRemoveFile}
size="small"
>
{t('common.delete')}
</Button>
</div>
) : (
<Upload
accept=".pdf,.doc,.docx,.xlsx,.xls,.jpg,.jpeg,.png"
customRequest={handleUpload}
showUploadList={false}
maxCount={1}
>
<Button icon={<UploadOutlined />}>{t('budget.uploadFile')}</Button>
</Upload>
)}
</Form.Item>
<Form.Item name="remark" label={t('common.remark')}>
<Input.TextArea rows={3} placeholder={t('budget.remarkPlaceholder')} />
</Form.Item>
</Form>
</Modal>
);
};
export default QuotationCreateModal;
@@ -1,264 +1,270 @@
import React, { useState, useEffect } from 'react';
import { Card, Typography, Button, Space, Tag, Progress, Empty, Spin, message, Row, Col, Divider } from 'antd';
import { FileTextOutlined, CameraOutlined, ScheduleOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import apiClient from '../../utils/request';
import dayjs from 'dayjs';
import { useAuthStore } from '../../store/authStore';
const { Title, Paragraph, Text } = Typography;
// 天气图标映射
const WEATHER_ICONS: Record<string, string> = {
sunny: '☀️',
cloudy: '⛅ 多云',
rainy: '🌧️',
stormy: '⛈️ 雷暴',
windy: '💨 大风',
};
// 项目状态映射
const STATUS_CONFIG: Record<string, { color: string; text: string }> = {
pending: { color: 'default', text: '待开始' },
active: { color: 'processing', text: '施工中' },
completed: { color: 'success', text: '完工' },
suspended: { color: 'warning', text: '暂停' },
cancelled: { color: 'error', text: '已取消' },
};
interface Project {
id: number;
project_code: string;
name: string;
customer_name: string;
status: string;
start_date: string;
expected_end_date: string;
contract_amount: number;
currency: string;
manager_name: string;
progress_percentage: number;
latest_log?: {
id: number;
log_date: string;
weather: string;
work_content: string;
};
}
const ConstructionList: React.FC = () => {
const [isMobile, setIsMobile] = useState(false);
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const navigate = useNavigate();
const { user } = useAuthStore();
useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth <= 768);
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
useEffect(() => {
fetchProjects();
}, []);
const fetchProjects = async () => {
setLoading(true);
try {
const res = await apiClient.get('/construction/my-projects');
if (res.data.success) {
setProjects(res.data.data);
}
} catch (error) {
console.error('获取项目列表失败:', error);
message.error('获取项目列表失败');
} finally {
setLoading(false);
}
};
const formatCurrency = (amount: number, currency: string = 'CNY') => {
const symbols: Record<string, string> = {
CNY: '¥',
USD: '$',
LAK: '₭',
THB: '฿',
};
const symbol = symbols[currency] || '¥';
return `${symbol}${(amount || 0).toLocaleString('zh-CN', { minimumFractionDigits: 0 })}`;
};
const isToday = (dateStr: string) => {
return dayjs(dateStr).isSame(dayjs(), 'day');
};
const renderProjectCard = (project: Project) => {
const statusConfig = STATUS_CONFIG[project.status] || STATUS_CONFIG.pending;
const hasTodayLog = project.latest_log && isToday(project.latest_log.log_date);
return (
<Card
key={project.id}
style={{
marginBottom: isMobile ? 12 : 16,
borderRadius: 12,
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
}}
styles={{ body: { padding: isMobile ? 16 : 20 } }}
>
{/* 项目头部 */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 12 }}>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<span style={{ fontSize: 20 }}>🎯</span>
<Text strong style={{ fontSize: isMobile ? 15 : 16 }}>{project.name}</Text>
</div>
<Text type="secondary" style={{ fontSize: 13 }}>
: {project.customer_name || '未指定'}
</Text>
</div>
<Tag color={statusConfig.color} style={{ marginLeft: 8 }}>
{statusConfig.text}
</Tag>
</div>
{/* 进度条 */}
<div style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<Text type="secondary" style={{ fontSize: 12 }}></Text>
<Text strong style={{ fontSize: 12 }}>{Math.round((project.progress_percentage || 0))}%</Text>
</div>
<Progress
percent={Math.round((project.progress_percentage || 0))}
showInfo={false}
strokeColor={{
'0%': '#108ee9',
'100%': '#87d068',
}}
trailColor="#f0f0f0"
/>
</div>
{/* 最新日志状态 */}
{project.status === 'active' && (
<div style={{
padding: '8px 12px',
background: hasTodayLog ? '#f6ffed' : '#fff7e6',
borderRadius: 8,
marginBottom: 12,
display: 'flex',
alignItems: 'center',
gap: 8
}}>
{hasTodayLog ? (
<>
<span></span>
<Text style={{ fontSize: 13 }}>
: {project.latest_log?.work_content?.substring(0, 30)}...
</Text>
</>
) : (
<>
<span></span>
<Text type="warning" style={{ fontSize: 13 }}>今日日志: 未填写</Text>
</>
)}
</div>
)}
<Divider style={{ margin: '12px 0' }} />
{/* 操作按钮 */}
<Row gutter={[8, 8]}>
<Col xs={24} sm={8}>
<Button
type={project.status === 'active' && !hasTodayLog ? 'primary' : 'default'}
icon={<FileTextOutlined />}
onClick={() => navigate(`/construction/${project.id}/logs`)}
block
size={isMobile ? 'large' : 'middle'}
style={{ borderRadius: 8 }}
>
{project.status === 'active' && !hasTodayLog ? '📝 写今日日志' : '📝 施工日志'}
</Button>
</Col>
<Col xs={24} sm={8}>
<Button
icon={<CameraOutlined />}
onClick={() => navigate(`/construction/${project.id}/logs`)}
block
size={isMobile ? 'large' : 'middle'}
style={{ borderRadius: 8 }}
>
📷
</Button>
</Col>
<Col xs={24} sm={8}>
<Button
icon={<ScheduleOutlined />}
onClick={() => navigate(`/construction/${project.id}/milestones`)}
block
size={isMobile ? 'large' : 'middle'}
style={{ borderRadius: 8 }}
>
📋
</Button>
</Col>
</Row>
</Card>
);
};
return (
<div style={{
padding: isMobile ? 12 : 24,
maxWidth: 1200,
margin: '0 auto'
}}>
{/* 页面标题 */}
<div style={{ marginBottom: isMobile ? 16 : 24 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Title level={isMobile ? 4 : 3} style={{ marginBottom: 0 }}></Title>
<Button
icon={<ReloadOutlined />}
onClick={fetchProjects}
loading={loading}
>
</Button>
</div>
<Paragraph type="secondary" style={{ marginTop: 8, marginBottom: 0 }}>
</Paragraph>
</div>
{/* 项目列表 */}
{loading ? (
<div style={{ textAlign: 'center', padding: 60 }}>
<Spin size="large" />
<Paragraph type="secondary" style={{ marginTop: 16 }}>...</Paragraph>
</div>
) : projects.length === 0 ? (
<Card style={{ borderRadius: 12 }}>
<Empty
description="暂无施工项目"
image={Empty.PRESENTED_IMAGE_SIMPLE}
>
<Text type="secondary"></Text>
</Empty>
</Card>
) : (
<div>
<Text type="secondary" style={{ marginBottom: 12, display: 'block' }}>
({projects.length})
</Text>
{projects.map(project => renderProjectCard(project))}
</div>
)}
</div>
);
};
export default ConstructionList;
import React, { useState, useEffect } from 'react';
import { Card, Typography, Button, Space, Tag, Progress, Empty, Spin, message, Row, Col, Divider } from 'antd';
import { FileTextOutlined, CameraOutlined, ScheduleOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import apiClient from '../../utils/request';
import dayjs from 'dayjs';
import { useAuthStore } from '../../store/authStore';
import { useLanguageStore } from '../../store/languageStore';
const { Title, Paragraph, Text } = Typography;
const WEATHER_ICONS: Record<string, string> = {
sunny: '☀️',
cloudy: '⛅',
rainy: '🌧️',
stormy: '⛈️',
windy: '💨',
};
interface Project {
id: number;
project_code: string;
name: string;
customer_name: string;
status: string;
start_date: string;
expected_end_date: string;
contract_amount: number;
currency: string;
manager_name: string;
progress_percentage: number;
latest_log?: {
id: number;
log_date: string;
weather: string;
work_content: string;
};
}
const ConstructionList: React.FC = () => {
const [isMobile, setIsMobile] = useState(false);
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const navigate = useNavigate();
const { user } = useAuthStore();
const { t, currentLanguage } = useLanguageStore();
const STATUS_CONFIG: Record<string, { color: string; text: string }> = {
pending: { color: 'default', text: t('construction.pendingStart') },
active: { color: 'processing', text: t('construction.underConstruction') },
completed: { color: 'success', text: t('construction.completed') },
suspended: { color: 'warning', text: t('construction.paused') },
cancelled: { color: 'error', text: t('construction.cancelled') },
};
useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth <= 768);
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
useEffect(() => {
fetchProjects();
}, []);
const fetchProjects = async () => {
setLoading(true);
try {
const res = await apiClient.get('/construction/my-projects');
if (res.data.success) {
setProjects(res.data.data);
}
} catch (error) {
console.error('获取项目列表失败:', error);
message.error(t('construction.getListFailed'));
} finally {
setLoading(false);
}
};
const formatCurrency = (amount: number, currency: string = 'CNY') => {
const symbols: Record<string, string> = {
CNY: '¥',
USD: '$',
LAK: '₭',
THB: '฿',
};
const symbol = symbols[currency] || '¥';
return `${symbol}${(amount || 0).toLocaleString('zh-CN', { minimumFractionDigits: 0 })}`;
};
const isToday = (dateStr: string) => {
return dayjs(dateStr).isSame(dayjs(), 'day');
};
const getWeatherLabel = (weather: string) => {
const icon = WEATHER_ICONS[weather] || '';
const textMap: Record<string, string> = {
sunny: t('construction.sunny'),
cloudy: t('construction.cloudy'),
rainy: t('construction.rain'),
stormy: t('construction.thunderstorm'),
windy: t('construction.windy'),
};
return `${icon} ${textMap[weather] || weather}`;
};
const renderProjectCard = (project: Project) => {
const statusConfig = STATUS_CONFIG[project.status] || STATUS_CONFIG.pending;
const hasTodayLog = project.latest_log && isToday(project.latest_log.log_date);
return (
<Card
key={project.id}
style={{
marginBottom: isMobile ? 12 : 16,
borderRadius: 12,
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
}}
styles={{ body: { padding: isMobile ? 16 : 20 } }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 12 }}>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<span style={{ fontSize: 20 }}>🎯</span>
<Text strong style={{ fontSize: isMobile ? 15 : 16 }}>{project.name}</Text>
</div>
<Text type="secondary" style={{ fontSize: 13 }}>
{t('construction.customerLabel')}{project.customer_name || t('common.notSet')}
</Text>
</div>
<Tag color={statusConfig.color} style={{ marginLeft: 8 }}>
{statusConfig.text}
</Tag>
</div>
<div style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<Text type="secondary" style={{ fontSize: 12 }}>{t('construction.progress')}</Text>
<Text strong style={{ fontSize: 12 }}>{Math.round((project.progress_percentage || 0))}%</Text>
</div>
<Progress
percent={Math.round((project.progress_percentage || 0))}
showInfo={false}
strokeColor={{
'0%': '#108ee9',
'100%': '#87d068',
}}
trailColor="#f0f0f0"
/>
</div>
{project.status === 'active' && (
<div style={{
padding: '8px 12px',
background: hasTodayLog ? '#f6ffed' : '#fff7e6',
borderRadius: 8,
marginBottom: 12,
display: 'flex',
alignItems: 'center',
gap: 8
}}>
{hasTodayLog ? (
<>
<span></span>
<Text style={{ fontSize: 13 }}>
{t('construction.todayLog')}{project.latest_log?.work_content?.substring(0, 30)}...
</Text>
</>
) : (
<>
<span></span>
<Text type="warning" style={{ fontSize: 13 }}>{t('construction.todayLogEmpty')}</Text>
</>
)}
</div>
)}
<Divider style={{ margin: '12px 0' }} />
<Row gutter={[8, 8]}>
<Col xs={24} sm={8}>
<Button
type={project.status === 'active' && !hasTodayLog ? 'primary' : 'default'}
icon={<FileTextOutlined />}
onClick={() => navigate(`/construction/${project.id}/logs`)}
block
size={isMobile ? 'large' : 'middle'}
style={{ borderRadius: 8 }}
>
{project.status === 'active' && !hasTodayLog ? `📝 ${t('construction.writeLog')}` : `📝 ${t('construction.constructionLog')}`}
</Button>
</Col>
<Col xs={24} sm={8}>
<Button
icon={<CameraOutlined />}
onClick={() => navigate(`/construction/${project.id}/logs`)}
block
size={isMobile ? 'large' : 'middle'}
style={{ borderRadius: 8 }}
>
{`📷 ${t('construction.uploadPhoto')}`}
</Button>
</Col>
<Col xs={24} sm={8}>
<Button
icon={<ScheduleOutlined />}
onClick={() => navigate(`/construction/${project.id}/milestones`)}
block
size={isMobile ? 'large' : 'middle'}
style={{ borderRadius: 8 }}
>
{`📋 ${t('construction.milestoneProgress')}`}
</Button>
</Col>
</Row>
</Card>
);
};
return (
<div style={{
padding: isMobile ? 12 : 24,
maxWidth: 1200,
margin: '0 auto'
}}>
<div style={{ marginBottom: isMobile ? 16 : 24 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Title level={isMobile ? 4 : 3} style={{ marginBottom: 0 }}>{t('construction.management')}</Title>
<Button
icon={<ReloadOutlined />}
onClick={fetchProjects}
loading={loading}
>
{t('common.refresh')}
</Button>
</div>
<Paragraph type="secondary" style={{ marginTop: 8, marginBottom: 0 }}>
{t('construction.description')}
</Paragraph>
</div>
{loading ? (
<div style={{ textAlign: 'center', padding: 60 }}>
<Spin size="large" />
<Paragraph type="secondary" style={{ marginTop: 16 }}>{t('common.loading')}</Paragraph>
</div>
) : projects.length === 0 ? (
<Card style={{ borderRadius: 12 }}>
<Empty
description={t('construction.noConstructionProjects')}
image={Empty.PRESENTED_IMAGE_SIMPLE}
>
<Text type="secondary">{t('construction.contactAdmin')}</Text>
</Empty>
</Card>
) : (
<div>
<Text type="secondary" style={{ marginBottom: 12, display: 'block' }}>
{t('construction.myProjects')} ({projects.length})
</Text>
{projects.map(project => renderProjectCard(project))}
</div>
)}
</div>
);
};
export default ConstructionList;
@@ -1,441 +1,441 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
Card, Typography, Button, Space, Modal, Form, Input, DatePicker, Select,
Upload, message, Spin, Empty, Image, Tag, Divider, Popconfirm, Row, Col
} from 'antd';
import {
PlusOutlined, ArrowLeftOutlined, DeleteOutlined,
CameraOutlined, CalendarOutlined, CloudOutlined
} from '@ant-design/icons';
import apiClient from '../../utils/request';
import dayjs from 'dayjs';
import { useAuthStore } from '../../store/authStore';
const { Title, Paragraph, Text } = Typography;
const { TextArea } = Input;
const { Option } = Select;
// 天气选项
const WEATHER_OPTIONS = [
{ value: 'sunny', label: '☀️ 晴', icon: '☀️' },
{ value: 'cloudy', label: '⛅ 多云', icon: '⛅' },
{ value: 'rainy', label: '🌧️ 雨', icon: '🌧️' },
{ value: 'stormy', label: '⛈️ 雷暴', icon: '⛈️' },
{ value: 'windy', label: '💨 大风', icon: '💨' },
];
interface Log {
id: number;
log_date: string;
weather: string;
work_content: string;
next_plan: string;
issues: string;
recorder_name: string;
photos: Photo[];
created_at: string;
}
interface Photo {
id: number;
photo_url: string;
photo_name: string;
photo_type: string;
file_size: number;
created_at: string;
}
const ConstructionLog: React.FC = () => {
const { id: projectId } = useParams<{ id: string }>();
const navigate = useNavigate();
const [isMobile, setIsMobile] = useState(false);
const [logs, setLogs] = useState<Log[]>([]);
const [loading, setLoading] = useState(true);
const [modalVisible, setModalVisible] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [projectInfo, setProjectInfo] = useState<any>(null);
const [form] = Form.useForm();
const { user } = useAuthStore();
useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth <= 768);
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
useEffect(() => {
if (projectId) {
fetchLogs();
fetchProjectInfo();
}
}, [projectId]);
const fetchLogs = async () => {
setLoading(true);
try {
const res = await apiClient.get(`/projects/${projectId}/construction-logs`);
if (res.data.success) {
setLogs(res.data.data);
}
} catch (error) {
console.error('获取日志列表失败:', error);
message.error('获取日志列表失败');
} finally {
setLoading(false);
}
};
const fetchProjectInfo = async () => {
try {
const res = await apiClient.get(`/projects/${projectId}`);
if (res.data.success) {
setProjectInfo(res.data.data);
}
} catch (error) {
console.error('获取项目信息失败:', error);
}
};
const handleSubmit = async () => {
try {
const values = await form.validateFields();
setSubmitting(true);
const res = await apiClient.post(`/projects/${projectId}/construction-logs`, {
log_date: values.log_date.format('YYYY-MM-DD'),
weather: values.weather,
work_content: values.work_content,
photos: '', // 暂时为空,后续添加照片上传功能
});
if (res.data.success) {
message.success('日志添加成功');
setModalVisible(false);
form.resetFields();
fetchLogs();
}
} catch (error) {
console.error('添加日志失败:', error);
message.error('添加日志失败');
} finally {
setSubmitting(false);
}
};
const handleDeleteLog = async (logId: number) => {
try {
const res = await apiClient.delete(`/construction-logs/${logId}`);
if (res.data.success) {
message.success('日志删除成功');
fetchLogs();
}
} catch (error) {
console.error('删除日志失败:', error);
message.error('删除日志失败');
}
};
// 按日期分组
const groupedLogs = logs.reduce((acc, log) => {
const month = dayjs(log.log_date).format('YYYY年MM月');
if (!acc[month]) {
acc[month] = [];
}
acc[month].push(log);
return acc;
}, {} as Record<string, Log[]>);
const getWeatherLabel = (value: string) => {
const option = WEATHER_OPTIONS.find(o => o.value === value);
return option ? option.label : value;
};
const formatFileSize = (bytes: number) => {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
};
const renderLogCard = (log: Log) => (
<Card
key={log.id}
style={{
marginBottom: 16,
borderRadius: 12,
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
}}
styles={{ body: { padding: isMobile ? 16 : 20 } }}
>
{/* 日志头部 */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<Space>
<CalendarOutlined style={{ color: '#1890ff' }} />
<Text strong style={{ fontSize: 15 }}>{dayjs(log.log_date).format('MM月DD日')}</Text>
<Tag color="blue">{getWeatherLabel(log.weather)}</Tag>
</Space>
<Space>
<Text type="secondary" style={{ fontSize: 12 }}>: {log.recorder_name || '未知'}</Text>
<Popconfirm
title="确定删除此日志?"
description="删除后无法恢复"
onConfirm={() => handleDeleteLog(log.id)}
okText="确定"
cancelText="取消"
>
<Button type="text" danger size="small" icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
</div>
{/* 工作内容 */}
{log.work_content && (
<div style={{ marginBottom: 12 }}>
<Text type="secondary" style={{ fontSize: 12 }}>:</Text>
<Paragraph style={{ margin: '4px 0 0', whiteSpace: 'pre-wrap' }}>
{log.work_content}
</Paragraph>
</div>
)}
{/* 明日计划 */}
{log.next_plan && (
<div style={{ marginBottom: 12 }}>
<Text type="secondary" style={{ fontSize: 12 }}>:</Text>
<Paragraph style={{ margin: '4px 0 0', whiteSpace: 'pre-wrap' }}>
{log.next_plan}
</Paragraph>
</div>
)}
{/* 问题记录 */}
{log.issues && (
<div style={{ marginBottom: 12 }}>
<Text type="secondary" style={{ fontSize: 12 }}>:</Text>
<Paragraph style={{ margin: '4px 0 0', color: '#fa8c16', whiteSpace: 'pre-wrap' }}>
{log.issues}
</Paragraph>
</div>
)}
{/* 照片展示 */}
{log.photos && log.photos.length > 0 && (
<div style={{ marginTop: 12 }}>
<Text type="secondary" style={{ fontSize: 12, marginBottom: 8, display: 'block' }}>
({log.photos.length}):
</Text>
<Image.PreviewGroup>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{log.photos.map(photo => (
<Image
key={photo.id}
src={photo.photo_url}
width={isMobile ? 80 : 100}
height={isMobile ? 80 : 100}
style={{
borderRadius: 8,
objectFit: 'cover',
cursor: 'pointer'
}}
placeholder={
<div style={{
width: isMobile ? 80 : 100,
height: isMobile ? 80 : 100,
background: '#f0f0f0',
borderRadius: 8,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
<CameraOutlined style={{ fontSize: 24, color: '#bfbfbf' }} />
</div>
}
/>
))}
</div>
</Image.PreviewGroup>
</div>
)}
</Card>
);
return (
<div style={{
padding: isMobile ? 12 : 24,
maxWidth: 800,
margin: '0 auto'
}}>
{/* 页面头部 */}
<div style={{ marginBottom: isMobile ? 16 : 24 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/construction')}
/>
<div>
<Title level={isMobile ? 4 : 3} style={{ margin: 0 }}>
</Title>
{projectInfo && (
<Text type="secondary" style={{ fontSize: 13 }}>
{projectInfo.name}
</Text>
)}
</div>
</div>
</div>
{/* 日志列表 */}
{loading ? (
<div style={{ textAlign: 'center', padding: 60 }}>
<Spin size="large" />
</div>
) : logs.length === 0 ? (
<Card style={{ borderRadius: 12 }}>
<Empty description="暂无施工日志">
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalVisible(true)}>
</Button>
</Empty>
</Card>
) : (
<div>
{Object.entries(groupedLogs).map(([month, monthLogs]) => (
<div key={month}>
<Divider orientation="left" style={{ margin: '16px 0' }}>
<Text strong style={{ fontSize: 14 }}>{month}</Text>
</Divider>
{monthLogs.map(log => renderLogCard(log))}
</div>
))}
</div>
)}
{/* 底部添加按钮 */}
<div style={{
position: 'fixed',
bottom: 24,
right: 24,
zIndex: 100
}}>
<Button
type="primary"
icon={<PlusOutlined />}
size="large"
onClick={() => setModalVisible(true)}
style={{
borderRadius: 24,
height: 48,
paddingLeft: 24,
paddingRight: 24,
boxShadow: '0 4px 12px rgba(24, 144, 255, 0.4)'
}}
>
</Button>
</div>
{/* 新增日志弹窗 */}
<Modal
title="新增施工日志"
open={modalVisible}
onOk={handleSubmit}
onCancel={() => setModalVisible(false)}
confirmLoading={submitting}
okText="提交"
cancelText="取消"
width={isMobile ? '95%' : 500}
style={{ top: 20 }}
>
<Form
form={form}
layout="vertical"
initialValues={{
log_date: dayjs(),
weather: 'sunny'
}}
>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="log_date"
label="日期"
rules={[{ required: true, message: '请选择日期' }]}
>
<DatePicker
style={{ width: '100%' }}
size="large"
disabledDate={(current) => current && current > dayjs().endOf('day')}
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="weather"
label="天气"
rules={[{ required: true, message: '请选择天气' }]}
>
<Select size="large">
{WEATHER_OPTIONS.map(opt => (
<Option key={opt.value} value={opt.value}>
{opt.label}
</Option>
))}
</Select>
</Form.Item>
</Col>
</Row>
<Form.Item
name="work_content"
label="今日工作"
rules={[{ required: true, message: '请填写今日工作内容' }]}
>
<TextArea
rows={3}
placeholder="描述今日完成的施工工作..."
size="large"
/>
</Form.Item>
<Form.Item name="next_plan" label="明日计划">
<TextArea
rows={2}
placeholder="明日工作计划..."
size="large"
/>
</Form.Item>
<Form.Item name="issues" label="问题记录">
<TextArea
rows={2}
placeholder="遇到的问题或需要协调的事项..."
size="large"
/>
</Form.Item>
<Form.Item label="上传照片">
<Upload
listType="picture-card"
multiple
maxCount={9}
accept="image/*"
beforeUpload={() => false}
>
<div>
<CameraOutlined style={{ fontSize: 20 }} />
<div style={{ marginTop: 4, fontSize: 12 }}></div>
</div>
</Upload>
<Text type="secondary" style={{ fontSize: 12 }}>
9
</Text>
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default ConstructionLog;
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
Card, Typography, Button, Space, Modal, Form, Input, DatePicker, Select,
Upload, message, Spin, Empty, Image, Tag, Divider, Popconfirm, Row, Col
} from 'antd';
import {
PlusOutlined, ArrowLeftOutlined, DeleteOutlined,
CameraOutlined, CalendarOutlined, CloudOutlined
} from '@ant-design/icons';
import apiClient from '../../utils/request';
import dayjs from 'dayjs';
import { useAuthStore } from '../../store/authStore';
import { useLanguageStore } from '../../store/languageStore';
const { Title, Paragraph, Text } = Typography;
const { TextArea } = Input;
const { Option } = Select;
const WEATHER_OPTIONS = [
{ value: 'sunny', icon: '☀️' },
{ value: 'cloudy', icon: '⛅' },
{ value: 'rainy', icon: '🌧️' },
{ value: 'stormy', icon: '⛈️' },
{ value: 'windy', icon: '💨' },
];
interface Log {
id: number;
log_date: string;
weather: string;
work_content: string;
next_plan: string;
issues: string;
recorder_name: string;
photos: Photo[];
created_at: string;
}
interface Photo {
id: number;
photo_url: string;
photo_name: string;
photo_type: string;
file_size: number;
created_at: string;
}
const ConstructionLog: React.FC = () => {
const { id: projectId } = useParams<{ id: string }>();
const navigate = useNavigate();
const [isMobile, setIsMobile] = useState(false);
const [logs, setLogs] = useState<Log[]>([]);
const [loading, setLoading] = useState(true);
const [modalVisible, setModalVisible] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [projectInfo, setProjectInfo] = useState<any>(null);
const [form] = Form.useForm();
const { user } = useAuthStore();
const { t, currentLanguage } = useLanguageStore();
useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth <= 768);
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
useEffect(() => {
if (projectId) {
fetchLogs();
fetchProjectInfo();
}
}, [projectId]);
const fetchLogs = async () => {
setLoading(true);
try {
const res = await apiClient.get(`/projects/${projectId}/construction-logs`);
if (res.data.success) {
setLogs(res.data.data);
}
} catch (error) {
console.error('获取日志列表失败:', error);
message.error(t('construction.getLogFailed'));
} finally {
setLoading(false);
}
};
const fetchProjectInfo = async () => {
try {
const res = await apiClient.get(`/projects/${projectId}`);
if (res.data.success) {
setProjectInfo(res.data.data);
}
} catch (error) {
console.error('获取项目信息失败:', error);
}
};
const handleSubmit = async () => {
try {
const values = await form.validateFields();
setSubmitting(true);
const res = await apiClient.post(`/projects/${projectId}/construction-logs`, {
log_date: values.log_date.format('YYYY-MM-DD'),
weather: values.weather,
work_content: values.work_content,
photos: '',
});
if (res.data.success) {
message.success(t('construction.logAddSuccess'));
setModalVisible(false);
form.resetFields();
fetchLogs();
}
} catch (error) {
console.error('添加日志失败:', error);
message.error(t('construction.logAddFailed'));
} finally {
setSubmitting(false);
}
};
const handleDeleteLog = async (logId: number) => {
try {
const res = await apiClient.delete(`/construction-logs/${logId}`);
if (res.data.success) {
message.success(t('construction.logDeleteSuccess'));
fetchLogs();
}
} catch (error) {
console.error('删除日志失败:', error);
message.error(t('construction.logDeleteFailed'));
}
};
const groupedLogs = logs.reduce((acc, log) => {
const month = dayjs(log.log_date).format(t('construction.yearMonth'));
if (!acc[month]) {
acc[month] = [];
}
acc[month].push(log);
return acc;
}, {} as Record<string, Log[]>);
const getWeatherLabel = (value: string) => {
const option = WEATHER_OPTIONS.find(o => o.value === 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) => {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
};
const renderLogCard = (log: Log) => (
<Card
key={log.id}
style={{
marginBottom: 16,
borderRadius: 12,
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
}}
styles={{ body: { padding: isMobile ? 16 : 20 } }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<Space>
<CalendarOutlined style={{ color: '#1890ff' }} />
<Text strong style={{ fontSize: 15 }}>{dayjs(log.log_date).format(t('construction.monthDay'))}</Text>
<Tag color="blue">{getWeatherLabel(log.weather)}</Tag>
</Space>
<Space>
<Text type="secondary" style={{ fontSize: 12 }}>{t('construction.recorderLabel')}{log.recorder_name || t('common.unknown')}</Text>
<Popconfirm
title={t('construction.deleteLogConfirmTitle')}
description={t('construction.deleteLogConfirmDesc')}
onConfirm={() => handleDeleteLog(log.id)}
okText={t('common.confirm')}
cancelText={t('common.cancel')}
>
<Button type="text" danger size="small" icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
</div>
{log.work_content && (
<div style={{ marginBottom: 12 }}>
<Text type="secondary" style={{ fontSize: 12 }}>{t('construction.todayWorkLabel')}</Text>
<Paragraph style={{ margin: '4px 0 0', whiteSpace: 'pre-wrap' }}>
{log.work_content}
</Paragraph>
</div>
)}
{log.next_plan && (
<div style={{ marginBottom: 12 }}>
<Text type="secondary" style={{ fontSize: 12 }}>{t('construction.tomorrowPlanLabel')}</Text>
<Paragraph style={{ margin: '4px 0 0', whiteSpace: 'pre-wrap' }}>
{log.next_plan}
</Paragraph>
</div>
)}
{log.issues && (
<div style={{ marginBottom: 12 }}>
<Text type="secondary" style={{ fontSize: 12 }}>{t('construction.issueRecordLabel')}</Text>
<Paragraph style={{ margin: '4px 0 0', color: '#fa8c16', whiteSpace: 'pre-wrap' }}>
{log.issues}
</Paragraph>
</div>
)}
{log.photos && log.photos.length > 0 && (
<div style={{ marginTop: 12 }}>
<Text type="secondary" style={{ fontSize: 12, marginBottom: 8, display: 'block' }}>
{t('construction.constructionPhoto')} ({log.photos.length}{t('common.sheet')}):
</Text>
<Image.PreviewGroup>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{log.photos.map(photo => (
<Image
key={photo.id}
src={photo.photo_url}
width={isMobile ? 80 : 100}
height={isMobile ? 80 : 100}
style={{
borderRadius: 8,
objectFit: 'cover',
cursor: 'pointer'
}}
placeholder={
<div style={{
width: isMobile ? 80 : 100,
height: isMobile ? 80 : 100,
background: '#f0f0f0',
borderRadius: 8,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
<CameraOutlined style={{ fontSize: 24, color: '#bfbfbf' }} />
</div>
}
/>
))}
</div>
</Image.PreviewGroup>
</div>
)}
</Card>
);
return (
<div style={{
padding: isMobile ? 12 : 24,
maxWidth: 800,
margin: '0 auto'
}}>
<div style={{ marginBottom: isMobile ? 16 : 24 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/construction')}
/>
<div>
<Title level={isMobile ? 4 : 3} style={{ margin: 0 }}>
{t('construction.constructionLog')}
</Title>
{projectInfo && (
<Text type="secondary" style={{ fontSize: 13 }}>
{projectInfo.name}
</Text>
)}
</div>
</div>
</div>
{loading ? (
<div style={{ textAlign: 'center', padding: 60 }}>
<Spin size="large" />
</div>
) : logs.length === 0 ? (
<Card style={{ borderRadius: 12 }}>
<Empty description={t('construction.noLog')}>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalVisible(true)}>
{t('construction.addFirstLog')}
</Button>
</Empty>
</Card>
) : (
<div>
{Object.entries(groupedLogs).map(([month, monthLogs]) => (
<div key={month}>
<Divider orientation="left" style={{ margin: '16px 0' }}>
<Text strong style={{ fontSize: 14 }}>{month}</Text>
</Divider>
{monthLogs.map(log => renderLogCard(log))}
</div>
))}
</div>
)}
<div style={{
position: 'fixed',
bottom: 24,
right: 24,
zIndex: 100
}}>
<Button
type="primary"
icon={<PlusOutlined />}
size="large"
onClick={() => setModalVisible(true)}
style={{
borderRadius: 24,
height: 48,
paddingLeft: 24,
paddingRight: 24,
boxShadow: '0 4px 12px rgba(24, 144, 255, 0.4)'
}}
>
{t('construction.newLog')}
</Button>
</div>
<Modal
title={t('construction.newConstructionLog')}
open={modalVisible}
onOk={handleSubmit}
onCancel={() => setModalVisible(false)}
confirmLoading={submitting}
okText={t('common.submit')}
cancelText={t('common.cancel')}
width={isMobile ? '95%' : 500}
style={{ top: 20 }}
>
<Form
form={form}
layout="vertical"
initialValues={{
log_date: dayjs(),
weather: 'sunny'
}}
>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="log_date"
label={t('common.date')}
rules={[{ required: true, message: t('construction.selectDate') }]}
>
<DatePicker
style={{ width: '100%' }}
size="large"
disabledDate={(current) => current && current > dayjs().endOf('day')}
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="weather"
label="天气"
rules={[{ required: true, message: t('construction.selectWeather') }]}
>
<Select size="large">
{WEATHER_OPTIONS.map(opt => (
<Option key={opt.value} value={opt.value}>
{opt.icon} {t(`construction.${opt.value}`)}
</Option>
))}
</Select>
</Form.Item>
</Col>
</Row>
<Form.Item
name="work_content"
label={t('construction.todayWorkLabel')}
rules={[{ required: true, message: t('construction.inputTodayWork') }]}
>
<TextArea
rows={3}
placeholder={t('construction.todayWorkPlaceholder')}
size="large"
/>
</Form.Item>
<Form.Item name="next_plan" label={t('construction.tomorrowPlanLabel')}>
<TextArea
rows={2}
placeholder={t('construction.tomorrowPlanPlaceholder')}
size="large"
/>
</Form.Item>
<Form.Item name="issues" label={t('construction.issueRecordLabel')}>
<TextArea
rows={2}
placeholder={t('construction.issuePlaceholder')}
size="large"
/>
</Form.Item>
<Form.Item label={t('construction.uploadPhoto')}>
<Upload
listType="picture-card"
multiple
maxCount={9}
accept="image/*"
beforeUpload={() => false}
>
<div>
<CameraOutlined style={{ fontSize: 20 }} />
<div style={{ marginTop: 4, fontSize: 12 }}>{t('construction.addPhoto')}</div>
</div>
</Upload>
<Text type="secondary" style={{ fontSize: 12 }}>
{t('construction.multiPhotoSupport')}
</Text>
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default ConstructionLog;
@@ -1,240 +1,237 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
Card, Typography, Button, Space, Tag, Spin, Empty, Timeline, Progress, Divider
} from 'antd';
import {
ArrowLeftOutlined, CheckCircleOutlined, ClockCircleOutlined,
SyncOutlined, CloseCircleOutlined
} from '@ant-design/icons';
import apiClient from '../../utils/request';
import dayjs from 'dayjs';
const { Title, Paragraph, Text } = Typography;
// 节点状态配置
const STATUS_CONFIG: Record<string, {
color: string;
text: string;
icon: React.ReactNode;
timelineColor: string;
}> = {
pending: {
color: 'default',
text: '待开始',
icon: <ClockCircleOutlined />,
timelineColor: 'gray'
},
in_progress: {
color: 'processing',
text: '进行中',
icon: <SyncOutlined spin />,
timelineColor: 'blue'
},
completed: {
color: 'success',
text: '已完成',
icon: <CheckCircleOutlined />,
timelineColor: 'green'
},
cancelled: {
color: 'error',
text: '已取消',
icon: <CloseCircleOutlined />,
timelineColor: 'red'
},
};
interface Milestone {
id: number;
node_name: string;
node_type: string;
status: string;
due_date: string;
trigger_condition: string;
created_at: string;
}
const ConstructionMilestones: React.FC = () => {
const { id: projectId } = useParams<{ id: string }>();
const navigate = useNavigate();
const [isMobile, setIsMobile] = useState(false);
const [milestones, setMilestones] = useState<Milestone[]>([]);
const [projectInfo, setProjectInfo] = useState<any>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth <= 768);
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
useEffect(() => {
if (projectId) {
fetchMilestones();
fetchProjectInfo();
}
}, [projectId]);
const fetchMilestones = async () => {
setLoading(true);
try {
const res = await apiClient.get(`/construction/projects/${projectId}/milestones`);
if (res.data.success) {
setMilestones(res.data.data);
}
} catch (error) {
console.error('获取节点列表失败:', error);
} finally {
setLoading(false);
}
};
const fetchProjectInfo = async () => {
try {
const res = await apiClient.get(`/projects/${projectId}`);
if (res.data.success) {
setProjectInfo(res.data.data);
}
} catch (error) {
console.error('获取项目信息失败:', error);
}
};
// 计算进度
const completedCount = milestones.filter(m => m.status === 'completed').length;
const totalCount = milestones.length;
const progressPercent = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0;
const renderTimelineItem = (milestone: Milestone, index: number) => {
const statusConfig = STATUS_CONFIG[milestone.status] || STATUS_CONFIG.pending;
return (
<Timeline.Item
key={milestone.id}
color={statusConfig.timelineColor}
dot={
<span style={{ fontSize: 16 }}>
{statusConfig.icon}
</span>
}
>
<Card
size="small"
style={{
marginBottom: 8,
borderRadius: 8,
background: milestone.status === 'completed' ? '#f6ffed' : '#fff',
}}
styles={{ body: { padding: 12 } }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<Text strong style={{ fontSize: 14 }}>{milestone.node_name}</Text>
{milestone.trigger_condition && (
<Paragraph
type="secondary"
style={{ margin: '4px 0 0', fontSize: 12 }}
>
{milestone.trigger_condition}
</Paragraph>
)}
</div>
<Tag color={statusConfig.color} icon={statusConfig.icon}>
{statusConfig.text}
</Tag>
</div>
{milestone.due_date && (
<Text type="secondary" style={{ fontSize: 12, marginTop: 4, display: 'block' }}>
: {dayjs(milestone.due_date).format('YYYY-MM-DD')}
</Text>
)}
</Card>
</Timeline.Item>
);
};
return (
<div style={{
padding: isMobile ? 12 : 24,
maxWidth: 800,
margin: '0 auto'
}}>
{/* 页面头部 */}
<div style={{ marginBottom: isMobile ? 16 : 24 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/construction')}
/>
<div>
<Title level={isMobile ? 4 : 3} style={{ margin: 0 }}>
</Title>
{projectInfo && (
<Text type="secondary" style={{ fontSize: 13 }}>
{projectInfo.name}
</Text>
)}
</div>
</div>
</div>
{/* 进度概览 */}
{!loading && milestones.length > 0 && (
<Card style={{ marginBottom: 16, borderRadius: 12 }}>
<div style={{ textAlign: 'center', marginBottom: 16 }}>
<Text type="secondary"></Text>
<Title level={2} style={{ margin: '8px 0 0' }}>{progressPercent}%</Title>
</div>
<Progress
percent={progressPercent}
strokeColor={{
'0%': '#108ee9',
'100%': '#87d068',
}}
/>
<div style={{ display: 'flex', justifyContent: 'center', gap: 24, marginTop: 16 }}>
<div style={{ textAlign: 'center' }}>
<Text strong style={{ fontSize: 20 }}>{completedCount}</Text>
<br />
<Text type="secondary" style={{ fontSize: 12 }}></Text>
</div>
<div style={{ textAlign: 'center' }}>
<Text strong style={{ fontSize: 20 }}>{totalCount - completedCount}</Text>
<br />
<Text type="secondary" style={{ fontSize: 12 }}></Text>
</div>
<div style={{ textAlign: 'center' }}>
<Text strong style={{ fontSize: 20 }}>{totalCount}</Text>
<br />
<Text type="secondary" style={{ fontSize: 12 }}></Text>
</div>
</div>
</Card>
)}
{/* 节点时间线 */}
{loading ? (
<div style={{ textAlign: 'center', padding: 60 }}>
<Spin size="large" />
</div>
) : milestones.length === 0 ? (
<Card style={{ borderRadius: 12 }}>
<Empty description="暂无施工节点">
<Text type="secondary"></Text>
</Empty>
</Card>
) : (
<Card style={{ borderRadius: 12 }}>
<Timeline style={{ marginTop: 16 }}>
{milestones.map((milestone, index) => renderTimelineItem(milestone, index))}
</Timeline>
</Card>
)}
</div>
);
};
export default ConstructionMilestones;
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
Card, Typography, Button, Space, Tag, Spin, Empty, Timeline, Progress, Divider
} from 'antd';
import {
ArrowLeftOutlined, CheckCircleOutlined, ClockCircleOutlined,
SyncOutlined, CloseCircleOutlined
} from '@ant-design/icons';
import apiClient from '../../utils/request';
import dayjs from 'dayjs';
import { useLanguageStore } from '../../store/languageStore';
const { Title, Paragraph, Text } = Typography;
interface Milestone {
id: number;
node_name: string;
node_type: string;
status: string;
due_date: string;
trigger_condition: string;
created_at: string;
}
const ConstructionMilestones: React.FC = () => {
const { id: projectId } = useParams<{ id: string }>();
const navigate = useNavigate();
const [isMobile, setIsMobile] = useState(false);
const [milestones, setMilestones] = useState<Milestone[]>([]);
const [projectInfo, setProjectInfo] = useState<any>(null);
const [loading, setLoading] = useState(true);
const { t, currentLanguage } = useLanguageStore();
const STATUS_CONFIG: Record<string, {
color: string;
text: string;
icon: React.ReactNode;
timelineColor: string;
}> = {
pending: {
color: 'default',
text: t('construction.pendingStart'),
icon: <ClockCircleOutlined />,
timelineColor: 'gray'
},
in_progress: {
color: 'processing',
text: t('construction.inProgress'),
icon: <SyncOutlined spin />,
timelineColor: 'blue'
},
completed: {
color: 'success',
text: t('construction.completed'),
icon: <CheckCircleOutlined />,
timelineColor: 'green'
},
cancelled: {
color: 'error',
text: t('construction.cancelled'),
icon: <CloseCircleOutlined />,
timelineColor: 'red'
},
};
useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth <= 768);
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
useEffect(() => {
if (projectId) {
fetchMilestones();
fetchProjectInfo();
}
}, [projectId]);
const fetchMilestones = async () => {
setLoading(true);
try {
const res = await apiClient.get(`/construction/projects/${projectId}/milestones`);
if (res.data.success) {
setMilestones(res.data.data);
}
} catch (error) {
console.error('获取节点列表失败:', error);
} finally {
setLoading(false);
}
};
const fetchProjectInfo = async () => {
try {
const res = await apiClient.get(`/projects/${projectId}`);
if (res.data.success) {
setProjectInfo(res.data.data);
}
} catch (error) {
console.error('获取项目信息失败:', error);
}
};
const completedCount = milestones.filter(m => m.status === 'completed').length;
const totalCount = milestones.length;
const progressPercent = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0;
const renderTimelineItem = (milestone: Milestone, index: number) => {
const statusConfig = STATUS_CONFIG[milestone.status] || STATUS_CONFIG.pending;
return (
<Timeline.Item
key={milestone.id}
color={statusConfig.timelineColor}
dot={
<span style={{ fontSize: 16 }}>
{statusConfig.icon}
</span>
}
>
<Card
size="small"
style={{
marginBottom: 8,
borderRadius: 8,
background: milestone.status === 'completed' ? '#f6ffed' : '#fff',
}}
styles={{ body: { padding: 12 } }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<Text strong style={{ fontSize: 14 }}>{milestone.node_name}</Text>
{milestone.trigger_condition && (
<Paragraph
type="secondary"
style={{ margin: '4px 0 0', fontSize: 12 }}
>
{milestone.trigger_condition}
</Paragraph>
)}
</div>
<Tag color={statusConfig.color} icon={statusConfig.icon}>
{statusConfig.text}
</Tag>
</div>
{milestone.due_date && (
<Text type="secondary" style={{ fontSize: 12, marginTop: 4, display: 'block' }}>
{t('construction.plannedComplete')}{dayjs(milestone.due_date).format('YYYY-MM-DD')}
</Text>
)}
</Card>
</Timeline.Item>
);
};
return (
<div style={{
padding: isMobile ? 12 : 24,
maxWidth: 800,
margin: '0 auto'
}}>
<div style={{ marginBottom: isMobile ? 16 : 24 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/construction')}
/>
<div>
<Title level={isMobile ? 4 : 3} style={{ margin: 0 }}>
{t('construction.milestoneProgress')}
</Title>
{projectInfo && (
<Text type="secondary" style={{ fontSize: 13 }}>
{projectInfo.name}
</Text>
)}
</div>
</div>
</div>
{!loading && milestones.length > 0 && (
<Card style={{ marginBottom: 16, borderRadius: 12 }}>
<div style={{ textAlign: 'center', marginBottom: 16 }}>
<Text type="secondary">{t('construction.overallProgress')}</Text>
<Title level={2} style={{ margin: '8px 0 0' }}>{progressPercent}%</Title>
</div>
<Progress
percent={progressPercent}
strokeColor={{
'0%': '#108ee9',
'100%': '#87d068',
}}
/>
<div style={{ display: 'flex', justifyContent: 'center', gap: 24, marginTop: 16 }}>
<div style={{ textAlign: 'center' }}>
<Text strong style={{ fontSize: 20 }}>{completedCount}</Text>
<br />
<Text type="secondary" style={{ fontSize: 12 }}>{t('construction.completed')}</Text>
</div>
<div style={{ textAlign: 'center' }}>
<Text strong style={{ fontSize: 20 }}>{totalCount - completedCount}</Text>
<br />
<Text type="secondary" style={{ fontSize: 12 }}>{t('construction.inProgress')}</Text>
</div>
<div style={{ textAlign: 'center' }}>
<Text strong style={{ fontSize: 20 }}>{totalCount}</Text>
<br />
<Text type="secondary" style={{ fontSize: 12 }}>{t('construction.totalNodes')}</Text>
</div>
</div>
</Card>
)}
{loading ? (
<div style={{ textAlign: 'center', padding: 60 }}>
<Spin size="large" />
</div>
) : milestones.length === 0 ? (
<Card style={{ borderRadius: 12 }}>
<Empty description={t('construction.noMilestones')}>
<Text type="secondary">{t('construction.milestoneConfigured')}</Text>
</Empty>
</Card>
) : (
<Card style={{ borderRadius: 12 }}>
<Timeline style={{ marginTop: 16 }}>
{milestones.map((milestone, index) => renderTimelineItem(milestone, index))}
</Timeline>
</Card>
)}
</div>
);
};
export default ConstructionMilestones;
@@ -3,6 +3,7 @@ import { Card, Button, Progress, Tag, List, Spin, message, Empty, Space, Typogra
import { ArrowLeftOutlined, RightOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import apiClient from '../../utils/request';
import { useLanguageStore } from '../../store/languageStore';
const { Title, Text } = Typography;
@@ -28,6 +29,7 @@ const ConstructionOverview: React.FC = () => {
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const { t, currentLanguage } = useLanguageStore();
useEffect(() => { fetchProjects(); }, []);
@@ -45,7 +47,7 @@ const ConstructionOverview: React.FC = () => {
}));
setProjects(enriched);
}
} catch (e) { message.error('获取项目列表失败'); }
} catch (e) { message.error(t('construction.getListFailed')); }
finally { setLoading(false); }
};
@@ -55,7 +57,7 @@ const ConstructionOverview: React.FC = () => {
};
const getStatusText = (status: string) => {
const map: Record<string, string> = { active: '施工中', in_progress: '施工中', planning: '待开工', completed: '已完工', suspended: '暂停' };
const map: Record<string, string> = { active: t('construction.underConstruction'), in_progress: t('construction.underConstruction'), planning: t('construction.pendingStart'), completed: t('construction.completed'), suspended: t('construction.paused') };
return map[status] || status;
};
@@ -64,28 +66,28 @@ const ConstructionOverview: React.FC = () => {
return (
<div style={{ padding: 24 }}>
<Card title={<Title level={4} style={{ margin: 0 }}></Title>}>
<Card title={<Title level={4} style={{ margin: 0 }}>{t('construction.overview')}</Title>}>
<Spin spinning={loading}>
{activeProjects.length === 0 && completedProjects.length === 0 ? (
<Empty description="暂无施工项目" />
<Empty description={t('construction.noProjects')} />
) : (
<>
{activeProjects.length > 0 && (
<>
<Text strong style={{ fontSize: 16 }}>{activeProjects.length}</Text>
<Text strong style={{ fontSize: 16 }}>{t('construction.underConstruction')}{activeProjects.length}{t('common.unit')}</Text>
<List
style={{ marginTop: 16 }}
dataSource={activeProjects}
renderItem={(project) => (
<List.Item
actions={[<Button type="primary" icon={<RightOutlined />} onClick={() => navigate(`/construction/progress/${project.id}`)}></Button>]}
actions={[<Button type="primary" icon={<RightOutlined />} onClick={() => navigate(`/construction/progress/${project.id}`)}>{t('construction.enter')}</Button>]}
>
<List.Item.Meta
title={<Space><Text strong>{project.name}</Text><Tag color={getStatusColor(project.status)}>{getStatusText(project.status)}</Tag></Space>}
description={
<div style={{ marginTop: 8 }}>
<div style={{ marginBottom: 4 }}>
<Text type="secondary">{project.current_phase || '未设置'}</Text>
<Text type="secondary">{t('construction.currentPhase')}{project.current_phase || t('common.notSet')}</Text>
{project.phases.length > 0 && <Text type="secondary" style={{ marginLeft: 16 }}>{project.phases.filter(p => p.status === 'completed').length}/{project.phases.length}</Text>}
</div>
<Progress percent={project.phase_progress || 0} size="small" strokeColor="#1890ff" />
@@ -99,16 +101,16 @@ const ConstructionOverview: React.FC = () => {
)}
{completedProjects.length > 0 && (
<>
<Text strong style={{ fontSize: 16, marginTop: 24, display: 'block' }}>{completedProjects.length}</Text>
<Text strong style={{ fontSize: 16, marginTop: 24, display: 'block' }}>{t('construction.completedProjects')}{completedProjects.length}{t('common.unit')}</Text>
<List
style={{ marginTop: 16 }}
dataSource={completedProjects}
renderItem={(project) => (
<List.Item
actions={[<Button icon={<RightOutlined />} onClick={() => navigate(`/construction/progress/${project.id}`)}></Button>]}
actions={[<Button icon={<RightOutlined />} onClick={() => navigate(`/construction/progress/${project.id}`)}>{t('common.view')}</Button>]}
>
<List.Item.Meta
title={<Space><Text>{project.name}</Text><Tag></Tag></Space>}
title={<Space><Text>{project.name}</Text><Tag>{t('construction.completed')}</Tag></Space>}
description={<Progress percent={100} size="small" />}
/>
</List.Item>
@@ -124,4 +126,4 @@ const ConstructionOverview: React.FC = () => {
);
};
export default ConstructionOverview;
export default ConstructionOverview;
File diff suppressed because it is too large Load Diff
+247 -194
View File
@@ -1,194 +1,247 @@
import React, { useState, useEffect } from 'react';
import { Card, Col, Row, Statistic, Table, Typography, Tag, Spin } from 'antd';
import {
ProjectOutlined,
DollarOutlined,
FileTextOutlined,
TeamOutlined
} from '@ant-design/icons';
import apiClient from '../../utils/request';
const { Title } = Typography;
const DashboardPage: React.FC = () => {
const [isMobile, setIsMobile] = useState(false);
const [projectData, setProjectData] = useState<any[]>([]);
const [stats, setStats] = useState({
ongoingProjects: 0,
monthlyReimbursements: 0,
pendingApprovals: 0,
teamMembers: 0
});
const [loading, setLoading] = useState(true);
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth <= 768);
};
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
useEffect(() => {
// 从 API 获取真实项目数据
const fetchProjects = async () => {
try {
const response = await apiClient.get('/projects');
if (response.data.success) {
const projects = response.data.data.map((project: any) => ({
key: project.id,
name: project.name,
status: project.status === 'planning' ? '规划中' :
project.status === 'active' ? '进行中' :
project.status === 'completed' ? '已完成' : project.status,
budget: parseFloat(project.budget || 0),
spent: parseFloat(project.spent || 0)
}));
setProjectData(projects);
}
} catch (error) {
console.error('获取项目列表失败:', error);
} finally {
setLoading(false);
}
};
fetchProjects();
}, []);
// 桌面端表格列
const desktopColumns = [
{ title: '项目名称', dataIndex: 'name', key: 'name' },
{
title: '状态',
dataIndex: 'status',
key: 'status',
render: (status: string) => {
const colorMap: Record<string, string> = {
'进行中': 'blue',
'已完成': 'green',
'规划中': 'orange',
};
return <Tag color={colorMap[status] || 'default'}>{status}</Tag>;
}
},
{
title: '预算',
dataIndex: 'budget',
key: 'budget',
render: (value: number) => `¥${value.toLocaleString()}`
},
{
title: '已花费',
dataIndex: 'spent',
key: 'spent',
render: (value: number) => `¥${value.toLocaleString()}`
}
];
// 移动端简化表格列
const mobileColumns = [
{ title: '项目', dataIndex: 'name', key: 'name', ellipsis: true },
{
title: '状态',
dataIndex: 'status',
key: 'status',
render: (status: string) => (
<Tag color={status === '已完成' ? 'green' : 'blue'} style={{ fontSize: 10 }}>
{status}
</Tag>
)
},
{
title: '预算/花费',
key: 'budget_spent',
render: (_: any, record: any) => (
<div style={{ fontSize: 12 }}>
<div>¥{(record.budget / 10000).toFixed(0)}</div>
<div style={{ color: '#888' }}>¥{(record.spent / 10000).toFixed(0)}</div>
</div>
)
}
];
return (
<div style={{ padding: isMobile ? 8 : 24 }}>
<Title level={isMobile ? 3 : 2} style={{ marginBottom: isMobile ? 12 : 24 }}>
📊
</Title>
{/* 统计卡片 - 移动端优化 */}
<Row gutter={[8, 8]} style={{ marginBottom: isMobile ? 12 : 24 }}>
<Col xs={12} sm={12} md={6}>
<Card size="small">
<Statistic
title={<span style={{ fontSize: 12 }}></span>}
value={stats.ongoingProjects}
prefix={<ProjectOutlined />}
valueStyle={{ color: '#1890ff', fontSize: isMobile ? 18 : undefined }}
/>
</Card>
</Col>
<Col xs={12} sm={12} md={6}>
<Card size="small">
<Statistic
title={<span style={{ fontSize: 12 }}></span>}
value={stats.monthlyReimbursements}
prefix={<DollarOutlined />}
valueStyle={{ color: '#52c41a', fontSize: isMobile ? 18 : undefined }}
suffix="元"
/>
</Card>
</Col>
<Col xs={12} sm={12} md={6}>
<Card size="small">
<Statistic
title={<span style={{ fontSize: 12 }}></span>}
value={stats.pendingApprovals}
prefix={<FileTextOutlined />}
valueStyle={{ color: '#faad14', fontSize: isMobile ? 18 : undefined }}
/>
</Card>
</Col>
<Col xs={12} sm={12} md={6}>
<Card size="small">
<Statistic
title={<span style={{ fontSize: 12 }}></span>}
value={stats.teamMembers}
prefix={<TeamOutlined />}
valueStyle={{ color: '#722ed1', fontSize: isMobile ? 18 : undefined }}
/>
</Card>
</Col>
</Row>
{/* 项目列表 */}
<Card
title="最近项目"
size="small"
styles={{ body: { padding: isMobile ? 8 : 24 } }}
>
{loading ? (
<div style={{ textAlign: 'center', padding: '40px 0' }}>
<Spin size="large" />
<div style={{ marginTop: 16, color: '#666' }}>...</div>
</div>
) : (
<Table
columns={isMobile ? mobileColumns : desktopColumns}
dataSource={projectData}
pagination={false}
scroll={isMobile ? { x: 400 } : undefined}
size={isMobile ? 'small' : 'middle'}
locale={{ emptyText: '暂无项目数据' }}
/>
)}
</Card>
</div>
);
};
export default DashboardPage;
import React, { useState, useEffect } from 'react';
import { Card, Col, Row, Statistic, Table, Typography, Tag, Spin } from 'antd';
import {
ProjectOutlined,
DollarOutlined,
FileTextOutlined,
TeamOutlined
} from '@ant-design/icons';
import apiClient from '../../utils/request';
import { useLanguageStore } from '../../store/languageStore';
const { Title } = Typography;
const DashboardPage: React.FC = () => {
const { t, currentLanguage } = useLanguageStore();
const [isMobile, setIsMobile] = useState(false);
const [projectData, setProjectData] = useState<any[]>([]);
const [stats, setStats] = useState({
ongoingProjects: 0,
monthlyReimbursements: 0,
pendingApprovals: 0,
teamMembers: 0
});
const [loading, setLoading] = useState(true);
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth <= 768);
};
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
useEffect(() => {
const fetchDashboardData = async () => {
try {
// 并行获取所有数据
const [projectsRes, advancesRes, reimburseRes, usersRes] = await Promise.allSettled([
apiClient.get('/projects'),
apiClient.get('/advances?status=pending'),
apiClient.get('/reimbursements?status=pending'),
apiClient.get('/users'),
]);
// 处理项目数据
let projects: any[] = [];
if (projectsRes.status === 'fulfilled' && projectsRes.value.data.success) {
projects = projectsRes.value.data.data.map((project: any) => ({
key: project.id,
name: project.name,
status: project.status,
budget: parseFloat(project.budget || project.contract_amount || 0),
spent: parseFloat(project.total_expense || project.spent || 0)
}));
setProjectData(projects);
}
// 计算进行中项目数
const ongoingProjects = projects.filter(p => p.status === 'active' || p.status === 'planning').length;
// 计算本月报销金额
let monthlyReimburse = 0;
try {
const financeRes = await apiClient.get('/financial-records?limit=999');
if (financeRes.data.success) {
const now = new Date();
const currentMonth = now.getMonth();
const currentYear = now.getFullYear();
monthlyReimburse = financeRes.data.data
.filter((r: any) => {
const d = new Date(r.record_date);
return d.getMonth() === currentMonth && d.getFullYear() === currentYear && r.txn_type === 'expense';
})
.reduce((sum: number, r: any) => sum + parseFloat(r.amount_cny || r.amount_original || 0), 0);
}
} catch {}
// 计算待审批数
let pendingApprovals = 0;
if (advancesRes.status === 'fulfilled' && advancesRes.value.data.success) {
pendingApprovals += (advancesRes.value.data.data || []).length;
}
if (reimburseRes.status === 'fulfilled' && reimburseRes.value.data.success) {
pendingApprovals += (reimburseRes.value.data.data || []).length;
}
// 团队成员数
let teamMembers = 0;
if (usersRes.status === 'fulfilled' && usersRes.value.data.success) {
teamMembers = (usersRes.value.data.data || []).length;
}
setStats({
ongoingProjects,
monthlyReimbursements: Math.round(monthlyReimburse),
pendingApprovals,
teamMembers
});
} catch (error) {
console.error('获取仪表盘数据失败:', error);
} finally {
setLoading(false);
}
};
fetchDashboardData();
}, []);
const statusDisplayMap: Record<string, string> = {
planning: t('project.planning'),
active: t('project.inProgress'),
completed: t('project.completed'),
paused: t('project.paused'),
};
const desktopColumns = [
{ title: t('dashboard.projectName'), dataIndex: 'name', key: 'name' },
{
title: t('common.status'),
dataIndex: 'status',
key: 'status',
render: (status: string) => {
const colorMap: Record<string, string> = {
planning: 'orange',
active: 'blue',
completed: 'green',
paused: 'default',
};
return <Tag color={colorMap[status] || 'default'}>{statusDisplayMap[status] || status}</Tag>;
}
},
{
title: t('dashboard.budget'),
dataIndex: 'budget',
key: 'budget',
render: (value: number) => `¥${value.toLocaleString()}`
},
{
title: t('dashboard.spent'),
dataIndex: 'spent',
key: 'spent',
render: (value: number) => `¥${value.toLocaleString()}`
}
];
const mobileColumns = [
{ title: t('dashboard.project'), dataIndex: 'name', key: 'name', ellipsis: true },
{
title: t('common.status'),
dataIndex: 'status',
key: 'status',
render: (status: string) => (
<Tag color={status === 'completed' ? 'green' : 'blue'} style={{ fontSize: 10 }}>
{statusDisplayMap[status] || status}
</Tag>
)
},
{
title: t('dashboard.budgetSpent'),
key: 'budget_spent',
render: (_: any, record: any) => (
<div style={{ fontSize: 12 }}>
<div>{t('dashboard.budgetLabel')}¥{(record.budget / 10000).toFixed(0)}{t('common.tenThousand')}</div>
<div style={{ color: '#888' }}>{t('dashboard.spentLabel')}¥{(record.spent / 10000).toFixed(0)}{t('common.tenThousand')}</div>
</div>
)
}
];
return (
<div style={{ padding: isMobile ? 8 : 24 }}>
<Title level={isMobile ? 3 : 2} style={{ marginBottom: isMobile ? 12 : 24 }}>
{t('dashboard.title')}
</Title>
<Row gutter={[8, 8]} style={{ marginBottom: isMobile ? 12 : 24 }}>
<Col xs={12} sm={12} md={6}>
<Card size="small">
<Statistic
title={<span style={{ fontSize: 12 }}>{t('dashboard.inProgressProjects')}</span>}
value={stats.ongoingProjects}
prefix={<ProjectOutlined />}
valueStyle={{ color: '#1890ff', fontSize: isMobile ? 18 : undefined }}
/>
</Card>
</Col>
<Col xs={12} sm={12} md={6}>
<Card size="small">
<Statistic
title={<span style={{ fontSize: 12 }}>{t('dashboard.monthlyReimburse')}</span>}
value={stats.monthlyReimbursements}
prefix={<DollarOutlined />}
valueStyle={{ color: '#52c41a', fontSize: isMobile ? 18 : undefined }}
suffix={t('common.yuan')}
/>
</Card>
</Col>
<Col xs={12} sm={12} md={6}>
<Card size="small">
<Statistic
title={<span style={{ fontSize: 12 }}>{t('dashboard.pendingApproval')}</span>}
value={stats.pendingApprovals}
prefix={<FileTextOutlined />}
valueStyle={{ color: '#faad14', fontSize: isMobile ? 18 : undefined }}
/>
</Card>
</Col>
<Col xs={12} sm={12} md={6}>
<Card size="small">
<Statistic
title={<span style={{ fontSize: 12 }}>{t('dashboard.teamMembers')}</span>}
value={stats.teamMembers}
prefix={<TeamOutlined />}
valueStyle={{ color: '#722ed1', fontSize: isMobile ? 18 : undefined }}
/>
</Card>
</Col>
</Row>
<Card
title={t('dashboard.recentProjects')}
size="small"
styles={{ body: { padding: isMobile ? 8 : 24 } }}
>
{loading ? (
<div style={{ textAlign: 'center', padding: '40px 0' }}>
<Spin size="large" />
<div style={{ marginTop: 16, color: '#666' }}>{t('common.loadingData')}</div>
</div>
) : (
<Table
columns={isMobile ? mobileColumns : desktopColumns}
dataSource={projectData}
pagination={false}
scroll={isMobile ? { x: 400 } : undefined}
size={isMobile ? 'small' : 'middle'}
locale={{ emptyText: t('common.noProjectData') }}
/>
)}
</Card>
</div>
);
};
export default DashboardPage;
+390 -150
View File
@@ -1,39 +1,103 @@
import React, { useState, useEffect } from 'react';
import { Card, Typography, Table, Statistic, Row, Col, Tag, Select, DatePicker, Space, Spin, Button, Modal, Form, Input, InputNumber, message, Divider } from 'antd';
import { DollarOutlined, RiseOutlined, FallOutlined, PlusOutlined, DownloadOutlined } from '@ant-design/icons';
import React, { useState, useEffect, useMemo } from 'react';
import { Card, Typography, Table, Statistic, Row, Col, Tag, Select, DatePicker, Space, Spin, Button, Modal, Form, Input, InputNumber, message, Divider, Tabs, Popconfirm, Upload } from 'antd';
import { DollarOutlined, RiseOutlined, FallOutlined, PlusOutlined, DownloadOutlined, DeleteOutlined, UploadOutlined, FundOutlined } from '@ant-design/icons';
import apiClient from '../../utils/request';
import dayjs from 'dayjs';
import * as XLSX from 'xlsx';
import { useLanguageStore } from '../../store/languageStore';
const { Title, Paragraph } = Typography;
const { Title } = Typography;
const { RangePicker } = DatePicker;
const LEVEL1_LABELS: Record<string, string> = { income: '收入', project: '项目支出', company: '公司支出' };
const LEVEL2_LABELS: Record<string, string> = {
contract_payment: '项目合同收款', deposit_refund: '质保金退回', shareholder_investment: '股东投资入股', other_income: '其他收入',
material: '材料采购', equipment: '设备采购', subcontract: '施工分包', labor: '人工工资',
travel: '差旅交通', accommodation: '食宿费用', freight: '运输物流', design: '勘测设计',
tools: '小型工具', client_relations: '客户/EDL关系', other_project: '其他项目支出',
salary: '工资薪酬', rent: '房租物业', office: '办公费用', commute: '交通通勤',
vehicle_maintenance: '车辆维保', assets: '固定资产', marketing: '营销拓展',
entertainment: '招待费用', welfare: '员工福利', logistics: '快递物流', other_company: '其他公司支出'
};
const COUNTERPARTY_TYPES = [
{ value: 'supplier', label: '供应商' }, { value: 'subcontractor', label: '分包商' },
{ value: 'customer', label: '客户' }, { value: 'employee', label: '员工' },
{ value: 'logistics', label: '物流公司' }, { value: 'shareholder', label: '股东' },
{ value: 'other', label: '其他' },
];
const CURRENCIES = [
{ value: 'CNY', label: 'CNY (人民币)' },
{ value: 'LAK', label: 'LAK (老挝基普)' },
{ value: 'USD', label: 'USD (美元)' },
{ value: 'THB', label: 'THB (泰铢)' },
];
const FinancePage: React.FC = () => {
const { t, currentLanguage } = useLanguageStore();
const LEVEL1_LABELS = useMemo<Record<string, string>>(() => ({
income: t('finance.incomeCategory'),
project: t('finance.projectExpense'),
company: t('finance.companyExpense'),
finance: t('cash.financeExpense'),
}), [t]);
const LEVEL2_LABELS = useMemo<Record<string, string>>(() => ({
contract_payment: t('finance.projectRevenue'),
deposit_refund: t('finance.warrantyReturn'),
shareholder_investment: t('finance.shareholderInvestment'),
other_income: t('finance.otherIncome'),
customer_advance: t('cash.customerAdvance'),
bank_loan: t('cash.bankLoan'),
other_loan: t('cash.otherLoan'),
dividend_income: t('cash.dividendIncome'),
interest_income: t('cash.interestIncome'),
asset_disposal: t('cash.assetDisposal'),
tax_refund: t('cash.taxRefund'),
government_subsidy: t('cash.governmentSubsidy'),
material: t('finance.materialPurchase'),
equipment: t('finance.equipmentPurchase'),
subcontract: t('finance.constructionSubcontract'),
construction_subcontract: t('finance.constructionSubcontract'),
labor: t('finance.laborWage'),
travel: t('finance.travelTransport'),
accommodation: t('finance.accommodationFood'),
freight: t('finance.transportLogistics'),
transport_logistics: t('finance.transportLogistics'),
design: t('finance.surveyDesign'),
survey_design: t('finance.surveyDesign'),
tools: t('finance.smallTools'),
client_relations: t('finance.customerEDLRelation'),
customer_edl: t('finance.customerEDLRelation'),
other_project: t('finance.otherProjectExpense'),
salary: t('finance.salaryWelfare'),
rent: t('finance.rentProperty'),
office: t('finance.officeExpense'),
commute: t('finance.commute'),
vehicle_maintenance: t('finance.vehicleMaintenance'),
assets: t('finance.fixedAsset'),
marketing: t('finance.marketing'),
entertainment: t('finance.entertainment'),
welfare: t('finance.employeeBenefit'),
logistics: t('finance.expressLogistics'),
other_company: t('finance.otherCompanyExpense'),
loan_repayment: t('cash.loanRepayment'),
interest_expense: t('cash.interestExpense'),
dividend_payment: t('cash.dividendPayment'),
tax_payment: t('cash.taxPayment'),
deposit_payment: t('cash.depositPayment'),
owner_expense: t('cash.ownerExpense'),
other_finance: t('cash.otherFinance'),
}), [t]);
const COUNTERPARTY_TYPES = useMemo(() => [
{ value: 'supplier', label: t('finance.counterpartySupplier') },
{ value: 'subcontractor', label: t('finance.counterpartySubcontractor') },
{ value: 'customer', label: t('finance.counterpartyCustomer') },
{ value: 'employee', label: t('finance.counterpartyEmployee') },
{ value: 'logistics', label: t('finance.counterpartyLogistics') },
{ value: 'shareholder', label: t('finance.counterpartyShareholder') },
{ value: 'bank', label: t('cash.counterpartyBank') },
{ value: 'other', label: t('finance.counterpartyOther') },
], [t, currentLanguage]);
const CURRENCIES = useMemo(() => [
{ value: 'CNY', label: t('finance.currencyCNY') },
{ value: 'LAK', label: t('finance.currencyLAK') },
{ value: 'USD', label: t('finance.currencyUSD') },
{ value: 'THB', label: t('finance.currencyTHB') },
], [t, currentLanguage]);
const SOURCE_MAP = useMemo<Record<string, string>>(() => ({
manual: t('finance.manual'),
cash_management: t('cash.sourceLabel'),
receipt: t('cash.receiptSource'),
advance: t('finance.advance'),
reimbursement: t('finance.reimbursement'),
payment_request: t('finance.payment'),
material: t('finance.material'),
primary_freight: t('finance.freight'),
secondary_freight: t('finance.freight'),
}), [t]);
const [activeTab, setActiveTab] = useState('overview');
const [loading, setLoading] = useState(false);
const [summary, setSummary] = useState<any>({ total_income: 0, total_expense: 0, net_profit: 0 });
const [byCategory, setByCategory] = useState<any[]>([]);
@@ -45,12 +109,24 @@ const FinancePage: React.FC = () => {
const [addModalVisible, setAddModalVisible] = useState(false);
const [addLoading, setAddLoading] = useState(false);
const [addModalType, setAddModalType] = useState<'income' | 'expense'>('income');
const [form] = Form.useForm();
const [categories, setCategories] = useState<any>({ income: [], project: [], company: [] });
const [categories, setCategories] = useState<any>({ income: [], project: [], company: [], finance: [] });
const [projects, setProjects] = useState<any[]>([]);
const [customers, setCustomers] = useState<any[]>([]);
const [suppliers, setSuppliers] = useState<any[]>([]);
const [exchangeRates, setExchangeRates] = useState<Record<string, number>>({ CNY: 1, LAK: 0.0003, USD: 7.2, THB: 0.2 });
const [cashRecords, setCashRecords] = useState<any[]>([]);
const [cashPagination, setCashPagination] = useState({ page: 1, pageSize: 20, total: 0 });
const [cashFilterType, setCashFilterType] = useState<string | undefined>(undefined);
const [cashDateRange, setCashDateRange] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null] | null>(null);
const [cashLoading, setCashLoading] = useState(false);
const [amountVal, setAmountVal] = useState<number>(0);
const [rateVal, setRateVal] = useState<number>(1);
useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth <= 768);
checkMobile();
@@ -65,6 +141,12 @@ const FinancePage: React.FC = () => {
apiClient.get('/projects', { params: { pageSize: 200 } }).then(res => {
if (res.data.success) setProjects(res.data.data || res.data.projects || []);
}).catch(() => {});
apiClient.get('/customers', { params: { pageSize: 200 } }).then(res => {
if (res.data.success) setCustomers(res.data.data || []);
}).catch(() => {});
apiClient.get('/suppliers', { params: { pageSize: 200 } }).then(res => {
if (res.data.success) setSuppliers(res.data.data || []);
}).catch(() => {});
apiClient.get('/exchange-rates/latest').then(res => {
if (res.data.success && res.data.data) {
const rates: Record<string, number> = { CNY: 1 };
@@ -110,42 +192,53 @@ const FinancePage: React.FC = () => {
setLoading(false);
};
useEffect(() => { fetchSummary(); fetchRecords(1); }, [dateRange, filterType]);
const fetchCashRecords = async (page = 1) => {
setCashLoading(true);
try {
const params: any = { page, pageSize: cashPagination.pageSize };
if (cashDateRange && cashDateRange[0]) {
params.date_from = cashDateRange[0].format('YYYY-MM-DD');
params.date_to = cashDateRange[1]?.format('YYYY-MM-DD');
}
if (cashFilterType) params.txn_type = cashFilterType;
const res = await apiClient.get('/cash-management/records', { params });
if (res.data.success) {
setCashRecords(res.data.data);
setCashPagination(res.data.pagination);
}
} catch (e) { console.error(e); }
setCashLoading(false);
};
const handleAdd = () => {
useEffect(() => { fetchSummary(); fetchRecords(1); }, [dateRange, filterType]);
useEffect(() => { if (activeTab === 'income' || activeTab === 'expense') fetchCashRecords(1); }, [activeTab, cashDateRange, cashFilterType]);
const handleAddIncome = () => {
setAddModalType('income');
form.resetFields();
form.setFieldsValue({ record_date: dayjs(), currency: 'CNY', exchange_rate: 1 });
setAmountVal(0);
setRateVal(1);
form.setFieldsValue({ record_date: dayjs(), currency: 'CNY', exchange_rate: 1, txn_type: 'income', category_level1: 'income' });
setAddModalVisible(true);
};
const handleTxnTypeChange = (v: string) => {
form.setFieldsValue({ category_level1: undefined, category_level2: undefined });
if (v === 'income') {
form.setFieldsValue({ category_level1: 'income' });
}
const handleAddExpense = () => {
setAddModalType('expense');
form.resetFields();
setAmountVal(0);
setRateVal(1);
form.setFieldsValue({ record_date: dayjs(), currency: 'CNY', exchange_rate: 1, txn_type: 'expense', category_level1: 'project' });
setAddModalVisible(true);
};
const handleLevel1Change = () => {
form.setFieldsValue({ category_level2: undefined });
form.setFieldsValue({ category_level2: undefined, project_id: undefined });
};
const handleCurrencyChange = (currency: string) => {
const rate = exchangeRates[currency] || 1;
setRateVal(rate);
form.setFieldsValue({ exchange_rate: rate });
const amount = form.getFieldValue('amount_original') || 0;
form.setFieldsValue({ amount_cny: Math.round(amount * rate * 100) / 100 });
};
const handleAmountChange = (val: number | null) => {
const rate = form.getFieldValue('exchange_rate') || 1;
const amount = val || 0;
form.setFieldsValue({ amount_cny: Math.round(amount * rate * 100) / 100 });
};
const handleRateChange = (val: number | null) => {
const amount = form.getFieldValue('amount_original') || 0;
const rate = val || 1;
form.setFieldsValue({ amount_cny: Math.round(amount * rate * 100) / 100 });
};
const handleSave = async () => {
@@ -153,37 +246,63 @@ const FinancePage: React.FC = () => {
const values = await form.validateFields();
setAddLoading(true);
const payload = {
...values,
txn_type: values.txn_type,
category_level1: values.category_level1,
category_level2: values.category_level2,
project_id: values.project_id || undefined,
amount: values.amount_original,
currency: values.currency,
exchange_rate: values.exchange_rate,
record_date: values.record_date?.format('YYYY-MM-DD'),
amount_cny: Math.round((values.amount_original || 0) * (values.exchange_rate || 1) * 100) / 100,
source: 'manual',
counterparty_name: values.counterparty_name,
counterparty_type: values.counterparty_type,
counterparty_id: values.counterparty_id,
description: values.description,
voucher_url: values.voucher_url,
};
if (payload.category_level1 !== 'project' && payload.category_level1 !== 'income') {
payload.project_id = undefined;
if (!['project', 'income'].includes(payload.category_level1)) {
delete payload.project_id;
}
await apiClient.post('/financial-records', payload);
message.success('录入成功');
await apiClient.post('/cash-management', payload);
message.success(t('finance.recordSuccess'));
setAddModalVisible(false);
fetchSummary();
fetchRecords(1);
if (activeTab === 'income' || activeTab === 'expense') fetchCashRecords(1);
} catch (e: any) {
if (e.response?.data?.message) message.error(e.response.data.message);
}
setAddLoading(false);
};
const handleDelete = async (id: number) => {
try {
await apiClient.delete(`/cash-management/${id}`);
message.success(t('common.deleteSuccess'));
fetchSummary();
fetchRecords(1);
if (activeTab === 'income' || activeTab === 'expense') fetchCashRecords(1);
} catch (e: any) {
if (e.response?.data?.message) message.error(e.response.data.message);
}
};
const handleExport = async () => {
try {
message.loading({ content: '正在导出...', key: 'export' });
message.loading({ content: t('finance.exporting'), key: 'export' });
const res = await apiClient.get('/financial-records', { params: { page: 1, pageSize: 5000 } });
if (!res.data.success) return;
const data = res.data.data;
const wb = XLSX.utils.book_new();
const headers = ['日期', '收支类型', '一级分类', '二级分类', '项目名称', '金额', '币种', '汇率', '等效人民币', '对方名称', '对方类型', '人员姓名', '描述', '凭证编号'];
const headers = [
t('finance.date'), t('finance.incomeType'), t('finance.level1Category'), t('finance.level2Category'),
t('finance.projectName'), t('finance.amount'), t('finance.currency'), t('finance.exchangeRate'),
t('finance.equivalentCNY'), t('finance.counterpartyName'), t('finance.counterpartyType'),
t('finance.personName'), t('finance.desc'), t('finance.source'),
];
const rows = data.map((r: any) => [
r.record_date,
r.txn_type === 'income' ? '收入' : '支出',
r.txn_type === 'income' ? t('finance.income') : t('finance.expense'),
LEVEL1_LABELS[r.category_level1] || r.category_level1,
LEVEL2_LABELS[r.category_level2] || r.category_level2,
r.project_name || '',
@@ -195,22 +314,25 @@ const FinancePage: React.FC = () => {
COUNTERPARTY_TYPES.find(c => c.value === r.counterparty_type)?.label || r.counterparty_type || '',
r.user_name || '',
r.description || '',
r.voucher_no || '',
SOURCE_MAP[r.source] || r.source || '',
]);
const ws = XLSX.utils.aoa_to_sheet([headers, ...rows]);
ws['!cols'] = headers.map(() => ({ wch: 14 }));
XLSX.utils.book_append_sheet(wb, ws, '财务记账');
XLSX.writeFile(wb, `财务记录_${dayjs().format('YYYYMMDD')}.xlsx`);
message.success({ content: '导出成功', key: 'export' });
XLSX.utils.book_append_sheet(wb, ws, t('finance.sheetName'));
XLSX.writeFile(wb, `${t('finance.title')}_${dayjs().format('YYYYMMDD')}.xlsx`);
message.success({ content: t('finance.exportSuccess'), key: 'export' });
} catch (e) {
message.error({ content: '导出失败', key: 'export' });
message.error({ content: t('finance.exportFailed'), key: 'export' });
}
};
const getLevel1Options = () => {
const txnType = form.getFieldValue('txn_type');
if (txnType === 'income') return [{ value: 'income', label: '收入' }];
return [{ value: 'project', label: '项目支出' }, { value: 'company', label: '公司支出' }];
const getLevel1Options = (txnType: string) => {
if (txnType === 'income') return [{ value: 'income', label: t('finance.incomeCategory') }];
return [
{ value: 'project', label: t('finance.projectExpense') },
{ value: 'company', label: t('finance.companyExpense') },
{ value: 'finance', label: t('cash.financeExpense') },
];
};
const getLevel2Options = () => {
@@ -221,69 +343,93 @@ const FinancePage: React.FC = () => {
const projectOptions = projects.map((p: any) => ({ value: p.id, label: p.name }));
const getCounterpartyOptions = () => {
const type = form.getFieldValue('counterparty_type');
if (type === 'customer') return customers.map((c: any) => ({ value: c.id, label: c.name }));
if (type === 'supplier') return suppliers.map((s: any) => ({ value: s.id, label: s.name }));
return [];
};
const equivalentCny = amountVal * rateVal;
const desktopColumns = [
{ title: '日期', dataIndex: 'record_date', width: 100, sorter: (a: any, b: any) => a.record_date?.localeCompare(b.record_date) },
{ title: '收支', dataIndex: 'txn_type', width: 60, render: (v: string) => <Tag color={v === 'income' ? 'green' : 'red'}>{v === 'income' ? '收入' : '支出'}</Tag> },
{ title: '一级分类', dataIndex: 'category_level1', width: 90, render: (v: string) => LEVEL1_LABELS[v] || v },
{ title: '二级分类', dataIndex: 'category_level2', width: 100, render: (v: string) => LEVEL2_LABELS[v] || v },
{ title: '项目', dataIndex: 'project_name', width: 140, ellipsis: true },
{ title: '原始金额', dataIndex: 'amount_original', width: 100, render: (v: number) => v?.toLocaleString(), align: 'right' as const },
{ title: '币种', dataIndex: 'currency', width: 50 },
{ title: '等效人民币', dataIndex: 'amount_cny', width: 110, render: (v: number) => `¥${v?.toLocaleString()}`, align: 'right' as const, sorter: (a: any, b: any) => a.amount_cny - b.amount_cny },
{ title: '人员', dataIndex: 'user_name', width: 70 },
{ title: '描述', dataIndex: 'description', ellipsis: true },
{ title: '来源', dataIndex: 'source', width: 80, render: (v: string) => {
const m: Record<string, string> = { manual: '手动', advance: '预支', reimbursement: '报销', payment_request: '付款', material: '材料', primary_freight: '运费', secondary_freight: '运费' };
return <Tag>{m[v] || v}</Tag>;
}},
{ title: t('finance.date'), dataIndex: 'record_date', width: 100, sorter: (a: any, b: any) => a.record_date?.localeCompare(b.record_date), render: (v: string) => v?.slice(0, 10) },
{ title: t('finance.incomeType'), dataIndex: 'txn_type', width: 60, render: (v: string) => <Tag color={v === 'income' ? 'green' : 'red'}>{v === 'income' ? t('finance.income') : t('finance.expense')}</Tag> },
{ title: t('finance.level1Category'), dataIndex: 'category_level1', width: 90, render: (v: string) => LEVEL1_LABELS[v] || v },
{ title: t('finance.level2Category'), dataIndex: 'category_level2', width: 100, render: (v: string) => LEVEL2_LABELS[v] || v },
{ title: t('finance.projectName'), dataIndex: 'project_name', width: 140, ellipsis: true },
{ title: t('finance.amount'), dataIndex: 'amount_original', width: 100, render: (v: number) => v?.toLocaleString(), align: 'right' as const },
{ title: t('finance.currency'), dataIndex: 'currency', width: 50 },
{ title: t('finance.equivalentCNY'), dataIndex: 'amount_cny', width: 110, render: (v: number) => `¥${v?.toLocaleString()}`, align: 'right' as const, sorter: (a: any, b: any) => a.amount_cny - b.amount_cny },
{ title: t('finance.personName'), dataIndex: 'user_name', width: 70 },
{ title: t('finance.desc'), dataIndex: 'description', ellipsis: true },
{ title: t('finance.source'), dataIndex: 'source', width: 80, render: (v: string) => <Tag>{SOURCE_MAP[v] || v}</Tag> },
];
const mobileColumns = [
{ title: '日期', dataIndex: 'record_date', width: 80 },
{ title: '收支', dataIndex: 'txn_type', width: 40, render: (v: string) => <span style={{ color: v === 'income' ? 'green' : 'red' }}>{v === 'income' ? '↑' : '↓'}</span> },
{ title: '分类', dataIndex: 'category_level2', width: 80, render: (v: string) => LEVEL2_LABELS[v] || v },
{ title: '金额', dataIndex: 'amount_cny', render: (v: number) => <b>¥{v?.toLocaleString()}</b>, align: 'right' as const },
{ title: t('finance.date'), dataIndex: 'record_date', width: 80, render: (v: string) => v?.slice(0, 10) },
{ title: t('finance.incomeType'), dataIndex: 'txn_type', width: 40, render: (v: string) => <span style={{ color: v === 'income' ? 'green' : 'red' }}>{v === 'income' ? '↑' : '↓'}</span> },
{ title: t('finance.level2Category'), dataIndex: 'category_level2', width: 80, render: (v: string) => LEVEL2_LABELS[v] || v },
{ title: t('finance.amount'), dataIndex: 'amount_cny', render: (v: number) => <b>¥{v?.toLocaleString()}</b>, align: 'right' as const },
];
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
<div>
<Title level={3} style={{ marginBottom: 0 }}></Title>
</div>
<Space>
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>
{isMobile ? '新增' : '新增记录'}
</Button>
<Button icon={<DownloadOutlined />} onClick={handleExport}>
{isMobile ? '导出' : '导出Excel'}
</Button>
</Space>
</div>
const cashColumns = [
{ title: t('finance.date'), dataIndex: 'record_date', width: 100, render: (v: string) => v?.slice(0, 10) },
{ title: t('finance.level1Category'), dataIndex: 'category_level1', width: 90, render: (v: string) => LEVEL1_LABELS[v] || v },
{ title: t('finance.level2Category'), dataIndex: 'category_level2', width: 110, render: (v: string) => LEVEL2_LABELS[v] || v },
{ title: t('finance.projectName'), dataIndex: 'project_name', width: 140, ellipsis: true },
{ title: t('finance.amount'), dataIndex: 'amount_original', width: 100, render: (v: number) => v?.toLocaleString(), align: 'right' as const },
{ title: t('finance.currency'), dataIndex: 'currency', width: 50 },
{ title: t('finance.equivalentCNY'), dataIndex: 'amount_cny', width: 110, render: (v: number) => `¥${v?.toLocaleString()}`, align: 'right' as const },
{ title: t('finance.counterpartyName'), dataIndex: 'counterparty_name', width: 100, ellipsis: true },
{ title: t('finance.desc'), dataIndex: 'description', ellipsis: true },
{
title: '', width: 50, render: (_: any, record: any) => (
<Popconfirm title={t('common.confirmDelete')} onConfirm={() => handleDelete(record.id)}>
<Button type="text" danger size="small" icon={<DeleteOutlined />} />
</Popconfirm>
)
},
];
const cashMobileColumns = [
{ title: t('finance.date'), dataIndex: 'record_date', width: 80, render: (v: string) => v?.slice(0, 10) },
{ title: t('finance.level2Category'), dataIndex: 'category_level2', width: 80, render: (v: string) => LEVEL2_LABELS[v] || v },
{ title: t('finance.amount'), dataIndex: 'amount_cny', render: (v: number, r: any) => <span style={{ color: r.txn_type === 'income' ? 'green' : 'red' }}>¥{v?.toLocaleString()}</span>, align: 'right' as const },
{
title: '', width: 40, render: (_: any, record: any) => (
<Popconfirm title={t('common.confirmDelete')} onConfirm={() => handleDelete(record.id)}>
<Button type="text" danger size="small" icon={<DeleteOutlined />} />
</Popconfirm>
)
},
];
const renderOverview = () => (
<>
<Row gutter={[8, 8]} style={{ marginBottom: 16 }}>
<Col xs={8} sm={8}>
<Card size="small" style={{ textAlign: 'center' }}>
<Statistic title={<span style={{ fontSize: 12 }}></span>} value={summary.total_income} prefix="¥"
<Statistic title={<span style={{ fontSize: 12 }}>{t('finance.totalIncome')}</span>} value={summary.total_income} prefix="¥"
valueStyle={{ color: '#3f8600', fontSize: isMobile ? 16 : undefined }} />
</Card>
</Col>
<Col xs={8} sm={8}>
<Card size="small" style={{ textAlign: 'center' }}>
<Statistic title={<span style={{ fontSize: 12 }}></span>} value={summary.total_expense} prefix="¥"
<Statistic title={<span style={{ fontSize: 12 }}>{t('finance.totalExpense')}</span>} value={summary.total_expense} prefix="¥"
valueStyle={{ color: '#cf1322', fontSize: isMobile ? 16 : undefined }} />
</Card>
</Col>
<Col xs={8} sm={8}>
<Card size="small" style={{ textAlign: 'center' }}>
<Statistic title={<span style={{ fontSize: 12 }}></span>} value={summary.net_profit} prefix="¥"
<Statistic title={<span style={{ fontSize: 12 }}>{t('finance.netProfit')}</span>} value={summary.net_profit} prefix="¥"
valueStyle={{ color: summary.net_profit >= 0 ? '#3f8600' : '#cf1322', fontSize: isMobile ? 16 : undefined }} />
</Card>
</Col>
</Row>
{byCategory.length > 0 && (
<Card title="支出分类汇总" size="small" style={{ marginBottom: 16 }}>
<Card title={t('finance.expenseSummary')} size="small" style={{ marginBottom: 16 }}>
<Row gutter={[8, 8]}>
{byCategory.filter(c => c.category_level1 !== 'income').map((c, i) => (
<Col xs={12} sm={8} md={6} key={i}>
@@ -295,13 +441,13 @@ const FinancePage: React.FC = () => {
</Card>
)}
<Card title="财务明细" size="small" styles={{ body: { padding: isMobile ? 8 : 24 } }}
<Card title={t('finance.detail')} size="small" styles={{ body: { padding: isMobile ? 8 : 24 } }}
extra={
<Space size="small" wrap>
<RangePicker size="small" onChange={(dates) => setDateRange(dates as any)} />
<Select size="small" allowClear placeholder="筛选类型" style={{ width: 100 }}
<Select size="small" allowClear placeholder={t('finance.filterType')} style={{ width: 100 }}
onChange={setFilterType} value={filterType}
options={[{ value: 'income', label: '收入' }, { value: 'expense', label: '支出' }]} />
options={[{ value: 'income', label: t('finance.income') }, { value: 'expense', label: t('finance.expense') }]} />
</Space>
}
>
@@ -318,92 +464,186 @@ const FinancePage: React.FC = () => {
total: pagination.total,
onChange: (page) => fetchRecords(page),
size: isMobile ? 'small' : 'default',
showTotal: (total) => `${total}`
showTotal: (total) => t('finance.totalRecords', { total })
}}
/>
</Spin>
</Card>
</>
);
const renderCashTab = (type: 'income' | 'expense') => {
const isIncome = type === 'income';
return (
<>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
<Space size="small" wrap>
<RangePicker size="small" onChange={(dates) => setCashDateRange(dates as any)} />
<Select size="small" allowClear placeholder={t('finance.level1Category')} style={{ width: 120 }}
onChange={(v) => { setCashFilterType(v); }}
options={isIncome
? [{ value: 'income', label: t('finance.incomeCategory') }]
: [
{ value: 'project', label: t('finance.projectExpense') },
{ value: 'company', label: t('finance.companyExpense') },
{ value: 'finance', label: t('cash.financeExpense') },
]
}
/>
</Space>
<Button type="primary" icon={<PlusOutlined />} onClick={isIncome ? handleAddIncome : handleAddExpense}>
{isMobile ? t('common.add') : (isIncome ? t('cash.addIncome') : t('cash.addExpense'))}
</Button>
</div>
<Card size="small" styles={{ body: { padding: isMobile ? 8 : 16 } }}>
<Spin spinning={cashLoading}>
<Table
dataSource={cashRecords.filter(r => isIncome ? r.txn_type === 'income' : r.txn_type === 'expense')}
columns={isMobile ? cashMobileColumns : cashColumns}
rowKey="id"
size={isMobile ? 'small' : 'middle'}
scroll={isMobile ? { x: 400 } : { x: 1000 }}
pagination={{
current: cashPagination.page,
pageSize: cashPagination.pageSize,
total: cashPagination.total,
onChange: (page) => fetchCashRecords(page),
size: isMobile ? 'small' : 'default',
showTotal: (total) => t('finance.totalRecords', { total })
}}
/>
</Spin>
</Card>
</>
);
};
const tabItems = [
{
key: 'overview',
label: t('cash.tabOverview'),
icon: <FundOutlined />,
children: renderOverview(),
},
{
key: 'income',
label: t('cash.tabIncome'),
icon: <RiseOutlined />,
children: renderCashTab('income'),
},
{
key: 'expense',
label: t('cash.tabExpense'),
icon: <FallOutlined />,
children: renderCashTab('expense'),
},
];
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
<Title level={3} style={{ marginBottom: 0 }}>{t('finance.title')}</Title>
<Space>
<Button icon={<DownloadOutlined />} onClick={handleExport}>
{isMobile ? t('common.export') : t('finance.exportExcel')}
</Button>
</Space>
</div>
<Tabs activeKey={activeTab} onChange={setActiveTab} items={tabItems} type="card" />
<Modal
title="新增财务记录"
title={addModalType === 'income' ? t('cash.addIncome') : t('cash.addExpense')}
open={addModalVisible}
onOk={handleSave}
onCancel={() => setAddModalVisible(false)}
confirmLoading={addLoading}
okText="保存"
cancelText="取消"
width={isMobile ? '95%' : 600}
okText={t('common.save')}
cancelText={t('common.cancel')}
width={isMobile ? '95%' : 640}
style={{ top: isMobile ? 10 : 40 }}
destroyOnClose
>
<Form form={form} layout={isMobile ? 'vertical' : 'horizontal'} labelCol={{ span: isMobile ? 24 : 6 }} wrapperCol={{ span: isMobile ? 24 : 18 }} size="middle">
<Form.Item name="record_date" label="日期" rules={[{ required: true, message: '请选择日期' }]}>
<Form.Item name="record_date" label={t('finance.date')} rules={[{ required: true, message: t('finance.selectDate') }]}>
<DatePicker style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="txn_type" label="收支类型" rules={[{ required: true, message: '请选择' }]}>
<Select onChange={handleTxnTypeChange} options={[{ value: 'income', label: '收入' }, { value: 'expense', label: '支出' }]} placeholder="请选择" />
<Form.Item name="category_level1" label={t('finance.level1Category')} rules={[{ required: true, message: t('finance.selectLevel1') }]}>
<Select onChange={handleLevel1Change} options={getLevel1Options(addModalType)} placeholder={t('finance.selectLevel1')} />
</Form.Item>
<Form.Item name="category_level1" label="一级分类" rules={[{ required: true, message: '请选择' }]}>
<Select onChange={handleLevel1Change} options={getLevel1Options()} placeholder="请选择" />
<Form.Item name="category_level2" label={t('finance.level2Category')} rules={[{ required: true, message: t('finance.selectLevel2') }]}>
<Select options={getLevel2Options()} placeholder={t('finance.selectLevel2')} notFoundContent={t('finance.selectLevel2')} />
</Form.Item>
<Form.Item name="category_level2" label="二级分类" rules={[{ required: true, message: '请选择' }]}>
<Select options={getLevel2Options()} placeholder="请先选择一级分类" notFoundContent="请先选择一级分类" />
</Form.Item>
<Form.Item name="project_id" label="项目名称"
rules={[{ required: form.getFieldValue('category_level1') === 'project' || (form.getFieldValue('category_level1') === 'income' && form.getFieldValue('category_level2') !== 'shareholder_investment' && form.getFieldValue('category_level2') !== 'other_income'), message: '请选择项目' }]}
>
<Select showSearch optionFilterProp="label" options={projectOptions} placeholder="选择项目" allowClear />
<Form.Item name="project_id" label={t('finance.projectName')}>
<Select showSearch optionFilterProp="label" options={projectOptions} placeholder={t('finance.selectProject')} allowClear />
</Form.Item>
<Row gutter={8}>
<Col span={10}>
<Form.Item name="amount_original" label={isMobile ? '金额' : '金额'} labelCol={{ span: isMobile ? 24 : 8 }} wrapperCol={{ span: isMobile ? 24 : 16 }} rules={[{ required: true, message: '请输入' }]}>
<InputNumber style={{ width: '100%' }} min={0} precision={2} onChange={handleAmountChange} placeholder="0" />
<Form.Item name="amount_original" label={t('finance.amount')} labelCol={{ span: isMobile ? 24 : 8 }} wrapperCol={{ span: isMobile ? 24 : 16 }} rules={[{ required: true, message: t('common.inputPlaceholder') }]}>
<InputNumber style={{ width: '100%' }} min={0} precision={2} onChange={(v) => setAmountVal(v || 0)} placeholder={t('finance.amountPlaceholder')} />
</Form.Item>
</Col>
<Col span={7}>
<Form.Item name="currency" label={isMobile ? '币种' : ''} labelCol={{ span: isMobile ? 24 : 0 }} wrapperCol={{ span: isMobile ? 24 : 24 }} rules={[{ required: true }]}>
<Form.Item name="currency" label={isMobile ? t('finance.currency') : ''} labelCol={{ span: isMobile ? 24 : 0 }} wrapperCol={{ span: isMobile ? 24 : 24 }} rules={[{ required: true }]}>
<Select options={CURRENCIES} onChange={handleCurrencyChange} />
</Form.Item>
</Col>
<Col span={7}>
<Form.Item name="exchange_rate" label={isMobile ? '汇率' : ''} labelCol={{ span: isMobile ? 24 : 0 }} wrapperCol={{ span: isMobile ? 24 : 24 }} rules={[{ required: true }]}>
<InputNumber style={{ width: '100%' }} min={0} step={0.0001} precision={6} onChange={handleRateChange} />
<Form.Item name="exchange_rate" label={isMobile ? t('finance.exchangeRate') : ''} labelCol={{ span: isMobile ? 24 : 0 }} wrapperCol={{ span: isMobile ? 24 : 24 }} rules={[{ required: true }]}>
<InputNumber style={{ width: '100%' }} min={0} step={0.0001} precision={6} onChange={(v) => setRateVal(v || 1)} />
</Form.Item>
</Col>
</Row>
<Form.Item label="等效人民币">
<Form.Item label={t('finance.equivalentCNY')}>
<span style={{ fontSize: 18, fontWeight: 'bold', color: '#1890ff' }}>
¥{((form.getFieldValue('amount_original') || 0) * (form.getFieldValue('exchange_rate') || 1)).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
¥{equivalentCny.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</span>
</Form.Item>
<Divider style={{ margin: '8px 0' }} />
<Form.Item name="counterparty_name" label="对方名称" labelCol={{ span: isMobile ? 24 : 6 }} wrapperCol={{ span: isMobile ? 24 : 18 }}>
<Input placeholder="收款方/付款方名称" />
<Form.Item name="counterparty_type" label={t('finance.counterpartyType')} labelCol={{ span: isMobile ? 24 : 6 }} wrapperCol={{ span: isMobile ? 24 : 18 }}>
<Select options={COUNTERPARTY_TYPES} placeholder={t('finance.selectType')} allowClear onChange={() => form.setFieldsValue({ counterparty_id: undefined })} />
</Form.Item>
<Form.Item name="counterparty_type" label="对方类型" labelCol={{ span: isMobile ? 24 : 6 }} wrapperCol={{ span: isMobile ? 24 : 18 }}>
<Select options={COUNTERPARTY_TYPES} placeholder="选择类型" allowClear />
{getCounterpartyOptions().length > 0 && (
<Form.Item name="counterparty_id" label={t('cash.counterpartySelect')} labelCol={{ span: isMobile ? 24 : 6 }} wrapperCol={{ span: isMobile ? 24 : 18 }}>
<Select showSearch optionFilterProp="label" options={getCounterpartyOptions()} placeholder={t('cash.counterpartySelectPlaceholder')} allowClear />
</Form.Item>
)}
<Form.Item name="counterparty_name" label={t('finance.counterpartyName')} labelCol={{ span: isMobile ? 24 : 6 }} wrapperCol={{ span: isMobile ? 24 : 18 }}>
<Input placeholder={t('finance.counterpartyPlaceholder')} />
</Form.Item>
<Form.Item name="user_name" label="人员姓名" labelCol={{ span: isMobile ? 24 : 6 }} wrapperCol={{ span: isMobile ? 24 : 18 }}>
<Input placeholder="关联员工姓名" />
<Form.Item name="description" label={t('finance.desc')} labelCol={{ span: isMobile ? 24 : 6 }} wrapperCol={{ span: isMobile ? 24 : 18 }}>
<Input.TextArea rows={2} placeholder={t('finance.descPlaceholder')} />
</Form.Item>
<Form.Item name="description" label="描述" labelCol={{ span: isMobile ? 24 : 6 }} wrapperCol={{ span: isMobile ? 24 : 18 }}>
<Input.TextArea rows={2} placeholder="补充说明" />
</Form.Item>
<Form.Item name="voucher_no" label="凭证编号" labelCol={{ span: isMobile ? 24 : 6 }} wrapperCol={{ span: isMobile ? 24 : 18 }}>
<Input placeholder="发票号/收据号" />
<Form.Item name="voucher_url" label={t('cash.voucherUpload')} labelCol={{ span: isMobile ? 24 : 6 }} wrapperCol={{ span: isMobile ? 24 : 18 }}>
<Upload
maxCount={1}
action="/api/upload/single"
headers={{ Authorization: `Bearer ${JSON.parse(localStorage.getItem('auth-storage') || '{}')?.state?.token || ''}` }}
onChange={(info) => {
if (info.file.status === 'done' && info.file.response?.url) {
form.setFieldsValue({ voucher_url: info.file.response.url });
message.success(t('cash.uploadSuccess'));
} else if (info.file.status === 'error') {
message.error(t('cash.uploadFailed'));
}
}}
onRemove={() => form.setFieldsValue({ voucher_url: undefined })}
>
<Button icon={<UploadOutlined />}>{t('cash.uploadVoucher')}</Button>
</Upload>
</Form.Item>
</Form>
</Modal>
File diff suppressed because it is too large Load Diff
+72 -61
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 apiClient from '../../utils/request';
import { useAuthStore } from '../../store/authStore';
import { useLanguageStore } from '../../store/languageStore';
import dayjs from 'dayjs';
const { Title, Paragraph } = Typography;
@@ -28,6 +29,7 @@ interface Project {
const ProjectsPage: React.FC = () => {
const navigate = useNavigate();
const { t, currentLanguage } = useLanguageStore();
const [isMobile, setIsMobile] = useState(false);
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
@@ -41,6 +43,7 @@ const ProjectsPage: React.FC = () => {
const [form] = Form.useForm();
const [customers, setCustomers] = useState<any[]>([]);
const [users, setUsers] = useState<any[]>([]);
const [templates, setTemplates] = useState<any[]>([]);
const { user: currentUser } = useAuthStore();
const isAdmin = currentUser?.role === 'admin' || false;
@@ -62,13 +65,13 @@ const ProjectsPage: React.FC = () => {
...p,
key: p.id.toString(),
progress: p.status === 'completed' ? 100 : (p.phase_progress || 0),
manager_name: p.manager_name || '未分配'
manager_name: p.manager_name || t('project.unassigned')
})));
} else {
message.error(`获取项目列表失败: ${response.data.message}`);
message.error(`${t('project.getListFailed')}: ${response.data.message}`);
}
} catch (error) {
message.error('获取项目列表失败');
message.error(t('project.getListFailed'));
} finally {
setLoading(false);
}
@@ -76,12 +79,14 @@ const ProjectsPage: React.FC = () => {
const fetchCreateOptions = async () => {
try {
const [custRes, userRes] = await Promise.all([
const [custRes, userRes, tplRes] = await Promise.all([
apiClient.get('/customers'),
apiClient.get('/users'),
apiClient.get('/process-templates'),
]);
if (custRes.data.success) setCustomers(custRes.data.data || []);
if (userRes.data.success) setUsers(userRes.data.data || []);
if (tplRes.data.success) setTemplates(tplRes.data.data || []);
} catch (e) { /* ignore */ }
};
@@ -99,17 +104,18 @@ const ProjectsPage: React.FC = () => {
const payload = {
...values,
manager_id: values.project_manager_id,
type_template_id: values.type_template_id || undefined,
start_date: values.start_date?.format('YYYY-MM-DD'),
end_date: values.end_date?.format('YYYY-MM-DD') || null,
contract_amount: values.contract_amount || 0,
};
const res = await apiClient.post('/projects', payload);
if (res.data.success) {
message.success('项目创建成功');
message.success(t('project.createSuccess'));
setCreateModalVisible(false);
fetchProjects();
} else {
message.error(res.data.message || '创建失败');
message.error(res.data.message || t('project.createFailed'));
}
} catch (e: any) {
if (e.response?.data?.message) message.error(e.response.data.message);
@@ -126,7 +132,7 @@ const ProjectsPage: React.FC = () => {
const handleDeleteConfirm = async () => {
if (!deleteProjectId) return;
if (deletePassword !== 'X123c321@') {
message.error('密码错误');
message.error(t('project.passwordError'));
return;
}
setDeleteLoading(true);
@@ -135,14 +141,14 @@ const ProjectsPage: React.FC = () => {
headers: { 'x-user-role': 'admin' }
});
if (response.data.success) {
message.success('项目删除成功');
message.success(t('project.deleteSuccess'));
setDeleteModalVisible(false);
fetchProjects();
} else {
message.error(response.data.message || '删除失败');
message.error(response.data.message || t('common.deleteFailed'));
}
} catch (error) {
message.error('删除项目失败');
message.error(t('common.operationFailed'));
} finally {
setDeleteLoading(false);
}
@@ -150,21 +156,21 @@ const ProjectsPage: React.FC = () => {
const desktopColumns = [
{
title: '项目名称', dataIndex: 'name', key: 'name', width: 250, ellipsis: true,
title: t('project.projectName'), dataIndex: 'name', key: 'name', width: 250, ellipsis: true,
render: (text: string, record: Project) => (
<a onClick={() => navigate(`/projects/${record.id}`)}>{text}</a>
),
},
{ title: '项目经理', dataIndex: 'manager_name', key: 'manager_name', width: 100 },
{ title: t('project.projectManager'), dataIndex: 'manager_name', key: 'manager_name', width: 100 },
{
title: '预算', dataIndex: 'budget', key: 'budget', width: 120,
title: t('project.budget'), dataIndex: 'budget', key: 'budget', width: 120,
render: (amount: string) => {
const val = parseFloat(amount || '0');
return val > 0 ? `¥${(val / 10000).toFixed(1)}` : '-';
return val > 0 ? `¥${(val / 10000).toFixed(1)}${t('common.tenThousand')}` : '-';
},
},
{
title: '进度', dataIndex: 'progress', key: 'progress', width: 120,
title: t('project.progress'), dataIndex: 'progress', key: 'progress', width: 120,
render: (progress: number) => (
<div style={{ width: 100 }}>
<div style={{ background: '#f0f0f0', borderRadius: 10, height: 8 }}>
@@ -178,23 +184,23 @@ const ProjectsPage: React.FC = () => {
),
},
{
title: '状态', dataIndex: 'status', key: 'status', width: 100,
title: t('project.status'), dataIndex: 'status', key: 'status', width: 100,
render: (status: string) => {
const statusMap: Record<string, { color: string; text: string }> = {
planning: { color: 'blue', text: '规划中' }, in_progress: { color: 'processing', text: '进行中' },
completed: { color: 'success', text: '已完成' }, suspended: { color: 'warning', text: '已暂停' },
active: { color: 'processing', text: '进行中' },
planning: { color: 'blue', text: t('project.planning') }, in_progress: { color: 'processing', text: t('project.inProgress') },
completed: { color: 'success', text: t('project.completed') }, suspended: { color: 'warning', text: t('project.paused') },
active: { color: 'processing', text: t('project.inProgress') },
};
const config = statusMap[status] || { color: 'default', text: status };
return <Tag color={config.color}>{config.text}</Tag>;
},
},
{
title: '操作', key: 'action', width: 140,
title: t('common.action'), key: 'action', width: 140,
render: (_: unknown, record: Project) => (
<Space>
<Button size="small" onClick={() => navigate(`/projects/${record.id}`)}></Button>
{isAdmin && <Button size="small" danger icon={<DeleteOutlined />} onClick={() => handleDeleteProject(record.id)}></Button>}
<Button size="small" onClick={() => navigate(`/projects/${record.id}`)}>{t('common.view')}</Button>
{isAdmin && <Button size="small" danger icon={<DeleteOutlined />} onClick={() => handleDeleteProject(record.id)}>{t('common.delete')}</Button>}
</Space>
),
},
@@ -202,13 +208,13 @@ const ProjectsPage: React.FC = () => {
const mobileColumns = [
{
title: '项目', dataIndex: 'name', key: 'name', ellipsis: true,
title: t('dashboard.project'), dataIndex: 'name', key: 'name', ellipsis: true,
render: (text: string, record: Project) => (
<a onClick={() => navigate(`/projects/${record.id}`)}>{text}</a>
),
},
{
title: '进度', dataIndex: 'progress', key: 'progress', width: 80,
title: t('project.progress'), dataIndex: 'progress', key: 'progress', width: 80,
render: (progress: number) => (
<div style={{ width: 60 }}>
<div style={{ background: '#f0f0f0', borderRadius: 4, height: 6 }}>
@@ -222,21 +228,21 @@ const ProjectsPage: React.FC = () => {
),
},
{
title: '状态', dataIndex: 'status', key: 'status', width: 70,
title: t('project.status'), dataIndex: 'status', key: 'status', width: 70,
render: (status: string) => {
const statusMap: Record<string, { color: string; text: string }> = {
planning: { color: 'blue', text: '规划' }, in_progress: { color: 'processing', text: '进行中' },
completed: { color: 'success', text: '完成' }, suspended: { color: 'warning', text: '暂停' },
active: { color: 'processing', text: '进行中' },
planning: { color: 'blue', text: t('project.plan') }, in_progress: { color: 'processing', text: t('project.inProgress') },
completed: { color: 'success', text: t('project.complete') }, suspended: { color: 'warning', text: t('project.pause') },
active: { color: 'processing', text: t('project.inProgress') },
};
const config = statusMap[status] || { color: 'default', text: status };
return <Tag color={config.color} style={{ fontSize: 10 }}>{config.text}</Tag>;
},
},
{
title: '操作', key: 'action', width: 60,
title: t('common.action'), key: 'action', width: 60,
render: (_: unknown, record: Project) => (
<Button size="small" onClick={() => navigate(`/projects/${record.id}`)}></Button>
<Button size="small" onClick={() => navigate(`/projects/${record.id}`)}>{t('common.view')}</Button>
),
},
];
@@ -245,15 +251,15 @@ const ProjectsPage: React.FC = () => {
<div style={{ padding: isMobile ? 8 : 24 }}>
<div style={{ marginBottom: isMobile ? 12 : 24, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
<div>
<Title level={isMobile ? 3 : 2} style={{ marginBottom: 8 }}></Title>
<Paragraph type="secondary" style={{ marginBottom: 0 }}></Paragraph>
<Title level={isMobile ? 3 : 2} style={{ marginBottom: 8 }}>{t('project.title')}</Title>
<Paragraph type="secondary" style={{ marginBottom: 0 }}>{t('project.description')}</Paragraph>
</div>
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreateClick}>
{isMobile ? '新建' : '新建项目'}
{isMobile ? t('common.create') : t('project.newProject')}
</Button>
</div>
<Card title="项目列表" size="small" styles={{ body: { padding: isMobile ? 8 : 24 } }}>
<Card title={t('project.list')} size="small" styles={{ body: { padding: isMobile ? 8 : 24 } }}>
{loading ? (
<div style={{ textAlign: 'center', padding: 40 }}><Spin /></div>
) : (
@@ -268,32 +274,37 @@ const ProjectsPage: React.FC = () => {
</Card>
<Modal
title="快速新建项目"
title={t('project.quickCreate')}
open={createModalVisible}
onOk={handleCreateSubmit}
onCancel={() => setCreateModalVisible(false)}
confirmLoading={createLoading}
okText="创建"
cancelText="取消"
okText={t('common.create')}
cancelText={t('common.cancel')}
width={isMobile ? '95%' : 560}
style={{ top: isMobile ? 10 : 40 }}
destroyOnClose
>
<Form form={form} layout="vertical" size="middle">
<Form.Item name="name" label="项目名称" rules={[{ required: true, message: '请输入项目名称' }]}>
<Input placeholder="如:万象省赛塔尼县22kV线路工程" />
<Form.Item name="name" label={t('project.projectName')} rules={[{ required: true, message: t('common.inputPlaceholder') + t('project.projectName') }]}>
<Input placeholder={t('project.projectNamePlaceholder')} />
</Form.Item>
<Form.Item name="type_template_id" label={t('project.projectTemplate')}>
<Select allowClear placeholder={t('project.selectTemplate')}
options={templates.map((tpl: any) => ({ value: tpl.id, label: tpl.name }))} />
</Form.Item>
<Row gutter={8}>
<Col span={12}>
<Form.Item name="customer_id" label="客户">
<Select showSearch optionFilterProp="label" allowClear placeholder="选择客户"
<Form.Item name="customer_id" label={t('project.customer')}>
<Select showSearch optionFilterProp="label" allowClear placeholder={t('project.selectCustomer')}
options={customers.map((c: any) => ({ value: c.id, label: c.name }))} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="project_manager_id" label="项目经理">
<Select showSearch optionFilterProp="label" allowClear placeholder="选择项目经理"
<Form.Item name="project_manager_id" label={t('project.projectManager')}>
<Select showSearch optionFilterProp="label" allowClear placeholder={t('project.selectManager')}
options={users.map((u: any) => ({ value: u.id, label: u.name || u.username }))} />
</Form.Item>
</Col>
@@ -301,16 +312,16 @@ const ProjectsPage: React.FC = () => {
<Row gutter={8}>
<Col span={12}>
<Form.Item name="contract_amount" label="合同金额">
<Form.Item name="contract_amount" label={t('project.contractAmount')}>
<InputNumber style={{ width: '100%' }} min={0} placeholder="0" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="status" label="项目状态">
<Form.Item name="status" label={t('project.projectStatus')}>
<Select options={[
{ value: 'planning', label: '规划中' },
{ value: 'in_progress', label: '进行中' },
{ value: 'completed', label: '已完成(补录历史项目)' },
{ value: 'planning', label: t('project.planning') },
{ value: 'in_progress', label: t('project.inProgress') },
{ value: 'completed', label: t('project.completedHistory') },
]} />
</Form.Item>
</Col>
@@ -318,44 +329,44 @@ const ProjectsPage: React.FC = () => {
<Row gutter={8}>
<Col span={12}>
<Form.Item name="start_date" label="开始日期">
<Form.Item name="start_date" label={t('project.startDate')}>
<DatePicker style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="end_date" label="结束日期">
<Form.Item name="end_date" label={t('project.endDate')}>
<DatePicker style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
<Form.Item name="location" label="项目地点">
<Input placeholder="如:老挝万象省" />
<Form.Item name="location" label={t('project.location')}>
<Input placeholder={t('project.locationPlaceholder')} />
</Form.Item>
<Form.Item name="description" label="项目描述">
<Input.TextArea rows={2} placeholder="简要描述项目内容" />
<Form.Item name="description" label={t('project.description')}>
<Input.TextArea rows={2} placeholder={t('project.descriptionPlaceholder')} />
</Form.Item>
</Form>
</Modal>
<Modal
title="删除确认"
title={t('project.deleteConfirm')}
open={deleteModalVisible}
onOk={handleDeleteConfirm}
onCancel={() => setDeleteModalVisible(false)}
confirmLoading={deleteLoading}
okText="确认删除"
cancelText="取消"
okText={t('common.deleteConfirm')}
cancelText={t('common.cancel')}
>
<div style={{ marginBottom: 16 }}>
<p></p>
<p></p>
<p>{t('project.deleteConfirmMsg')}</p>
<p>{t('project.deletePassMsg')}</p>
</div>
<Input.Password placeholder="请输入管理员密码" value={deletePassword} onChange={(e) => setDeletePassword(e.target.value)} size="large" />
<Input.Password placeholder={t('common.inputPassword')} value={deletePassword} onChange={(e) => setDeletePassword(e.target.value)} size="large" />
</Modal>
</div>
);
};
export default ProjectsPage;
export default ProjectsPage;
File diff suppressed because it is too large Load Diff
+62 -36
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 { RiseOutlined, FallOutlined, DollarOutlined } from '@ant-design/icons';
import apiClient from '../../utils/request';
import dayjs from 'dayjs';
import { useLanguageStore } from '../../store/languageStore';
const { Title, Paragraph } = Typography;
const LEVEL2_LABELS: Record<string, string> = {
contract_payment: '项目合同收款', deposit_refund: '质保金退回', shareholder_investment: '股东投资入股', other_income: '其他收入',
material: '材料采购', equipment: '设备采购', subcontract: '施工分包', labor: '人工工资',
travel: '差旅交通', accommodation: '食宿费用', freight: '运输物流', design: '勘测设计',
tools: '小型工具', client_relations: '客户/EDL关系', other_project: '其他项目支出',
salary: '工资薪酬', rent: '房租物业', office: '办公费用', commute: '交通通勤',
vehicle_maintenance: '车辆维保', assets: '固定资产', marketing: '营销拓展',
entertainment: '招待费用', welfare: '员工福利', logistics: '快递物流', other_company: '其他公司支出'
};
const ReportsPage: React.FC = () => {
const { t, currentLanguage } = useLanguageStore();
const LEVEL2_LABELS = useMemo<Record<string, string>>(() => ({
contract_payment: t('finance.projectRevenue'),
deposit_refund: t('finance.warrantyReturn'),
shareholder_investment: t('finance.shareholderInvestment'),
other_income: t('finance.otherIncome'),
material: t('finance.materialPurchase'),
equipment: t('finance.equipmentPurchase'),
subcontract: t('finance.constructionSubcontract'),
construction_subcontract: t('finance.constructionSubcontract'),
labor: t('finance.laborWage'),
travel: t('finance.travelTransport'),
accommodation: t('finance.accommodationFood'),
freight: t('finance.transportLogistics'),
transport_logistics: t('finance.transportLogistics'),
design: t('finance.surveyDesign'),
survey_design: t('finance.surveyDesign'),
tools: t('finance.smallTools'),
client_relations: t('finance.customerEDLRelation'),
customer_edl: t('finance.customerEDLRelation'),
other_project: t('finance.otherProjectExpense'),
salary: t('finance.salaryWelfare'),
rent: t('finance.rentProperty'),
office: t('finance.officeExpense'),
commute: t('finance.commute'),
vehicle_maintenance: t('finance.vehicleMaintenance'),
assets: t('finance.fixedAsset'),
marketing: t('finance.marketing'),
entertainment: t('finance.entertainment'),
welfare: t('finance.employeeBenefit'),
logistics: t('finance.expressLogistics'),
other_company: t('finance.otherCompanyExpense'),
}), [t]);
const [loading, setLoading] = useState(false);
const [isMobile, setIsMobile] = useState(false);
const [summary, setSummary] = useState<any>({ total_income: 0, total_expense: 0, net_profit: 0 });
@@ -57,31 +83,31 @@ const ReportsPage: React.FC = () => {
}));
const desktopColumns = [
{ title: '月份', dataIndex: 'month', key: 'month' },
{ title: '总收入', dataIndex: 'income', key: 'income', render: (v: number) => `¥${parseFloat(v).toLocaleString()}` },
{ title: '总支出', dataIndex: 'expense', key: 'expense', render: (v: number) => `¥${parseFloat(v).toLocaleString()}` },
{ title: t('reports.month'), dataIndex: 'month', key: 'month' },
{ title: t('reports.totalIncome'), dataIndex: 'income', key: 'income', render: (v: number) => `¥${parseFloat(v).toLocaleString()}` },
{ title: t('reports.totalExpense'), dataIndex: 'expense', key: 'expense', render: (v: number) => `¥${parseFloat(v).toLocaleString()}` },
{
title: '净利润', dataIndex: 'profit', key: 'profit',
title: t('reports.netProfit'), dataIndex: 'profit', key: 'profit',
render: (v: number) => <span style={{ color: v >= 0 ? 'green' : 'red', fontWeight: 'bold' }}>¥{v.toLocaleString()}</span>
},
];
const mobileColumns = [
{ title: '月份', dataIndex: 'month', key: 'month', render: (v: string) => v.replace('-', '/') },
{ title: '收入', dataIndex: 'income', key: 'income', render: (v: number) => <span style={{ color: 'green' }}>¥{(parseFloat(v) / 10000).toFixed(1)}</span> },
{ title: '支出', dataIndex: 'expense', key: 'expense', render: (v: number) => <span style={{ color: 'red' }}>¥{(parseFloat(v) / 10000).toFixed(1)}</span> },
{ title: t('reports.month'), dataIndex: 'month', key: 'month', render: (v: string) => v.replace('-', '/') },
{ title: t('reports.totalIncome'), dataIndex: 'income', key: 'income', render: (v: number) => <span style={{ color: 'green' }}>¥{(parseFloat(v) / 10000).toFixed(1)}{t('common.tenThousand')}</span> },
{ title: t('reports.totalExpense'), dataIndex: 'expense', key: 'expense', render: (v: number) => <span style={{ color: 'red' }}>¥{(parseFloat(v) / 10000).toFixed(1)}{t('common.tenThousand')}</span> },
{
title: '利润', dataIndex: 'profit', key: 'profit',
render: (v: number) => <b style={{ color: v >= 0 ? 'green' : 'red' }}>¥{(v / 10000).toFixed(1)}</b>
title: t('reports.netProfit'), dataIndex: 'profit', key: 'profit',
render: (v: number) => <b style={{ color: v >= 0 ? 'green' : 'red' }}>¥{(v / 10000).toFixed(1)}{t('common.tenThousand')}</b>
},
];
const categoryColumns = [
{ title: '分类', dataIndex: 'category_level2', render: (v: string) => LEVEL2_LABELS[v] || v },
{ title: '金额(¥)', dataIndex: 'total_amount', render: (v: number) => parseFloat(v).toLocaleString(), align: 'right' as const },
{ title: '笔数', dataIndex: 'count', align: 'center' as const },
{ title: t('finance.level2Category'), dataIndex: 'category_level2', render: (v: string) => LEVEL2_LABELS[v] || v },
{ title: t('finance.amount'), dataIndex: 'total_amount', render: (v: number) => parseFloat(v).toLocaleString(), align: 'right' as const },
{ title: t('project.count'), dataIndex: 'count', align: 'center' as const },
{
title: '占比', render: (_: unknown, r: any) => {
title: t('project.ratio'), render: (_: unknown, r: any) => {
const total = byCategory.filter(c => c.category_level1 === r.category_level1).reduce((s, c) => s + parseFloat(c.total_amount), 0);
return total > 0 ? `${(parseFloat(r.total_amount) / total * 100).toFixed(1)}%` : '-';
}
@@ -95,30 +121,30 @@ const ReportsPage: React.FC = () => {
return (
<div style={{ padding: isMobile ? 8 : 24 }}>
<div style={{ marginBottom: isMobile ? 12 : 24 }}>
<Title level={isMobile ? 3 : 2} style={{ marginBottom: 8 }}></Title>
<Paragraph type="secondary" style={{ marginBottom: 0 }}></Paragraph>
<Title level={isMobile ? 3 : 2} style={{ marginBottom: 8 }}>{t('reports.title')}</Title>
<Paragraph type="secondary" style={{ marginBottom: 0 }}>{t('reports.description')}</Paragraph>
</div>
<Row gutter={[8, 8]} style={{ marginBottom: 16 }}>
<Col xs={8} sm={8}>
<Card size="small" style={{ textAlign: 'center' }}>
<Statistic title="总收入" value={summary.total_income} prefix="¥" valueStyle={{ color: '#3f8600', fontSize: isMobile ? 16 : undefined }} />
<Statistic title={t('reports.totalIncome')} value={summary.total_income} prefix="¥" valueStyle={{ color: '#3f8600', fontSize: isMobile ? 16 : undefined }} />
</Card>
</Col>
<Col xs={8} sm={8}>
<Card size="small" style={{ textAlign: 'center' }}>
<Statistic title="总支出" value={summary.total_expense} prefix="¥" valueStyle={{ color: '#cf1322', fontSize: isMobile ? 16 : undefined }} />
<Statistic title={t('reports.totalExpense')} value={summary.total_expense} prefix="¥" valueStyle={{ color: '#cf1322', fontSize: isMobile ? 16 : undefined }} />
</Card>
</Col>
<Col xs={8} sm={8}>
<Card size="small" style={{ textAlign: 'center' }}>
<Statistic title="净利润" value={summary.net_profit} prefix="¥" valueStyle={{ color: summary.net_profit >= 0 ? '#3f8600' : '#cf1322', fontSize: isMobile ? 16 : undefined }} />
<Statistic title={t('reports.netProfit')} value={summary.net_profit} prefix="¥" valueStyle={{ color: summary.net_profit >= 0 ? '#3f8600' : '#cf1322', fontSize: isMobile ? 16 : undefined }} />
</Card>
</Col>
</Row>
<Card title="月度财务报表" size="small" style={{ marginBottom: 16 }}
extra={<DatePicker picker="month" size="small" allowClear onChange={(d) => setSelectedMonth(d)} placeholder="选择月份" />}
<Card title={t('reports.monthlyReport')} size="small" style={{ marginBottom: 16 }}
extra={<DatePicker picker="month" size="small" allowClear onChange={(d) => setSelectedMonth(d)} placeholder={t('reports.selectMonth')} />}
>
<Spin spinning={loading}>
<Table
@@ -133,7 +159,7 @@ const ReportsPage: React.FC = () => {
return (
<Table.Summary fixed>
<Table.Summary.Row>
<Table.Summary.Cell index={0}><strong></strong></Table.Summary.Cell>
<Table.Summary.Cell index={0}><strong>{t('common.total')}</strong></Table.Summary.Cell>
<Table.Summary.Cell index={1}><strong>¥{ti.toLocaleString()}</strong></Table.Summary.Cell>
<Table.Summary.Cell index={2}><strong>¥{te.toLocaleString()}</strong></Table.Summary.Cell>
<Table.Summary.Cell index={3}>
@@ -150,21 +176,21 @@ const ReportsPage: React.FC = () => {
<Row gutter={[16, 16]}>
{incomeCategories.length > 0 && (
<Col xs={24} md={8}>
<Card title={<span><Tag color="green"></Tag></span>} size="small">
<Card title={<span><Tag color="green">{t('reports.categoryTag')}</Tag>{t('reports.incomeCategory')}</span>} size="small">
<Table dataSource={incomeCategories} columns={categoryColumns} rowKey="category_level2" pagination={false} size="small" />
</Card>
</Col>
)}
{projectCategories.length > 0 && (
<Col xs={24} md={8}>
<Card title={<span><Tag color="blue"></Tag></span>} size="small">
<Card title={<span><Tag color="blue">{t('reports.projectTag')}</Tag>{t('reports.projectExpenseCategory')}</span>} size="small">
<Table dataSource={projectCategories} columns={categoryColumns} rowKey="category_level2" pagination={false} size="small" />
</Card>
</Col>
)}
{companyCategories.length > 0 && (
<Col xs={24} md={8}>
<Card title={<span><Tag color="orange"></Tag></span>} size="small">
<Card title={<span><Tag color="orange">{t('reports.companyTag')}</Tag>{t('reports.companyExpenseCategory')}</span>} size="small">
<Table dataSource={companyCategories} columns={categoryColumns} rowKey="category_level2" pagination={false} size="small" />
</Card>
</Col>
@@ -174,4 +200,4 @@ const ReportsPage: React.FC = () => {
);
};
export default ReportsPage;
export default ReportsPage;
+5 -1
View File
@@ -10,6 +10,8 @@ export interface User {
role: 'admin' | 'manager' | 'user' | 'finance'
department?: string
avatar?: string
passport?: string
driverLicense?: string
token?: string
}
@@ -93,7 +95,9 @@ export const useAuthStore = create<AuthState>()(
email: userData.email,
role: userData.role,
department: userData.department,
avatar: userData.avatar
avatar: userData.avatar,
passport: userData.passport,
driverLicense: userData.driverLicense
}
set({
+64 -56
View File
@@ -1,56 +1,64 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import dayjs from 'dayjs'
import { type LanguageCode, getLanguage, getTranslation } from '../locales'
interface LanguageState {
currentLanguage: LanguageCode
setLanguage: (code: LanguageCode) => void
getLanguageInfo: () => any
t: (key: string) => string
}
export const useLanguageStore = create<LanguageState>()(
persist(
(set, get) => ({
currentLanguage: 'zh-CN',
setLanguage: (code: LanguageCode) => {
set({ currentLanguage: code })
// 更新dayjs语言
import('dayjs/locale/zh-cn')
import('dayjs/locale/th')
const localeMap: Record<LanguageCode, string> = {
'zh-CN': 'zh-cn',
'th-TH': 'th',
'lo-LA': 'en',
'en-US': 'en'
}
dayjs.locale(localeMap[code])
},
getLanguageInfo: () => {
return getLanguage(get().currentLanguage)
},
t: (key: string): string => {
const translation = getTranslation(get().currentLanguage)
const keys = key.split('.')
let result: any = translation
for (const k of keys) {
if (result && typeof result === 'object') {
result = result[k]
} else {
return key // 找不到翻译,返回key
}
}
return typeof result === 'string' ? result : key
}
}),
{
name: 'language-storage',
}
)
)
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import dayjs from 'dayjs'
import { type LanguageCode, getLanguage, getTranslation } from '../locales'
interface LanguageState {
currentLanguage: LanguageCode
setLanguage: (code: LanguageCode) => void
getLanguageInfo: () => any
t: (key: string, params?: Record<string, string | number>) => string
}
export const useLanguageStore = create<LanguageState>()(
persist(
(set, get) => ({
currentLanguage: 'zh-CN',
setLanguage: (code: LanguageCode) => {
set({ currentLanguage: code })
// 更新dayjs语言
import('dayjs/locale/zh-cn')
import('dayjs/locale/th')
const localeMap: Record<LanguageCode, string> = {
'zh-CN': 'zh-cn',
'th-TH': 'th',
'lo-LA': 'en',
'en-US': 'en'
}
dayjs.locale(localeMap[code])
},
getLanguageInfo: () => {
return getLanguage(get().currentLanguage)
},
t: (key: string, params?: Record<string, string | number>): string => {
const translation = getTranslation(get().currentLanguage)
const keys = key.split('.')
let result: any = translation
for (const k of keys) {
if (result && typeof result === 'object') {
result = result[k]
} else {
return 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
}
}),
{
name: 'language-storage',
}
)
)