Files
nexus/Project/fonrey/UI_DESIGN/组织人事_UI.html
2026-04-29 15:43:49 +08:00

1936 lines
104 KiB
HTML
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.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1366" />
<title>Fonrey 组织人事 · 静态原型</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: {
50: '#F0FDFA',
100: '#CCFBF1',
200: '#99F6E4',
500: '#14B8A6',
600: '#0F766E',
700: '#115E59',
800: '#134E4A'
},
neutral: {
50: '#F8FAFC',
100: '#F1F5F9',
200: '#E2E8F0',
300: '#CBD5E1',
400: '#94A3B8',
500: '#64748B',
600: '#475569',
700: '#334155',
800: '#1E293B',
900: '#0F172A'
},
success: { 50: '#F0FDF4', 600: '#16A34A' },
warning: { 50: '#FFFBEB', 600: '#D97706' },
danger: { 50: '#FEF2F2', 600: '#DC2626' },
info: { 50: '#EFF6FF', 600: '#2563EB' }
}
}
}
};
</script>
<style>
:root {
--bg-page: #F8FAFC;
--bg-card: #FFFFFF;
--bg-subtle: #F1F5F9;
--text-primary: #0F172A;
--text-secondary: #64748B;
--border: #E2E8F0;
}
body {
background: var(--bg-page);
color: var(--text-primary);
}
[x-cloak] { display: none !important; }
.tabular-nums { font-variant-numeric: tabular-nums; }
.bg-surface { background: var(--bg-card); }
.bg-subtle { background: var(--bg-subtle); }
.border-surface { border-color: var(--border); }
.text-surface { color: var(--text-primary); }
.text-muted { color: var(--text-secondary); }
.main-tab {
color: #64748B;
border-bottom: 2px solid transparent;
}
.main-tab.active {
color: #0F766E;
border-bottom-color: #0F766E;
font-weight: 600;
}
.action-link {
font-size: 12px;
color: #2563EB;
}
.action-link:hover { text-decoration: underline; }
.badge {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 2px 8px;
font-size: 12px;
line-height: 1.4;
white-space: nowrap;
}
.badge-success { background: #F0FDF4; color: #16A34A; }
.badge-warning { background: #FFFBEB; color: #D97706; }
.badge-danger { background: #FEF2F2; color: #DC2626; }
.badge-info { background: #EFF6FF; color: #2563EB; }
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-thumb { background: #CBD5E1; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #94A3B8; }
</style>
</head>
<body class="text-sm antialiased" x-data="orgPage()" x-init="init()" @keydown.escape.window="closeAllPanels()">
<!-- Top Bar -->
<header class="fixed top-0 left-0 right-0 h-14 z-30 bg-primary-800 flex items-center justify-between">
<div class="flex items-center gap-2 px-4 w-60 shrink-0">
<div class="w-7 h-7 rounded-md bg-primary-500 flex items-center justify-center text-white text-sm font-semibold">F</div>
<span class="text-base font-semibold text-white">Fonrey</span>
</div>
<nav class="flex items-center gap-1 flex-1 px-2" aria-label="主导航">
<a class="px-3 py-1.5 text-sm rounded-md text-primary-100 hover:bg-primary-700 hover:text-white">首页</a>
<a class="px-3 py-1.5 text-sm rounded-md text-primary-100 hover:bg-primary-700 hover:text-white">房源</a>
<a class="px-3 py-1.5 text-sm rounded-md text-primary-100 hover:bg-primary-700 hover:text-white">客源</a>
<a class="px-3 py-1.5 text-sm rounded-md text-primary-100 hover:bg-primary-700 hover:text-white">营销</a>
<a class="px-3 py-1.5 text-sm rounded-md text-primary-100 hover:bg-primary-700 hover:text-white">交易</a>
<a class="px-3 py-1.5 text-sm rounded-md text-primary-100 hover:bg-primary-700 hover:text-white">数据</a>
<a class="px-3 py-1.5 text-sm rounded-md bg-primary-600 text-white font-medium">人事</a>
<a class="px-3 py-1.5 text-sm rounded-md text-primary-100 hover:bg-primary-700 hover:text-white">系统</a>
<a class="px-3 py-1.5 text-sm rounded-md text-primary-100 hover:bg-primary-700 hover:text-white">三网</a>
</nav>
<div class="flex items-center gap-1 px-4 shrink-0">
<button class="p-1.5 text-primary-200 hover:bg-primary-700 hover:text-white rounded-md" aria-label="消息">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9A6 6 0 0 0 6 9v.75a8.967 8.967 0 0 1-2.312 6.022 23.848 23.848 0 0 0 5.454 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0"/></svg>
</button>
<div class="flex items-center gap-2 pl-3 ml-1 border-l border-primary-700">
<div class="w-8 h-8 rounded-full bg-primary-600 text-white flex items-center justify-center text-sm font-semibold"></div>
<span class="text-sm font-medium text-primary-100">魏深</span>
</div>
</div>
</header>
<!-- Side Bar -->
<aside class="fixed left-0 top-14 h-[calc(100vh-56px)] w-60 z-20 border-r border-surface bg-surface overflow-y-auto">
<nav class="p-3 space-y-0.5">
<div class="px-2 pt-2 pb-1 text-xs font-medium text-muted uppercase tracking-wide">组织人事</div>
<a class="flex items-center gap-2 px-2 py-1.5 rounded-md bg-primary-50 text-primary-700 font-medium">组织结构</a>
<a class="flex items-center gap-2 px-2 py-1.5 rounded-md text-neutral-700 hover:bg-neutral-100">部门架构图</a>
<a class="flex items-center gap-2 px-2 py-1.5 rounded-md text-neutral-700 hover:bg-neutral-100">员工通讯录</a>
<a class="flex items-center gap-2 px-2 py-1.5 rounded-md text-neutral-700 hover:bg-neutral-100">职务管理</a>
</nav>
</aside>
<!-- Main -->
<main class="ml-60 pt-[72px] min-h-screen px-6 py-5">
<div class="mx-auto max-w-[1760px] space-y-4">
<section class="bg-surface border border-surface rounded-lg p-4">
<div class="flex items-start justify-between gap-4">
<div>
<nav class="flex items-center gap-1 text-xs text-muted mb-2" aria-label="面包屑">
<a href="#" class="hover:text-neutral-700">人事OA</a>
<span>/</span>
<a href="#" class="hover:text-neutral-700">组织人事</a>
<span>/</span>
<span class="text-surface" x-text="mainTab === 'org' ? '组织结构' : (mainTab === 'chart' ? '部门架构图' : '员工通讯录')"></span>
</nav>
<h1 class="text-xl font-semibold text-surface">组织人事</h1>
</div>
<button class="px-3 py-1.5 rounded-md border border-danger-600 text-danger-600 bg-white hover:bg-danger-50" @click="notify('进入员工入职黑名单(原型)')">员工入职黑名单</button>
</div>
</section>
<section class="bg-surface border border-surface rounded-lg px-4">
<nav class="flex items-center gap-6 overflow-x-auto" aria-label="组织人事视图切换">
<button class="main-tab py-3 whitespace-nowrap" :class="{ 'active': mainTab === 'org' }" @click="switchMainTab('org')">组织结构</button>
<button class="main-tab py-3 whitespace-nowrap" :class="{ 'active': mainTab === 'chart' }" @click="switchMainTab('chart')">部门架构图</button>
<button class="main-tab py-3 whitespace-nowrap" :class="{ 'active': mainTab === 'contacts' }" @click="switchMainTab('contacts')">员工通讯录</button>
</nav>
</section>
<!-- 组织结构 -->
<template x-if="mainTab === 'org'">
<section class="grid grid-cols-[320px_1fr] gap-4">
<!-- 左侧组织树 -->
<section class="bg-surface border border-surface rounded-lg p-3 space-y-3">
<button class="w-full px-3 py-2 rounded-md bg-primary-600 text-white hover:bg-primary-700" @click="openDeptModal('add', selectedDeptId)">+ 新增部门</button>
<input x-model.trim="deptTreeSearch" type="text" placeholder="搜索部门" class="w-full px-3 py-2 rounded-md border border-surface bg-white focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-600/40" />
<label class="inline-flex items-center gap-2 text-xs text-muted">
<input type="checkbox" class="rounded border-surface" x-model="showClosedDepartments" />
<span>显示已关闭部门</span>
</label>
<div class="border border-surface rounded-md p-2 max-h-[680px] overflow-y-auto">
<template x-for="row in deptTreeRows" :key="'dept-tree-' + row.id">
<div class="group flex items-center gap-1 py-1 rounded-md" :class="selectedDeptId === row.id ? 'bg-primary-50' : 'hover:bg-neutral-50'" :style="`padding-left:${row.depth * 14}px`">
<button class="w-5 h-5 text-neutral-400 hover:text-neutral-700" @click="toggleDeptExpand(row.id)" :aria-label="deptExpanded[row.id] ? '收起节点' : '展开节点'">
<template x-if="row.hasChildren">
<span x-text="deptExpanded[row.id] ? '▾' : '▸'"></span>
</template>
<template x-if="!row.hasChildren">
<span class="opacity-40"></span>
</template>
</button>
<button class="flex-1 text-left" @click="selectedDeptId = row.id">
<span class="text-[13px]" :class="selectedDeptId === row.id ? 'text-primary-700 font-medium' : 'text-surface'" x-text="row.name"></span>
<span class="text-[11px] text-muted ml-1" x-text="`(${row.employeeCount}人)`"></span>
<span x-show="!row.isActive" class="ml-1 text-[10px] text-danger-600">已关闭</span>
</button>
<div class="hidden group-hover:flex items-center gap-2 pr-1">
<button class="text-[11px] text-info-600" @click="openDeptModal('edit', row.id)">编辑</button>
<button x-show="row.id !== rootDeptId" class="text-[11px] text-danger-600" @click="deleteDepartment(row.id)">删除</button>
</div>
</div>
</template>
</div>
</section>
<!-- 右侧员工区域 -->
<section class="space-y-4">
<section class="bg-surface border border-surface rounded-lg p-4">
<div class="flex items-start justify-between gap-3">
<div>
<h2 class="text-base font-semibold" x-text="selectedDepartment ? selectedDepartment.name : '-' "></h2>
<p class="text-xs text-muted mt-1">部门详情(组织结构维护)</p>
</div>
<div class="flex items-center gap-2">
<button class="px-3 py-1.5 rounded-md border border-warning-600 text-warning-600 bg-white hover:bg-warning-50" @click="notify('发起入职邀请(原型)')">入职邀请</button>
<button class="px-3 py-1.5 rounded-md bg-primary-600 text-white hover:bg-primary-700" @click="openDeptModal('edit', selectedDeptId)">编辑</button>
</div>
</div>
<div class="grid grid-cols-2 gap-x-8 gap-y-2 mt-3 text-sm">
<div class="flex"><span class="w-24 text-muted">部门名称</span><span x-text="selectedDepartment ? selectedDepartment.name : '-' "></span></div>
<div class="flex"><span class="w-24 text-muted">上级部门</span><span x-text="departmentNameById(selectedDepartment ? selectedDepartment.parentId : null) || '-' "></span></div>
<div class="flex"><span class="w-24 text-muted">部门级别</span><span x-text="levelLabel(selectedDepartment ? selectedDepartment.level : '')"></span></div>
<div class="flex"><span class="w-24 text-muted">部门属性</span><span x-text="selectedDepartment && selectedDepartment.attribute ? selectedDepartment.attribute : '-' "></span></div>
<div class="flex"><span class="w-24 text-muted">部门电话</span><span x-text="selectedDepartment && selectedDepartment.phone ? selectedDepartment.phone : '-' "></span></div>
<div class="flex"><span class="w-24 text-muted">部门状态</span><span x-text="selectedDepartment && selectedDepartment.isActive ? '启用' : '关闭' "></span></div>
</div>
</section>
<section class="bg-warning-50 border border-warning-600/20 rounded-lg px-4 py-2 text-xs text-warning-600">
账号数上限 17已使用账号数 17其中入职审批中员工 1不包含离职、共享账号、已删除账号
</section>
<section class="bg-warning-50 border border-danger-600/20 rounded-lg px-4 py-2 text-xs text-danger-600 flex items-center justify-between">
<span>经公安系统校验,有 1 个员工的身份证和真实姓名不匹配,请将这些员工的证件正确录入。</span>
<button class="action-link" @click="notify('已筛选证件异常员工(原型)')">立即筛选数据</button>
</section>
<section class="bg-surface border border-surface rounded-lg p-4 space-y-3">
<div class="grid grid-cols-5 gap-3">
<input x-model.trim="employeeFilters.keyword" type="text" placeholder="姓名/工号/电话" class="px-3 py-2 rounded-md border border-surface focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-600/40" />
<select x-model="employeeFilters.jobTitle" class="px-3 py-2 rounded-md border border-surface bg-white">
<option value="">请选择职务</option>
<template x-for="opt in jobOptions" :key="'filter-job-'+opt.title">
<option :value="opt.title" x-text="opt.title"></option>
</template>
</select>
<select x-model="employeeFilters.status" class="px-3 py-2 rounded-md border border-surface bg-white">
<option value="">员工状态(全选)</option>
<option value="active">正式</option>
<option value="probation">试用</option>
<option value="frozen">冻结</option>
<option value="resigned">离职</option>
</select>
<select x-model="employeeFilters.approval" class="px-3 py-2 rounded-md border border-surface bg-white">
<option value="">审批状态(全选)</option>
<option value="入职审">入职审</option>
<option value="通过">通过</option>
<option value="驳回">驳回</option>
</select>
<div class="flex items-center gap-2">
<button class="px-3 py-2 rounded-md bg-primary-600 text-white hover:bg-primary-700" @click="employeePage = 1">查询</button>
<button class="px-3 py-2 rounded-md border border-surface hover:bg-neutral-50" @click="resetEmployeeFilters()">清空条件</button>
</div>
</div>
<div class="grid grid-cols-4 gap-3">
<select x-model="employeeFilters.level" class="px-3 py-2 rounded-md border border-surface bg-white">
<option value="">部门级别(全选)</option>
<template x-for="lv in levelOptions" :key="'lv-filter-'+lv.value">
<option :value="lv.value" x-text="lv.label"></option>
</template>
</select>
<select x-model="employeeFilters.systemAdmin" class="px-3 py-2 rounded-md border border-surface bg-white">
<option value="">系统管理员(不限)</option>
<option value="yes"></option>
<option value="no"></option>
</select>
<input x-model="employeeFilters.joinedFrom" type="date" class="px-3 py-2 rounded-md border border-surface bg-white" />
<input x-model="employeeFilters.joinedTo" type="date" class="px-3 py-2 rounded-md border border-surface bg-white" />
</div>
<label class="inline-flex items-center gap-2 text-xs text-muted">
<input type="checkbox" class="rounded border-surface" x-model="employeeFilters.includeChildren" />
<span>显示下属部门员工</span>
</label>
</section>
<section class="bg-surface border border-surface rounded-lg p-3 flex items-center justify-between gap-3">
<div class="flex items-center gap-2 flex-wrap">
<button class="px-3 py-1.5 rounded-md bg-primary-600 text-white hover:bg-primary-700" @click="openOnboardDrawer()">新增员工</button>
<button class="px-3 py-1.5 rounded-md border border-surface hover:bg-neutral-50" @click="notify('导出员工(原型)')">导出员工</button>
<button class="px-3 py-1.5 rounded-md border border-surface" :class="selectedEmployeeCount===0 ? 'opacity-50 cursor-not-allowed' : 'hover:bg-neutral-50'" :disabled="selectedEmployeeCount===0" @click="openTransferDrawerByBatch()">批量调动员工</button>
<button class="px-3 py-1.5 rounded-md border border-surface" :class="selectedEmployeeCount===0 ? 'opacity-50 cursor-not-allowed' : 'hover:bg-neutral-50'" :disabled="selectedEmployeeCount===0" @click="notify('批量设置员工上级(原型)')">批量设置员工上级</button>
<button class="px-3 py-1.5 rounded-md border border-surface" :class="selectedEmployeeCount===0 ? 'opacity-50 cursor-not-allowed' : 'hover:bg-neutral-50'" :disabled="selectedEmployeeCount===0" @click="notify('更多批量操作(原型)')">更多</button>
</div>
<button class="action-link" @click="openTransferLogsModal()">员工异动记录</button>
</section>
<section class="bg-surface border border-surface rounded-lg overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead class="bg-subtle border-b border-surface">
<tr class="text-left">
<th scope="col" class="px-3 py-2 w-10">
<input type="checkbox" class="rounded border-surface" :checked="employeeAllOnPageSelected" @click.prevent="toggleEmployeePageSelect(!employeeAllOnPageSelected)" aria-label="全选当前页员工" />
</th>
<th scope="col" class="px-3 py-2">姓名/昵称</th>
<th scope="col" class="px-3 py-2">员工号</th>
<th scope="col" class="px-3 py-2">职务</th>
<th scope="col" class="px-3 py-2">部门</th>
<th scope="col" class="px-3 py-2">部门级别</th>
<th scope="col" class="px-3 py-2">上级</th>
<th scope="col" class="px-3 py-2">电话</th>
<th scope="col" class="px-3 py-2">入职时间</th>
<th scope="col" class="px-3 py-2">审批状态</th>
<th scope="col" class="px-3 py-2">操作</th>
</tr>
</thead>
<tbody>
<template x-if="employeePagedRows.length === 0">
<tr>
<td colspan="11" class="px-4 py-12 text-center">
<p class="text-base font-medium text-surface">暂无匹配数据</p>
<p class="text-xs text-muted mt-1">请尝试调整筛选条件</p>
</td>
</tr>
</template>
<template x-for="row in employeePagedRows" :key="'emp-row-'+row.id">
<tr class="border-b border-surface hover:bg-neutral-50/50">
<td class="px-3 py-2 align-top">
<input type="checkbox" class="rounded border-surface" :checked="isEmployeeSelected(row.id)" @click.prevent="toggleEmployeeSelect(row.id, !isEmployeeSelected(row.id))" aria-label="选择员工" />
</td>
<td class="px-3 py-2 align-top">
<div class="flex items-center gap-2">
<span class="w-7 h-7 rounded-full bg-neutral-100 text-neutral-600 flex items-center justify-center text-xs font-semibold" x-text="row.name.slice(0,1)"></span>
<div>
<button class="text-info-600 hover:underline" @click="openEmployeeDetail(row.id)" x-text="row.name"></button>
<div class="text-xs text-muted" x-text="row.nickname || '-' "></div>
</div>
<span x-show="row.idRisk" class="text-danger-600" title="证件信息不匹配"></span>
</div>
</td>
<td class="px-3 py-2 align-top" x-text="row.employeeNo"></td>
<td class="px-3 py-2 align-top" x-text="row.jobTitle"></td>
<td class="px-3 py-2 align-top" x-text="departmentNameById(row.orgUnitId)"></td>
<td class="px-3 py-2 align-top" x-text="levelLabel(departmentLevelById(row.orgUnitId))"></td>
<td class="px-3 py-2 align-top" x-text="staffNameById(row.supervisorId) || '-' "></td>
<td class="px-3 py-2 align-top tabular-nums" x-text="maskPhone(row.phone)"></td>
<td class="px-3 py-2 align-top tabular-nums" x-text="row.joinedAt"></td>
<td class="px-3 py-2 align-top">
<span class="badge" :class="row.approval==='通过' ? 'badge-success' : (row.approval==='驳回' ? 'badge-danger' : 'badge-warning')" x-text="row.approval"></span>
</td>
<td class="px-3 py-2 align-top">
<div class="flex items-center gap-3 text-xs">
<button class="action-link" @click="openEmployeeDetail(row.id)">查看</button>
<button class="action-link" @click="openTransferDrawer(row.id)">调动</button>
<button class="action-link" @click="openLeaveModal(row.id)">离职</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<div class="px-4 py-3 border-t border-surface bg-white flex items-center justify-between">
<div class="text-xs text-muted" x-text="`共 ${employeeFilteredRows.length} 条`"></div>
<div class="flex items-center gap-1">
<button class="px-2 py-1 rounded border border-surface text-xs" :disabled="employeePage===1" :class="employeePage===1 ? 'opacity-40 cursor-not-allowed' : 'hover:bg-neutral-50'" @click="employeePage = Math.max(1, employeePage-1)">上一页</button>
<span class="px-2 py-1 text-xs" x-text="employeePage"></span>
<button class="px-2 py-1 rounded border border-surface text-xs" :disabled="employeePage>=employeeTotalPages" :class="employeePage>=employeeTotalPages ? 'opacity-40 cursor-not-allowed' : 'hover:bg-neutral-50'" @click="employeePage = Math.min(employeeTotalPages, employeePage+1)">下一页</button>
</div>
<div class="text-xs text-muted flex items-center gap-2">
<span>20 条/页</span>
<span>跳至</span>
<input type="number" min="1" :max="employeeTotalPages" x-model.number="employeeJumpPage" class="w-14 px-2 py-1 rounded border border-surface text-center" />
<span></span>
<button class="px-2 py-1 rounded border border-surface hover:bg-neutral-50" @click="jumpEmployeePage()">确定</button>
</div>
</div>
</section>
</section>
</section>
</template>
<!-- 部门架构图 -->
<template x-if="mainTab === 'chart'">
<section class="space-y-4">
<section class="bg-surface border border-surface rounded-lg p-4 flex items-center justify-between gap-3">
<div class="flex items-center gap-3">
<label class="text-xs text-muted">部门</label>
<select x-model.number="chartFilters.deptId" class="px-3 py-2 rounded-md border border-surface bg-white">
<option value="">请选择</option>
<template x-for="d in activeDepartmentOptions" :key="'chart-dept-'+d.id">
<option :value="d.id" x-text="d.name"></option>
</template>
</select>
<label class="inline-flex items-center gap-2 text-xs text-muted">
<input type="checkbox" class="rounded border-surface" x-model="chartFilters.showClosed" />
<span>显示已关闭部门</span>
</label>
</div>
<div class="flex items-center gap-2">
<button class="w-8 h-8 rounded border border-surface hover:bg-neutral-50" @click="notify('放大架构图(原型)')" aria-label="放大">+</button>
<button class="w-8 h-8 rounded border border-surface hover:bg-neutral-50" @click="notify('缩小架构图(原型)')" aria-label="缩小">-</button>
<button class="w-8 h-8 rounded border border-surface hover:bg-neutral-50" @click="notify('导出架构图(原型)')" aria-label="导出"></button>
<button class="w-8 h-8 rounded border border-surface hover:bg-neutral-50" @click="notify('适应窗口(原型)')" aria-label="适应"></button>
<button class="w-8 h-8 rounded border border-surface hover:bg-neutral-50" @click="notify('重置视图(原型)')" aria-label="重置"></button>
</div>
</section>
<section class="bg-info-50 border border-info-600/20 rounded-lg px-4 py-2 text-xs text-info-600">
*最多 8 个层级数量,可对下图进行拖拽/缩放操作
</section>
<section class="bg-surface border border-surface rounded-lg p-6 overflow-auto min-h-[520px]">
<div class="min-w-[980px]">
<div class="flex justify-center">
<div class="w-72 bg-white border border-surface rounded-lg p-3 text-center shadow-sm">
<p class="text-sm font-semibold" x-text="chartRootNode.name"></p>
<p class="text-xs text-muted mt-1" x-text="`${chartNodeEmployeeCount(chartRootNode.id)} 人`"></p>
<button class="mt-2 text-xs text-info-600" @click="toggleChartExpand(chartRootNode.id)" x-text="isChartExpanded(chartRootNode.id) ? '收起' : '展开'"></button>
</div>
</div>
<div x-show="isChartExpanded(chartRootNode.id)" class="mt-10 flex items-start justify-center gap-6">
<template x-for="child in chartChildren(chartRootNode.id)" :key="'chart-child-'+child.id">
<div class="w-64">
<div class="bg-white border border-surface rounded-lg p-3 shadow-sm">
<div class="flex items-center justify-between">
<p class="text-sm font-semibold" x-text="child.name"></p>
<span class="badge badge-info" x-text="levelLabel(child.level)"></span>
</div>
<p class="text-xs text-muted mt-2" x-text="`负责人:${child.manager || '未设置部门负责人'}`"></p>
<p class="text-xs text-muted mt-1" x-text="`部门人数:${chartNodeEmployeeCount(child.id)}`"></p>
<p class="text-xs text-muted mt-1" x-text="`直属下级:${chartChildren(child.id).length}`"></p>
<button x-show="chartChildren(child.id).length > 0" class="mt-2 text-xs text-info-600" @click="toggleChartExpand(child.id)" x-text="isChartExpanded(child.id) ? '收起下级' : '展开下级'"></button>
</div>
<div x-show="isChartExpanded(child.id) && chartChildren(child.id).length > 0" class="mt-3 space-y-2">
<template x-for="sub in chartChildren(child.id)" :key="'chart-sub-'+sub.id">
<div class="bg-neutral-50 border border-surface rounded-md p-2">
<div class="flex items-center justify-between">
<span class="text-xs font-medium" x-text="sub.name"></span>
<span class="text-[11px] text-muted" x-text="levelLabel(sub.level)"></span>
</div>
<p class="text-[11px] text-muted mt-1" x-text="`人数:${chartNodeEmployeeCount(sub.id)} 下级:${chartChildren(sub.id).length}`"></p>
</div>
</template>
</div>
</div>
</template>
</div>
</div>
</section>
</section>
</template>
<!-- 员工通讯录 -->
<template x-if="mainTab === 'contacts'">
<section class="space-y-4">
<section class="bg-surface border border-surface rounded-lg p-4">
<div class="grid grid-cols-5 gap-3">
<select x-model.number="contactFilters.deptId" class="px-3 py-2 rounded-md border border-surface bg-white">
<option value="">部门(请选择)</option>
<template x-for="d in activeDepartmentOptions" :key="'contact-dept-'+d.id">
<option :value="d.id" x-text="d.name"></option>
</template>
</select>
<select x-model="contactFilters.jobTitle" class="px-3 py-2 rounded-md border border-surface bg-white">
<option value="">职务(请选择)</option>
<template x-for="opt in jobOptions" :key="'contact-job-'+opt.title">
<option :value="opt.title" x-text="opt.title"></option>
</template>
</select>
<select x-model="contactFilters.birthday" class="px-3 py-2 rounded-md border border-surface bg-white">
<option value="">生日(不限)</option>
<option value="thisMonth">本月生日</option>
</select>
<input x-model.trim="contactFilters.keyword" type="text" placeholder="姓名/电话/分机/邮件" class="px-3 py-2 rounded-md border border-surface" />
<div class="flex items-center gap-2">
<button class="px-3 py-2 rounded-md bg-primary-600 text-white hover:bg-primary-700" @click="contactPage = 1">查询</button>
<button class="px-3 py-2 rounded-md border border-surface hover:bg-neutral-50" @click="resetContactFilters()">清除条件</button>
</div>
</div>
</section>
<section class="bg-surface border border-surface rounded-lg overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead class="bg-subtle border-b border-surface">
<tr class="text-left">
<th scope="col" class="px-3 py-2">部门</th>
<th scope="col" class="px-3 py-2">姓名</th>
<th scope="col" class="px-3 py-2">职务</th>
<th scope="col" class="px-3 py-2">性别</th>
<th scope="col" class="px-3 py-2">生日</th>
<th scope="col" class="px-3 py-2">电话</th>
<th scope="col" class="px-3 py-2">分机</th>
<th scope="col" class="px-3 py-2">邮箱</th>
</tr>
</thead>
<tbody>
<template x-if="contactPagedRows.length === 0">
<tr>
<td colspan="8" class="px-4 py-12 text-center text-muted">暂无匹配数据</td>
</tr>
</template>
<template x-for="row in contactPagedRows" :key="'contact-row-'+row.id">
<tr class="border-b border-surface hover:bg-neutral-50/50">
<td class="px-3 py-2" x-text="departmentNameById(row.orgUnitId)"></td>
<td class="px-3 py-2">
<div class="flex items-center gap-2">
<span class="w-6 h-6 rounded-full bg-neutral-100 text-neutral-600 flex items-center justify-center text-xs" x-text="row.name.slice(0,1)"></span>
<span x-text="row.name"></span>
</div>
</td>
<td class="px-3 py-2" x-text="row.jobTitle"></td>
<td class="px-3 py-2" x-text="row.gender || '-' "></td>
<td class="px-3 py-2 tabular-nums" x-text="birthdayMonthDay(row.birthday)"></td>
<td class="px-3 py-2 tabular-nums">
<span x-text="isPhoneVisible(row.id) ? row.phone : maskPhone(row.phone)"></span>
<button class="action-link ml-2" @click="notify(`拨打 ${row.name}(原型)`)">拨打</button>
<button class="action-link ml-1" @click="togglePhoneVisible(row.id)">查看号码</button>
</td>
<td class="px-3 py-2" x-text="row.extension || '-' "></td>
<td class="px-3 py-2" x-text="row.email || '-' "></td>
</tr>
</template>
</tbody>
</table>
</div>
<div class="px-4 py-3 border-t border-surface bg-white flex items-center justify-between">
<div class="text-xs text-muted" x-text="`共 ${contactFilteredRows.length} 条`"></div>
<div class="flex items-center gap-1">
<button class="px-2 py-1 rounded border border-surface text-xs" :disabled="contactPage===1" :class="contactPage===1 ? 'opacity-40 cursor-not-allowed' : 'hover:bg-neutral-50'" @click="contactPage = Math.max(1, contactPage-1)">上一页</button>
<span class="px-2 py-1 text-xs" x-text="contactPage"></span>
<button class="px-2 py-1 rounded border border-surface text-xs" :disabled="contactPage>=contactTotalPages" :class="contactPage>=contactTotalPages ? 'opacity-40 cursor-not-allowed' : 'hover:bg-neutral-50'" @click="contactPage = Math.min(contactTotalPages, contactPage+1)">下一页</button>
</div>
<div class="text-xs text-muted">20 条/页</div>
</div>
</section>
</section>
</template>
</div>
</main>
<!-- 部门新增/编辑 Modal -->
<div x-cloak x-show="deptModal.open" class="fixed inset-0 z-40">
<div class="absolute inset-0 bg-neutral-900/40" @click="closeDeptModal()"></div>
<div class="absolute inset-0 flex items-center justify-center p-4 pointer-events-none">
<div class="w-full max-w-3xl bg-white rounded-xl shadow-xl pointer-events-auto flex flex-col max-h-[92vh]">
<div class="flex items-center justify-between px-5 py-4 border-b border-surface">
<h3 class="text-base font-semibold" x-text="deptModal.mode === 'add' ? '部门新增' : '部门编辑'"></h3>
<button class="p-1 text-muted hover:bg-neutral-100 rounded" @click="closeDeptModal()"></button>
</div>
<div class="flex-1 overflow-y-auto px-5 py-4 space-y-4">
<div class="bg-info-50 border border-info-600/20 rounded-lg px-3 py-2 text-xs text-info-600">
1. 店组级别部门必须挂在门店下2. 经纪人/店管的所属部门只能是门店/店组3. 经纪人是职务类别为置业顾问的员工。
</div>
<h4 class="text-sm font-semibold">部门基本信息</h4>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-xs text-muted mb-1"><span class="text-danger-600">*</span> 部门名称</label>
<input x-model.trim="deptModal.form.name" type="text" placeholder="请输入部门名称" class="w-full px-3 py-2 rounded-md border border-surface" />
<p x-show="deptModal.errors.name" class="text-xs text-danger-600 mt-1" x-text="deptModal.errors.name"></p>
</div>
<div>
<label class="block text-xs text-muted mb-1"><span class="text-danger-600">*</span> 上级部门</label>
<select x-model.number="deptModal.form.parentId" class="w-full px-3 py-2 rounded-md border border-surface bg-white">
<option value="">请选择</option>
<template x-for="d in parentDepartmentOptions(deptModal.form.id)" :key="'parent-opt-'+d.id">
<option :value="d.id" x-text="d.name"></option>
</template>
</select>
<p x-show="deptModal.errors.parentId" class="text-xs text-danger-600 mt-1" x-text="deptModal.errors.parentId"></p>
</div>
<div>
<label class="block text-xs text-muted mb-1"><span class="text-danger-600">*</span> 部门级别</label>
<select x-model="deptModal.form.level" class="w-full px-3 py-2 rounded-md border border-surface bg-white">
<option value="">请选择部门级别</option>
<template x-for="lv in levelOptions" :key="'lv-opt-'+lv.value">
<option :value="lv.value" x-text="lv.label"></option>
</template>
</select>
<p x-show="deptModal.errors.level" class="text-xs text-danger-600 mt-1" x-text="deptModal.errors.level"></p>
</div>
<div>
<label class="block text-xs text-muted mb-1">部门属性</label>
<select x-model="deptModal.form.attribute" class="w-full px-3 py-2 rounded-md border border-surface bg-white">
<option value="">请选择</option>
<option value="直营">直营</option>
<option value="加盟">加盟</option>
</select>
</div>
<div>
<label class="block text-xs text-muted mb-1">部门地址</label>
<input x-model.trim="deptModal.form.address" type="text" placeholder="请输入部门地址" class="w-full px-3 py-2 rounded-md border border-surface" />
</div>
<div>
<label class="block text-xs text-muted mb-1">部门负责人</label>
<select x-model.number="deptModal.form.managerId" class="w-full px-3 py-2 rounded-md border border-surface bg-white">
<option value="">请选择</option>
<template x-for="e in activeEmployees" :key="'mgr-'+e.id">
<option :value="e.id" x-text="e.name"></option>
</template>
</select>
</div>
<div>
<label class="block text-xs text-muted mb-1">部门电话</label>
<input x-model.trim="deptModal.form.phone" type="text" placeholder="请输入部门电话" class="w-full px-3 py-2 rounded-md border border-surface" />
</div>
<div>
<label class="block text-xs text-muted mb-1">部门状态</label>
<select x-model="deptModal.form.isActive" class="w-full px-3 py-2 rounded-md border border-surface bg-white">
<option :value="true">启用</option>
<option :value="false">关闭</option>
</select>
</div>
</div>
<p x-show="deptModal.errors.rule" class="text-xs text-danger-600" x-text="deptModal.errors.rule"></p>
</div>
<div class="px-5 py-3 border-t border-surface bg-neutral-50 flex items-center justify-end gap-2">
<button class="px-4 py-1.5 rounded-md border border-surface hover:bg-white" @click="closeDeptModal()">取消</button>
<button class="px-4 py-1.5 rounded-md bg-primary-600 text-white hover:bg-primary-700" @click="saveDepartment()">保存</button>
</div>
</div>
</div>
</div>
<!-- 新增员工 Drawer -->
<div x-cloak x-show="onboardDrawer.open" class="fixed inset-0 z-40">
<div class="absolute inset-0 bg-neutral-900/40" @click="closeOnboardDrawer()"></div>
<div class="absolute right-0 top-0 h-full w-[640px] bg-white shadow-xl flex flex-col border-l border-surface">
<div class="flex items-center justify-between px-5 py-4 border-b border-surface">
<h3 class="text-base font-semibold">新增员工 / 办理入职</h3>
<button class="p-1 text-muted hover:bg-neutral-100 rounded" @click="closeOnboardDrawer()"></button>
</div>
<div class="flex-1 overflow-y-auto px-5 py-4 space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-xs text-muted mb-1"><span class="text-danger-600">*</span> 姓名</label>
<input data-test="input-onboard-name" x-model.trim="onboardDrawer.form.name" type="text" placeholder="请输入姓名" class="w-full px-3 py-2 rounded-md border border-surface" />
<p x-show="onboardDrawer.errors.name" class="text-xs text-danger-600 mt-1" x-text="onboardDrawer.errors.name"></p>
</div>
<div>
<label class="block text-xs text-muted mb-1">昵称</label>
<input x-model.trim="onboardDrawer.form.nickname" type="text" placeholder="请输入昵称" class="w-full px-3 py-2 rounded-md border border-surface" />
</div>
<div>
<label class="block text-xs text-muted mb-1"><span class="text-danger-600">*</span> 手机号</label>
<input data-test="input-onboard-phone" x-model.trim="onboardDrawer.form.phone" type="text" placeholder="请输入11位手机号" class="w-full px-3 py-2 rounded-md border border-surface" />
<p x-show="onboardDrawer.errors.phone" class="text-xs text-danger-600 mt-1" x-text="onboardDrawer.errors.phone"></p>
</div>
<div>
<label class="block text-xs text-muted mb-1">入职日期</label>
<input x-model="onboardDrawer.form.joinedAt" type="date" class="w-full px-3 py-2 rounded-md border border-surface bg-white" />
</div>
<div>
<label class="block text-xs text-muted mb-1"><span class="text-danger-600">*</span> 所属门店/店组</label>
<select data-test="select-onboard-dept" x-model.number="onboardDrawer.form.orgUnitId" class="w-full px-3 py-2 rounded-md border border-surface bg-white">
<option value="">请选择</option>
<template x-for="d in storeAndGroupDepartments" :key="'onboard-dept-'+d.id">
<option :value="d.id" x-text="`${d.name}${levelLabel(d.level)}`"></option>
</template>
</select>
<p x-show="onboardDrawer.errors.orgUnitId" class="text-xs text-danger-600 mt-1" x-text="onboardDrawer.errors.orgUnitId"></p>
</div>
<div>
<label class="block text-xs text-muted mb-1"><span class="text-danger-600">*</span> 职务</label>
<select data-test="select-onboard-job" x-model="onboardDrawer.form.jobTitle" class="w-full px-3 py-2 rounded-md border border-surface bg-white" @change="syncOnboardJobMeta()">
<option value="">请选择</option>
<template x-for="j in jobOptions" :key="'onboard-job-'+j.title">
<option :value="j.title" x-text="j.title"></option>
</template>
</select>
<p x-show="onboardDrawer.errors.jobTitle" class="text-xs text-danger-600 mt-1" x-text="onboardDrawer.errors.jobTitle"></p>
</div>
<div>
<label class="block text-xs text-muted mb-1">职务类别(联动)</label>
<input x-model="onboardDrawer.form.jobCategory" type="text" readonly class="w-full px-3 py-2 rounded-md border border-surface bg-neutral-50 text-muted" />
</div>
<div>
<label class="block text-xs text-muted mb-1">默认角色(联动)</label>
<input x-model="onboardDrawer.form.role" type="text" readonly class="w-full px-3 py-2 rounded-md border border-surface bg-neutral-50 text-muted" />
</div>
<div>
<label class="block text-xs text-muted mb-1">直属上级</label>
<select x-model.number="onboardDrawer.form.supervisorId" class="w-full px-3 py-2 rounded-md border border-surface bg-white">
<option value="">请选择</option>
<template x-for="e in activeEmployees" :key="'onboard-super-'+e.id">
<option :value="e.id" x-text="`${departmentNameById(e.orgUnitId)}-${e.name}`"></option>
</template>
</select>
</div>
<div>
<label class="block text-xs text-muted mb-1">状态</label>
<select x-model="onboardDrawer.form.status" class="w-full px-3 py-2 rounded-md border border-surface bg-white">
<option value="probation">试用</option>
<option value="active">正式</option>
</select>
</div>
</div>
<p x-show="onboardDrawer.errors.rule" class="text-xs text-danger-600" x-text="onboardDrawer.errors.rule"></p>
<div x-show="onboardDrawer.createdAccount" class="bg-success-50 border border-success-600/20 rounded-lg p-3 text-xs">
<p class="font-semibold text-success-600">账号创建成功</p>
<p class="mt-1">登录账号:<span class="tabular-nums" x-text="onboardDrawer.createdAccount.username"></span></p>
<p>初始密码:<span class="tabular-nums" x-text="onboardDrawer.createdAccount.password"></span></p>
<p class="text-muted mt-1">已模拟发送给员工,可立即登录系统(原型)。</p>
</div>
</div>
<div class="px-5 py-3 border-t border-surface bg-neutral-50 flex items-center justify-end gap-2">
<button class="px-4 py-1.5 rounded-md border border-surface hover:bg-white" @click="closeOnboardDrawer()">取消</button>
<button data-test="btn-onboard-submit" class="px-4 py-1.5 rounded-md bg-primary-600 text-white hover:bg-primary-700" @click="submitOnboard()">提交</button>
</div>
</div>
</div>
<!-- 员工离职 Modal -->
<div x-cloak x-show="leaveModal.open" class="fixed inset-0 z-40">
<div class="absolute inset-0 bg-neutral-900/40" @click="closeLeaveModal()"></div>
<div class="absolute inset-0 flex items-center justify-center p-4 pointer-events-none">
<div class="w-full max-w-xl bg-white rounded-xl shadow-xl pointer-events-auto">
<div class="flex items-center justify-between px-5 py-4 border-b border-surface">
<h3 class="text-base font-semibold">员工离职</h3>
<button class="p-1 text-muted hover:bg-neutral-100 rounded" @click="closeLeaveModal()"></button>
</div>
<div class="px-5 py-4 space-y-4">
<div class="text-sm font-medium" x-text="`${leaveModal.employee ? leaveModal.employee.name : '-'} 的业务信息统计`"></div>
<div class="grid grid-cols-3 gap-3 text-xs">
<div class="bg-subtle rounded-md px-3 py-2"><span class="text-muted">房源数量</span><p class="tabular-nums mt-1" x-text="leaveModal.stats.house"></p></div>
<div class="bg-subtle rounded-md px-3 py-2"><span class="text-muted">客源数量</span><p class="tabular-nums mt-1" x-text="leaveModal.stats.client"></p></div>
<div class="bg-subtle rounded-md px-3 py-2"><span class="text-muted">营销客数量</span><p class="tabular-nums mt-1" x-text="leaveModal.stats.market"></p></div>
</div>
<p class="text-xs text-danger-600">注:若不转给任何账号,则离职成功后业务信息仍属于该离职员工 <button class="action-link ml-1" @click="notify('进入转移业务归属(原型)')">转移业务归属</button></p>
<div>
<label class="block text-xs text-muted mb-1"><span class="text-danger-600">*</span> 离职日期</label>
<input data-test="input-leave-date" x-model="leaveModal.form.resignedAt" type="date" class="w-full px-3 py-2 rounded-md border border-surface bg-white" />
<p x-show="leaveModal.errors.resignedAt" class="text-xs text-danger-600 mt-1" x-text="leaveModal.errors.resignedAt"></p>
</div>
<div>
<label class="block text-xs text-muted mb-1"><span class="text-danger-600">*</span> 离职类型</label>
<select data-test="select-leave-type" x-model="leaveModal.form.type" class="w-full px-3 py-2 rounded-md border border-surface bg-white">
<option value="">请选择</option>
<option value="自离">自离</option>
<option value="协商离职">协商离职</option>
<option value="辞退">辞退</option>
</select>
<p x-show="leaveModal.errors.type" class="text-xs text-danger-600 mt-1" x-text="leaveModal.errors.type"></p>
</div>
<div>
<label class="block text-xs text-muted mb-1">备注</label>
<textarea x-model.trim="leaveModal.form.remark" rows="3" maxlength="50" placeholder="50字以内" class="w-full px-3 py-2 rounded-md border border-surface"></textarea>
</div>
</div>
<div class="px-5 py-3 border-t border-surface bg-neutral-50 flex items-center justify-end gap-2">
<button class="px-4 py-1.5 rounded-md border border-surface hover:bg-white" @click="closeLeaveModal()">取消</button>
<button data-test="btn-leave-submit" class="px-4 py-1.5 rounded-md bg-primary-600 text-white hover:bg-primary-700" @click="submitLeave()">确定</button>
</div>
</div>
</div>
</div>
<!-- 员工调动 Drawer -->
<div x-cloak x-show="transferDrawer.open" class="fixed inset-0 z-40">
<div class="absolute inset-0 bg-neutral-900/40" @click="closeTransferDrawer()"></div>
<div class="absolute right-0 top-0 h-full w-[760px] bg-white shadow-xl flex flex-col border-l border-surface">
<div class="flex items-center justify-between px-5 py-4 border-b border-surface">
<h3 class="text-base font-semibold">员工调动</h3>
<button class="p-1 text-muted hover:bg-neutral-100 rounded" @click="closeTransferDrawer()"></button>
</div>
<div class="flex-1 overflow-y-auto px-5 py-4 space-y-4">
<div class="bg-warning-50 border border-warning-600/20 rounded-lg p-3 text-xs">
<p class="font-semibold" x-text="`${transferDrawer.employee ? transferDrawer.employee.name : '-'} 的业务信息统计`"></p>
<p class="mt-1">房源数量:<span class="tabular-nums" x-text="transferDrawer.stats.house"></span></p>
<p class="mt-1 text-danger-600">注:若不转给任何账号,则业务信息跟随到新部门 <button class="action-link ml-1" @click="notify('进入转移业务归属(原型)')">转移业务归属</button></p>
</div>
<div>
<label class="block text-xs text-muted mb-1"><span class="text-danger-600">*</span> 调动日期</label>
<input data-test="input-transfer-date" x-model="transferDrawer.form.transferDate" type="date" class="w-full px-3 py-2 rounded-md border border-surface bg-white" />
<p class="text-[11px] text-muted mt-1">若日期为今日之前的日期,若当天有已提交日报,当天之后的日报将进行调动。</p>
<p x-show="transferDrawer.errors.transferDate" class="text-xs text-danger-600 mt-1" x-text="transferDrawer.errors.transferDate"></p>
</div>
<div class="overflow-hidden border border-surface rounded-lg">
<table class="min-w-full text-xs">
<thead class="bg-subtle border-b border-surface">
<tr>
<th class="px-3 py-2 text-left w-28">字段</th>
<th class="px-3 py-2 text-left">调动前</th>
<th class="px-3 py-2 text-left">调动后</th>
</tr>
</thead>
<tbody class="divide-y divide-surface">
<tr>
<td class="px-3 py-2"><span class="text-danger-600">*</span> 部门</td>
<td class="px-3 py-2" x-text="departmentNameById(transferDrawer.employee ? transferDrawer.employee.orgUnitId : null)"></td>
<td class="px-3 py-2">
<select data-test="select-transfer-dept" x-model.number="transferDrawer.form.orgUnitId" class="w-full px-2 py-1.5 rounded border border-surface bg-white text-xs">
<option value="">请选择</option>
<template x-for="d in storeAndGroupDepartments" :key="'transfer-dept-'+d.id">
<option :value="d.id" x-text="`${d.name}${levelLabel(d.level)}`"></option>
</template>
</select>
<p x-show="transferDrawer.errors.orgUnitId" class="text-xs text-danger-600 mt-1" x-text="transferDrawer.errors.orgUnitId"></p>
</td>
</tr>
<tr>
<td class="px-3 py-2">部门级别</td>
<td class="px-3 py-2" x-text="levelLabel(departmentLevelById(transferDrawer.employee ? transferDrawer.employee.orgUnitId : null))"></td>
<td class="px-3 py-2 text-muted" x-text="levelLabel(departmentLevelById(transferDrawer.form.orgUnitId)) || '-' "></td>
</tr>
<tr>
<td class="px-3 py-2"><span class="text-danger-600">*</span> 职务</td>
<td class="px-3 py-2" x-text="transferDrawer.employee ? transferDrawer.employee.jobTitle : '-' "></td>
<td class="px-3 py-2">
<select data-test="select-transfer-job" x-model="transferDrawer.form.jobTitle" class="w-full px-2 py-1.5 rounded border border-surface bg-white text-xs" @change="syncTransferJobMeta()">
<option value="">请选择</option>
<template x-for="j in jobOptions" :key="'transfer-job-'+j.title"><option :value="j.title" x-text="j.title"></option></template>
</select>
<p x-show="transferDrawer.errors.jobTitle" class="text-xs text-danger-600 mt-1" x-text="transferDrawer.errors.jobTitle"></p>
</td>
</tr>
<tr>
<td class="px-3 py-2">职务类别</td>
<td class="px-3 py-2" x-text="transferDrawer.employee ? transferDrawer.employee.jobCategory : '-' "></td>
<td class="px-3 py-2 text-muted" x-text="transferDrawer.form.jobCategory || '-' "></td>
</tr>
<tr>
<td class="px-3 py-2"><span class="text-danger-600">*</span> 角色</td>
<td class="px-3 py-2" x-text="transferDrawer.employee ? transferDrawer.employee.role : '-' "></td>
<td class="px-3 py-2">
<input data-test="input-transfer-role" x-model.trim="transferDrawer.form.role" type="text" class="w-full px-2 py-1.5 rounded border border-surface text-xs" placeholder="请输入角色" />
<p x-show="transferDrawer.errors.role" class="text-xs text-danger-600 mt-1" x-text="transferDrawer.errors.role"></p>
</td>
</tr>
<tr>
<td class="px-3 py-2"><span class="text-danger-600">*</span> 直属上级</td>
<td class="px-3 py-2" x-text="staffNameById(transferDrawer.employee ? transferDrawer.employee.supervisorId : null) || '-' "></td>
<td class="px-3 py-2">
<select x-model.number="transferDrawer.form.supervisorId" :disabled="transferDrawer.form.noSupervisor" class="w-full px-2 py-1.5 rounded border border-surface bg-white text-xs disabled:bg-neutral-100 disabled:text-muted">
<option value="">请选择</option>
<template x-for="e in activeEmployees" :key="'transfer-super-'+e.id"><option :value="e.id" x-text="`${departmentNameById(e.orgUnitId)}-${e.name}`"></option></template>
</select>
<label class="inline-flex items-center gap-1 mt-1">
<input type="checkbox" class="rounded border-surface" x-model="transferDrawer.form.noSupervisor" />
<span class="text-[11px] text-muted">无直属上级</span>
</label>
<p x-show="transferDrawer.errors.supervisorId" class="text-xs text-danger-600 mt-1" x-text="transferDrawer.errors.supervisorId"></p>
</td>
</tr>
</tbody>
</table>
</div>
<div>
<label class="block text-xs text-muted mb-1">备注(此次调动为:<span class="text-danger-600" x-text="transferCategoryText"></span></label>
<textarea x-model.trim="transferDrawer.form.remark" rows="3" maxlength="30" placeholder="备注内容不超过30个字符" class="w-full px-3 py-2 rounded-md border border-surface"></textarea>
</div>
</div>
<div class="px-5 py-3 border-t border-surface bg-neutral-50 flex items-center justify-end gap-2">
<button class="px-4 py-1.5 rounded-md border border-surface hover:bg-white" @click="closeTransferDrawer()">取消</button>
<button data-test="btn-transfer-submit" class="px-4 py-1.5 rounded-md bg-primary-600 text-white hover:bg-primary-700" @click="submitTransfer()">提交</button>
</div>
</div>
</div>
<!-- 员工详情 Drawer -->
<div x-cloak x-show="employeeDetail.open" class="fixed inset-0 z-40">
<div class="absolute inset-0 bg-neutral-900/40" @click="closeEmployeeDetail()"></div>
<div class="absolute right-0 top-0 h-full w-[920px] bg-white shadow-xl flex flex-col border-l border-surface">
<div class="flex items-center justify-between px-5 py-4 border-b border-surface">
<h3 class="text-base font-semibold">员工详情</h3>
<button class="p-1 text-muted hover:bg-neutral-100 rounded" @click="closeEmployeeDetail()"></button>
</div>
<div class="flex-1 overflow-y-auto p-5">
<div class="grid grid-cols-[240px_1fr] gap-4">
<aside class="space-y-3">
<div class="border border-surface rounded-lg p-3 text-center">
<div class="w-16 h-16 mx-auto rounded-full bg-neutral-100 text-neutral-600 flex items-center justify-center text-lg font-semibold" x-text="detailEmployee ? detailEmployee.name.slice(0,1) : '-' "></div>
<p class="font-semibold mt-2" x-text="detailEmployee ? detailEmployee.name : '-' "></p>
<p class="text-xs text-muted mt-1" x-text="detailEmployee ? departmentNameById(detailEmployee.orgUnitId) : '-' "></p>
<div class="grid grid-cols-2 gap-2 mt-3 text-xs">
<div class="bg-subtle rounded px-2 py-1"><span class="text-muted">职务</span><p x-text="detailEmployee ? detailEmployee.jobTitle : '-' "></p></div>
<div class="bg-subtle rounded px-2 py-1"><span class="text-muted">工号</span><p x-text="detailEmployee ? detailEmployee.employeeNo : '-' "></p></div>
</div>
</div>
<div class="border border-surface rounded-lg p-2 space-y-1 text-sm">
<button class="w-full text-left px-2 py-1.5 rounded" :class="employeeDetail.tab==='basic' ? 'bg-primary-50 text-primary-700' : 'hover:bg-neutral-50'" @click="employeeDetail.tab='basic'">员工基本信息</button>
<button class="w-full text-left px-2 py-1.5 rounded" :class="employeeDetail.tab==='transfer' ? 'bg-primary-50 text-primary-700' : 'hover:bg-neutral-50'" @click="employeeDetail.tab='transfer'">异动记录</button>
<button class="w-full text-left px-2 py-1.5 rounded" :class="employeeDetail.tab==='account' ? 'bg-primary-50 text-primary-700' : 'hover:bg-neutral-50'" @click="employeeDetail.tab='account'">账号信息</button>
</div>
</aside>
<section class="border border-surface rounded-lg p-4">
<div class="flex items-center justify-between mb-4">
<h4 class="text-base font-semibold" x-text="employeeDetail.tab==='basic' ? '员工基本信息' : (employeeDetail.tab==='transfer' ? '异动记录' : '账号信息')"></h4>
<button x-show="employeeDetail.tab==='basic'" class="px-3 py-1.5 rounded-md bg-primary-600 text-white hover:bg-primary-700" @click="notify('进入员工信息编辑(原型)')">编辑</button>
</div>
<template x-if="employeeDetail.tab==='basic'">
<div class="space-y-4 text-sm">
<div>
<h5 class="font-semibold mb-2">任职信息</h5>
<div class="grid grid-cols-2 gap-x-8 gap-y-2">
<div class="flex"><span class="w-24 text-muted">昵称</span><span x-text="detailEmployee ? detailEmployee.nickname || '-' : '-' "></span></div>
<div class="flex"><span class="w-24 text-muted">工号</span><span x-text="detailEmployee ? detailEmployee.employeeNo : '-' "></span></div>
<div class="flex"><span class="w-24 text-muted">入职日期</span><span x-text="detailEmployee ? detailEmployee.joinedAt : '-' "></span></div>
<div class="flex"><span class="w-24 text-muted">状态</span><span x-text="detailEmployee ? employeeStatusLabel(detailEmployee.status) : '-' "></span></div>
<div class="flex"><span class="w-24 text-muted">职务</span><span x-text="detailEmployee ? detailEmployee.jobTitle : '-' "></span></div>
<div class="flex"><span class="w-24 text-muted">职务类别</span><span x-text="detailEmployee ? detailEmployee.jobCategory : '-' "></span></div>
<div class="flex"><span class="w-24 text-muted">部门</span><span x-text="detailEmployee ? departmentNameById(detailEmployee.orgUnitId) : '-' "></span></div>
<div class="flex"><span class="w-24 text-muted">直属上级</span><span x-text="detailEmployee ? (staffNameById(detailEmployee.supervisorId) || '-') : '-' "></span></div>
</div>
</div>
<div>
<h5 class="font-semibold mb-2">联系方式</h5>
<div class="grid grid-cols-2 gap-x-8 gap-y-2">
<div class="flex"><span class="w-24 text-muted">手机号</span><span x-text="detailEmployee ? maskPhone(detailEmployee.phone) : '-' "></span></div>
<div class="flex"><span class="w-24 text-muted">通讯录号码</span><span>不隐藏</span></div>
</div>
</div>
<div>
<h5 class="font-semibold mb-2">个人信息</h5>
<div class="grid grid-cols-2 gap-x-8 gap-y-2">
<div class="flex"><span class="w-24 text-muted">真实姓名</span><span x-text="detailEmployee ? detailEmployee.name : '-' "></span></div>
<div class="flex"><span class="w-24 text-muted">性别</span><span x-text="detailEmployee ? detailEmployee.gender || '-' : '-' "></span></div>
<div class="flex"><span class="w-24 text-muted">证件类型</span><span>身份证</span></div>
<div class="flex"><span class="w-24 text-muted">证件号码</span><span x-text="detailEmployee && detailEmployee.idRisk ? '410*********3037未匹配' : '410*********3037已认证'"></span></div>
</div>
</div>
</div>
</template>
<template x-if="employeeDetail.tab==='transfer'">
<div class="overflow-x-auto">
<table class="min-w-full text-xs">
<thead class="bg-subtle border border-surface">
<tr>
<th class="px-3 py-2 text-left">异动时间</th>
<th class="px-3 py-2 text-left">类别</th>
<th class="px-3 py-2 text-left"></th>
<th class="px-3 py-2 text-left"></th>
<th class="px-3 py-2 text-left">备注</th>
<th class="px-3 py-2 text-left">操作人</th>
</tr>
</thead>
<tbody class="divide-y divide-surface">
<template x-for="log in detailEmployeeLogs" :key="'detail-log-'+log.id">
<tr>
<td class="px-3 py-2" x-text="log.transferDate"></td>
<td class="px-3 py-2" x-text="log.typeLabel"></td>
<td class="px-3 py-2" x-text="log.oldValue"></td>
<td class="px-3 py-2" x-text="log.newValue"></td>
<td class="px-3 py-2" x-text="log.remark || '-' "></td>
<td class="px-3 py-2" x-text="log.operator"></td>
</tr>
</template>
</tbody>
</table>
</div>
</template>
<template x-if="employeeDetail.tab==='account'">
<div class="space-y-4 text-sm">
<div class="border border-surface rounded p-3">
<h5 class="font-semibold mb-2">员工登录系统账号信息</h5>
<div class="grid grid-cols-2 gap-x-8 gap-y-2">
<div class="flex"><span class="w-24 text-muted">账号</span><span x-text="detailEmployee ? detailEmployee.phone : '-' "></span></div>
<div class="flex"><span class="w-24 text-muted">状态</span><span x-text="detailEmployee ? (detailEmployee.status==='frozen' ? '冻结' : '启用') : '-' "></span></div>
<div class="flex"><span class="w-24 text-muted">原登录账号</span><span x-text="detailEmployee ? `${departmentNameById(detailEmployee.orgUnitId)} ${detailEmployee.name}` : '-' "></span></div>
<div class="flex"><span class="w-24 text-muted">公众号绑定</span><span>未绑定</span></div>
</div>
</div>
<div class="border border-surface rounded p-3">
<h5 class="font-semibold mb-2">中国网络经纪人账号</h5>
<table class="min-w-full text-xs">
<thead class="bg-subtle border border-surface"><tr><th class="px-2 py-1 text-left">账号</th><th class="px-2 py-1 text-left">手机号</th><th class="px-2 py-1 text-left">实名信息是否一致</th></tr></thead>
<tbody><tr><td class="px-2 py-2">-</td><td class="px-2 py-2">-</td><td class="px-2 py-2">-</td></tr></tbody>
</table>
</div>
</div>
</template>
</section>
</div>
</div>
</div>
</div>
<!-- 异动记录汇总 Modal -->
<div x-cloak x-show="transferLogsModal.open" class="fixed inset-0 z-40">
<div class="absolute inset-0 bg-neutral-900/40" @click="closeTransferLogsModal()"></div>
<div class="absolute inset-0 flex items-center justify-center p-4 pointer-events-none">
<div class="w-full max-w-6xl bg-white rounded-xl shadow-xl pointer-events-auto flex flex-col max-h-[92vh]">
<div class="flex items-center justify-between px-5 py-4 border-b border-surface">
<h3 class="text-base font-semibold">异动记录</h3>
<button class="p-1 text-muted hover:bg-neutral-100 rounded" @click="closeTransferLogsModal()"></button>
</div>
<div class="px-5 py-4 space-y-3 overflow-y-auto">
<div class="grid grid-cols-6 gap-3">
<select x-model="transferLogsModal.filters.type" class="px-3 py-2 rounded-md border border-surface bg-white">
<option value="">请选择类型</option>
<option value="入职">入职</option>
<option value="员工调动">员工调动</option>
<option value="离职">离职</option>
</select>
<input x-model="transferLogsModal.filters.from" type="date" class="px-3 py-2 rounded-md border border-surface" />
<input x-model="transferLogsModal.filters.to" type="date" class="px-3 py-2 rounded-md border border-surface" />
<select x-model.number="transferLogsModal.filters.deptId" class="px-3 py-2 rounded-md border border-surface bg-white">
<option value="">请选择部门</option>
<template x-for="d in activeDepartmentOptions" :key="'log-dept-'+d.id"><option :value="d.id" x-text="d.name"></option></template>
</select>
<input x-model.trim="transferLogsModal.filters.keyword" type="text" placeholder="姓名/员工编号" class="px-3 py-2 rounded-md border border-surface" />
<div class="flex items-center gap-2">
<button class="px-3 py-2 rounded-md bg-primary-600 text-white hover:bg-primary-700" @click="noop()">查询</button>
<button class="px-3 py-2 rounded-md border border-surface hover:bg-neutral-50" @click="resetTransferLogFilters()">清空条件</button>
</div>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<button class="px-3 py-1.5 rounded-md border border-surface hover:bg-neutral-50" @click="notify('新增异动记录(原型)')">新增异动记录</button>
<button class="px-3 py-1.5 rounded-md border border-surface hover:bg-neutral-50" @click="notify('报表导出中,请稍候(原型)')">报表导出</button>
</div>
<div class="text-xs text-muted" x-text="`共 ${filteredTransferLogs.length} 条`"></div>
</div>
<div class="overflow-auto border border-surface rounded-lg">
<table class="min-w-full text-xs">
<thead class="bg-subtle border-b border-surface">
<tr>
<th class="px-3 py-2 text-left">当前部门</th>
<th class="px-3 py-2 text-left">员工</th>
<th class="px-3 py-2 text-left">员工编号</th>
<th class="px-3 py-2 text-left">员工状态</th>
<th class="px-3 py-2 text-left">当前职务</th>
<th class="px-3 py-2 text-left">类型</th>
<th class="px-3 py-2 text-left"></th>
<th class="px-3 py-2 text-left"></th>
<th class="px-3 py-2 text-left">备注</th>
<th class="px-3 py-2 text-left">操作人</th>
<th class="px-3 py-2 text-left">异动时间</th>
</tr>
</thead>
<tbody class="divide-y divide-surface">
<template x-for="log in filteredTransferLogs" :key="'log-row-'+log.id">
<tr>
<td class="px-3 py-2" x-text="log.department"></td>
<td class="px-3 py-2" x-text="log.staffName"></td>
<td class="px-3 py-2" x-text="log.employeeNo"></td>
<td class="px-3 py-2" x-text="log.staffStatus"></td>
<td class="px-3 py-2" x-text="log.jobTitle"></td>
<td class="px-3 py-2" x-text="log.typeLabel"></td>
<td class="px-3 py-2" x-text="log.oldValue"></td>
<td class="px-3 py-2" x-text="log.newValue"></td>
<td class="px-3 py-2" x-text="log.remark || '-' "></td>
<td class="px-3 py-2" x-text="log.operator"></td>
<td class="px-3 py-2" x-text="log.transferDate"></td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Toast -->
<div x-cloak x-show="toast.show" class="fixed right-6 top-20 z-50" x-transition>
<div class="px-3 py-2 rounded-md shadow border text-sm" :class="toast.type === 'error' ? 'bg-danger-50 border-danger-600/20 text-danger-600' : 'bg-success-50 border-success-600/20 text-success-600'" x-text="toast.message"></div>
</div>
<script>
function orgPage() {
return {
mainTab: 'org',
rootDeptId: 1,
deptTreeSearch: '',
showClosedDepartments: false,
departments: [
{ id: 1, name: '上海沪房房地产经纪事务所', parentId: null, level: 'company', attribute: '直营', isActive: true, managerId: null, phone: '' },
{ id: 2, name: '沪居地产', parentId: 1, level: 'division', attribute: '直营', isActive: true, managerId: null, phone: '' },
{ id: 3, name: '系统管理组', parentId: 1, level: 'functional', attribute: '直营', isActive: true, managerId: null, phone: '' },
{ id: 4, name: '上海大区', parentId: 2, level: 'region', attribute: '直营', isActive: true, managerId: null, phone: '' },
{ id: 5, name: '静安门店', parentId: 4, level: 'store', attribute: '直营', isActive: true, managerId: null, phone: '021-66778899' },
{ id: 6, name: '上海豪园店二组', parentId: 5, level: 'group', attribute: '直营', isActive: true, managerId: null, phone: '' },
{ id: 7, name: '浦东门店', parentId: 4, level: 'store', attribute: '直营', isActive: true, managerId: null, phone: '' },
{ id: 8, name: '历史停用门店', parentId: 4, level: 'store', attribute: '直营', isActive: false, managerId: null, phone: '' }
],
deptExpanded: {},
selectedDeptId: 2,
jobOptions: [
{ title: '高级业务员', category: '置业顾问', role: '高级业务员' },
{ title: '分行经理', category: '店管', role: '分行经理' },
{ title: '行政人员', category: '其他职能', role: '行政人员' },
{ title: '系统管理员', category: '其他职能', role: '管理员' }
],
levelOptions: [
{ value: 'division', label: '事业部' },
{ value: 'region', label: '大区' },
{ value: 'area', label: '区域' },
{ value: 'district', label: '片区' },
{ value: 'store', label: '门店' },
{ value: 'group', label: '店组' },
{ value: 'functional', label: '职能' }
],
employees: [
{ id: 101, name: '刘源', nickname: '源源', employeeNo: '41', orgUnitId: 6, supervisorId: 102, jobTitle: '高级业务员', jobCategory: '置业顾问', role: '高级业务员', status: 'active', phone: '15901850696', joinedAt: '2026-03-09', approval: '通过', idRisk: false, gender: '男', birthday: '1998-10-02', extension: '801', email: 'liuyuan@fonrey.com', isSystemAdmin: false },
{ id: 102, name: '刘文龙', nickname: '', employeeNo: '17', orgUnitId: 6, supervisorId: null, jobTitle: '分行经理', jobCategory: '店管', role: '分行经理', status: 'active', phone: '13800001234', joinedAt: '2025-07-18', approval: '通过', idRisk: false, gender: '男', birthday: '1989-07-18', extension: '802', email: 'liuwenlong@fonrey.com', isSystemAdmin: false },
{ id: 103, name: '金怡', nickname: '', employeeNo: '18', orgUnitId: 5, supervisorId: null, jobTitle: '行政人员', jobCategory: '其他职能', role: '行政人员', status: 'active', phone: '13712345678', joinedAt: '2024-05-11', approval: '通过', idRisk: true, gender: '女', birthday: '1993-10-09', extension: '803', email: 'jinyi@fonrey.com', isSystemAdmin: false },
{ id: 104, name: '孙海鹏', nickname: '', employeeNo: '56', orgUnitId: 7, supervisorId: null, jobTitle: '高级业务员', jobCategory: '置业顾问', role: '高级业务员', status: 'probation', phone: '13611112222', joinedAt: '2026-04-03', approval: '入职审', idRisk: false, gender: '男', birthday: '2000-02-17', extension: '', email: '', isSystemAdmin: false },
{ id: 105, name: '周伟', nickname: '', employeeNo: '63', orgUnitId: 6, supervisorId: 102, jobTitle: '高级业务员', jobCategory: '置业顾问', role: '高级业务员', status: 'active', phone: '13566667777', joinedAt: '2026-01-14', approval: '通过', idRisk: false, gender: '男', birthday: '1997-04-24', extension: '805', email: 'zhouwei@fonrey.com', isSystemAdmin: false },
{ id: 106, name: '杜利强', nickname: '', employeeNo: '1', orgUnitId: 3, supervisorId: null, jobTitle: '系统管理员', jobCategory: '其他职能', role: '管理员', status: 'active', phone: '13988889999', joinedAt: '2023-01-01', approval: '通过', idRisk: false, gender: '男', birthday: '1988-01-01', extension: '', email: 'admin@fonrey.com', isSystemAdmin: true }
],
employeeFilters: {
keyword: '',
jobTitle: '',
status: '',
approval: '',
includeChildren: true,
level: '',
systemAdmin: '',
joinedFrom: '',
joinedTo: ''
},
selectedEmployeeIds: [],
employeePage: 1,
employeeJumpPage: 1,
contactFilters: {
deptId: '',
jobTitle: '',
birthday: '',
keyword: ''
},
contactPage: 1,
visibleContactPhones: [],
chartFilters: {
deptId: '',
showClosed: false
},
chartExpanded: {},
deptModal: {
open: false,
mode: 'add',
form: { id: null, name: '', parentId: '', level: '', attribute: '直营', managerId: '', phone: '', address: '', isActive: true },
errors: {}
},
onboardDrawer: {
open: false,
form: { name: '', nickname: '', phone: '', orgUnitId: '', jobTitle: '', jobCategory: '', role: '', supervisorId: '', joinedAt: '', status: 'probation' },
errors: {},
createdAccount: null
},
leaveModal: {
open: false,
employee: null,
stats: { house: 0, client: 0, market: 0 },
form: { resignedAt: '', type: '', remark: '' },
errors: {}
},
transferDrawer: {
open: false,
employee: null,
stats: { house: 0 },
form: { transferDate: '', orgUnitId: '', jobTitle: '', jobCategory: '', role: '', supervisorId: '', noSupervisor: false, remark: '' },
errors: {}
},
employeeDetail: {
open: false,
employeeId: null,
tab: 'basic'
},
transferLogsModal: {
open: false,
filters: { type: '', from: '', to: '', deptId: '', keyword: '' }
},
transferLogs: [
{ id: 1, staffId: 104, staffName: '孙海鹏', employeeNo: '56', department: '浦东门店', staffStatus: '试用', jobTitle: '高级业务员', type: 'onboard', typeLabel: '入职', oldValue: '-', newValue: '浦东门店', remark: '', operator: '金怡-静安门店', transferDate: '2026-04-03' },
{ id: 2, staffId: 105, staffName: '周伟', employeeNo: '63', department: '上海豪园店二组', staffStatus: '正式', jobTitle: '高级业务员', type: 'transfer', typeLabel: '员工调动', oldValue: '静安门店', newValue: '上海豪园店二组', remark: '平调', operator: '杜利强-系统管理组', transferDate: '2026-02-12' },
{ id: 3, staffId: 103, staffName: '金怡', employeeNo: '18', department: '静安门店', staffStatus: '正式', jobTitle: '行政人员', type: 'onboard', typeLabel: '入职', oldValue: '-', newValue: '静安门店', remark: '', operator: '杜利强-系统管理组', transferDate: '2024-05-11' }
],
toast: { show: false, message: '', type: 'success' },
init() {
this.departments.forEach((d) => {
if (d.parentId === null || d.parentId === 1 || d.parentId === 2 || d.parentId === 4) {
this.deptExpanded[d.id] = true;
}
this.chartExpanded[d.id] = true;
});
this.employeeJumpPage = this.employeePage;
},
noop() {},
switchMainTab(tab) {
this.mainTab = tab;
},
levelLabel(level) {
const map = {
company: '公司',
division: '事业部',
region: '大区',
area: '区域',
district: '片区',
store: '门店',
group: '店组',
functional: '职能'
};
return map[level] || '-';
},
employeeStatusLabel(status) {
const map = { active: '正式', probation: '试用', frozen: '冻结', resigned: '离职' };
return map[status] || status;
},
maskPhone(phone) {
if (!phone) return '-';
return String(phone).replace(/^(\d{3})\d{4}(\d{2,4})$/, '$1******$2');
},
birthdayMonthDay(dateStr) {
if (!dateStr || !dateStr.includes('-')) return '-';
const parts = dateStr.split('-');
return `${parts[1]}-${parts[2]}`;
},
departmentById(id) {
return this.departments.find(d => d.id === Number(id));
},
departmentNameById(id) {
const d = this.departmentById(id);
return d ? d.name : '';
},
departmentLevelById(id) {
const d = this.departmentById(id);
return d ? d.level : '';
},
staffById(id) {
return this.employees.find(e => e.id === Number(id));
},
staffNameById(id) {
const s = this.staffById(id);
return s ? s.name : '';
},
get activeEmployees() {
return this.employees.filter(e => e.status !== 'resigned');
},
get activeDepartmentOptions() {
return this.departments.filter(d => d.id !== this.rootDeptId && (d.isActive || this.showClosedDepartments));
},
get storeAndGroupDepartments() {
return this.departments.filter(d => (d.level === 'store' || d.level === 'group') && d.isActive);
},
parentDepartmentOptions(currentId = null) {
return this.departments.filter(d => d.id !== Number(currentId) && (d.isActive || this.showClosedDepartments));
},
get selectedDepartment() {
return this.departmentById(this.selectedDeptId);
},
employeeCountByDept(id) {
return this.employees.filter(e => e.orgUnitId === id && e.status !== 'resigned').length;
},
getChildren(parentId) {
return this.departments.filter(d => d.parentId === parentId && (this.showClosedDepartments || d.isActive));
},
get deptTreeRows() {
const rows = [];
const search = this.deptTreeSearch.toLowerCase();
const travel = (id, depth) => {
const node = this.departmentById(id);
if (!node) return;
const children = this.getChildren(id);
const hasChildren = children.length > 0;
const hitSelf = !search || node.name.toLowerCase().includes(search);
const hitChild = children.some(c => c.name.toLowerCase().includes(search));
const visible = (!search || hitSelf || hitChild || depth === 0);
if (visible) {
rows.push({
id: node.id,
name: node.name,
depth,
hasChildren,
employeeCount: this.employeeCountByDept(node.id),
isActive: node.isActive
});
}
if (!hasChildren) return;
const expanded = !!this.deptExpanded[node.id] || !!search;
if (!expanded) return;
children.forEach(child => travel(child.id, depth + 1));
};
travel(this.rootDeptId, 0);
return rows;
},
toggleDeptExpand(id) {
this.deptExpanded[id] = !this.deptExpanded[id];
},
subtreeIds(rootId) {
const ids = [Number(rootId)];
const loop = (pid) => {
this.departments.filter(d => d.parentId === pid).forEach((child) => {
ids.push(child.id);
loop(child.id);
});
};
loop(Number(rootId));
return ids;
},
get employeeFilteredRows() {
const f = this.employeeFilters;
const keyword = f.keyword.toLowerCase();
const targetDeptIds = (() => {
const baseId = this.selectedDeptId;
if (!baseId) return [];
return f.includeChildren ? this.subtreeIds(baseId) : [baseId];
})();
return this.employees.filter((e) => {
if (!targetDeptIds.includes(e.orgUnitId)) return false;
if (keyword && !(`${e.name}${e.employeeNo}${e.phone}`.toLowerCase().includes(keyword))) return false;
if (f.jobTitle && e.jobTitle !== f.jobTitle) return false;
if (f.status && e.status !== f.status) return false;
if (f.approval && e.approval !== f.approval) return false;
if (f.level && this.departmentLevelById(e.orgUnitId) !== f.level) return false;
if (f.systemAdmin === 'yes' && !e.isSystemAdmin) return false;
if (f.systemAdmin === 'no' && e.isSystemAdmin) return false;
if (f.joinedFrom && e.joinedAt < f.joinedFrom) return false;
if (f.joinedTo && e.joinedAt > f.joinedTo) return false;
return true;
});
},
get employeeTotalPages() {
return Math.max(1, Math.ceil(this.employeeFilteredRows.length / 20));
},
get employeePagedRows() {
const start = (this.employeePage - 1) * 20;
return this.employeeFilteredRows.slice(start, start + 20);
},
jumpEmployeePage() {
const n = Number(this.employeeJumpPage) || 1;
this.employeePage = Math.min(this.employeeTotalPages, Math.max(1, n));
},
resetEmployeeFilters() {
this.employeeFilters = {
keyword: '',
jobTitle: '',
status: '',
approval: '',
includeChildren: true,
level: '',
systemAdmin: '',
joinedFrom: '',
joinedTo: ''
};
this.employeePage = 1;
},
isEmployeeSelected(id) {
return this.selectedEmployeeIds.includes(id);
},
toggleEmployeeSelect(id, checked) {
if (checked) {
if (!this.selectedEmployeeIds.includes(id)) this.selectedEmployeeIds.push(id);
} else {
this.selectedEmployeeIds = this.selectedEmployeeIds.filter(x => x !== id);
}
},
get employeeAllOnPageSelected() {
if (this.employeePagedRows.length === 0) return false;
return this.employeePagedRows.every(r => this.selectedEmployeeIds.includes(r.id));
},
toggleEmployeePageSelect(checked) {
const ids = this.employeePagedRows.map(r => r.id);
if (checked) {
this.selectedEmployeeIds = [...new Set([...this.selectedEmployeeIds, ...ids])];
} else {
this.selectedEmployeeIds = this.selectedEmployeeIds.filter(id => !ids.includes(id));
}
},
get selectedEmployeeCount() {
return this.selectedEmployeeIds.length;
},
openDeptModal(mode, deptId = null) {
this.deptModal.open = true;
this.deptModal.mode = mode;
this.deptModal.errors = {};
if (mode === 'edit' && deptId) {
const d = this.departmentById(deptId);
if (!d) return;
this.deptModal.form = {
id: d.id,
name: d.name,
parentId: d.parentId,
level: d.level,
attribute: d.attribute || '直营',
managerId: d.managerId || '',
phone: d.phone || '',
address: d.address || '',
isActive: d.isActive
};
} else {
this.deptModal.form = {
id: null,
name: '',
parentId: Number(deptId) || this.selectedDeptId || this.rootDeptId,
level: '',
attribute: '直营',
managerId: '',
phone: '',
address: '',
isActive: true
};
}
},
closeDeptModal() {
this.deptModal.open = false;
this.deptModal.errors = {};
},
saveDepartment() {
const f = this.deptModal.form;
this.deptModal.errors = {};
if (!f.name) this.deptModal.errors.name = '请输入部门名称';
if (!f.parentId && f.parentId !== 0) this.deptModal.errors.parentId = '请选择上级部门';
if (!f.level) this.deptModal.errors.level = '请选择部门级别';
const parent = this.departmentById(f.parentId);
if (f.level === 'group' && parent && parent.level !== 'store') {
this.deptModal.errors.rule = '店组级别部门必须挂在门店下';
}
if (Object.keys(this.deptModal.errors).length > 0) return;
if (this.deptModal.mode === 'add') {
const nextId = Math.max(...this.departments.map(d => d.id)) + 1;
this.departments.push({
id: nextId,
name: f.name,
parentId: Number(f.parentId),
level: f.level,
attribute: f.attribute,
isActive: f.isActive,
managerId: Number(f.managerId) || null,
phone: f.phone || '',
address: f.address || ''
});
this.deptExpanded[Number(f.parentId)] = true;
this.selectedDeptId = nextId;
this.notify('新增部门成功(原型)');
} else {
this.departments = this.departments.map((d) => d.id === f.id ? {
...d,
name: f.name,
parentId: Number(f.parentId),
level: f.level,
attribute: f.attribute,
isActive: f.isActive,
managerId: Number(f.managerId) || null,
phone: f.phone || '',
address: f.address || ''
} : d);
this.notify('编辑部门成功(原型)');
}
this.closeDeptModal();
},
deleteDepartment(deptId) {
const ids = this.subtreeIds(deptId);
const hasEmployee = this.employees.some(e => ids.includes(e.orgUnitId) && e.status !== 'resigned');
if (hasEmployee) {
this.notify('该部门存在员工,无法删除', 'error');
return;
}
this.departments = this.departments.filter(d => !ids.includes(d.id));
if (!this.departmentById(this.selectedDeptId)) this.selectedDeptId = this.rootDeptId;
this.notify('部门删除成功(原型)');
},
openOnboardDrawer() {
this.onboardDrawer.open = true;
this.onboardDrawer.errors = {};
this.onboardDrawer.createdAccount = null;
this.onboardDrawer.form = {
name: '',
nickname: '',
phone: '',
orgUnitId: '',
jobTitle: '',
jobCategory: '',
role: '',
supervisorId: '',
joinedAt: new Date().toISOString().slice(0, 10),
status: 'probation'
};
},
closeOnboardDrawer() {
this.onboardDrawer.open = false;
this.onboardDrawer.errors = {};
},
syncOnboardJobMeta() {
const found = this.jobOptions.find(j => j.title === this.onboardDrawer.form.jobTitle);
this.onboardDrawer.form.jobCategory = found ? found.category : '';
this.onboardDrawer.form.role = found ? found.role : '';
},
randomPassword() {
return Math.random().toString(36).slice(-8);
},
submitOnboard() {
const f = this.onboardDrawer.form;
this.onboardDrawer.errors = {};
if (!f.name) this.onboardDrawer.errors.name = '请输入姓名';
if (!f.phone) this.onboardDrawer.errors.phone = '请输入手机号';
if (f.phone && !/^1\d{10}$/.test(f.phone)) this.onboardDrawer.errors.phone = '手机号格式不正确';
if (!f.orgUnitId) this.onboardDrawer.errors.orgUnitId = '请选择所属门店/店组';
if (!f.jobTitle) this.onboardDrawer.errors.jobTitle = '请选择职务';
const selectedDept = this.departmentById(f.orgUnitId);
if (selectedDept && !(selectedDept.level === 'store' || selectedDept.level === 'group')) {
this.onboardDrawer.errors.rule = '经纪人/店管所属部门只能是门店/店组';
}
if ((f.jobCategory === '置业顾问' || f.jobCategory === '店管') && selectedDept && !(selectedDept.level === 'store' || selectedDept.level === 'group')) {
this.onboardDrawer.errors.rule = '当前职务类别仅可归属门店/店组';
}
if (Object.keys(this.onboardDrawer.errors).length > 0) return;
const nextId = Math.max(...this.employees.map(e => e.id)) + 1;
const nextNo = String(Math.max(...this.employees.map(e => Number(e.employeeNo))) + 1);
this.employees.unshift({
id: nextId,
name: f.name,
nickname: f.nickname,
employeeNo: nextNo,
orgUnitId: Number(f.orgUnitId),
supervisorId: Number(f.supervisorId) || null,
jobTitle: f.jobTitle,
jobCategory: f.jobCategory,
role: f.role,
status: f.status,
phone: f.phone,
joinedAt: f.joinedAt,
approval: '入职审',
idRisk: false,
gender: '-',
birthday: '',
extension: '',
email: '',
isSystemAdmin: false
});
const pwd = this.randomPassword();
this.onboardDrawer.createdAccount = { username: f.phone, password: pwd };
this.transferLogs.unshift({
id: Math.max(...this.transferLogs.map(l => l.id)) + 1,
staffId: nextId,
staffName: f.name,
employeeNo: nextNo,
department: this.departmentNameById(f.orgUnitId),
staffStatus: this.employeeStatusLabel(f.status),
jobTitle: f.jobTitle,
type: 'onboard',
typeLabel: '入职',
oldValue: '-',
newValue: this.departmentNameById(f.orgUnitId),
remark: '',
operator: '杜利强-系统管理组',
transferDate: f.joinedAt
});
this.notify('入职办理成功,账号已创建(原型)');
this.employeePage = 1;
},
openLeaveModal(employeeId) {
const emp = this.staffById(employeeId);
if (!emp) return;
this.leaveModal.open = true;
this.leaveModal.employee = emp;
this.leaveModal.errors = {};
this.leaveModal.form = { resignedAt: '', type: '', remark: '' };
this.leaveModal.stats = {
house: emp.id === 104 ? 564 : (emp.id === 105 ? 13 : 21),
client: emp.id === 104 ? 21 : 9,
market: emp.id === 104 ? 3 : 1
};
},
closeLeaveModal() {
this.leaveModal.open = false;
this.leaveModal.errors = {};
},
submitLeave() {
const f = this.leaveModal.form;
this.leaveModal.errors = {};
if (!f.resignedAt) this.leaveModal.errors.resignedAt = '请选择离职日期';
if (!f.type) this.leaveModal.errors.type = '请选择离职类型';
if (Object.keys(this.leaveModal.errors).length > 0) return;
const emp = this.leaveModal.employee;
this.employees = this.employees.map(e => e.id === emp.id ? { ...e, status: 'resigned', approval: '通过', resignedAt: f.resignedAt } : e);
this.transferLogs.unshift({
id: Math.max(...this.transferLogs.map(l => l.id)) + 1,
staffId: emp.id,
staffName: emp.name,
employeeNo: emp.employeeNo,
department: this.departmentNameById(emp.orgUnitId),
staffStatus: '离职',
jobTitle: emp.jobTitle,
type: 'resign',
typeLabel: '离职',
oldValue: this.departmentNameById(emp.orgUnitId),
newValue: '离职',
remark: `${f.type}${f.remark ? '' + f.remark : ''}`,
operator: '杜利强-系统管理组',
transferDate: f.resignedAt
});
this.closeLeaveModal();
this.notify('离职办理成功(原型)');
},
openTransferDrawer(employeeId) {
const emp = this.staffById(employeeId);
if (!emp) return;
this.transferDrawer.open = true;
this.transferDrawer.employee = emp;
this.transferDrawer.errors = {};
this.transferDrawer.stats = { house: emp.id === 105 ? 13 : 9 };
this.transferDrawer.form = {
transferDate: new Date().toISOString().slice(0, 10),
orgUnitId: emp.orgUnitId,
jobTitle: emp.jobTitle,
jobCategory: emp.jobCategory,
role: emp.role,
supervisorId: emp.supervisorId || '',
noSupervisor: !emp.supervisorId,
remark: ''
};
},
openTransferDrawerByBatch() {
if (this.selectedEmployeeIds.length === 0) return;
this.openTransferDrawer(this.selectedEmployeeIds[0]);
},
closeTransferDrawer() {
this.transferDrawer.open = false;
this.transferDrawer.errors = {};
},
syncTransferJobMeta() {
const found = this.jobOptions.find(j => j.title === this.transferDrawer.form.jobTitle);
this.transferDrawer.form.jobCategory = found ? found.category : '';
if (found && !this.transferDrawer.form.role) this.transferDrawer.form.role = found.role;
},
get transferCategoryText() {
const emp = this.transferDrawer.employee;
if (!emp) return '-';
const before = this.departmentLevelById(emp.orgUnitId);
const after = this.departmentLevelById(this.transferDrawer.form.orgUnitId);
const order = ['division', 'region', 'area', 'district', 'store', 'group'];
const b = order.indexOf(before);
const a = order.indexOf(after);
if (a === -1 || b === -1 || a === b) return '平调';
return a > b ? '降职' : '晋升';
},
submitTransfer() {
const f = this.transferDrawer.form;
const emp = this.transferDrawer.employee;
this.transferDrawer.errors = {};
if (!f.transferDate) this.transferDrawer.errors.transferDate = '请选择调动日期';
if (!f.orgUnitId) this.transferDrawer.errors.orgUnitId = '请选择调动后部门';
if (!f.jobTitle) this.transferDrawer.errors.jobTitle = '请选择调动后职务';
if (!f.role) this.transferDrawer.errors.role = '请输入角色';
if (!f.noSupervisor && !f.supervisorId) this.transferDrawer.errors.supervisorId = '请选择直属上级或勾选无直属上级';
if (Object.keys(this.transferDrawer.errors).length > 0) return;
const oldDept = this.departmentNameById(emp.orgUnitId);
const newDept = this.departmentNameById(f.orgUnitId);
this.employees = this.employees.map((e) => e.id === emp.id ? {
...e,
orgUnitId: Number(f.orgUnitId),
jobTitle: f.jobTitle,
jobCategory: f.jobCategory,
role: f.role,
supervisorId: f.noSupervisor ? null : Number(f.supervisorId) || null
} : e);
this.transferLogs.unshift({
id: Math.max(...this.transferLogs.map(l => l.id)) + 1,
staffId: emp.id,
staffName: emp.name,
employeeNo: emp.employeeNo,
department: newDept,
staffStatus: this.employeeStatusLabel(emp.status),
jobTitle: f.jobTitle,
type: 'transfer',
typeLabel: '员工调动',
oldValue: oldDept,
newValue: newDept,
remark: f.remark || this.transferCategoryText,
operator: '杜利强-系统管理组',
transferDate: f.transferDate
});
this.closeTransferDrawer();
this.notify('员工调动成功(原型)');
},
openEmployeeDetail(employeeId) {
this.employeeDetail.open = true;
this.employeeDetail.employeeId = employeeId;
this.employeeDetail.tab = 'basic';
},
closeEmployeeDetail() {
this.employeeDetail.open = false;
},
get detailEmployee() {
return this.staffById(this.employeeDetail.employeeId);
},
get detailEmployeeLogs() {
const emp = this.detailEmployee;
if (!emp) return [];
return this.transferLogs.filter(l => l.staffId === emp.id);
},
openTransferLogsModal() {
this.transferLogsModal.open = true;
},
closeTransferLogsModal() {
this.transferLogsModal.open = false;
},
resetTransferLogFilters() {
this.transferLogsModal.filters = { type: '', from: '', to: '', deptId: '', keyword: '' };
},
get filteredTransferLogs() {
const f = this.transferLogsModal.filters;
const kw = (f.keyword || '').toLowerCase();
return this.transferLogs.filter((l) => {
if (f.type && l.typeLabel !== f.type) return false;
if (f.from && l.transferDate < f.from) return false;
if (f.to && l.transferDate > f.to) return false;
if (f.deptId && this.departmentNameById(f.deptId) !== l.department) return false;
if (kw && !(`${l.staffName}${l.employeeNo}`.toLowerCase().includes(kw))) return false;
return true;
});
},
get chartRootNode() {
const id = Number(this.chartFilters.deptId) || this.rootDeptId;
return this.departmentById(id) || this.departmentById(this.rootDeptId);
},
chartChildren(parentId) {
return this.departments.filter(d => d.parentId === parentId && (this.chartFilters.showClosed || d.isActive));
},
chartNodeEmployeeCount(id) {
return this.employees.filter(e => e.orgUnitId === id && e.status !== 'resigned').length;
},
isChartExpanded(id) {
return !!this.chartExpanded[id];
},
toggleChartExpand(id) {
this.chartExpanded[id] = !this.chartExpanded[id];
},
get contactFilteredRows() {
const f = this.contactFilters;
const kw = f.keyword.toLowerCase();
return this.employees.filter((e) => {
if (e.status === 'resigned') return false;
if (f.deptId && e.orgUnitId !== Number(f.deptId)) return false;
if (f.jobTitle && e.jobTitle !== f.jobTitle) return false;
if (f.birthday === 'thisMonth' && (!e.birthday || e.birthday.slice(5, 7) !== new Date().toISOString().slice(5, 7))) return false;
if (kw && !(`${e.name}${e.phone}${e.extension || ''}${e.email || ''}`.toLowerCase().includes(kw))) return false;
return true;
});
},
get contactTotalPages() {
return Math.max(1, Math.ceil(this.contactFilteredRows.length / 20));
},
get contactPagedRows() {
const start = (this.contactPage - 1) * 20;
return this.contactFilteredRows.slice(start, start + 20);
},
resetContactFilters() {
this.contactFilters = { deptId: '', jobTitle: '', birthday: '', keyword: '' };
this.contactPage = 1;
},
isPhoneVisible(id) {
return this.visibleContactPhones.includes(id);
},
togglePhoneVisible(id) {
if (this.visibleContactPhones.includes(id)) {
this.visibleContactPhones = this.visibleContactPhones.filter(x => x !== id);
} else {
this.visibleContactPhones.push(id);
}
},
closeAllPanels() {
this.closeDeptModal();
this.closeOnboardDrawer();
this.closeLeaveModal();
this.closeTransferDrawer();
this.closeEmployeeDetail();
this.closeTransferLogsModal();
},
notify(message, type = 'success') {
this.toast = { show: true, message, type };
setTimeout(() => {
this.toast.show = false;
}, 1800);
}
};
}
</script>
</body>
</html>