Files
TurboHR/frontend/src/pages/platform/PlatformOrgs.tsx
T

400 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
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.
/**
* 企业租户管理页 — 列表、搜索、查看详情、编辑套餐、删除
*/
import { useEffect, useState } from 'react'
import { Search, Building2, Eye, Trash2, Edit2, Plus } from 'lucide-react'
import api from '../../lib/api'
import { Input, Select, Label } from '../../components/ui/Input'
import Button from '../../components/ui/Button'
interface Org {
id: string; name: string; plan: string; maxEmployees: number
city: string | null; contactName: string | null; contactPhone: string | null
payrollFrequency: number; retirementReminderEnabled: boolean
createdAt: string; updatedAt: string
employeeCount: number; userCount: number; contractCount: number; payslipCount: number
}
const PLAN_LABELS: Record<string, string> = { FREE: '免费版', PRO: '专业版', ENTERPRISE: '企业版' }
const PLAN_COLORS: Record<string, string> = { FREE: 'bg-gray-100 text-gray-700', PRO: 'bg-blue-50 text-blue-700', ENTERPRISE: 'bg-purple-50 text-purple-700' }
export default function PlatformOrgs() {
const [orgs, setOrgs] = useState<Org[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize] = useState(20)
const [search, setSearch] = useState('')
const [planFilter, setPlanFilter] = useState('')
const [loading, setLoading] = useState(true)
const [editOrg, setEditOrg] = useState<Org | null>(null)
const [deleteOrg, setDeleteOrg] = useState<Org | null>(null)
const [createOpen, setCreateOpen] = useState(false)
const [createForm, setCreateForm] = useState({
name: '', plan: 'FREE', maxEmployees: 20, city: '',
contactName: '', contactPhone: '',
adminName: '管理员', adminPhone: '', adminPassword: '12345678',
})
const [creating, setCreating] = useState(false)
const [editAdmin, setEditAdmin] = useState({ name: '', phone: '', password: '' })
const [savingAdmin, setSavingAdmin] = useState(false)
const fetchOrgs = async () => {
setLoading(true)
try {
const params: any = { page, pageSize }
if (search) params.search = search
if (planFilter) params.plan = planFilter
const res = await api.get('/platform/orgs', { params }) as any
setOrgs(res.data.list)
setTotal(res.data.total)
} finally {
setLoading(false)
}
}
useEffect(() => { fetchOrgs() }, [page, planFilter])
useEffect(() => { setPage(1) }, [search, planFilter])
const totalPages = Math.ceil(total / pageSize)
const handleEditOrg = async (org: Org) => {
setEditOrg(org)
setEditAdmin({ name: '', phone: '', password: '' })
// 加载企业管理员信息
try {
const res = await api.get(`/platform/orgs/${org.id}`) as any
const admin = res.data.users?.find((u: any) => u.role === 'ADMIN')
if (admin) {
setEditAdmin({ name: admin.name || '', phone: admin.phone || '', password: '' })
}
} catch {
// 忽略
}
}
const handleSaveEdit = async () => {
if (!editOrg) return
try {
await api.put(`/platform/orgs/${editOrg.id}`, {
name: editOrg.name,
plan: editOrg.plan,
maxEmployees: editOrg.maxEmployees,
city: editOrg.city,
contactName: editOrg.contactName,
contactPhone: editOrg.contactPhone,
})
// 如果管理员信息有改动,同步保存
if (editAdmin.name || editAdmin.phone || editAdmin.password) {
await api.put(`/platform/orgs/${editOrg.id}/admin`, {
adminName: editAdmin.name || undefined,
adminPhone: editAdmin.phone || undefined,
adminPassword: editAdmin.password || undefined,
})
}
setEditOrg(null)
fetchOrgs()
} catch (err: any) {
alert(err.response?.data?.error?.message || '保存失败')
}
}
const handleCreate = async () => {
if (!createForm.name || !createForm.adminPhone || !createForm.adminPassword) {
alert('企业名称、管理员手机号、密码不能为空')
return
}
setCreating(true)
try {
await api.post('/platform/orgs', createForm)
setCreateOpen(false)
setCreateForm({
name: '', plan: 'FREE', maxEmployees: 20, city: '',
contactName: '', contactPhone: '',
adminName: '管理员', adminPhone: '', adminPassword: '12345678',
})
fetchOrgs()
} catch (err: any) {
alert(err.response?.data?.error?.message || '创建失败')
} finally {
setCreating(false)
}
}
const handleDelete = async () => {
if (!deleteOrg) return
try {
await api.delete(`/platform/orgs/${deleteOrg.id}`)
setDeleteOrg(null)
fetchOrgs()
} catch (err: any) {
alert(err.response?.data?.error?.message || '删除失败')
}
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900"></h1>
<p className="text-sm text-gray-500 mt-1"> {total} </p>
</div>
{/* 搜索栏 */}
<div className="flex flex-wrap gap-3">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
<Input
placeholder="搜索企业名称、城市、联系人..."
className="pl-10"
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && fetchOrgs()}
/>
</div>
<Select
className="w-32"
value={planFilter}
onChange={(e) => setPlanFilter(e.target.value)}
>
<option value=""></option>
<option value="FREE"></option>
<option value="PRO"></option>
<option value="ENTERPRISE"></option>
</Select>
<Button variant="secondary" onClick={fetchOrgs}>
</Button>
<Button onClick={() => setCreateOpen(true)}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
{/* 表格 */}
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 text-gray-500 text-xs">
<th className="text-left px-4 py-3 font-medium"></th>
<th className="text-left px-4 py-3 font-medium"></th>
<th className="text-left px-4 py-3 font-medium"></th>
<th className="text-right px-4 py-3 font-medium"></th>
<th className="text-right px-4 py-3 font-medium"></th>
<th className="text-right px-4 py-3 font-medium"></th>
<th className="text-left px-4 py-3 font-medium"></th>
<th className="text-center px-4 py-3 font-medium"></th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={8} className="text-center py-12 text-gray-400">...</td></tr>
) : orgs.length === 0 ? (
<tr><td colSpan={8} className="text-center py-12 text-gray-400"></td></tr>
) : orgs.map((org) => (
<tr key={org.id} className="border-b border-gray-100 hover:bg-gray-50">
<td className="px-4 py-3">
<div className="text-gray-900 font-medium">{org.name}</div>
<div className="text-xs text-gray-400">{org.contactName || '-'} {org.contactPhone || ''}</div>
</td>
<td className="px-4 py-3">
<span className={`px-2 py-0.5 rounded text-xs font-medium ${PLAN_COLORS[org.plan] || 'bg-gray-100 text-gray-700'}`}>
{PLAN_LABELS[org.plan] || org.plan}
</span>
</td>
<td className="px-4 py-3 text-gray-700">{org.city || '-'}</td>
<td className="px-4 py-3 text-right text-gray-700">{org.employeeCount}</td>
<td className="px-4 py-3 text-right text-gray-700">{org.userCount}</td>
<td className="px-4 py-3 text-right text-gray-700">{org.contractCount}</td>
<td className="px-4 py-3 text-gray-400 text-xs">{new Date(org.createdAt).toLocaleDateString('zh-CN')}</td>
<td className="px-4 py-3">
<div className="flex items-center justify-center gap-1">
<button
onClick={() => handleEditOrg(org)}
className="p-1.5 rounded text-gray-400 hover:text-primary hover:bg-gray-100"
title="编辑"
>
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={() => setDeleteOrg(org)}
className="p-1.5 rounded text-gray-400 hover:text-red-500 hover:bg-gray-100"
title="删除"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{/* 分页 */}
{totalPages > 1 && (
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-200">
<span className="text-xs text-gray-500"> {page} / {totalPages} {total} </span>
<div className="flex gap-2">
<Button size="sm" variant="secondary" disabled={page <= 1} onClick={() => setPage(page - 1)}></Button>
<Button size="sm" variant="secondary" disabled={page >= totalPages} onClick={() => setPage(page + 1)}></Button>
</div>
</div>
)}
</div>
{/* 创建企业弹窗 */}
{createOpen && (
<div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center p-4" onClick={() => setCreateOpen(false)}>
<div className="bg-white border border-gray-200 rounded-lg p-6 w-full max-w-lg max-h-[90vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
<h2 className="text-lg font-semibold text-gray-900 mb-4"></h2>
<div className="space-y-3">
<div className="border-b border-gray-100 pb-3">
<h3 className="text-xs text-primary font-medium mb-3"></h3>
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2">
<Label> *</Label>
<Input placeholder="如:北京XX科技有限公司" value={createForm.name} onChange={(e) => setCreateForm({ ...createForm, name: e.target.value })} />
</div>
<div>
<Label></Label>
<Select value={createForm.plan} onChange={(e) => setCreateForm({ ...createForm, plan: e.target.value })}>
<option value="FREE"></option>
<option value="PRO"></option>
<option value="ENTERPRISE"></option>
</Select>
</div>
<div>
<Label></Label>
<Input type="number" value={createForm.maxEmployees} onChange={(e) => setCreateForm({ ...createForm, maxEmployees: parseInt(e.target.value) || 0 })} />
</div>
<div>
<Label></Label>
<Input placeholder="如:北京" value={createForm.city} onChange={(e) => setCreateForm({ ...createForm, city: e.target.value })} />
</div>
<div>
<Label></Label>
<Input value={createForm.contactName} onChange={(e) => setCreateForm({ ...createForm, contactName: e.target.value })} />
</div>
<div className="col-span-2">
<Label></Label>
<Input value={createForm.contactPhone} onChange={(e) => setCreateForm({ ...createForm, contactPhone: e.target.value })} />
</div>
</div>
</div>
<div className="border-b border-gray-100 pb-3">
<h3 className="text-xs text-primary font-medium mb-3"></h3>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Input value={createForm.adminName} onChange={(e) => setCreateForm({ ...createForm, adminName: e.target.value })} />
</div>
<div>
<Label> *</Label>
<Input placeholder="13800000000" maxLength={11} value={createForm.adminPhone} onChange={(e) => setCreateForm({ ...createForm, adminPhone: e.target.value })} />
</div>
<div className="col-span-2">
<Label> *8</Label>
<Input value={createForm.adminPassword} onChange={(e) => setCreateForm({ ...createForm, adminPassword: e.target.value })} />
</div>
</div>
</div>
<div className="flex gap-2 pt-2">
<Button className="flex-1" onClick={handleCreate} disabled={creating}>
{creating ? '创建中...' : '确认创建'}
</Button>
<Button variant="secondary" onClick={() => setCreateOpen(false)}></Button>
</div>
</div>
</div>
</div>
)}
{/* 编辑弹窗 */}
{editOrg && (
<div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center p-4" onClick={() => setEditOrg(null)}>
<div className="bg-white border border-gray-200 rounded-lg p-6 w-full max-w-md" onClick={(e) => e.stopPropagation()}>
<h2 className="text-lg font-semibold text-gray-900 mb-4"></h2>
<div className="space-y-3">
<div>
<Label></Label>
<Input value={editOrg.name} onChange={(e) => setEditOrg({ ...editOrg, name: e.target.value })} />
</div>
<div>
<Label></Label>
<Select value={editOrg.plan} onChange={(e) => setEditOrg({ ...editOrg, plan: e.target.value })}>
<option value="FREE"></option>
<option value="PRO"></option>
<option value="ENTERPRISE"></option>
</Select>
</div>
<div>
<Label></Label>
<Input type="number" value={editOrg.maxEmployees} onChange={(e) => setEditOrg({ ...editOrg, maxEmployees: parseInt(e.target.value) || 0 })} />
</div>
<div>
<Label></Label>
<Input value={editOrg.city || ''} onChange={(e) => setEditOrg({ ...editOrg, city: e.target.value })} />
</div>
<div>
<Label></Label>
<Input value={editOrg.contactName || ''} onChange={(e) => setEditOrg({ ...editOrg, contactName: e.target.value })} />
</div>
<div>
<Label></Label>
<Input value={editOrg.contactPhone || ''} onChange={(e) => setEditOrg({ ...editOrg, contactPhone: e.target.value })} />
</div>
{/* 管理员账号 */}
<div className="border-t border-gray-100 pt-3 mt-3">
<h3 className="text-xs text-primary font-medium mb-3"></h3>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Input value={editAdmin.name} onChange={(e) => setEditAdmin({ ...editAdmin, name: e.target.value })} />
</div>
<div>
<Label></Label>
<Input value={editAdmin.phone} maxLength={11} onChange={(e) => setEditAdmin({ ...editAdmin, phone: e.target.value })} />
</div>
<div className="col-span-2">
<Label></Label>
<Input placeholder="输入新密码(至少8位)" value={editAdmin.password} onChange={(e) => setEditAdmin({ ...editAdmin, password: e.target.value })} />
</div>
</div>
</div>
<div className="flex gap-2 pt-2">
<Button className="flex-1" onClick={handleSaveEdit}></Button>
<Button variant="secondary" onClick={() => setEditOrg(null)}></Button>
</div>
</div>
</div>
</div>
)}
{/* 删除确认 */}
{deleteOrg && (
<div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center p-4" onClick={() => setDeleteOrg(null)}>
<div className="bg-white border border-gray-200 rounded-lg p-6 w-full max-w-sm" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-full bg-red-50 flex items-center justify-center">
<Trash2 className="w-5 h-5 text-red-500" />
</div>
<div>
<h2 className="text-lg font-semibold text-gray-900"></h2>
<p className="text-xs text-gray-500"></p>
</div>
</div>
<p className="text-sm text-gray-600 mb-4">
<span className="text-gray-900 font-medium">{deleteOrg.name}</span>
{deleteOrg.employeeCount} {deleteOrg.contractCount} {deleteOrg.payslipCount}
</p>
<div className="flex gap-2">
<Button variant="danger" className="flex-1" onClick={handleDelete}></Button>
<Button variant="secondary" onClick={() => setDeleteOrg(null)}></Button>
</div>
</div>
</div>
)}
</div>
)
}