feat: 亮灯表重构 — 点击卡片弹窗替代跳转周报
后端 light_board.py: - 每张客户卡片返回 plans[] (活跃工作计划) + recent_visits[] (最近2条) - 计划过期检测 (plan_overdue) 前端 LightBoard.vue: - 点击卡片 → el-dialog 弹窗 (不再跳转周报) - 绿灯: 显示最近拜访记录 - 黄/红灯: 客户信息 + 快速制定拜访计划表单 - 卡片显示计划标签 (📅正常 / ⚠过期闪烁) - 弹窗底部保留「查看周报记录」链接 计划过期逻辑: plan_date < 当月首日 且 status=计划中 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,15 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { dashboardApi } from '@/api/dashboard'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import api from '@/api/index'
|
||||
import { todayStr } from '@/utils'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const loading = ref(false)
|
||||
const board = ref<any>(null)
|
||||
const expandedManagers = ref<Set<string>>(new Set())
|
||||
const monthOffset = ref(0)
|
||||
|
||||
// ── Dialog state ──
|
||||
const dialogVisible = ref(false)
|
||||
const selectedCustomer = ref<any>(null)
|
||||
const planDate = ref(todayStr())
|
||||
const planContent = ref('')
|
||||
const planSaving = ref(false)
|
||||
|
||||
const referenceMonth = computed(() => {
|
||||
const d = new Date()
|
||||
d.setMonth(d.getMonth() + monthOffset.value)
|
||||
@@ -18,22 +29,12 @@ const referenceMonth = computed(() => {
|
||||
|
||||
const isCurrentMonth = computed(() => monthOffset.value >= 0)
|
||||
|
||||
function changeMonth(delta: number) {
|
||||
monthOffset.value += delta
|
||||
loadData()
|
||||
}
|
||||
|
||||
function goCurrentMonth() {
|
||||
monthOffset.value = 0
|
||||
loadData()
|
||||
}
|
||||
function changeMonth(delta: number) { monthOffset.value += delta; loadData() }
|
||||
function goCurrentMonth() { monthOffset.value = 0; loadData() }
|
||||
|
||||
function toggleManager(id: string) {
|
||||
if (expandedManagers.value.has(id)) {
|
||||
expandedManagers.value.delete(id)
|
||||
} else {
|
||||
expandedManagers.value.add(id)
|
||||
}
|
||||
if (expandedManagers.value.has(id)) expandedManagers.value.delete(id)
|
||||
else expandedManagers.value.add(id)
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
@@ -45,17 +46,11 @@ async function loadData() {
|
||||
board.value = res.data
|
||||
} catch (e: any) {
|
||||
ElMessage.error('加载亮灯表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
|
||||
function goCustomerVisits(customerId: string) {
|
||||
router.push({ path: '/weekly-report', query: { customer_id: customerId } })
|
||||
}
|
||||
|
||||
function goManagerReport(managerId: string) {
|
||||
router.push({ path: '/weekly-report', query: { manager_id: managerId } })
|
||||
}
|
||||
@@ -65,6 +60,49 @@ const coverageColor = (rate: number): string => {
|
||||
if (rate >= 0.5) return 'var(--gold)'
|
||||
return 'var(--vermilion)'
|
||||
}
|
||||
|
||||
// ── Customer Dialog ──
|
||||
function openCustomerDialog(cust: any) {
|
||||
selectedCustomer.value = cust
|
||||
planContent.value = ''
|
||||
// Default plan date: today for red, Friday for yellow
|
||||
if (cust.status === 'red') {
|
||||
planDate.value = todayStr()
|
||||
} else if (cust.status === 'yellow') {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() + ((5 - d.getDay()) % 7 + 7) % 7 || 7)
|
||||
planDate.value = d.toISOString().slice(0, 10)
|
||||
} else {
|
||||
planDate.value = todayStr()
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleCreatePlan() {
|
||||
if (!planContent.value.trim()) { ElMessage.warning('请输入计划内容'); return }
|
||||
planSaving.value = true
|
||||
try {
|
||||
await api.post('/work-plans/', {
|
||||
customer_id: selectedCustomer.value.customer_id,
|
||||
plan_content: planContent.value.trim(),
|
||||
plan_date: planDate.value,
|
||||
status: '计划中',
|
||||
})
|
||||
ElMessage.success('拜访计划已制定')
|
||||
planContent.value = ''
|
||||
dialogVisible.value = false
|
||||
await loadData() // Refresh to show new plan on card
|
||||
} catch (e: any) {
|
||||
ElMessage.error('制定失败: ' + (e.response?.data?.detail || e.message))
|
||||
} finally { planSaving.value = false }
|
||||
}
|
||||
|
||||
function goWeeklyReport(customerId: string) {
|
||||
router.push({ path: '/weekly-report', query: { customer_id: customerId } })
|
||||
}
|
||||
|
||||
// Status label map
|
||||
const statusLabel: Record<string, string> = { green: '本月已拜访', yellow: '上月已拜访 (临期)', red: '急需拜访', gray: '未分配' }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -92,41 +130,17 @@ const coverageColor = (rate: number): string => {
|
||||
|
||||
<!-- ═══ Team Summary Bar ═══ -->
|
||||
<div v-if="board" class="team-bar">
|
||||
<div class="team-stat">
|
||||
<span class="team-stat-num">{{ board.team_summary.total_customers }}</span>
|
||||
<span class="team-stat-label">总客户</span>
|
||||
</div>
|
||||
<div class="team-stat team-stat--green">
|
||||
<span class="team-stat-num">{{ board.team_summary.visited_this_month }}</span>
|
||||
<span class="team-stat-label">已拜访</span>
|
||||
</div>
|
||||
<div class="team-stat team-stat--yellow">
|
||||
<span class="team-stat-num">{{ board.team_summary.visited_last_month_only }}</span>
|
||||
<span class="team-stat-label">临期</span>
|
||||
</div>
|
||||
<div class="team-stat team-stat--red">
|
||||
<span class="team-stat-num">{{ board.team_summary.not_visited_2months }}</span>
|
||||
<span class="team-stat-label">未拜访</span>
|
||||
</div>
|
||||
<div class="team-stat" v-if="board.team_summary.unassigned">
|
||||
<span class="team-stat-num">{{ board.team_summary.unassigned }}</span>
|
||||
<span class="team-stat-label">未分配</span>
|
||||
</div>
|
||||
<div class="team-stat team-stat--coverage">
|
||||
<span class="team-stat-num">{{ (board.team_summary.coverage_rate * 100).toFixed(0) }}%</span>
|
||||
<span class="team-stat-label">覆盖率</span>
|
||||
</div>
|
||||
<div class="team-stat"><span class="team-stat-num">{{ board.team_summary.total_customers }}</span><span class="team-stat-label">总客户</span></div>
|
||||
<div class="team-stat team-stat--green"><span class="team-stat-num">{{ board.team_summary.visited_this_month }}</span><span class="team-stat-label">已拜访</span></div>
|
||||
<div class="team-stat team-stat--yellow"><span class="team-stat-num">{{ board.team_summary.visited_last_month_only }}</span><span class="team-stat-label">临期</span></div>
|
||||
<div class="team-stat team-stat--red"><span class="team-stat-num">{{ board.team_summary.not_visited_2months }}</span><span class="team-stat-label">未拜访</span></div>
|
||||
<div class="team-stat" v-if="board.team_summary.unassigned"><span class="team-stat-num">{{ board.team_summary.unassigned }}</span><span class="team-stat-label">未分配</span></div>
|
||||
<div class="team-stat team-stat--coverage"><span class="team-stat-num">{{ (board.team_summary.coverage_rate * 100).toFixed(0) }}%</span><span class="team-stat-label">覆盖率</span></div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ Manager Rows ═══ -->
|
||||
<div v-if="board" class="manager-list">
|
||||
<div
|
||||
v-for="m in board.managers"
|
||||
:key="m.manager_id"
|
||||
class="manager-row"
|
||||
:class="{ 'manager-row--expanded': expandedManagers.has(m.manager_id) }"
|
||||
>
|
||||
<!-- Summary Row (always visible) -->
|
||||
<div v-for="m in board.managers" :key="m.manager_id" class="manager-row" :class="{ 'manager-row--expanded': expandedManagers.has(m.manager_id) }">
|
||||
<div class="manager-summary" @click="toggleManager(m.manager_id)">
|
||||
<div class="manager-info">
|
||||
<span class="manager-expand">{{ expandedManagers.has(m.manager_id) ? '▼' : '▶' }}</span>
|
||||
@@ -134,32 +148,18 @@ const coverageColor = (rate: number): string => {
|
||||
<span class="manager-count">{{ m.total_customers }} 个客户</span>
|
||||
</div>
|
||||
<div class="manager-lights">
|
||||
<span class="light-dot light-dot--green" :title="`已拜访 ${m.visited_this_month}`">{{ m.visited_this_month }}</span>
|
||||
<span class="light-dot light-dot--yellow" :title="`临期 ${m.visited_last_month_only}`">{{ m.visited_last_month_only }}</span>
|
||||
<span class="light-dot light-dot--red" :title="`未拜访 ${m.not_visited_2months}`">{{ m.not_visited_2months }}</span>
|
||||
<span class="light-dot light-dot--green">{{ m.visited_this_month }}</span>
|
||||
<span class="light-dot light-dot--yellow">{{ m.visited_last_month_only }}</span>
|
||||
<span class="light-dot light-dot--red">{{ m.not_visited_2months }}</span>
|
||||
</div>
|
||||
<div class="manager-coverage">
|
||||
<div class="coverage-bar">
|
||||
<div class="coverage-fill" :style="{
|
||||
width: (m.coverage_rate * 100) + '%',
|
||||
background: coverageColor(m.coverage_rate),
|
||||
}"></div>
|
||||
</div>
|
||||
<span class="coverage-pct" :style="{ color: coverageColor(m.coverage_rate) }">
|
||||
{{ (m.coverage_rate * 100).toFixed(0) }}%
|
||||
</span>
|
||||
<div class="coverage-bar"><div class="coverage-fill" :style="{ width: (m.coverage_rate * 100) + '%', background: coverageColor(m.coverage_rate) }"></div></div>
|
||||
<span class="coverage-pct" :style="{ color: coverageColor(m.coverage_rate) }">{{ (m.coverage_rate * 100).toFixed(0) }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Customer Cards (expanded) -->
|
||||
<div v-if="expandedManagers.has(m.manager_id)" class="customer-grid">
|
||||
<div
|
||||
v-for="cust in m.customers"
|
||||
:key="cust.customer_id"
|
||||
class="customer-card"
|
||||
:class="`customer-card--${cust.status}`"
|
||||
@click="goCustomerVisits(cust.customer_id)"
|
||||
>
|
||||
<div v-for="cust in m.customers" :key="cust.customer_id" class="customer-card" :class="`customer-card--${cust.status}`" @click="openCustomerDialog(cust)">
|
||||
<div class="card-status-stripe"></div>
|
||||
<div class="card-body">
|
||||
<div class="card-name-row">
|
||||
@@ -171,12 +171,16 @@ const coverageColor = (rate: number): string => {
|
||||
<span v-if="cust.in_use_services" class="card-services">{{ cust.in_use_services }}</span>
|
||||
</div>
|
||||
<div class="card-visit-info">
|
||||
<span class="card-last-visit" v-if="cust.last_visit_date">
|
||||
{{ { green: '最近', yellow: '上月', red: '上次' }[cust.status] }}:{{ cust.last_visit_date }}
|
||||
</span>
|
||||
<span class="card-last-visit" v-if="cust.last_visit_date">{{ { green: '最近', yellow: '上月', red: '上次' }[cust.status] }}:{{ cust.last_visit_date }}</span>
|
||||
<span class="card-last-visit card-last-visit--never" v-else>从未拜访</span>
|
||||
<span v-if="cust.last_visit_method" class="card-method">{{ cust.last_visit_method }}</span>
|
||||
</div>
|
||||
<!-- Plan badges -->
|
||||
<div v-if="cust.plans?.length" class="card-plans">
|
||||
<span v-for="p in cust.plans" :key="p.plan_date" class="plan-badge" :class="{ 'plan-overdue': p.plan_overdue }">
|
||||
{{ p.plan_overdue ? '⚠ 过期' : '📅' }} {{ p.plan_date }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="cust.status === 'red' && cust.consecutive_missed_months > 0" class="card-warning">
|
||||
⚠ 连续 {{ cust.consecutive_missed_months }} 个月未拜访
|
||||
</div>
|
||||
@@ -196,25 +200,12 @@ const coverageColor = (rate: number): string => {
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="expandedManagers.has('unassigned')" class="customer-grid">
|
||||
<div
|
||||
v-for="cust in board.unassigned_customers"
|
||||
:key="cust.customer_id"
|
||||
class="customer-card customer-card--gray"
|
||||
@click="router.push('/customers')"
|
||||
>
|
||||
<div v-for="cust in board.unassigned_customers" :key="cust.customer_id" class="customer-card customer-card--gray" @click="router.push('/customers')">
|
||||
<div class="card-status-stripe"></div>
|
||||
<div class="card-body">
|
||||
<div class="card-name-row">
|
||||
<span class="status-circle status-circle--gray"></span>
|
||||
<strong class="card-name">{{ cust.customer_name }}</strong>
|
||||
</div>
|
||||
<div class="card-meta">
|
||||
<span v-if="cust.industry" class="card-industry">{{ cust.industry }}</span>
|
||||
<span v-if="cust.in_use_services" class="card-services">{{ cust.in_use_services }}</span>
|
||||
</div>
|
||||
<div class="card-visit-info">
|
||||
<span class="card-last-visit card-last-visit--never">未分配客户经理</span>
|
||||
</div>
|
||||
<div class="card-name-row"><span class="status-circle status-circle--gray"></span><strong class="card-name">{{ cust.customer_name }}</strong></div>
|
||||
<div class="card-meta"><span v-if="cust.industry" class="card-industry">{{ cust.industry }}</span></div>
|
||||
<div class="card-visit-info"><span class="card-last-visit card-last-visit--never">未分配客户经理</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -222,9 +213,63 @@ const coverageColor = (rate: number): string => {
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="!loading && !board" class="empty-state">
|
||||
<p class="empty-text">暂无数据</p>
|
||||
</div>
|
||||
<div v-if="!loading && !board" class="empty-state"><p class="empty-text">暂无数据</p></div>
|
||||
|
||||
<!-- ═══ Customer Detail Dialog ═══ -->
|
||||
<el-dialog v-model="dialogVisible" :title="selectedCustomer?.customer_name" width="520px" v-if="selectedCustomer">
|
||||
<!-- Status header -->
|
||||
<div class="dlg-status" :class="`dlg-status--${selectedCustomer.status}`">
|
||||
{{ statusLabel[selectedCustomer.status] }}
|
||||
<span v-if="selectedCustomer.consecutive_missed_months > 1" style="margin-left:6px">
|
||||
(连续 {{ selectedCustomer.consecutive_missed_months }} 个月)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Customer Info -->
|
||||
<div class="dlg-info">
|
||||
<div class="dlg-info-row" v-if="selectedCustomer.industry"><span class="dlg-label">行业</span><span>{{ selectedCustomer.industry }}</span></div>
|
||||
<div class="dlg-info-row" v-if="selectedCustomer.in_use_services"><span class="dlg-label">在用业务</span><span>{{ selectedCustomer.in_use_services }}</span></div>
|
||||
<div class="dlg-info-row" v-if="selectedCustomer.monthly_fee"><span class="dlg-label">月费</span><span>{{ selectedCustomer.monthly_fee }}</span></div>
|
||||
<div class="dlg-info-row"><span class="dlg-label">上次拜访</span><span>{{ selectedCustomer.last_visit_date || '从未' }}<span v-if="selectedCustomer.last_visit_method"> · {{ selectedCustomer.last_visit_method }}</span></span></div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Visits (green/yellow) -->
|
||||
<div v-if="selectedCustomer.recent_visits?.length" class="dlg-section">
|
||||
<h4 class="dlg-section-title">最近拜访记录</h4>
|
||||
<div v-for="v in selectedCustomer.recent_visits" :key="v.visit_date" class="dlg-visit-item">
|
||||
<span class="dlg-vdate">{{ v.visit_date }}</span>
|
||||
<span class="dlg-vmethod">{{ v.visit_method }}</span>
|
||||
<span class="dlg-vcontent">{{ v.content }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Existing Plans -->
|
||||
<div v-if="selectedCustomer.plans?.length" class="dlg-section">
|
||||
<h4 class="dlg-section-title">已有计划</h4>
|
||||
<div v-for="p in selectedCustomer.plans" :key="p.plan_date" class="dlg-plan-item" :class="{ 'dlg-plan-overdue': p.plan_overdue }">
|
||||
<span>📅 {{ p.plan_date }}</span>
|
||||
<span>{{ p.plan_content }}</span>
|
||||
<el-tag v-if="p.plan_overdue" size="small" type="danger">已过期</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Plan Form (yellow/red) -->
|
||||
<div v-if="selectedCustomer.status !== 'green' && selectedCustomer.status !== 'gray'" class="dlg-section dlg-plan-form">
|
||||
<h4 class="dlg-section-title">{{ selectedCustomer.status === 'red' ? '⚡ 快速制定拜访计划' : '📋 制定拜访计划' }}</h4>
|
||||
<div class="plan-form-row">
|
||||
<el-date-picker v-model="planDate" type="date" style="width:150px" value-format="YYYY-MM-DD" />
|
||||
<el-input v-model="planContent" placeholder="计划内容,如:上门拜访了解云业务需求" style="flex:1" :disabled="planSaving" />
|
||||
<el-button type="primary" :loading="planSaving" @click="handleCreatePlan">制定</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Link to weekly report -->
|
||||
<div class="dlg-section" style="text-align:center;padding-top:8px">
|
||||
<el-link type="primary" :underline="false" @click="goWeeklyReport(selectedCustomer.customer_id); dialogVisible=false">
|
||||
🔗 查看该客户周报记录
|
||||
</el-link>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -232,252 +277,103 @@ const coverageColor = (rate: number): string => {
|
||||
/* ═══ Page Header ═══ */
|
||||
.page-head { margin-bottom: 20px; }
|
||||
.page-head-row { display: flex; justify-content: space-between; align-items: flex-start; }
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-family: 'ZCOOL XiaoWei', STSong, serif;
|
||||
font-size: 22px; font-weight: 400;
|
||||
color: var(--ink); letter-spacing: 0.06em;
|
||||
}
|
||||
.page-title { margin: 0; font-family: 'ZCOOL XiaoWei', STSong, serif; font-size: 22px; font-weight: 400; color: var(--ink); letter-spacing: 0.06em; }
|
||||
.month-nav { display: flex; align-items: center; gap: 8px; margin: 4px 0 6px; }
|
||||
.month-nav-btn { background: var(--surface); border: 1px solid var(--warm-border); padding: 3px 8px; cursor: pointer; color: var(--warm-gray); font-size: 12px; }
|
||||
.month-nav-btn:hover:not(:disabled) { color: var(--ink); border-color: var(--ink); }
|
||||
.month-nav-btn:disabled { opacity: 0.3; cursor: not-allowed; }
|
||||
.month-label { font-family: 'JetBrains Mono', 'SF Mono', monospace; font-size: 14px; color: var(--ink); letter-spacing: 0.04em; }
|
||||
.month-label { font-family: 'JetBrains Mono', monospace; font-size: 14px; color: var(--ink); letter-spacing: 0.04em; }
|
||||
.month-nav-reset { background: none; border: 1px solid var(--gold); color: var(--gold); padding: 3px 8px; cursor: pointer; font-size: 12px; }
|
||||
.month-nav-reset:hover { background: var(--gold); color: #fff; }
|
||||
.page-rule { width: 32px; height: 3px; background: var(--gold); margin-top: 12px; }
|
||||
|
||||
/* ═══ Team Summary Bar ═══ */
|
||||
.team-bar {
|
||||
display: flex; gap: 0;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--warm-border);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.team-stat {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 16px 8px;
|
||||
border-right: 1px solid var(--warm-border);
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
}
|
||||
/* ═══ Team Bar ═══ */
|
||||
.team-bar { display: flex; gap: 0; background: var(--surface); border: 1px solid var(--warm-border); margin-bottom: 20px; }
|
||||
.team-stat { flex: 1; text-align: center; padding: 16px 8px; border-right: 1px solid var(--warm-border); display: flex; flex-direction: column; gap: 4px; }
|
||||
.team-stat:last-child { border-right: none; }
|
||||
.team-stat-num {
|
||||
font-family: 'ZCOOL XiaoWei', STSong, serif;
|
||||
font-size: 26px; color: var(--ink); line-height: 1.1;
|
||||
}
|
||||
.team-stat-label {
|
||||
font-family: 'Noto Serif SC', STSong, serif;
|
||||
font-size: 11px; color: var(--warm-gray); letter-spacing: 0.04em;
|
||||
}
|
||||
.team-stat-num { font-family: 'ZCOOL XiaoWei', STSong, serif; font-size: 26px; color: var(--ink); line-height: 1.1; }
|
||||
.team-stat-label { font-family: 'Noto Serif SC', STSong, serif; font-size: 11px; color: var(--warm-gray); letter-spacing: 0.04em; }
|
||||
.team-stat--green .team-stat-num { color: var(--sage); }
|
||||
.team-stat--yellow .team-stat-num { color: var(--gold); }
|
||||
.team-stat--red .team-stat-num { color: var(--vermilion); }
|
||||
.team-stat--coverage .team-stat-num { color: var(--ink); }
|
||||
|
||||
/* ═══ Manager Rows ═══ */
|
||||
.manager-list {
|
||||
display: flex; flex-direction: column; gap: 8px;
|
||||
}
|
||||
.manager-row {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--warm-border);
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.manager-row--expanded {
|
||||
border-color: var(--ink);
|
||||
}
|
||||
|
||||
/* ═══ Manager Summary ═══ */
|
||||
.manager-summary {
|
||||
display: flex; align-items: center; gap: 16px;
|
||||
padding: 14px 18px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
user-select: none;
|
||||
}
|
||||
.manager-summary:hover {
|
||||
background: rgba(196,147,74,0.03);
|
||||
}
|
||||
.manager-info {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.manager-expand {
|
||||
font-size: 10px; color: var(--warm-gray);
|
||||
width: 14px; flex-shrink: 0;
|
||||
}
|
||||
.manager-name {
|
||||
font-family: 'ZCOOL XiaoWei', STSong, serif;
|
||||
font-size: 15px; color: var(--ink); letter-spacing: 0.04em;
|
||||
cursor: pointer;
|
||||
}
|
||||
.manager-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.manager-row { background: var(--surface); border: 1px solid var(--warm-border); transition: border-color 0.2s; }
|
||||
.manager-row--expanded { border-color: var(--ink); }
|
||||
.manager-summary { display: flex; align-items: center; gap: 16px; padding: 14px 18px; cursor: pointer; transition: background 0.2s; user-select: none; }
|
||||
.manager-summary:hover { background: rgba(196,147,74,0.03); }
|
||||
.manager-info { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; }
|
||||
.manager-expand { font-size: 10px; color: var(--warm-gray); width: 14px; flex-shrink: 0; }
|
||||
.manager-name { font-family: 'ZCOOL XiaoWei', STSong, serif; font-size: 15px; color: var(--ink); letter-spacing: 0.04em; cursor: pointer; }
|
||||
.manager-name:hover { color: var(--gold); }
|
||||
.manager-count {
|
||||
font-family: 'Noto Serif SC', STSong, serif;
|
||||
font-size: 12px; color: var(--warm-gray); letter-spacing: 0.03em;
|
||||
}
|
||||
.manager-lights {
|
||||
display: flex; gap: 6px;
|
||||
}
|
||||
.light-dot {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
min-width: 26px; height: 26px; border-radius: 4px;
|
||||
font-family: 'JetBrains Mono', 'SF Mono', monospace;
|
||||
font-size: 12px; font-weight: 600;
|
||||
cursor: help;
|
||||
}
|
||||
.light-dot--green { background: #EDF2EC; color: var(--sage); }
|
||||
.manager-count { font-family: 'Noto Serif SC', STSong, serif; font-size: 12px; color: var(--warm-gray); }
|
||||
.manager-lights { display: flex; gap: 6px; }
|
||||
.light-dot { display: inline-flex; align-items: center; justify-content: center; min-width: 26px; height: 26px; border-radius: 4px; font-family: 'JetBrains Mono', monospace; font-size: 12px; font-weight: 600; cursor: help; }
|
||||
.light-dot--green { background: #EDF2EC; color: var(--sage); }
|
||||
.light-dot--yellow { background: #FBF6EE; color: var(--gold); }
|
||||
.light-dot--red { background: #FBF1EE; color: var(--vermilion); }
|
||||
|
||||
.manager-coverage {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
width: 180px; flex-shrink: 0;
|
||||
}
|
||||
.coverage-bar {
|
||||
flex: 1; height: 6px;
|
||||
background: var(--paper-dark);
|
||||
border-radius: 3px; overflow: hidden;
|
||||
}
|
||||
.coverage-fill {
|
||||
height: 100%; border-radius: 3px;
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
.coverage-pct {
|
||||
font-family: 'JetBrains Mono', 'SF Mono', monospace;
|
||||
font-size: 13px; font-weight: 600;
|
||||
width: 40px; text-align: right;
|
||||
}
|
||||
.light-dot--red { background: #FBF1EE; color: var(--vermilion); }
|
||||
.manager-coverage { display: flex; align-items: center; gap: 10px; width: 180px; flex-shrink: 0; }
|
||||
.coverage-bar { flex: 1; height: 6px; background: var(--paper-dark); border-radius: 3px; overflow: hidden; }
|
||||
.coverage-fill { height: 100%; border-radius: 3px; transition: width 0.5s ease; }
|
||||
.coverage-pct { font-family: 'JetBrains Mono', monospace; font-size: 13px; font-weight: 600; width: 40px; text-align: right; }
|
||||
|
||||
/* ═══ Customer Grid ═══ */
|
||||
.customer-grid {
|
||||
display: flex; flex-wrap: wrap; gap: 10px;
|
||||
padding: 0 18px 16px;
|
||||
border-top: 1px solid var(--warm-border);
|
||||
padding-top: 14px;
|
||||
}
|
||||
.customer-grid { display: flex; flex-wrap: wrap; gap: 10px; padding: 0 18px 16px; border-top: 1px solid var(--warm-border); padding-top: 14px; }
|
||||
|
||||
/* ═══ Customer Card ═══ */
|
||||
.customer-card {
|
||||
width: 220px;
|
||||
background: var(--paper);
|
||||
border: 1px solid var(--warm-border);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
.customer-card:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(28,55,56,0.06);
|
||||
}
|
||||
.customer-card { width: 220px; background: var(--paper); border: 1px solid var(--warm-border); cursor: pointer; transition: all 0.2s; display: flex; overflow: hidden; position: relative; }
|
||||
.customer-card:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(28,55,56,0.06); }
|
||||
.customer-card:active { transform: scale(0.99); }
|
||||
|
||||
.card-status-stripe {
|
||||
width: 4px; flex-shrink: 0;
|
||||
}
|
||||
.customer-card--green .card-status-stripe { background: var(--sage); }
|
||||
.card-status-stripe { width: 4px; flex-shrink: 0; }
|
||||
.customer-card--green .card-status-stripe { background: var(--sage); }
|
||||
.customer-card--yellow .card-status-stripe { background: var(--gold); }
|
||||
.customer-card--red .card-status-stripe { background: var(--vermilion); }
|
||||
.customer-card--gray .card-status-stripe { background: var(--warm-gray); }
|
||||
|
||||
.customer-card--red {
|
||||
animation: pulse-warn 3s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse-warn {
|
||||
0%, 100% { border-color: var(--warm-border); }
|
||||
50% { border-color: rgba(184,71,46,0.35); }
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 12px 14px;
|
||||
flex: 1; min-width: 0;
|
||||
}
|
||||
.card-name-row {
|
||||
display: flex; align-items: flex-start; gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
/* ═══ Status Circle Icons (geometric, no text) ═══ */
|
||||
.status-circle {
|
||||
display: inline-block;
|
||||
width: 16px; height: 16px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
/* Visited this month — solid filled circle ● */
|
||||
.status-circle--green {
|
||||
background: var(--sage);
|
||||
box-shadow: 0 0 0 2px rgba(74,103,65,0.15);
|
||||
}
|
||||
/* Visited last month only — half-filled circle ◐ */
|
||||
.status-circle--yellow {
|
||||
background: conic-gradient(var(--gold) 50%, transparent 50%);
|
||||
box-shadow: inset 0 0 0 1.5px var(--gold);
|
||||
}
|
||||
/* Not visited 2+ months — empty circle ○ */
|
||||
.status-circle--red {
|
||||
background: transparent;
|
||||
border: 2px solid var(--vermilion);
|
||||
box-shadow: 0 0 0 2px rgba(184,71,46,0.08);
|
||||
}
|
||||
/* Unassigned — dash */
|
||||
.status-circle--gray {
|
||||
background: transparent;
|
||||
border: 1.5px dashed var(--warm-gray);
|
||||
}
|
||||
|
||||
.card-name {
|
||||
font-family: 'ZCOOL XiaoWei', STSong, serif;
|
||||
font-size: 13px; color: var(--ink); letter-spacing: 0.03em;
|
||||
line-height: 1.4;
|
||||
word-break: break-all;
|
||||
}
|
||||
.card-meta {
|
||||
display: flex; gap: 6px; flex-wrap: wrap;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.card-industry, .card-services {
|
||||
font-family: 'Noto Serif SC', STSong, serif;
|
||||
font-size: 10px; color: var(--warm-gray);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.card-visit-info {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.card-last-visit {
|
||||
font-family: 'JetBrains Mono', 'SF Mono', monospace;
|
||||
font-size: 10px; color: var(--warm-gray);
|
||||
}
|
||||
.customer-card--red .card-status-stripe { background: var(--vermilion); }
|
||||
.customer-card--gray .card-status-stripe { background: var(--warm-gray); }
|
||||
.customer-card--red { animation: pulse-warn 3s ease-in-out infinite; }
|
||||
@keyframes pulse-warn { 0%,100% { border-color: var(--warm-border); } 50% { border-color: rgba(184,71,46,0.35); } }
|
||||
.card-body { padding: 12px 14px; flex: 1; min-width: 0; }
|
||||
.card-name-row { display: flex; align-items: flex-start; gap: 8px; margin-bottom: 6px; }
|
||||
.status-circle { display: inline-block; width: 16px; height: 16px; border-radius: 50%; flex-shrink: 0; margin-top: 2px; }
|
||||
.status-circle--green { background: var(--sage); box-shadow: 0 0 0 2px rgba(74,103,65,0.15); }
|
||||
.status-circle--yellow { background: conic-gradient(var(--gold) 50%, transparent 50%); box-shadow: inset 0 0 0 1.5px var(--gold); }
|
||||
.status-circle--red { background: transparent; border: 2px solid var(--vermilion); box-shadow: 0 0 0 2px rgba(184,71,46,0.08); }
|
||||
.status-circle--gray { background: transparent; border: 1.5px dashed var(--warm-gray); }
|
||||
.card-name { font-family: 'ZCOOL XiaoWei', STSong, serif; font-size: 13px; color: var(--ink); letter-spacing: 0.03em; line-height: 1.4; word-break: break-all; }
|
||||
.card-meta { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 6px; }
|
||||
.card-industry,.card-services { font-family: 'Noto Serif SC', STSong, serif; font-size: 10px; color: var(--warm-gray); }
|
||||
.card-visit-info { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
|
||||
.card-last-visit { font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--warm-gray); }
|
||||
.card-last-visit--never { color: var(--vermilion); }
|
||||
.card-method {
|
||||
font-family: 'Noto Serif SC', STSong, serif;
|
||||
font-size: 10px; padding: 1px 6px;
|
||||
border: 1px solid var(--warm-border);
|
||||
color: var(--warm-gray);
|
||||
}
|
||||
.card-warning {
|
||||
margin-top: 6px;
|
||||
font-family: 'Noto Serif SC', STSong, serif;
|
||||
font-size: 10px; color: var(--vermilion);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
/* ═══ Unassigned Section ═══ */
|
||||
.unassigned-section {
|
||||
border-style: dashed;
|
||||
border-color: var(--warm-gray);
|
||||
}
|
||||
.unassigned-summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ═══ Empty ═══ */
|
||||
.card-method { font-family: 'Noto Serif SC', STSong, serif; font-size: 10px; padding: 1px 6px; border: 1px solid var(--warm-border); color: var(--warm-gray); }
|
||||
.card-plans { display: flex; gap: 4px; flex-wrap: wrap; }
|
||||
.plan-badge { font-family: 'JetBrains Mono', monospace; font-size: 9px; padding: 2px 6px; border-radius: 8px; background: #EDF2EC; color: var(--sage); }
|
||||
.plan-badge.plan-overdue { background: #FBF1EE; color: var(--vermilion); animation: pulse-warn 2s ease-in-out infinite; }
|
||||
.card-warning { margin-top: 6px; font-family: 'Noto Serif SC', STSong, serif; font-size: 10px; color: var(--vermilion); }
|
||||
.unassigned-section { border-style: dashed; border-color: var(--warm-gray); }
|
||||
.unassigned-summary { cursor: pointer; }
|
||||
.empty-state { text-align: center; padding: 48px 0; }
|
||||
.empty-text { font-family: 'Noto Serif SC', STSong, serif; font-size: 15px; color: var(--warm-gray); }
|
||||
.empty { text-align: center; padding: 20px; color: var(--c-text-muted); font-family: 'Noto Serif SC', STSong, serif; }
|
||||
|
||||
/* ═══ Dialog ═══ */
|
||||
.dlg-status { padding: 8px 12px; border-radius: 6px; font-size: 13px; font-weight: 600; margin-bottom: 12px; }
|
||||
.dlg-status--green { background: #EDF2EC; color: var(--sage); }
|
||||
.dlg-status--yellow { background: #FBF6EE; color: var(--gold); }
|
||||
.dlg-status--red { background: #FBF1EE; color: var(--vermilion); }
|
||||
.dlg-status--gray { background: #f0ede5; color: var(--warm-gray); }
|
||||
.dlg-info { background: var(--c-bg-light, #faf9f6); border-radius: 6px; padding: 12px 16px; margin-bottom: 12px; }
|
||||
.dlg-info-row { display: flex; gap: 16px; padding: 4px 0; font-size: 13px; }
|
||||
.dlg-label { color: var(--c-text-muted); min-width: 60px; }
|
||||
.dlg-section { border-top: 1px solid var(--warm-border); padding-top: 10px; margin-top: 10px; }
|
||||
.dlg-section-title { margin: 0 0 8px; font-size: 13px; color: var(--ink); }
|
||||
.dlg-visit-item { display: flex; gap: 8px; padding: 4px 0; font-size: 12px; }
|
||||
.dlg-vdate { font-family: 'JetBrains Mono', monospace; color: var(--warm-gray); white-space: nowrap; }
|
||||
.dlg-vmethod { color: var(--sage); white-space: nowrap; }
|
||||
.dlg-vcontent { color: var(--c-text-muted); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dlg-plan-item { display: flex; align-items: center; gap: 8px; padding: 4px 0; font-size: 12px; }
|
||||
.dlg-plan-item.dlg-plan-overdue { background: #FBF1EE; margin: 2px -4px; padding: 4px; border-radius: 4px; }
|
||||
.dlg-plan-form { background: var(--c-bg-light, #faf9f6); padding: 10px 12px; border-radius: 6px; }
|
||||
.plan-form-row { display: flex; gap: 8px; align-items: center; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user