Files
qiji/frontend/src/views/desktop/LightBoard.vue
T
v6ole 3285e22142 feat: 导入增强 + 客户合并 + 同伴自定义 + 性能优化
=== 导入系统全面增强 ===
- 5 Sheet 完整导入:拜访/计划/商机/要客/纪要
- 同访人智能解析:中英文逗号/顿号/分号 → 系统用户UUID + 外部人员TEXT
- 客户自动创建:Excel中不存在的客户自动入库
- 模糊名称匹配:去空格 + 包含关系纠错
- manager_id 修正:导入时用客户分配的经理(非Excel列/非导入人)
- 模板更新:Sheet5「客户经理」→「填报人」,同访人示例含逗号分隔

=== 客户合并功能 ===
- PUT 改名碰撞检测 → 409 + 合并预览
- GET merge-preview / POST merge 端点
- 事务级迁移:Visit/WorkPlan/MiniBusiness/KeyVisit/联系人/分配
- 去重逻辑:联系人(name+phone)、分配(manager+role)
- last_visit_date 取最大值

=== 拜访记录完善 ===
- visits 新增 companion_names TEXT[] 列
- 同访人支持自定义输入(外部人员),el-select allow-create
- 移动端 VisitForm + PC端 ManagerWorkspace 统一
- 周报「客户经理」→「相关人员」(创建人+同访人)
- 纪要「客户经理」→「填报人」
- 删除拜访后重新计算 customer.last_visit_date

=== 客户选择放开 ===
- 客户经理可看到全部客户(不再限自己分配的)
- 拜访时客户下拉返回全量

=== 前端性能优化 ===
- Element Plus 按需加载 (unplugin-vue-components + unplugin-element-plus)
- 周报照片URL并行请求 (Promise.all)
- onMounted 三路并行 (loadReport + dropdowns + AI)
- nginx Cache-Control: immutable for /assets/
- Google Fonts preconnect hints
- 图片上传前 Canvas 压缩 (max 1920px, quality 0.8)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-29 22:25:04 +08:00

380 lines
22 KiB
Vue
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.
<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)
// ── Single 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)
return d.toISOString().slice(0, 7)
})
const isCurrentMonth = computed(() => monthOffset.value >= 0)
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)
}
async function loadData() {
loading.value = true
try {
const refDate = new Date()
refDate.setMonth(refDate.getMonth() + monthOffset.value)
const res = await dashboardApi.getLightBoard({ reference_date: refDate.toISOString().slice(0, 10) })
board.value = res.data
} catch (e: any) {
ElMessage.error('加载亮灯表失败')
} finally { loading.value = false }
}
onMounted(loadData)
function goManagerReport(managerId: string) {
router.push({ path: '/weekly-report', query: { manager_id: managerId } })
}
const coverageColor = (rate: number): string => {
if (rate >= 0.8) return 'var(--sage)'
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>
<div class="light-board" v-loading="loading">
<!-- ═══ Page Header ═══ -->
<div class="page-head">
<div class="page-head-row">
<div>
<h2 class="page-title">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" style="margin-right:8px; color: var(--gold); vertical-align: -4px;">
<path d="M12 2l2.4 7.2h7.6l-6 4.8 2.4 7.2-6.4-4.8-6.4 4.8 2.4-7.2-6-4.8h7.6z"/>
</svg>
客户拜访亮灯表
</h2>
<div class="month-nav">
<button class="month-nav-btn" @click="changeMonth(-1)"></button>
<span class="month-label">{{ referenceMonth }}</span>
<button class="month-nav-btn" @click="changeMonth(1)" :disabled="isCurrentMonth"></button>
<button v-if="!isCurrentMonth" class="month-nav-reset" @click="goCurrentMonth">回到本月</button>
</div>
</div>
</div>
<div class="page-rule"></div>
</div>
<!-- ═══ 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>
<!-- ═══ 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) }">
<div class="manager-summary" @click="toggleManager(m.manager_id)">
<div class="manager-info">
<span class="manager-expand">{{ expandedManagers.has(m.manager_id) ? '▼' : '▶' }}</span>
<span class="manager-name" @click.stop="goManagerReport(m.manager_id)">{{ m.manager_name }}</span>
<span class="manager-count">{{ m.total_customers }} 个客户</span>
</div>
<div class="manager-lights">
<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>
</div>
<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="openCustomerDialog(cust)">
<div class="card-status-stripe"></div>
<div class="card-body">
<div class="card-name-row">
<span class="status-circle" :class="`status-circle--${cust.status}`"></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" 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>
</div>
</div>
<div v-if="m.customers.length === 0" class="empty">暂无客户</div>
</div>
</div>
<!-- ═══ Unassigned Customers ═══ -->
<div v-if="board.unassigned_customers.length > 0" class="manager-row unassigned-section">
<div class="manager-summary unassigned-summary" @click="toggleManager('unassigned')">
<div class="manager-info">
<span class="manager-expand">{{ expandedManagers.has('unassigned') ? '▼' : '▶' }}</span>
<span class="manager-name" style="color: var(--warm-gray)">未分配客户</span>
<span class="manager-count">{{ board.unassigned_customers.length }} </span>
</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 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></div>
<div class="card-visit-info"><span class="card-last-visit card-last-visit--never">未分配客户经理</span></div>
</div>
</div>
</div>
</div>
</div>
<!-- Empty State -->
<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>
<style scoped>
/* ═══ 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; }
.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', 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 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--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 { 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); }
.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', 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 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:active { transform: scale(0.99); }
.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 { 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-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>