2026-06-26 16:32:10 +08:00

346 lines
11 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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>
);
}