346 lines
11 KiB
JavaScript
346 lines
11 KiB
JavaScript
'use client';
|
||
import { useState, useEffect, useCallback } from 'react';
|
||
import { useRouter } from 'next/navigation';
|
||
import { useUser } from '../layout';
|
||
import { authFetch, STATUS_NAMES, ROLE_NAMES, STATUS_COLORS } from '@/lib/auth-client';
|
||
|
||
const PAGE_SIZE = 10;
|
||
|
||
function Toast({ message, type, onClose }) {
|
||
useEffect(() => {
|
||
const timer = setTimeout(onClose, 3000);
|
||
return () => clearTimeout(timer);
|
||
}, [onClose]);
|
||
return (
|
||
<div className={`toast toast-${type}`}>
|
||
{type === 'success' ? '✅' : type === 'error' ? '❌' : 'ℹ️'} {message}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function ContractsPage() {
|
||
const user = useUser();
|
||
const router = useRouter();
|
||
|
||
// 筛选状态
|
||
const [filters, setFilters] = useState({
|
||
status: '',
|
||
date_from: '',
|
||
date_to: '',
|
||
search: '',
|
||
});
|
||
const [appliedFilters, setAppliedFilters] = useState({
|
||
status: '',
|
||
date_from: '',
|
||
date_to: '',
|
||
search: '',
|
||
});
|
||
|
||
// 数据状态
|
||
const [contracts, setContracts] = useState([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [page, setPage] = useState(1);
|
||
const [total, setTotal] = useState(0);
|
||
const [exporting, setExporting] = useState(false);
|
||
const [toasts, setToasts] = useState([]);
|
||
|
||
const showToast = useCallback((message, type = 'info') => {
|
||
const id = Date.now();
|
||
setToasts(prev => [...prev, { id, message, type }]);
|
||
}, []);
|
||
|
||
const removeToast = useCallback((id) => {
|
||
setToasts(prev => prev.filter(t => t.id !== id));
|
||
}, []);
|
||
|
||
// 构建查询参数
|
||
const buildQuery = useCallback((pageNum, filterObj) => {
|
||
const params = new URLSearchParams();
|
||
params.set('page', pageNum);
|
||
params.set('page_size', PAGE_SIZE);
|
||
if (filterObj.status) params.set('status', filterObj.status);
|
||
if (filterObj.date_from) params.set('date_from', filterObj.date_from);
|
||
if (filterObj.date_to) params.set('date_to', filterObj.date_to);
|
||
if (filterObj.search) params.set('search', filterObj.search);
|
||
return params.toString();
|
||
}, []);
|
||
|
||
// 加载数据
|
||
const loadContracts = useCallback(async (pageNum = 1, filterObj = appliedFilters) => {
|
||
setLoading(true);
|
||
try {
|
||
const query = buildQuery(pageNum, filterObj);
|
||
const res = await authFetch(`/api/contracts?${query}`);
|
||
const data = await res.json();
|
||
setContracts(data.contracts || []);
|
||
setTotal(data.total || 0);
|
||
setPage(pageNum);
|
||
} catch (err) {
|
||
showToast('加载合同列表失败', 'error');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [appliedFilters, buildQuery, showToast]);
|
||
|
||
useEffect(() => {
|
||
loadContracts(1, appliedFilters);
|
||
}, []);
|
||
|
||
// 查询
|
||
const handleSearch = () => {
|
||
setAppliedFilters({ ...filters });
|
||
loadContracts(1, { ...filters });
|
||
};
|
||
|
||
// 重置
|
||
const handleReset = () => {
|
||
const empty = { status: '', date_from: '', date_to: '', search: '' };
|
||
setFilters(empty);
|
||
setAppliedFilters(empty);
|
||
loadContracts(1, empty);
|
||
};
|
||
|
||
// 导出
|
||
const handleExport = async () => {
|
||
setExporting(true);
|
||
try {
|
||
const params = new URLSearchParams();
|
||
if (appliedFilters.status) params.set('status', appliedFilters.status);
|
||
if (appliedFilters.date_from) params.set('date_from', appliedFilters.date_from);
|
||
if (appliedFilters.date_to) params.set('date_to', appliedFilters.date_to);
|
||
if (appliedFilters.search) params.set('search', appliedFilters.search);
|
||
|
||
const res = await authFetch(`/api/contracts/export?${params.toString()}`);
|
||
if (!res.ok) throw new Error('导出失败');
|
||
|
||
const blob = await res.blob();
|
||
const url = window.URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `合同列表_${new Date().toLocaleDateString('zh-CN')}.xlsx`;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
window.URL.revokeObjectURL(url);
|
||
showToast('导出成功', 'success');
|
||
} catch (err) {
|
||
showToast('导出失败,请稍后重试', 'error');
|
||
} finally {
|
||
setExporting(false);
|
||
}
|
||
};
|
||
|
||
const formatDate = (dateStr) => {
|
||
if (!dateStr) return '-';
|
||
return new Date(dateStr).toLocaleDateString('zh-CN');
|
||
};
|
||
|
||
const formatMoney = (val) => {
|
||
if (val == null) return '-';
|
||
return new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY' }).format(val);
|
||
};
|
||
|
||
const totalPages = Math.ceil(total / PAGE_SIZE);
|
||
|
||
// 生成分页页码
|
||
const getPageNumbers = () => {
|
||
const pages = [];
|
||
const maxVisible = 5;
|
||
let start = Math.max(1, page - Math.floor(maxVisible / 2));
|
||
let end = Math.min(totalPages, start + maxVisible - 1);
|
||
if (end - start + 1 < maxVisible) {
|
||
start = Math.max(1, end - maxVisible + 1);
|
||
}
|
||
for (let i = start; i <= end; i++) {
|
||
pages.push(i);
|
||
}
|
||
return pages;
|
||
};
|
||
|
||
return (
|
||
<div className="animate-fadeIn">
|
||
{/* Toast 通知 */}
|
||
<div className="toast-container">
|
||
{toasts.map(t => (
|
||
<Toast key={t.id} message={t.message} type={t.type} onClose={() => removeToast(t.id)} />
|
||
))}
|
||
</div>
|
||
|
||
<div className="page-header">
|
||
<div>
|
||
<h1 className="page-title">合同流转</h1>
|
||
|
||
</div>
|
||
<div style={{ display: 'flex', gap: '12px' }}>
|
||
{user?.role === 'employee' && (
|
||
<button
|
||
className="btn btn-primary"
|
||
onClick={() => router.push('/dashboard/contracts/create')}
|
||
>
|
||
➕ 新建合同
|
||
</button>
|
||
)}
|
||
<button
|
||
className="btn btn-outline"
|
||
onClick={handleExport}
|
||
disabled={exporting}
|
||
>
|
||
{exporting ? '⏳ 导出中...' : '📥 导出 Excel'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 筛选栏 */}
|
||
<div className="filter-bar">
|
||
<select
|
||
className="form-select"
|
||
value={filters.status}
|
||
onChange={(e) => setFilters(prev => ({ ...prev, status: e.target.value }))}
|
||
>
|
||
<option value="">全部状态</option>
|
||
{Object.entries(STATUS_NAMES).map(([key, label]) => (
|
||
<option key={key} value={key}>{label}</option>
|
||
))}
|
||
</select>
|
||
|
||
<input
|
||
type="date"
|
||
className="form-input"
|
||
value={filters.date_from}
|
||
onChange={(e) => setFilters(prev => ({ ...prev, date_from: e.target.value }))}
|
||
placeholder="开始日期"
|
||
/>
|
||
|
||
<input
|
||
type="date"
|
||
className="form-input"
|
||
value={filters.date_to}
|
||
onChange={(e) => setFilters(prev => ({ ...prev, date_to: e.target.value }))}
|
||
placeholder="结束日期"
|
||
/>
|
||
|
||
<input
|
||
type="text"
|
||
className="form-input"
|
||
value={filters.search}
|
||
onChange={(e) => setFilters(prev => ({ ...prev, search: e.target.value }))}
|
||
placeholder="搜索项目名称 / 合作单位..."
|
||
onKeyDown={(e) => { if (e.key === 'Enter') handleSearch(); }}
|
||
/>
|
||
|
||
<div className="filter-actions">
|
||
<button className="btn btn-primary btn-sm" onClick={handleSearch}>
|
||
🔍 查询
|
||
</button>
|
||
<button className="btn btn-ghost btn-sm" onClick={handleReset}>
|
||
🔄 重置
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 数据表格 */}
|
||
<div className="card">
|
||
<div className="card-body" style={{ padding: 0 }}>
|
||
{loading ? (
|
||
<div className="loading-overlay">
|
||
<div className="loading-spinner"></div>
|
||
</div>
|
||
) : contracts.length === 0 ? (
|
||
<div className="empty-state">
|
||
<div className="empty-state-icon">📭</div>
|
||
<p className="empty-state-text">暂无合同数据</p>
|
||
</div>
|
||
) : (
|
||
<div className="table-container">
|
||
<table className="data-table">
|
||
<thead>
|
||
<tr>
|
||
<th>经办日期</th>
|
||
<th>项目名称</th>
|
||
<th>合作单位</th>
|
||
<th>合同金额</th>
|
||
{/* <th>预估利润</th> */}
|
||
<th>收/付款</th>
|
||
<th>状态</th>
|
||
<th>操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{contracts.map((c) => (
|
||
<tr key={c.id}>
|
||
<td>{formatDate(c.created_at)}</td>
|
||
<td>
|
||
<a
|
||
href={`/dashboard/contracts/${c.id}`}
|
||
onClick={(e) => { e.preventDefault(); router.push(`/dashboard/contracts/${c.id}`); }}
|
||
style={{ fontWeight: 600, color: 'var(--primary)' }}
|
||
>
|
||
{c.project_name || '-'}
|
||
</a>
|
||
</td>
|
||
<td>{c.partner_name || '-'}</td>
|
||
<td style={{ fontWeight: 600, color: 'var(--text-dark)' }}>
|
||
{formatMoney(c.contract_amount)}
|
||
</td>
|
||
{/* <td>{c.payment_type === 'pay' ? '-' : formatMoney(c.estimated_profit)}</td> */}
|
||
<td>{c.payment_type === 'receive' ? '收款' : c.payment_type === 'pay' ? '付款' : '-'}</td>
|
||
<td>
|
||
<span className={`status-badge status-${c.status}`}>
|
||
{STATUS_NAMES[c.status] || c.status}
|
||
</span>
|
||
</td>
|
||
<td>
|
||
<button
|
||
className="btn btn-outline btn-sm"
|
||
onClick={() => router.push(`/dashboard/contracts/${c.id}`)}
|
||
>
|
||
查看
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 分页 */}
|
||
{totalPages > 0 && (
|
||
<div className="pagination">
|
||
<button
|
||
className="pagination-btn"
|
||
disabled={page <= 1}
|
||
onClick={() => loadContracts(page - 1, appliedFilters)}
|
||
>
|
||
‹ 上一页
|
||
</button>
|
||
|
||
{getPageNumbers().map((p) => (
|
||
<button
|
||
key={p}
|
||
className={`pagination-btn ${p === page ? 'active' : ''}`}
|
||
onClick={() => loadContracts(p, appliedFilters)}
|
||
>
|
||
{p}
|
||
</button>
|
||
))}
|
||
|
||
<button
|
||
className="pagination-btn"
|
||
disabled={page >= totalPages}
|
||
onClick={() => loadContracts(page + 1, appliedFilters)}
|
||
>
|
||
下一页 ›
|
||
</button>
|
||
|
||
<span style={{ marginLeft: '12px', fontSize: '13px', color: 'var(--gray-500)' }}>
|
||
共 {total} 条
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|