1390 lines
62 KiB
TypeScript
1390 lines
62 KiB
TypeScript
import { useState, useEffect } from 'react'
|
||
import { toast } from 'sonner'
|
||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||
import { Building2, Users, CreditCard, Plus, Bell, Download, Upload, FileSpreadsheet, Clock, CheckCircle, AlertCircle, ClipboardList, LayoutGrid, PenTool } from 'lucide-react'
|
||
import { settingsApi, notificationsApi } from '../lib/api-services'
|
||
import { useAuthStore } from '../store/authStore'
|
||
import { getPageSize, setPageSize as setGlobalPageSize } from '../lib/pageSize'
|
||
import Card from '../components/ui/Card'
|
||
import Button from '../components/ui/Button'
|
||
import { Input, Label, Select } from '../components/ui/Input'
|
||
import Modal from '../components/ui/Modal'
|
||
import { useConfirm } from '../hooks/useConfirm'
|
||
|
||
|
||
export default function Settings() {
|
||
const queryClient = useQueryClient()
|
||
const [activeSection, setActiveSection] = useState<'org' | 'users' | 'plan' | 'notifications' | 'retirement' | 'import' | 'export'>('org')
|
||
|
||
const { data: orgData } = useQuery<any>({
|
||
queryKey: ['org-settings'],
|
||
queryFn: async () => {
|
||
return await settingsApi.org()
|
||
},
|
||
})
|
||
|
||
const { data: usersData } = useQuery<any>({
|
||
queryKey: ['users'],
|
||
queryFn: async () => {
|
||
return await settingsApi.users()
|
||
},
|
||
})
|
||
|
||
const updateOrgMutation = useMutation({
|
||
mutationFn: (data: any) => settingsApi.updateOrg(data),
|
||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['org-settings'] }),
|
||
})
|
||
|
||
const sections = [
|
||
{ key: 'org' as const, label: '企业信息', icon: Building2 },
|
||
{ key: 'users' as const, label: '用户管理', icon: Users },
|
||
{ key: 'plan' as const, label: '套餐', icon: CreditCard },
|
||
{ key: 'notifications' as const, label: '通知设置', icon: Bell },
|
||
{ key: 'retirement' as const, label: '退休提醒', icon: Clock },
|
||
{ key: 'import' as const, label: '数据导入', icon: FileSpreadsheet },
|
||
{ key: 'export' as const, label: '数据导出', icon: Download },
|
||
]
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
<div className="flex items-center gap-2">
|
||
<Building2 className="h-5 w-5 text-primary" />
|
||
<div>
|
||
<h1 className="text-base font-semibold">系统设置</h1>
|
||
<p className="mt-1 text-sm text-gray-500">配置企业资料、用户权限、套餐与通知偏好</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex gap-1 border-b">
|
||
{sections.map((s) => {
|
||
const Icon = s.icon
|
||
return (
|
||
<button
|
||
key={s.key}
|
||
onClick={() => setActiveSection(s.key)}
|
||
className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||
activeSection === s.key ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'
|
||
}`}
|
||
>
|
||
<Icon className="w-4 h-4" />
|
||
{s.label}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
{activeSection === 'org' && (
|
||
<OrgSettings orgData={orgData} onSave={(data) => updateOrgMutation.mutate(data)} saving={updateOrgMutation.isPending} />
|
||
)}
|
||
{activeSection === 'users' && <UserSettings usersData={usersData} />}
|
||
{activeSection === 'plan' && <PlanSettings orgData={orgData} />}
|
||
{activeSection === 'notifications' && <NotificationSettings />}
|
||
{activeSection === 'retirement' && (
|
||
<RetirementSettings orgData={orgData} onSave={(data) => updateOrgMutation.mutate(data)} />
|
||
)}
|
||
{activeSection === 'import' && <ImportSettings />}
|
||
{activeSection === 'export' && <ExportSettings />}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function OrgSettings({ orgData, onSave, saving }: { orgData: any; onSave: (data: any) => void; saving: boolean }) {
|
||
const [pageSize, setPageSize] = useState(getPageSize())
|
||
const [form, setForm] = useState({
|
||
name: '',
|
||
contactName: '',
|
||
contactPhone: '',
|
||
payrollDays: [5] as number[],
|
||
payrollReminderDays: 3,
|
||
retirementReminderEnabled: false,
|
||
esignPolicyEnabled: false,
|
||
esignPayslipEnabled: false,
|
||
esignOnboardingEnabled: false,
|
||
esignTrainingEnabled: false,
|
||
esignPerformanceEnabled: false,
|
||
esignDisciplinaryEnabled: false,
|
||
})
|
||
|
||
useEffect(() => {
|
||
if (orgData) {
|
||
setForm({
|
||
name: orgData.name || '',
|
||
contactName: orgData.contactName || '',
|
||
contactPhone: orgData.contactPhone || '',
|
||
payrollDays: Array.isArray(orgData.payrollDays) && orgData.payrollDays.length > 0 ? orgData.payrollDays : [5],
|
||
payrollReminderDays: orgData.payrollReminderDays ?? 3,
|
||
retirementReminderEnabled: orgData.retirementReminderEnabled || false,
|
||
esignPolicyEnabled: orgData.esignPolicyEnabled || false,
|
||
esignPayslipEnabled: orgData.esignPayslipEnabled || false,
|
||
esignOnboardingEnabled: orgData.esignOnboardingEnabled || false,
|
||
esignTrainingEnabled: orgData.esignTrainingEnabled || false,
|
||
esignPerformanceEnabled: orgData.esignPerformanceEnabled || false,
|
||
esignDisciplinaryEnabled: orgData.esignDisciplinaryEnabled || false,
|
||
})
|
||
}
|
||
}, [orgData])
|
||
|
||
return (
|
||
<Card>
|
||
<h2 className="text-sm font-medium mb-4">企业信息</h2>
|
||
<div className="space-y-3">
|
||
<div>
|
||
<Label>企业名称</Label>
|
||
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="企业名称" />
|
||
</div>
|
||
<div>
|
||
<Label>联系人</Label>
|
||
<Input value={form.contactName} onChange={(e) => setForm({ ...form, contactName: e.target.value })} placeholder="联系人姓名" />
|
||
</div>
|
||
<div>
|
||
<Label>联系电话</Label>
|
||
<Input value={form.contactPhone} onChange={(e) => setForm({ ...form, contactPhone: e.target.value })} placeholder="联系电话" />
|
||
</div>
|
||
<div>
|
||
<Label>发薪日期</Label>
|
||
<p className="text-xs text-gray-500 mt-1 mb-2">设置每月发薪日期(可多选),系统将在工作日历中显示,并提前提醒</p>
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{Array.from({ length: 28 }, (_, i) => i + 1).map(day => (
|
||
<button
|
||
key={day}
|
||
type="button"
|
||
onClick={() => {
|
||
const days = form.payrollDays.includes(day)
|
||
? form.payrollDays.filter(d => d !== day)
|
||
: [...form.payrollDays, day].sort((a, b) => a - b)
|
||
setForm({ ...form, payrollDays: days })
|
||
}}
|
||
className={`w-9 h-9 rounded-md text-xs font-medium border transition-colors ${
|
||
form.payrollDays.includes(day)
|
||
? 'bg-primary text-white border-primary'
|
||
: 'bg-white text-gray-600 border-gray-200 hover:bg-gray-50'
|
||
}`}
|
||
>
|
||
{day}
|
||
</button>
|
||
))}
|
||
</div>
|
||
{form.payrollDays.length > 0 && (
|
||
<p className="text-xs text-gray-500 mt-2">已选:每月 {form.payrollDays.map(d => `${d}号`).join('、')} 发薪</p>
|
||
)}
|
||
</div>
|
||
<div>
|
||
<Label>发薪提前提醒天数</Label>
|
||
<div className="flex items-center gap-3">
|
||
<Input type="number" min={0} max={30} value={form.payrollReminderDays} onChange={(e) => setForm({ ...form, payrollReminderDays: Number(e.target.value) })} className="!w-24" />
|
||
<span className="text-xs text-gray-500">天前在工作台提醒发薪</span>
|
||
</div>
|
||
</div>
|
||
<Button onClick={() => onSave(form)} disabled={saving}>
|
||
{saving ? '保存中...' : '保存'}
|
||
</Button>
|
||
</div>
|
||
|
||
{/* 电子签署设置 */}
|
||
<div className="mt-6 pt-6 border-t">
|
||
<div className="flex items-center gap-2 mb-4">
|
||
<PenTool className="w-5 h-5 text-primary" />
|
||
<h3 className="font-medium">电子签署设置</h3>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-4">
|
||
{([
|
||
{ key: 'esignPolicyEnabled', label: '规章制度电子签', desc: '开启后,员工阅读规章制度时需电子签署;关闭时保持阅读确认' },
|
||
{ key: 'esignPayslipEnabled', label: '工资条电子签', desc: '开启后,员工确认工资条时需电子签署;关闭时保持点击确认' },
|
||
{ key: 'esignOnboardingEnabled', label: '入职文件电子签', desc: '开启后,HR审批通过入职流程时自动创建入职文件签署;关闭时不签' },
|
||
{ key: 'esignTrainingEnabled', label: '培训记录电子签', desc: '开启后,员工签收培训记录时需电子签署;关闭时保持点击签收' },
|
||
{ key: 'esignPerformanceEnabled', label: '绩效考核电子签', desc: '开启后,员工签字确认绩效时需电子签署;关闭时保持点击确认' },
|
||
{ key: 'esignDisciplinaryEnabled', label: '违纪记录电子签', desc: '开启后,员工签字确认违纪记录时需电子签署;关闭时保持点击确认' },
|
||
] as const).map((item) => (
|
||
<div key={item.key} className="flex items-center justify-between border rounded-lg p-3">
|
||
<div className="flex-1 mr-3">
|
||
<div className="text-sm font-medium">{item.label}</div>
|
||
<p className="text-xs text-gray-500 mt-0.5">{item.desc}</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
const next = !form[item.key]
|
||
setForm({ ...form, [item.key]: next })
|
||
onSave({ [item.key]: next })
|
||
}}
|
||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors flex-shrink-0 ${form[item.key] ? 'bg-primary' : 'bg-gray-200'}`}
|
||
>
|
||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${form[item.key] ? 'translate-x-6' : 'translate-x-1'}`} />
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 显示设置 */}
|
||
<div className="mt-6 pt-6 border-t">
|
||
<div className="flex items-center gap-2 mb-4">
|
||
<LayoutGrid className="w-5 h-5 text-primary" />
|
||
<h3 className="font-medium">显示设置</h3>
|
||
</div>
|
||
<div className="space-y-4">
|
||
<div>
|
||
<Label>列表分页大小</Label>
|
||
<p className="text-xs text-gray-500 mt-1 mb-2">设置所有列表页面每页显示的记录条数,保存后立即生效</p>
|
||
<div className="flex items-center gap-3">
|
||
<Select value={String(pageSize)} onChange={(e) => setPageSize(parseInt(e.target.value, 10))} className="!w-32">
|
||
<option value="10">10 条/页</option>
|
||
<option value="20">20 条/页</option>
|
||
<option value="50">50 条/页</option>
|
||
<option value="100">100 条/页</option>
|
||
</Select>
|
||
<Button size="sm" onClick={() => { setGlobalPageSize(pageSize); toast.success(`分页大小已设置为 ${pageSize} 条/页`) }}>保存</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
function RetirementSettings({ orgData, onSave }: { orgData: any; onSave: (data: any) => void }) {
|
||
const queryClient = useQueryClient()
|
||
const [confirming, setConfirming] = useState(false)
|
||
const enabled = orgData?.retirementReminderEnabled || false
|
||
|
||
const { data: policyData, isLoading } = useQuery<any>({
|
||
queryKey: ['retirement-policy'],
|
||
queryFn: async () => {
|
||
return await settingsApi.retirementPolicy()
|
||
},
|
||
enabled,
|
||
})
|
||
|
||
const confirmMutation = useMutation({
|
||
mutationFn: (id: string) => settingsApi.confirmRetirementPolicy(id),
|
||
onSuccess: () => {
|
||
toast.success('退休政策已确认生效')
|
||
setConfirming(false)
|
||
queryClient.invalidateQueries({ queryKey: ['retirement-policy'] })
|
||
},
|
||
onError: () => toast.error('确认失败'),
|
||
})
|
||
|
||
const confirmed = policyData?.confirmed
|
||
const pending = policyData?.pending
|
||
|
||
const renderPolicyRules = () => (
|
||
<div className="space-y-2">
|
||
<div className="text-xs font-medium text-gray-600">改革规则(基准退休年龄 → 目标退休年龄)</div>
|
||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||
<div className="bg-white rounded-md p-2 text-center border border-gray-200">
|
||
<div className="text-xs text-gray-500">男性职工</div>
|
||
<div className="text-sm font-semibold text-gray-700">60岁 → 63岁</div>
|
||
<div className="text-xs text-gray-400">每4个月延1个月</div>
|
||
</div>
|
||
<div className="bg-white rounded-md p-2 text-center border border-gray-200">
|
||
<div className="text-xs text-gray-500">女性干部</div>
|
||
<div className="text-sm font-semibold text-gray-700">55岁 → 58岁</div>
|
||
<div className="text-xs text-gray-400">每4个月延1个月</div>
|
||
</div>
|
||
<div className="bg-white rounded-md p-2 text-center border border-gray-200">
|
||
<div className="text-xs text-gray-500">女性工人</div>
|
||
<div className="text-sm font-semibold text-gray-700">50岁 → 55岁</div>
|
||
<div className="text-xs text-gray-400">每2个月延1个月</div>
|
||
</div>
|
||
</div>
|
||
<p className="text-xs text-gray-400">个人退休年龄根据出生年月逐人计算,达到基准退休年龄的时间点不同,延迟月数也不同。</p>
|
||
</div>
|
||
)
|
||
|
||
return (
|
||
<Card>
|
||
<div className="flex items-center justify-between mb-4">
|
||
<div className="flex items-center gap-2">
|
||
<Clock className="w-5 h-5 text-primary" />
|
||
<h3 className="font-medium">退休提醒</h3>
|
||
{enabled && (
|
||
<div className="flex gap-2 ml-4">
|
||
{pending && (
|
||
<Button size="sm" onClick={() => { setConfirming(true); confirmMutation.mutate(pending.id) }} disabled={confirming}>
|
||
{confirming ? '确认中...' : '确认生效'}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<label className="flex items-center gap-2 cursor-pointer">
|
||
<span className="text-sm text-gray-500">{enabled ? '已开启' : '未开启'}</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => onSave({ retirementReminderEnabled: !enabled })}
|
||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${enabled ? 'bg-primary' : 'bg-gray-300'}`}
|
||
>
|
||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enabled ? 'translate-x-6' : 'translate-x-1'}`} />
|
||
</button>
|
||
</label>
|
||
</div>
|
||
|
||
{enabled && (
|
||
<div className="space-y-3">
|
||
{/* 当前生效政策 */}
|
||
{confirmed && (
|
||
<div className="rounded-lg border border-green-200 bg-green-50 p-3">
|
||
<div className="flex items-center gap-2 mb-2">
|
||
<CheckCircle className="w-4 h-4 text-green-600" />
|
||
<span className="text-sm font-medium text-green-800">当前生效政策(v{confirmed.version})</span>
|
||
<span className="text-xs text-gray-500 ml-auto">
|
||
确认于 {new Date(confirmed.confirmedAt).toLocaleDateString('zh-CN')}
|
||
</span>
|
||
</div>
|
||
{renderPolicyRules()}
|
||
</div>
|
||
)}
|
||
|
||
{/* 待确认政策 */}
|
||
{pending && (
|
||
<div className="rounded-lg border border-orange-200 bg-orange-50 p-3">
|
||
<div className="flex items-center gap-2 mb-2">
|
||
<AlertCircle className="w-4 h-4 text-orange-600" />
|
||
<span className="text-sm font-medium text-orange-800">检测到新政策版本(v{pending.version}),请确认后生效</span>
|
||
</div>
|
||
{renderPolicyRules()}
|
||
</div>
|
||
)}
|
||
|
||
{/* 无政策 */}
|
||
{!confirmed && !pending && !isLoading && (
|
||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-3 text-center">
|
||
<p className="text-sm text-gray-500">暂无退休政策数据,系统将自动获取最新政策</p>
|
||
</div>
|
||
)}
|
||
|
||
{isLoading && <p className="text-sm text-gray-400 text-center">加载中...</p>}
|
||
</div>
|
||
)}
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
function UserSettings({ usersData }: { usersData: any }) {
|
||
const queryClient = useQueryClient()
|
||
const [showAddModal, setShowAddModal] = useState(false)
|
||
const [editingUser, setEditingUser] = useState<any>(null)
|
||
const users = usersData || []
|
||
|
||
const updateMutation = useMutation({
|
||
mutationFn: ({ id, data }: { id: string; data: any }) => settingsApi.updateUser(id, data),
|
||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }),
|
||
})
|
||
|
||
const toggleDisableMutation = useMutation({
|
||
mutationFn: (id: string) => settingsApi.toggleDisable(id),
|
||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }),
|
||
})
|
||
|
||
return (
|
||
<Card>
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h2 className="text-sm font-medium">用户管理</h2>
|
||
<Button size="sm" onClick={() => setShowAddModal(true)}>
|
||
<Plus className="w-4 h-4 mr-1" />添加用户
|
||
</Button>
|
||
</div>
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="border-b text-left text-xs text-gray-500">
|
||
<th className="py-2 px-3 font-medium">姓名</th>
|
||
<th className="py-2 px-3 font-medium">手机号</th>
|
||
<th className="py-2 px-3 font-medium">角色</th>
|
||
<th className="py-2 px-3 font-medium">状态</th>
|
||
<th className="py-2 px-3 font-medium">最近登录</th>
|
||
<th className="py-2 px-3 font-medium">操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{users.map((u: any) => (
|
||
<tr key={u.id} className="border-b last:border-0">
|
||
<td className="py-3 px-3 font-medium">{u.name}</td>
|
||
<td className="py-3 px-3 text-gray-600">{u.phone}</td>
|
||
<td className="py-3 px-3">
|
||
<span className="text-xs px-2 py-0.5 rounded bg-gray-100 text-gray-600">
|
||
{u.role === 'ADMIN' ? '管理员' : u.role === 'HR' ? 'HR' : '查看者'}
|
||
</span>
|
||
</td>
|
||
<td className="py-3 px-3">
|
||
<span className={u.disabled ? 'text-danger' : 'text-safe'}>
|
||
{u.disabled ? '已禁用' : '正常'}
|
||
</span>
|
||
</td>
|
||
<td className="py-3 px-3 text-gray-500">
|
||
{u.lastLoginAt ? new Date(u.lastLoginAt).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '—'}
|
||
</td>
|
||
<td className="py-3 px-3">
|
||
<div className="flex gap-2">
|
||
<button className="text-primary hover:underline" onClick={() => setEditingUser(u)}>编辑</button>
|
||
<button
|
||
className={u.disabled ? 'text-safe hover:underline' : 'text-danger hover:underline'}
|
||
onClick={() => toggleDisableMutation.mutate(u.id)}
|
||
>
|
||
{u.disabled ? '启用' : '禁用'}
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<AddUserModal open={showAddModal} onClose={() => setShowAddModal(false)} />
|
||
<EditUserModal user={editingUser} onClose={() => setEditingUser(null)} onSave={(data) => { updateMutation.mutate({ id: editingUser.id, data }); setEditingUser(null) }} />
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
function EditUserModal({ user, onClose, onSave }: { user: any; onClose: () => void; onSave: (data: any) => void }) {
|
||
const [form, setForm] = useState({ name: '', phone: '', role: 'HR' })
|
||
const [loading, setLoading] = useState(false)
|
||
const [error, setError] = useState('')
|
||
|
||
useEffect(() => {
|
||
if (user) {
|
||
setForm({ name: user.name || '', phone: user.phone || '', role: user.role || 'HR' })
|
||
setError('')
|
||
}
|
||
}, [user])
|
||
|
||
const handleSubmit = async () => {
|
||
setLoading(true)
|
||
setError('')
|
||
try {
|
||
onSave(form)
|
||
} catch (err: any) {
|
||
setError(err.response?.data?.error?.message || '保存失败')
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
if (!user) return null
|
||
|
||
return (
|
||
<Modal open={!!user} onClose={onClose} title="编辑用户">
|
||
<div className="space-y-3">
|
||
{error && <div className="px-3 py-2 rounded-md bg-red-50 text-red-700 text-xs">{error}</div>}
|
||
<div>
|
||
<Label>姓名 *</Label>
|
||
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
||
</div>
|
||
<div>
|
||
<Label>手机号 *</Label>
|
||
<Input type="tel" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} maxLength={11} />
|
||
</div>
|
||
<div>
|
||
<Label>角色</Label>
|
||
<Select value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value })}>
|
||
<option value="HR">HR</option>
|
||
<option value="ADMIN">管理员</option>
|
||
<option value="VIEWER">查看者</option>
|
||
</Select>
|
||
</div>
|
||
<div className="flex justify-end gap-2">
|
||
<Button variant="secondary" onClick={onClose}>取消</Button>
|
||
<Button onClick={handleSubmit} disabled={loading || !form.name || !form.phone}>
|
||
{loading ? '保存中...' : '保存'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
)
|
||
}
|
||
|
||
function AddUserModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||
const [form, setForm] = useState({ name: '', phone: '', password: '', role: 'HR' })
|
||
const [loading, setLoading] = useState(false)
|
||
const [error, setError] = useState('')
|
||
|
||
const handleSubmit = async () => {
|
||
setLoading(true)
|
||
setError('')
|
||
try {
|
||
await settingsApi.addUser(form)
|
||
onClose()
|
||
} catch (err: any) {
|
||
setError(err.response?.data?.error?.message || '添加失败')
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<Modal open={open} onClose={onClose} title="添加用户">
|
||
<div className="space-y-3">
|
||
{error && <div className="px-3 py-2 rounded-md bg-red-50 text-red-700 text-xs">{error}</div>}
|
||
<div>
|
||
<Label>姓名 *</Label>
|
||
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
||
</div>
|
||
<div>
|
||
<Label>手机号 *</Label>
|
||
<Input type="tel" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} maxLength={11} />
|
||
</div>
|
||
<div>
|
||
<Label>初始密码 *</Label>
|
||
<Input type="password" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} />
|
||
</div>
|
||
<div>
|
||
<Label>角色</Label>
|
||
<Select value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value })}>
|
||
<option value="HR">HR</option>
|
||
<option value="ADMIN">管理员</option>
|
||
<option value="VIEWER">查看者</option>
|
||
</Select>
|
||
</div>
|
||
<div className="flex justify-end gap-2">
|
||
<Button variant="secondary" onClick={onClose}>取消</Button>
|
||
<Button onClick={handleSubmit} disabled={loading || !form.name || !form.phone || !form.password}>
|
||
{loading ? '添加中...' : '添加'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
)
|
||
}
|
||
|
||
function ExportSettings() {
|
||
const [format, setFormat] = useState<'json' | 'excel'>('json')
|
||
const [mask, setMask] = useState(false)
|
||
const [gzip, setGzip] = useState(true)
|
||
const [exporting, setExporting] = useState(false)
|
||
const [selectedModules, setSelectedModules] = useState<Record<string, boolean>>({
|
||
employees: true, contracts: true, terminations: true, payrollBatches: true,
|
||
payslips: true, socialRecords: true, housingRecords: true, riskItems: true,
|
||
})
|
||
|
||
const moduleLabels: Record<string, string> = {
|
||
employees: '员工信息', contracts: '劳动合同', terminations: '离职记录',
|
||
payrollBatches: '发薪批次', payslips: '工资条', socialRecords: '社保记录',
|
||
housingRecords: '公积金记录', riskItems: '风险项',
|
||
}
|
||
|
||
const handleExport = async () => {
|
||
setExporting(true)
|
||
try {
|
||
const token = useAuthStore.getState().accessToken
|
||
const modules = Object.keys(selectedModules).filter(k => selectedModules[k]).join(',')
|
||
const params = new URLSearchParams({ format, mask: String(mask), modules })
|
||
if (format === 'json' && !gzip) params.set('gzip', 'false')
|
||
const res = await fetch(`/api/v1/export/all?${params}`, {
|
||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||
})
|
||
const blob = await res.blob()
|
||
const url = URL.createObjectURL(blob)
|
||
const a = document.createElement('a')
|
||
a.href = url
|
||
const ext = format === 'excel' ? 'xlsx' : (gzip ? 'json.gz' : 'json')
|
||
a.download = `export-${new Date().toISOString().slice(0, 10)}.${ext}`
|
||
a.click()
|
||
URL.revokeObjectURL(url)
|
||
} catch {
|
||
toast.error('导出失败')
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
<p className="text-xs text-gray-500">选择需要导出的数据模块和格式</p>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
{Object.keys(moduleLabels).map(key => (
|
||
<label key={key} className="flex items-center gap-2 text-xs">
|
||
<input
|
||
type="checkbox"
|
||
checked={selectedModules[key]}
|
||
onChange={(e) => setSelectedModules({ ...selectedModules, [key]: e.target.checked })}
|
||
/>
|
||
{moduleLabels[key]}
|
||
</label>
|
||
))}
|
||
</div>
|
||
<div className="flex items-center gap-4">
|
||
<label className="flex items-center gap-2 text-xs">
|
||
<input type="radio" checked={format === 'json'} onChange={() => setFormat('json')} />
|
||
JSON
|
||
</label>
|
||
<label className="flex items-center gap-2 text-xs">
|
||
<input type="radio" checked={format === 'excel'} onChange={() => setFormat('excel')} />
|
||
Excel
|
||
</label>
|
||
<label className="flex items-center gap-2 text-xs">
|
||
<input type="checkbox" checked={mask} onChange={(e) => setMask(e.target.checked)} />
|
||
敏感字段脱敏
|
||
</label>
|
||
{format === 'json' && (
|
||
<label className="flex items-center gap-2 text-xs">
|
||
<input type="checkbox" checked={gzip} onChange={(e) => setGzip(e.target.checked)} />
|
||
Gzip 压缩
|
||
</label>
|
||
)}
|
||
</div>
|
||
<Button variant="secondary" size="sm" onClick={handleExport} disabled={exporting}>
|
||
<Download className="w-4 h-4 mr-1" />{exporting ? '导出中...' : '导出选中数据'}
|
||
</Button>
|
||
|
||
{/* 验收测试结果导出 */}
|
||
<div className="border-t border-gray-200 pt-3 mt-3">
|
||
<div className="flex items-center gap-2 mb-2">
|
||
<ClipboardList className="w-4 h-4 text-primary" />
|
||
<h3 className="text-sm font-medium">验收测试结果导出</h3>
|
||
</div>
|
||
<p className="text-xs text-gray-500 mb-2">导出验收测试清单中各验收人的填写结果,包含每项功能的验收结果和备注</p>
|
||
<Button variant="secondary" size="sm" onClick={handleAcceptanceExport}>
|
||
<Download className="w-4 h-4 mr-1" />导出验收结果(MD)
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** 导出验收测试结果为 Markdown 文档 */
|
||
function handleAcceptanceExport() {
|
||
try {
|
||
const PREFIX = 'turbohr_acceptance_'
|
||
const keys = Object.keys(localStorage).filter(k => k.startsWith(PREFIX))
|
||
if (keys.length === 0) {
|
||
toast.info('暂无已保存的验收结果,请先在顶部「验收测试」中填写并保存')
|
||
return
|
||
}
|
||
|
||
const resultMap: Record<string, string> = { pass: '✅通过', fail: '❌失败', partial: '⚠️部分通过', untested: '🚫未测试' }
|
||
let md = `# TurboHR 验收测试结果\n\n`
|
||
md += `> **导出时间:** ${new Date().toLocaleString('zh-CN')}\n`
|
||
md += `> **验收人数:** ${keys.length}\n\n`
|
||
|
||
for (const key of keys) {
|
||
const raw = localStorage.getItem(key)
|
||
if (!raw) continue
|
||
const data = JSON.parse(raw)
|
||
const name = data.verifier || key.substring(PREFIX.length)
|
||
const results: Record<string, string> = data.results || {}
|
||
const remarks: Record<string, string> = data.remarks || {}
|
||
|
||
const values = Object.values(results)
|
||
const pass = values.filter(v => v === 'pass').length
|
||
const fail = values.filter(v => v === 'fail').length
|
||
const partial = values.filter(v => v === 'partial').length
|
||
const untested = values.filter(v => v === 'untested' || !v).length
|
||
const total = values.length
|
||
const tested = pass + fail + partial
|
||
const rate = total > 0 ? Math.round(tested / total * 100) : 0
|
||
|
||
md += `## 验收人:${name}\n\n`
|
||
if (data.savedAt) md += `> 保存时间:${new Date(data.savedAt).toLocaleString('zh-CN')}\n`
|
||
md += `\n### 统计\n\n`
|
||
md += `| 指标 | 数值 |\n|------|------|\n`
|
||
md += `| 总项数 | ${total} |\n`
|
||
md += `| 通过 | ${pass} |\n`
|
||
md += `| 失败 | ${fail} |\n`
|
||
md += `| 部分通过 | ${partial} |\n`
|
||
md += `| 未测试 | ${untested} |\n`
|
||
md += `| 完成率 | ${rate}% |\n\n`
|
||
|
||
if (data.conclusionResult) {
|
||
const crText: Record<string, string> = { pass: '通过', conditional: '有条件通过', fail: '不通过' }
|
||
md += `### 验收结论\n\n`
|
||
md += `- **总体结论:** ${crText[data.conclusionResult] || '未选择'}\n`
|
||
if (data.conclusionDate) md += `- **验收日期:** ${data.conclusionDate}\n`
|
||
if (data.conclusionIssues) md += `- **遗留问题:**\n${data.conclusionIssues}\n`
|
||
md += `\n`
|
||
}
|
||
|
||
md += `### 明细\n\n`
|
||
md += `| # | 结果 | 备注 |\n|---|:----:|------|\n`
|
||
for (const [fid, result] of Object.entries(results)) {
|
||
md += `| ${fid} | ${resultMap[result] || '🚫未测试'} | ${remarks[fid] || ''} |\n`
|
||
}
|
||
md += `\n`
|
||
}
|
||
|
||
md += `\n---\n*由企业用工专家系统自动生成*\n`
|
||
|
||
const blob = new Blob([md], { type: 'text/markdown;charset=utf-8' })
|
||
const url = URL.createObjectURL(blob)
|
||
const a = document.createElement('a')
|
||
a.href = url
|
||
a.download = `acceptance-results-${new Date().toISOString().slice(0, 10)}.md`
|
||
a.click()
|
||
URL.revokeObjectURL(url)
|
||
toast.success(`已导出 ${keys.length} 位验收人的测试结果`)
|
||
} catch {
|
||
toast.error('导出验收结果失败')
|
||
}
|
||
}
|
||
|
||
function PlanSettings({ orgData }: { orgData: any }) {
|
||
const queryClient = useQueryClient()
|
||
const confirm = useConfirm()
|
||
const plan = orgData?.plan || 'FREE'
|
||
|
||
const { data: usageData } = useQuery<any>({
|
||
queryKey: ['usage'],
|
||
queryFn: async () => {
|
||
return await settingsApi.usage()
|
||
},
|
||
})
|
||
|
||
const planMutation = useMutation({
|
||
mutationFn: (newPlan: string) => settingsApi.updatePlan(newPlan),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['org'] })
|
||
queryClient.invalidateQueries({ queryKey: ['usage'] })
|
||
},
|
||
})
|
||
|
||
const plans = [
|
||
{ key: 'FREE', label: '免费版', price: '¥0/月', features: ['10人以内', '基础风险检测', '10次AI问答/月'] },
|
||
{ key: 'PRO', label: '专业版', price: '¥299/月', features: ['100人以内', '全功能风险检测', '100次AI问答/月', '合同审查'] },
|
||
{ key: 'ENTERPRISE', label: '企业版', price: '联系客服', features: ['无限人数', '无限AI问答', '专属客服', 'API接入'] },
|
||
]
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
{usageData && (
|
||
<Card>
|
||
<h3 className="text-xs font-medium text-gray-700 mb-3">当前用量</h3>
|
||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs">
|
||
<div>
|
||
<div className="text-gray-500">员工数</div>
|
||
<div className="font-medium text-base">{usageData.employeeCount}<span className="text-gray-500 text-xs">/{usageData.maxEmployees}</span></div>
|
||
</div>
|
||
<div>
|
||
<div className="text-gray-500">AI 对话</div>
|
||
<div className="font-medium text-base">{usageData.aiConversations}</div>
|
||
</div>
|
||
<div>
|
||
<div className="text-gray-500">合同数</div>
|
||
<div className="font-medium text-base">{usageData.contracts}</div>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
)}
|
||
<div className="grid md:grid-cols-3 gap-4">
|
||
{plans.map((p) => (
|
||
<Card key={p.key}>
|
||
<div className={`px-4 py-3 rounded-t-lg ${plan === p.key ? 'bg-primary text-white' : 'bg-gray-50'}`}>
|
||
<div className="font-medium">{p.label}</div>
|
||
<div className={`text-base font-bold ${plan === p.key ? 'text-white' : 'text-gray-900'}`}>{p.price}</div>
|
||
</div>
|
||
<div className="p-4 space-y-2">
|
||
{p.features.map((f, i) => (
|
||
<div key={i} className="text-xs text-gray-600 flex items-center gap-2">
|
||
<span className="text-safe">✓</span> {f}
|
||
</div>
|
||
))}
|
||
<div className="pt-2">
|
||
{plan === p.key ? (
|
||
<div className="text-xs text-center text-primary font-medium">当前套餐</div>
|
||
) : (
|
||
<Button
|
||
variant="secondary"
|
||
className="w-full"
|
||
size="sm"
|
||
onClick={async () => {
|
||
if (await confirm({ title: '切换套餐', message: `确定切换到${p.label}?`, variant: 'primary' })) planMutation.mutate(p.key)
|
||
}}
|
||
disabled={planMutation.isPending}
|
||
>
|
||
{planMutation.isPending ? '切换中...' : '升级'}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function NotificationSettings() {
|
||
const queryClient = useQueryClient()
|
||
const [form, setForm] = useState<any>({})
|
||
|
||
const { data: setting } = useQuery<any>({
|
||
queryKey: ['notification-settings'],
|
||
queryFn: async () => {
|
||
return await notificationsApi.settings()
|
||
},
|
||
})
|
||
|
||
useEffect(() => {
|
||
if (setting) setForm(setting)
|
||
}, [setting])
|
||
|
||
const updateMutation = useMutation({
|
||
mutationFn: (data: any) => notificationsApi.updateSettings(data),
|
||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['notification-settings'] }),
|
||
})
|
||
|
||
const testWechatMutation = useMutation({
|
||
mutationFn: () => notificationsApi.test('wechat') as any,
|
||
onSuccess: (res: any) => {
|
||
toast.success(res.success ? res.data.message : (res.error?.message || '测试失败'))
|
||
},
|
||
})
|
||
|
||
const testEmailMutation = useMutation({
|
||
mutationFn: () => notificationsApi.test('email') as any,
|
||
onSuccess: (res: any) => {
|
||
toast.success(res.success ? res.data.message : (res.error?.message || '测试失败'))
|
||
},
|
||
})
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
<Card>
|
||
<h2 className="text-sm font-medium mb-4">通知设置</h2>
|
||
<div className="space-y-3">
|
||
<label className="flex items-center justify-between">
|
||
<span className="text-xs">合同到期提醒</span>
|
||
<input type="checkbox" checked={form.contractExpiry ?? true} onChange={(e) => setForm({ ...form, contractExpiry: e.target.checked })} />
|
||
</label>
|
||
<div>
|
||
<Label>提前提醒天数</Label>
|
||
<Input type="number" value={form.expiryDays ?? 30} onChange={(e) => setForm({ ...form, expiryDays: Number(e.target.value) })} />
|
||
</div>
|
||
<label className="flex items-center justify-between">
|
||
<span className="text-xs">未签合同提醒</span>
|
||
<input type="checkbox" checked={form.contractUnsigned ?? true} onChange={(e) => setForm({ ...form, contractUnsigned: e.target.checked })} />
|
||
</label>
|
||
<label className="flex items-center justify-between">
|
||
<span className="text-xs">加班超时提醒</span>
|
||
<input type="checkbox" checked={form.overtimeAlert ?? true} onChange={(e) => setForm({ ...form, overtimeAlert: e.target.checked })} />
|
||
</label>
|
||
<label className="flex items-center justify-between">
|
||
<span className="text-xs">工资条发布通知</span>
|
||
<input type="checkbox" checked={form.payslipReady ?? true} onChange={(e) => setForm({ ...form, payslipReady: e.target.checked })} />
|
||
</label>
|
||
<div className="border-t pt-3 space-y-3">
|
||
<div className="text-xs font-medium">月度事务提醒</div>
|
||
<div className="text-xs text-gray-500">设置每月截止日,到期后自动生成待办提醒</div>
|
||
<div className="grid grid-cols-3 gap-3">
|
||
<div>
|
||
<Label>社保缴纳日</Label>
|
||
<Input type="number" min={1} max={28} value={form.socialInsDay ?? 15} onChange={(e) => setForm({ ...form, socialInsDay: Number(e.target.value) })} />
|
||
</div>
|
||
<div>
|
||
<Label>公积金缴纳日</Label>
|
||
<Input type="number" min={1} max={28} value={form.housingFundDay ?? 15} onChange={(e) => setForm({ ...form, housingFundDay: Number(e.target.value) })} />
|
||
</div>
|
||
<div>
|
||
<Label>个税申报日</Label>
|
||
<Input type="number" min={1} max={28} value={form.taxDay ?? 15} onChange={(e) => setForm({ ...form, taxDay: Number(e.target.value) })} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="border-t pt-3">
|
||
<Label>企业微信 Webhook(选填)</Label>
|
||
<div className="flex gap-2">
|
||
<Input value={form.wechatWebhook || ''} onChange={(e) => setForm({ ...form, wechatWebhook: e.target.value || null })} placeholder="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=..." />
|
||
<Button variant="secondary" size="sm" onClick={() => testWechatMutation.mutate()} disabled={testWechatMutation.isPending || !form.wechatWebhook}>
|
||
{testWechatMutation.isPending ? '测试中...' : '测试'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
<label className="flex items-center justify-between">
|
||
<span className="text-xs">邮件通知</span>
|
||
<input type="checkbox" checked={form.emailNotify ?? false} onChange={(e) => setForm({ ...form, emailNotify: e.target.checked })} />
|
||
</label>
|
||
{form.emailNotify && (
|
||
<div>
|
||
<Label>通知邮箱</Label>
|
||
<div className="flex gap-2">
|
||
<Input value={form.email || ''} onChange={(e) => setForm({ ...form, email: e.target.value || null })} placeholder="hr@example.com" />
|
||
<Button variant="secondary" size="sm" onClick={() => testEmailMutation.mutate()} disabled={testEmailMutation.isPending || !form.email}>
|
||
{testEmailMutation.isPending ? '测试中...' : '测试'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
<Button onClick={() => updateMutation.mutate(form)} disabled={updateMutation.isPending}>
|
||
{updateMutation.isPending ? '保存中...' : '保存设置'}
|
||
</Button>
|
||
</div>
|
||
</Card>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export function ImportSettings() {
|
||
const [importType, setImportType] = useState<'init' | 'monthly'>('init')
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
<div className="flex gap-1 border-b">
|
||
<button
|
||
onClick={() => setImportType('init')}
|
||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${importType === 'init' ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'}`}
|
||
>
|
||
初始化导入
|
||
</button>
|
||
<button
|
||
onClick={() => setImportType('monthly')}
|
||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${importType === 'monthly' ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'}`}
|
||
>
|
||
月度导入
|
||
</button>
|
||
</div>
|
||
{importType === 'init' ? <InitImport /> : <MonthlyImport />}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function InitImport() {
|
||
const queryClient = useQueryClient()
|
||
const [file, setFile] = useState<File | null>(null)
|
||
const [result, setResult] = useState<any>(null)
|
||
const [uploading, setUploading] = useState(false)
|
||
const [error, setError] = useState('')
|
||
const [preview, setPreview] = useState<any>(null)
|
||
const [previewing, setPreviewing] = useState(false)
|
||
const [previewTab, setPreviewTab] = useState('employees')
|
||
|
||
const handlePreview = async () => {
|
||
if (!file) return
|
||
setPreviewing(true)
|
||
setError('')
|
||
setPreview(null)
|
||
try {
|
||
const token = useAuthStore.getState().accessToken
|
||
const formData = new FormData()
|
||
formData.append('file', file)
|
||
const res = await fetch('/api/v1/import/excel/preview', {
|
||
method: 'POST',
|
||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||
body: formData,
|
||
})
|
||
const data = await res.json()
|
||
if (!data.success) {
|
||
setError(data.error?.message || '预览失败')
|
||
} else {
|
||
setPreview(data.data)
|
||
}
|
||
} catch (e: any) {
|
||
setError(e?.message || '预览失败')
|
||
} finally {
|
||
setPreviewing(false)
|
||
}
|
||
}
|
||
|
||
const handleExportErrors = async () => {
|
||
if (!preview?.errors?.length) return
|
||
try {
|
||
const token = useAuthStore.getState().accessToken
|
||
const res = await fetch('/api/v1/import/excel/error-log', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||
},
|
||
body: JSON.stringify({ errors: preview.errors }),
|
||
})
|
||
const blob = await res.blob()
|
||
const url = URL.createObjectURL(blob)
|
||
const a = document.createElement('a')
|
||
a.href = url
|
||
a.download = `import-errors-${Date.now()}.xlsx`
|
||
a.click()
|
||
URL.revokeObjectURL(url)
|
||
} catch {
|
||
toast.error('导出错误日志失败')
|
||
}
|
||
}
|
||
|
||
const handleUpload = async () => {
|
||
if (!file) return
|
||
setUploading(true)
|
||
setError('')
|
||
setResult(null)
|
||
try {
|
||
const token = useAuthStore.getState().accessToken
|
||
const formData = new FormData()
|
||
formData.append('file', file)
|
||
const res = await fetch('/api/v1/import/excel', {
|
||
method: 'POST',
|
||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||
body: formData,
|
||
})
|
||
const data = await res.json()
|
||
if (!data.success) {
|
||
setError(data.error?.message || '导入失败')
|
||
toast.error(data.error?.message || '导入失败')
|
||
} else {
|
||
setResult(data.data)
|
||
if (data.data.errors?.length > 0) {
|
||
toast.warning(`导入完成,但有 ${data.data.errors.length} 条错误,请查看详情`)
|
||
} else {
|
||
toast.success(`成功导入员工 ${data.data.employees} 人`)
|
||
}
|
||
queryClient.invalidateQueries({ queryKey: ['roster'] })
|
||
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
|
||
}
|
||
} catch (e: any) {
|
||
setError(e?.message || '上传失败')
|
||
} finally {
|
||
setUploading(false)
|
||
}
|
||
}
|
||
|
||
const handleDownloadTemplate = async () => {
|
||
try {
|
||
const token = useAuthStore.getState().accessToken
|
||
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
|
||
const res = await fetch(`${baseURL}/import/template`, {
|
||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||
})
|
||
const blob = await res.blob()
|
||
const url = URL.createObjectURL(blob)
|
||
const a = document.createElement('a')
|
||
a.href = url
|
||
a.download = '员工导入模板.xlsx'
|
||
a.click()
|
||
URL.revokeObjectURL(url)
|
||
} catch {
|
||
setError('下载模板失败')
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
<Card>
|
||
<div className="space-y-4">
|
||
<div>
|
||
<h3 className="text-xs font-medium text-gray-700 mb-1">初始化数据导入</h3>
|
||
<p className="text-xs text-gray-500">首次使用系统时,批量导入已有员工、合同、历史考勤/加班/违纪记录</p>
|
||
</div>
|
||
|
||
<div className="flex gap-2">
|
||
<Button variant="secondary" size="sm" onClick={handleDownloadTemplate}>
|
||
<Download className="w-4 h-4 mr-1" />下载导入模板
|
||
</Button>
|
||
<Button variant="secondary" size="sm" onClick={handlePreview} disabled={!file || previewing}>
|
||
{previewing ? '预览中...' : '预览数据'}
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="border-2 border-dashed border-gray-200 rounded-lg p-6 text-center">
|
||
<FileSpreadsheet className="w-8 h-8 text-gray-300 mx-auto mb-2" />
|
||
<input type="file" accept=".xlsx,.xls" onChange={(e) => { setFile(e.target.files?.[0] || null); setResult(null); setError(''); setPreview(null) }} className="hidden" id="import-file-init" />
|
||
<label htmlFor="import-file-init" className="cursor-pointer text-xs text-primary hover:underline">
|
||
{file ? file.name : '点击选择 Excel 文件'}
|
||
</label>
|
||
</div>
|
||
|
||
{error && <div className="px-3 py-2 rounded-md bg-red-50 text-red-700 text-xs">{error}</div>}
|
||
|
||
{preview && (
|
||
<div className="border rounded-lg p-3 space-y-3">
|
||
<div className="flex items-center justify-between">
|
||
<div className="text-xs font-medium">
|
||
预览:共 {preview.summary?.totalRows || 0} 行,正常 {preview.summary?.normalRows || 0} 行,错误 {preview.summary?.errorRows || 0} 行
|
||
</div>
|
||
{preview.errors?.length > 0 && (
|
||
<Button variant="secondary" size="sm" onClick={handleExportErrors}>
|
||
<Download className="w-4 h-4 mr-1" />导出错误日志
|
||
</Button>
|
||
)}
|
||
</div>
|
||
<div className="flex gap-1 border-b">
|
||
{['employees', 'contracts', 'overtime', 'disciplinary', 'attendance'].map(tab => {
|
||
const labels: any = { employees: '员工信息', contracts: '劳动合同', overtime: '加班记录', disciplinary: '违纪记录', attendance: '考勤记录' }
|
||
const rows = preview[tab] || []
|
||
if (rows.length === 0) return null
|
||
return (
|
||
<button key={tab} onClick={() => setPreviewTab(tab)}
|
||
className={`px-2 py-1 text-xs border-b-2 ${previewTab === tab ? 'border-primary text-primary' : 'border-transparent text-gray-500'}`}>
|
||
{labels[tab]} ({rows.length})
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
<div className="overflow-x-auto max-h-60 overflow-y-auto">
|
||
<table className="w-full text-sm">
|
||
<thead className="sticky top-0 bg-white">
|
||
<tr className="border-b text-left text-xs text-gray-500">
|
||
<th className="py-1 px-2">行号</th>
|
||
<th className="py-1 px-2">姓名</th>
|
||
<th className="py-1 px-2">状态</th>
|
||
<th className="py-1 px-2">错误/警告</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{(preview[previewTab] || []).map((row: any, i: number) => (
|
||
<tr key={i} className="border-b last:border-0">
|
||
<td className="py-1 px-2 text-gray-500">{row.rowNo}</td>
|
||
<td className="py-1 px-2">{row.name || row.idCard || '—'}</td>
|
||
<td className="py-1 px-2">
|
||
<span className={row.status === 'error' ? 'text-danger' : row.status === 'warning' ? 'text-amber-600' : 'text-safe'}>
|
||
{row.status === 'error' ? '错误' : row.status === 'warning' ? '警告' : '正常'}
|
||
</span>
|
||
</td>
|
||
<td className="py-1 px-2 text-gray-500">
|
||
{row.errors?.join('; ') || row.warnings?.join('; ') || '—'}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{result && (
|
||
<div className="px-3 py-2 rounded-md bg-green-50 text-green-700 text-xs space-y-1">
|
||
<div className="font-medium">导入完成</div>
|
||
<div className="flex gap-4">
|
||
<span>成功导入:员工 {result.employees} 人、合同 {result.contracts} 份</span>
|
||
{result.overtime > 0 && <span>加班 {result.overtime} 条</span>}
|
||
{result.disciplinary > 0 && <span>违纪 {result.disciplinary} 条</span>}
|
||
{result.attendance > 0 && <span>考勤 {result.attendance} 条</span>}
|
||
</div>
|
||
{result.skipped > 0 && (
|
||
<div className="text-amber-600">跳过 {result.skipped} 条(数据不完整或格式错误)</div>
|
||
)}
|
||
{result.duplicates > 0 && (
|
||
<div className="text-amber-600">重复 {result.duplicates} 条(身份证号已存在)</div>
|
||
)}
|
||
{result.errors?.length > 0 && (
|
||
<div className="mt-2 pt-2 border-t border-green-200">
|
||
<div className="font-medium text-amber-600 flex items-center justify-between">
|
||
<span>错误详情({result.errors.length}条):</span>
|
||
<button className="text-xs text-primary hover:underline" onClick={async () => {
|
||
try {
|
||
const token = useAuthStore.getState().accessToken
|
||
const errorList = result.errors.map((e: string, i: number) => {
|
||
const detail = result.details?.[i] || {}
|
||
return { sheet: detail.sheet || '员工信息', row: detail.row || i + 2, name: detail.name || '', errors: [e] }
|
||
})
|
||
const res = await fetch('/api/v1/import/excel/error-log', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
||
body: JSON.stringify({ errors: errorList }),
|
||
})
|
||
const blob = await res.blob()
|
||
const url = URL.createObjectURL(blob)
|
||
const a = document.createElement('a')
|
||
a.href = url
|
||
a.download = '导入错误日志.xlsx'
|
||
a.click()
|
||
URL.revokeObjectURL(url)
|
||
} catch {
|
||
toast.error('导出错误日志失败')
|
||
}
|
||
}}>
|
||
导出错误日志
|
||
</button>
|
||
</div>
|
||
{result.errors.slice(0, 10).map((e: string, i: number) => (<div key={i} className="text-amber-600">{e}</div>))}
|
||
{result.errors.length > 10 && <div className="text-amber-600">...还有 {result.errors.length - 10} 条,请导出错误日志查看全部</div>}
|
||
</div>
|
||
)}
|
||
{/* 导入明细 */}
|
||
{result.details?.filter((d: any) => d.status === 'success').length > 0 && (
|
||
<div className="mt-2 pt-2 border-t border-green-200">
|
||
<div className="font-medium text-gray-600 mb-1">导入明细:</div>
|
||
<div className="max-h-40 overflow-y-auto">
|
||
{result.details.filter((d: any) => d.status === 'success').map((d: any, i: number) => (
|
||
<div key={i} className="flex gap-3 text-xs text-gray-500">
|
||
<span>第{d.row}行</span>
|
||
<span>{d.name}</span>
|
||
{d.employeeId && <span className="text-gray-400">ID: {d.employeeId.slice(0, 8)}...</span>}
|
||
<span className="text-safe">✓ {d.message}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex justify-end">
|
||
<Button onClick={handleUpload} disabled={!file || uploading}>
|
||
<Upload className="w-4 h-4 mr-1" />{uploading ? '导入中...' : '开始导入'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
|
||
<Card>
|
||
<div className="text-xs text-gray-500 space-y-1">
|
||
<div className="font-medium text-gray-700 mb-1">Sheet 页说明</div>
|
||
<div>- <b>员工信息</b>:姓名、部门、性别、手机号、身份证号、入职日期、月工资、社保基数、公积金基数等</div>
|
||
<div>- <b>劳动合同</b>:身份证号(优先匹配)、姓名(备选)、合同类型、签订日期、起止日期、试用期等</div>
|
||
<div>- <b>加班记录</b>:身份证号(优先匹配)、姓名(备选)、日期、加班时长、加班类型</div>
|
||
<div>- <b>违纪记录</b>:身份证号(优先匹配)、姓名(备选)、日期、违纪类型、描述、处罚</div>
|
||
<div>- <b>考勤记录</b>:身份证号(优先匹配)、姓名(备选)、日期、考勤状态、上下班时间</div>
|
||
<div className="mt-2 text-gray-500">各 Sheet 优先用「身份证号」精确匹配员工,未填身份证号时用「姓名」兑底(重名可能匹配错误)</div>
|
||
</div>
|
||
</Card>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function MonthlyImport() {
|
||
const queryClient = useQueryClient()
|
||
const [file, setFile] = useState<File | null>(null)
|
||
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
|
||
const [result, setResult] = useState<any>(null)
|
||
const [uploading, setUploading] = useState(false)
|
||
const [error, setError] = useState('')
|
||
|
||
const handleUpload = async () => {
|
||
if (!file) return
|
||
setUploading(true)
|
||
setError('')
|
||
setResult(null)
|
||
try {
|
||
const token = useAuthStore.getState().accessToken
|
||
const formData = new FormData()
|
||
formData.append('file', file)
|
||
formData.append('month', month)
|
||
const res = await fetch('/api/v1/import/monthly', {
|
||
method: 'POST',
|
||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||
body: formData,
|
||
})
|
||
const data = await res.json()
|
||
if (!data.success) {
|
||
setError(data.error?.message || '导入失败')
|
||
toast.error(data.error?.message || '导入失败')
|
||
} else {
|
||
setResult(data.data)
|
||
if (data.data.errors?.length > 0) {
|
||
toast.warning(`导入完成,但有 ${data.data.errors.length} 条错误,请查看详情`)
|
||
} else {
|
||
toast.success('月度数据导入成功')
|
||
}
|
||
queryClient.invalidateQueries({ queryKey: ['roster'] })
|
||
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
|
||
}
|
||
} catch (e: any) {
|
||
setError(e?.message || '上传失败')
|
||
} finally {
|
||
setUploading(false)
|
||
}
|
||
}
|
||
|
||
const handleDownloadTemplate = async () => {
|
||
try {
|
||
const token = useAuthStore.getState().accessToken
|
||
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
|
||
const res = await fetch(`${baseURL}/import/monthly-template`, {
|
||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||
})
|
||
const blob = await res.blob()
|
||
const url = URL.createObjectURL(blob)
|
||
const a = document.createElement('a')
|
||
a.href = url
|
||
a.download = '月度增减员导入模板.xlsx'
|
||
a.click()
|
||
URL.revokeObjectURL(url)
|
||
} catch {
|
||
setError('下载模板失败')
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
<Card>
|
||
<div className="space-y-4">
|
||
<div>
|
||
<h3 className="text-xs font-medium text-gray-700 mb-1">月度数据导入</h3>
|
||
<p className="text-xs text-gray-500">每月定期导入当月考勤、加班、薪资调整、社保/公积金增减员变动</p>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-3">
|
||
<div>
|
||
<Label>导入月份</Label>
|
||
<Input type="month" value={month} onChange={(e) => setMonth(e.target.value)} className="!w-40" />
|
||
</div>
|
||
<div className="pt-5">
|
||
<Button variant="secondary" size="sm" onClick={handleDownloadTemplate}>
|
||
<Download className="w-4 h-4 mr-1" />下载月度模板
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="border-2 border-dashed border-gray-200 rounded-lg p-6 text-center">
|
||
<FileSpreadsheet className="w-8 h-8 text-gray-300 mx-auto mb-2" />
|
||
<input type="file" accept=".xlsx,.xls" onChange={(e) => { setFile(e.target.files?.[0] || null); setResult(null); setError('') }} className="hidden" id="import-file-monthly" />
|
||
<label htmlFor="import-file-monthly" className="cursor-pointer text-xs text-primary hover:underline">
|
||
{file ? file.name : '点击选择 Excel 文件'}
|
||
</label>
|
||
</div>
|
||
|
||
{error && <div className="px-3 py-2 rounded-md bg-red-50 text-red-700 text-xs">{error}</div>}
|
||
|
||
{result && (
|
||
<div className="px-3 py-2 rounded-md bg-green-50 text-green-700 text-xs space-y-1">
|
||
<div className="font-medium">{result.month} 月度导入完成</div>
|
||
{result.attendance > 0 && <div>考勤记录:{result.attendance} 条</div>}
|
||
{result.overtime > 0 && <div>加班记录:{result.overtime} 条</div>}
|
||
{result.salaryChanges > 0 && <div>薪资调整:{result.salaryChanges} 人</div>}
|
||
{result.socialInsChanges > 0 && <div>社保变动:{result.socialInsChanges} 人</div>}
|
||
{result.housingFundChanges > 0 && <div>公积金变动:{result.housingFundChanges} 人</div>}
|
||
{result.strategies && (
|
||
<div className="mt-2 pt-2 border-t border-green-200">
|
||
<div className="font-medium text-gray-600">覆盖策略:</div>
|
||
{Object.entries(result.strategies).map(([k, v]) => (
|
||
<div key={k} className="text-gray-500">{k}:{v as string}</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
{result.errors?.length > 0 && (
|
||
<div className="mt-2 pt-2 border-t border-green-200">
|
||
<div className="flex items-center justify-between">
|
||
<div className="font-medium text-amber-600">部分错误({result.errors.length}条):</div>
|
||
<button className="text-xs text-primary hover:underline" onClick={async () => {
|
||
try {
|
||
const token = useAuthStore.getState().accessToken
|
||
const errorList = result.errors.map((e: string, i: number) => ({ sheet: '月度导入', row: i + 2, name: '', errors: [e] }))
|
||
const res = await fetch('/api/v1/import/excel/error-log', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
||
body: JSON.stringify({ errors: errorList }),
|
||
})
|
||
const blob = await res.blob()
|
||
const url = URL.createObjectURL(blob)
|
||
const a = document.createElement('a')
|
||
a.href = url
|
||
a.download = `import-errors-${Date.now()}.xlsx`
|
||
a.click()
|
||
URL.revokeObjectURL(url)
|
||
} catch { toast.error('导出失败') }
|
||
}}>导出错误日志</button>
|
||
</div>
|
||
{result.errors.slice(0, 10).map((e: string, i: number) => (<div key={i} className="text-amber-600">{e}</div>))}
|
||
{result.errors.length > 10 && <div className="text-amber-600">...还有 {result.errors.length - 10} 条</div>}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex justify-end">
|
||
<Button onClick={handleUpload} disabled={!file || uploading}>
|
||
<Upload className="w-4 h-4 mr-1" />{uploading ? '导入中...' : '开始导入'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
|
||
<Card>
|
||
<div className="text-xs text-gray-500 space-y-1">
|
||
<div className="font-medium text-gray-700 mb-1">Sheet 页说明</div>
|
||
<div>- <b>考勤记录</b>:身份证号(优先匹配)、姓名(备选)、日期、考勤状态、上下班时间(同日重复导入会覆盖)</div>
|
||
<div>- <b>加班记录</b>:身份证号(优先匹配)、姓名(备选)、日期、加班时长、加班类型(同月重复导入会累加)</div>
|
||
<div>- <b>薪资调整</b>:身份证号(优先匹配)、姓名(备选)、调整后月薪、生效日期、调薪原因(自动关闭旧薪资记录)</div>
|
||
<div>- <b>社保变动</b>:身份证号(优先匹配)、姓名(备选)、变动类型(增员/调基/减员)、缴费基数</div>
|
||
<div>- <b>公积金变动</b>:身份证号(优先匹配)、姓名(备选)、变动类型(增员/调基/减员)、缴费基数</div>
|
||
<div className="mt-2 text-gray-500">所有 Sheet 优先用「身份证号」精确匹配员工,未填时用「姓名」兑底(重名可能匹配错误)</div>
|
||
</div>
|
||
</Card>
|
||
</div>
|
||
)
|
||
}
|
||
|