372 lines
14 KiB
TypeScript
372 lines
14 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { Card, Typography, Button, Space, Table, Tag, message, Spin, Modal, Input, Form, Select, InputNumber, DatePicker, Row, Col } from 'antd';
|
|
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;
|
|
|
|
interface Project {
|
|
id: number
|
|
project_code: string
|
|
name: string
|
|
customer_id: number
|
|
customer_name?: string
|
|
status: string
|
|
budget: string
|
|
spent: string
|
|
start_date: string
|
|
end_date: string
|
|
description: string
|
|
manager_name?: string
|
|
progress?: number
|
|
contract_amount?: number
|
|
location?: string
|
|
}
|
|
|
|
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);
|
|
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
|
|
const [deleteProjectId, setDeleteProjectId] = useState<number | null>(null);
|
|
const [deletePassword, setDeletePassword] = useState('');
|
|
const [deleteLoading, setDeleteLoading] = useState(false);
|
|
|
|
const [createModalVisible, setCreateModalVisible] = useState(false);
|
|
const [createLoading, setCreateLoading] = useState(false);
|
|
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;
|
|
|
|
useEffect(() => {
|
|
const checkMobile = () => setIsMobile(window.innerWidth <= 768);
|
|
checkMobile();
|
|
window.addEventListener('resize', checkMobile);
|
|
return () => window.removeEventListener('resize', checkMobile);
|
|
}, []);
|
|
|
|
useEffect(() => { fetchProjects(); }, []);
|
|
|
|
const fetchProjects = async () => {
|
|
try {
|
|
const response = await apiClient.get('/projects');
|
|
if (response.data.success) {
|
|
setProjects(response.data.data.map((p: Project) => ({
|
|
...p,
|
|
key: p.id.toString(),
|
|
progress: p.status === 'completed' ? 100 : (p.phase_progress || 0),
|
|
manager_name: p.manager_name || t('project.unassigned')
|
|
})));
|
|
} else {
|
|
message.error(`${t('project.getListFailed')}: ${response.data.message}`);
|
|
}
|
|
} catch (error) {
|
|
message.error(t('project.getListFailed'));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const fetchCreateOptions = async () => {
|
|
try {
|
|
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 */ }
|
|
};
|
|
|
|
const handleCreateClick = () => {
|
|
form.resetFields();
|
|
form.setFieldsValue({ status: 'in_progress', start_date: dayjs() });
|
|
fetchCreateOptions();
|
|
setCreateModalVisible(true);
|
|
};
|
|
|
|
const handleCreateSubmit = async () => {
|
|
try {
|
|
const values = await form.validateFields();
|
|
setCreateLoading(true);
|
|
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(t('project.createSuccess'));
|
|
setCreateModalVisible(false);
|
|
fetchProjects();
|
|
} else {
|
|
message.error(res.data.message || t('project.createFailed'));
|
|
}
|
|
} catch (e: any) {
|
|
if (e.response?.data?.message) message.error(e.response.data.message);
|
|
}
|
|
setCreateLoading(false);
|
|
};
|
|
|
|
const handleDeleteProject = (projectId: number) => {
|
|
setDeleteProjectId(projectId);
|
|
setDeletePassword('');
|
|
setDeleteModalVisible(true);
|
|
};
|
|
|
|
const handleDeleteConfirm = async () => {
|
|
if (!deleteProjectId) return;
|
|
if (deletePassword !== 'X123c321@') {
|
|
message.error(t('project.passwordError'));
|
|
return;
|
|
}
|
|
setDeleteLoading(true);
|
|
try {
|
|
const response = await apiClient.delete(`/projects/${deleteProjectId}`, {
|
|
headers: { 'x-user-role': 'admin' }
|
|
});
|
|
if (response.data.success) {
|
|
message.success(t('project.deleteSuccess'));
|
|
setDeleteModalVisible(false);
|
|
fetchProjects();
|
|
} else {
|
|
message.error(response.data.message || t('common.deleteFailed'));
|
|
}
|
|
} catch (error) {
|
|
message.error(t('common.operationFailed'));
|
|
} finally {
|
|
setDeleteLoading(false);
|
|
}
|
|
};
|
|
|
|
const desktopColumns = [
|
|
{
|
|
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: t('project.projectManager'), dataIndex: 'manager_name', key: 'manager_name', width: 100 },
|
|
{
|
|
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)}${t('common.tenThousand')}` : '-';
|
|
},
|
|
},
|
|
{
|
|
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 }}>
|
|
<div style={{
|
|
background: progress > 80 ? '#52c41a' : progress > 50 ? '#1890ff' : '#faad14',
|
|
borderRadius: 10, height: 8, width: `${progress}%`,
|
|
}} />
|
|
</div>
|
|
<span style={{ fontSize: 12, color: '#888' }}>{progress}%</span>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
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: 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: t('common.action'), key: 'action', width: 140,
|
|
render: (_: unknown, record: Project) => (
|
|
<Space>
|
|
<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>
|
|
),
|
|
},
|
|
];
|
|
|
|
const mobileColumns = [
|
|
{
|
|
title: t('dashboard.project'), dataIndex: 'name', key: 'name', ellipsis: true,
|
|
render: (text: string, record: Project) => (
|
|
<a onClick={() => navigate(`/projects/${record.id}`)}>{text}</a>
|
|
),
|
|
},
|
|
{
|
|
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 }}>
|
|
<div style={{
|
|
background: progress > 80 ? '#52c41a' : progress > 50 ? '#1890ff' : '#faad14',
|
|
borderRadius: 4, height: 6, width: `${progress}%`,
|
|
}} />
|
|
</div>
|
|
<span style={{ fontSize: 10, color: '#888' }}>{progress}%</span>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
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: 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: t('common.action'), key: 'action', width: 60,
|
|
render: (_: unknown, record: Project) => (
|
|
<Button size="small" onClick={() => navigate(`/projects/${record.id}`)}>{t('common.view')}</Button>
|
|
),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<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 }}>{t('project.title')}</Title>
|
|
<Paragraph type="secondary" style={{ marginBottom: 0 }}>{t('project.description')}</Paragraph>
|
|
</div>
|
|
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreateClick}>
|
|
{isMobile ? t('common.create') : t('project.newProject')}
|
|
</Button>
|
|
</div>
|
|
|
|
<Card title={t('project.list')} size="small" styles={{ body: { padding: isMobile ? 8 : 24 } }}>
|
|
{loading ? (
|
|
<div style={{ textAlign: 'center', padding: 40 }}><Spin /></div>
|
|
) : (
|
|
<Table
|
|
dataSource={projects}
|
|
columns={isMobile ? mobileColumns : desktopColumns}
|
|
pagination={{ pageSize: isMobile ? 5 : 10, size: isMobile ? 'small' : 'default' }}
|
|
scroll={isMobile ? { x: 350 } : undefined}
|
|
size={isMobile ? 'small' : 'middle'}
|
|
/>
|
|
)}
|
|
</Card>
|
|
|
|
<Modal
|
|
title={t('project.quickCreate')}
|
|
open={createModalVisible}
|
|
onOk={handleCreateSubmit}
|
|
onCancel={() => setCreateModalVisible(false)}
|
|
confirmLoading={createLoading}
|
|
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={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={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={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>
|
|
</Row>
|
|
|
|
<Row gutter={8}>
|
|
<Col span={12}>
|
|
<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={t('project.projectStatus')}>
|
|
<Select options={[
|
|
{ value: 'planning', label: t('project.planning') },
|
|
{ value: 'in_progress', label: t('project.inProgress') },
|
|
{ value: 'completed', label: t('project.completedHistory') },
|
|
]} />
|
|
</Form.Item>
|
|
</Col>
|
|
</Row>
|
|
|
|
<Row gutter={8}>
|
|
<Col span={12}>
|
|
<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={t('project.endDate')}>
|
|
<DatePicker style={{ width: '100%' }} />
|
|
</Form.Item>
|
|
</Col>
|
|
</Row>
|
|
|
|
<Form.Item name="location" label={t('project.location')}>
|
|
<Input placeholder={t('project.locationPlaceholder')} />
|
|
</Form.Item>
|
|
|
|
<Form.Item name="description" label={t('project.description')}>
|
|
<Input.TextArea rows={2} placeholder={t('project.descriptionPlaceholder')} />
|
|
</Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
|
|
<Modal
|
|
title={t('project.deleteConfirm')}
|
|
open={deleteModalVisible}
|
|
onOk={handleDeleteConfirm}
|
|
onCancel={() => setDeleteModalVisible(false)}
|
|
confirmLoading={deleteLoading}
|
|
okText={t('common.deleteConfirm')}
|
|
cancelText={t('common.cancel')}
|
|
>
|
|
<div style={{ marginBottom: 16 }}>
|
|
<p>{t('project.deleteConfirmMsg')}</p>
|
|
<p>{t('project.deletePassMsg')}</p>
|
|
</div>
|
|
<Input.Password placeholder={t('common.inputPassword')} value={deletePassword} onChange={(e) => setDeletePassword(e.target.value)} size="large" />
|
|
</Modal>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ProjectsPage; |