qiangdanxitong/frontend/src/views/TaskProgressOverview.vue
2026-02-07 18:32:50 +08:00

1168 lines
31 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.

<template>
<DefaultLayout>
<div class="task-progress-page">
<div class="dashboard-shell">
<div class="headline">
<div class="headline-text">
<p class="eyebrow">数据分析 / 任务进度</p>
<h1>全量任务进度指挥舱</h1>
<p class="subtitle">统一展示任务状态、风险和部门负载,保持与现有数据分析页面一致的视觉规范。</p>
</div>
<div class="headline-actions">
<div class="snapshot-chip">
<span>快照时间</span>
<strong>{{ snapshotTimeText }}</strong>
</div>
<a-button type="primary" @click="refreshData">
<template #icon><Icon name="refresh" size="sm" /></template>
刷新快照
</a-button>
</div>
</div>
<div class="filter-row">
<div class="filter-left">
<a-date-picker
v-model:value="selectedMonth"
picker="month"
:locale="locale"
format="YYYY年MM月"
valueFormat="YYYY-MM"
:allow-clear="true"
@change="handleMonthChange"
placeholder="选择月份"
/>
<a-select
v-model:value="dateRange"
class="filter-select"
@change="handleDateRangeChange"
:disabled="!!selectedMonth"
>
<a-select-option value="week">近一周</a-select-option>
<a-select-option value="month">近一月</a-select-option>
<a-select-option value="quarter">近三月</a-select-option>
<a-select-option value="year">近一年</a-select-option>
<a-select-option value="all">全部</a-select-option>
</a-select>
<a-select
v-model:value="selectedDeptId"
class="filter-select"
allow-clear
show-search
:filter-option="filterDept"
placeholder="全部部门"
@change="handleDeptChange"
>
<a-select-option v-for="dept in departmentOptions" :key="dept.id" :value="dept.id">
{{ dept.deptName }}
</a-select-option>
</a-select>
</div>
<div class="filter-right">
<a-button type="link" @click="resetFilters">重置筛选</a-button>
</div>
</div>
<a-spin :spinning="loading">
<div class="kpi-grid">
<div class="kpi-card" v-for="card in heroCards" :key="card.label">
<div class="kpi-label">{{ card.label }}</div>
<div class="kpi-value">{{ card.value }}</div>
<div class="kpi-meta">{{ card.meta }}</div>
</div>
</div>
<div class="hero-grid">
<div class="panel galaxy-panel">
<div class="panel-header">
<div>
<h3>任务银河图</h3>
<p>颜色 = 状态 · X = 创建时间 · Y = 进度百分比</p>
</div>
<a-tag color="green" v-if="galaxyCount">{{ galaxyCount }} 颗节点</a-tag>
</div>
<div class="chart-layer" ref="galaxyChartRef"></div>
<div v-if="!galaxyHasData" class="panel-empty">
<a-empty description="暂无任务样本" />
</div>
</div>
<div class="panel summary-panel">
<div class="status-block">
<div class="panel-header">
<div>
<h3>状态分布</h3>
<p>按快照聚合</p>
</div>
</div>
<div class="chart-layer" ref="statusChartRef"></div>
<div v-if="!statusHasData" class="panel-empty flat">
<a-empty description="暂无数据" />
</div>
</div>
<div class="trend-block">
<div class="panel-header">
<div>
<h3>完成趋势</h3>
<p>近 30 天创建 / 完成对比</p>
</div>
</div>
<div class="chart-layer" ref="trendChartRef"></div>
<div v-if="!trendHasData" class="panel-empty flat">
<a-empty description="暂无趋势" />
</div>
</div>
</div>
</div>
<div class="secondary-grid">
<div class="panel stage-panel">
<div class="panel-header">
<div>
<h3>流程耗时断面</h3>
<p>审批 - 制作 - 评审 分段均耗</p>
</div>
</div>
<div class="stage-list">
<div class="stage-item" v-for="segment in stageSegments" :key="segment.stage">
<div class="stage-label">
<span>{{ segment.stage }}</span>
<strong>{{ formatHours(segment.avgHours) }}</strong>
</div>
<div class="stage-bar">
<div class="stage-bar-fill" :style="{ width: segment.percentage + '%'}"></div>
</div>
<div class="stage-percentage">{{ segment.percentage.toFixed(1) }}%</div>
</div>
</div>
<ul class="insight-list">
<li v-for="insight in insightTips" :key="insight">{{ insight }}</li>
</ul>
</div>
<div class="panel risk-panel">
<div class="panel-header">
<div>
<h3>风险雷达墙</h3>
<p>优先展示即将超时与已超时任务</p>
</div>
<a-tag color="orange" v-if="riskCards.length">{{ riskCards.length }} 项</a-tag>
</div>
<div class="risk-grid" v-if="riskCards.length">
<div
v-for="risk in riskCards"
:key="risk.taskId"
class="risk-card"
:class="risk.severity === 'danger' ? 'danger' : 'warning'"
>
<div class="risk-meta">
<span class="risk-type">{{ riskBadge(risk) }}</span>
<button class="ghost-link" @click="copyTaskNo(risk.taskNo)">复制编号</button>
</div>
<h4>{{ risk.title }}</h4>
<p class="risk-dept">{{ risk.deptName }} · {{ risk.ownerName || '未分配' }}</p>
<p class="risk-reason">{{ risk.warningReason }}</p>
<p class="risk-deadline">截止:{{ formatDateTime(risk.deadline) }}</p>
</div>
</div>
<div v-else class="panel-empty flat">
<a-empty description="暂无风险任务" />
</div>
</div>
</div>
<div class="panel dept-panel">
<div class="panel-header">
<div>
<h3>部门热力矩阵</h3>
<p>按照 Workload Index 排序,气泡越亮代表负载越高</p>
</div>
</div>
<div class="dept-grid">
<div
v-for="dept in deptHeatmap"
:key="dept.deptId"
class="dept-card"
:style="{ background: workloadToBackground(dept.workloadIndex) }"
>
<div class="dept-title">
<h4>{{ dept.deptName }}</h4>
<span class="badge">WI {{ dept.workloadIndex.toFixed(1) }}</span>
</div>
<div class="dept-metrics">
<div>
<span>待处理</span>
<strong>{{ dept.pendingCount }}</strong>
</div>
<div>
<span>进行中</span>
<strong>{{ dept.inProgressCount }}</strong>
</div>
<div>
<span>超时</span>
<strong>{{ dept.overdueCount }}</strong>
</div>
</div>
</div>
</div>
</div>
<div class="panel task-panel">
<div class="panel-header">
<div>
<h3>关键任务序列</h3>
<p>按最新创建时间取前 20 个任务,可直接复制编号</p>
</div>
<a-tag color="green" v-if="taskTableData.length">TOP {{ taskTableData.length }}</a-tag>
</div>
<a-table
:columns="taskColumns"
:data-source="taskTableData"
row-key="taskId"
:pagination="{ pageSize: 10 }"
size="middle"
class="dark-table"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'title'">
<div class="task-title">
<span class="task-no" @click="copyTaskNo(record.taskNo)">{{ record.taskNo }}</span>
<span>{{ record.title }}</span>
</div>
</template>
<template v-else-if="column.key === 'status'">
<span class="status-pill" :style="{ background: resolveStatusColor(record.status) }">
{{ statusLabel(record.status) }}
</span>
</template>
<template v-else-if="column.key === 'progress'">
<div class="progress-bar">
<div class="progress-bar-fill" :style="{ width: (record.progress || 0) + '%' }"></div>
<span>{{ record.progress || 0 }}%</span>
</div>
</template>
<template v-else-if="column.key === 'deadline'">
{{ formatDateTime(record.deadline) }}
</template>
<template v-else-if="column.key === 'priority'">
<span class="priority-pill" :class="priorityClass(record.priority)">
{{ priorityLabel(record.priority) }}
</span>
</template>
<template v-else-if="column.key === 'risk'">
<span :class="record.overdue ? 'risk-flag danger' : record.nearDeadline ? 'risk-flag warning' : 'risk-flag muted'">
{{ record.overdue ? '超时' : record.nearDeadline ? '临近' : '正常' }}
</span>
</template>
<template v-else>
{{ record[column.dataIndex] || '-' }}
</template>
</template>
</a-table>
</div>
</a-spin>
</div>
</div>
</DefaultLayout>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import DefaultLayout from '@/layouts/DefaultLayout.vue'
import Icon from '@/components/Icon.vue'
import { message } from 'ant-design-vue'
import * as echarts from 'echarts'
import dayjs from 'dayjs'
import 'dayjs/locale/zh-cn'
import zhCN from 'ant-design-vue/es/locale/zh_CN'
import type { DepartmentOverview } from '@/api/department'
import { getDepartmentList } from '@/api/department'
import {
getTaskProgressOverview,
type TaskProgressOverview,
type TaskProgressMetrics,
type TaskProgressStageSegment,
type TaskProgressDepartmentLoad,
type TaskProgressTaskPoint,
type TaskProgressRiskTask
} from '@/api/analytics'
const locale = zhCN
const selectedMonth = ref<string | null>(null)
const dateRange = ref('month')
const selectedDeptId = ref<number | undefined>(undefined)
const departmentOptions = ref<DepartmentOverview[]>([])
const loading = ref(false)
const overview = ref<TaskProgressOverview | null>(null)
const galaxyChartRef = ref<HTMLDivElement | null>(null)
const statusChartRef = ref<HTMLDivElement | null>(null)
const trendChartRef = ref<HTMLDivElement | null>(null)
let galaxyChart: echarts.ECharts | null = null
let statusChart: echarts.ECharts | null = null
let trendChart: echarts.ECharts | null = null
dayjs.locale('zh-cn')
const emptyMetrics: TaskProgressMetrics = {
totalTasks: 0,
pendingTasks: 0,
inProgressTasks: 0,
reviewTasks: 0,
completedTasks: 0,
overdueTasks: 0,
completionRate: 0,
onTimeRate: 0,
avgCycleHours: 0,
riskTaskCount: 0
}
const metrics = computed(() => overview.value?.metrics || emptyMetrics)
const heroCards = computed(() => [
{ label: '完成率', value: formatPercent(metrics.value.completionRate), meta: `${metrics.value.completedTasks} 已完结` },
{ label: '准时率', value: formatPercent(metrics.value.onTimeRate), meta: '基于近 30 天' },
{ label: '风险任务', value: metrics.value.riskTaskCount, meta: `${metrics.value.overdueTasks} 已超时` },
{ label: '平均周期', value: `${metrics.value.avgCycleHours.toFixed(1)}h`, meta: '发起至完结' },
{ label: '待处理', value: metrics.value.pendingTasks, meta: `${metrics.value.inProgressTasks} 进行中` },
{ label: '总任务', value: metrics.value.totalTasks, meta: `${metrics.value.reviewTasks} 待审核` }
])
const snapshotTimeText = computed(() => {
if (!overview.value?.snapshotTime) {
return '待生成'
}
return dayjs(overview.value.snapshotTime).format('YYYY-MM-DD HH:mm')
})
const stageSegments = computed<TaskProgressStageSegment[]>(() => overview.value?.stageSegments || [])
const riskCards = computed<TaskProgressRiskTask[]>(() => (overview.value?.riskTasks || []).slice(0, 6))
const deptHeatmap = computed<TaskProgressDepartmentLoad[]>(() => (overview.value?.departmentLoads || []).slice(0, 6))
const deptMaxWorkload = computed(() => {
const loads = overview.value?.departmentLoads || []
if (!loads.length) return 1
return Math.max(...loads.map(load => load.workloadIndex || 0), 1)
})
const taskTableData = computed<TaskProgressTaskPoint[]>(() => (overview.value?.taskPoints || []).slice(0, 20))
const galaxyCount = computed(() => overview.value?.taskPoints?.length || 0)
const galaxyHasData = computed(() => galaxyCount.value > 0)
const statusHasData = computed(() => Object.keys(overview.value?.statusDistribution || {}).length > 0)
const trendHasData = computed(() => (overview.value?.trend || []).length > 0)
const insightTips = computed(() => {
const tips: string[] = []
if (metrics.value.overdueTasks > 0) {
tips.push(`存在 ${metrics.value.overdueTasks} 个已超时任务,建议优先协调资源。`)
}
if (metrics.value.inProgressTasks > metrics.value.completedTasks) {
tips.push('进行中任务量高于已完结,关注流转效率。')
}
if ((stageSegments.value[1]?.percentage || 0) > 50) {
tips.push('制作环节占比超过 50%,可量化拆分子阶段。')
}
if (!tips.length) {
tips.push('整体运行平稳,保持当前节奏。')
}
return tips
})
const taskColumns = [
{ title: '任务', dataIndex: 'title', key: 'title' },
{ title: '部门', dataIndex: 'deptName', key: 'deptName' },
{ title: '负责人', dataIndex: 'receiverName', key: 'receiverName' },
{ title: '状态', dataIndex: 'status', key: 'status', width: 120 },
{ title: '进度', dataIndex: 'progress', key: 'progress', width: 180 },
{ title: '截止', dataIndex: 'deadline', key: 'deadline', width: 180 },
{ title: '优先级', dataIndex: 'priority', key: 'priority', width: 120 },
{ title: '风险', dataIndex: 'risk', key: 'risk', width: 120 }
]
const refreshData = async () => {
loading.value = true
try {
const data = await getTaskProgressOverview({
dateRange: dateRange.value,
month: selectedMonth.value || undefined,
deptId: selectedDeptId.value
})
overview.value = data
await nextTick()
renderCharts()
} catch (error) {
console.error('加载任务进度总览失败', error)
message.error('加载任务进度总览失败')
} finally {
loading.value = false
}
}
const fetchDepartments = async () => {
try {
const list = await getDepartmentList({ status: 1 })
if (Array.isArray(list)) {
departmentOptions.value = list
} else if (Array.isArray(list?.data)) {
departmentOptions.value = list.data
}
} catch (error) {
console.warn('获取部门列表失败', error)
}
}
const handleMonthChange = (value: string | null) => {
selectedMonth.value = value
refreshData()
}
const handleDeptChange = (value: number | undefined) => {
selectedDeptId.value = value
refreshData()
}
const handleDateRangeChange = (value: string) => {
dateRange.value = value
if (!selectedMonth.value) {
refreshData()
}
}
const resetFilters = () => {
selectedMonth.value = null
dateRange.value = 'month'
selectedDeptId.value = undefined
refreshData()
}
const filterDept = (input: string, option: any) => {
return option?.children?.toLowerCase().includes(input.toLowerCase())
}
const copyTaskNo = async (taskNo?: string) => {
if (!taskNo) return
try {
await navigator.clipboard.writeText(taskNo)
message.success('任务编号已复制')
} catch (error) {
console.error('复制失败', error)
}
}
const workloadToBackground = (value: number) => {
const ratio = Math.min(1, value / (deptMaxWorkload.value || 1))
const alpha = 0.08 + ratio * 0.24
return `linear-gradient(135deg, rgba(49,240,139,${alpha}), rgba(255,255,255,0.02))`
}
const priorityLabel = (priority?: number) => {
switch (priority) {
case 3:
return '特急'
case 2:
return '高'
case 1:
return '中'
default:
return '普通'
}
}
const priorityClass = (priority?: number) => {
if (priority === 3) return 'danger'
if (priority === 2) return 'warning'
if (priority === 1) return 'info'
return 'muted'
}
const statusMap: Record<string, { label: string; color: string }> = {
pending: { label: '待处理', color: '#ffa940' },
pending_audit: { label: '待审核', color: '#ffa940' },
pending_demand_approval: { label: '需求审核', color: '#ffa940' },
pending_receiving_approval: { label: '接单审核', color: '#ffa940' },
in_progress: { label: '进行中', color: '#3c7eff' },
review: { label: '评审中', color: '#a855f7' },
submitted: { label: '待确认', color: '#a855f7' },
completed: { label: '已完结', color: '#31f08b' }
}
const resolveStatusColor = (status?: string) => statusMap[status || '']?.color || 'rgba(255,255,255,0.2)'
const statusLabel = (status?: string) => statusMap[status || '']?.label || '其他'
const formatPercent = (value: number) => `${value.toFixed(1)}%`
const formatHours = (value: number) => `${value.toFixed(1)}h`
const formatDateTime = (value?: string) => (value ? dayjs(value).format('MM-DD HH:mm') : '--')
const riskBadge = (risk: TaskProgressRiskTask) => (risk.severity === 'danger' ? '⚠ 超时' : '△ 预警')
const disposeCharts = () => {
galaxyChart?.dispose()
statusChart?.dispose()
trendChart?.dispose()
galaxyChart = null
statusChart = null
trendChart = null
}
const renderCharts = () => {
renderGalaxyChart()
renderStatusChart()
renderTrendChart()
}
const renderGalaxyChart = () => {
if (!galaxyChartRef.value) return
if (!galaxyChart) {
galaxyChart = echarts.init(galaxyChartRef.value)
}
const points = overview.value?.taskPoints || []
if (!points.length) {
galaxyChart.clear()
return
}
const scatterData = points.map(point => ({
value: [
point.createTime ? dayjs(point.createTime).valueOf() : Date.now(),
point.progress || 0,
Number(point.score || 1)
],
symbolSize: 6 + Math.min(12, Number(point.score || 1)),
itemStyle: {
color: resolveStatusColor(point.status),
shadowBlur: 18,
shadowColor: resolveStatusColor(point.status)
},
task: point
}))
galaxyChart.setOption({
backgroundColor: 'transparent',
tooltip: {
trigger: 'item',
backgroundColor: '#1e1e1e',
borderColor: 'rgba(255,255,255,0.16)',
textStyle: { color: '#e5e5e5' },
formatter: (params: any) => {
const task = params.data.task as TaskProgressTaskPoint
return `任务:${task.title}<br/>状态:${statusLabel(task.status)}<br/>积分:${task.score || '-'}<br/>进度:${task.progress || 0}%`
}
},
xAxis: {
type: 'time',
axisLine: { lineStyle: { color: 'rgba(255,255,255,0.16)' } },
axisLabel: { color: 'rgba(255,255,255,0.6)' },
splitLine: { lineStyle: { color: 'rgba(255,255,255,0.06)' } }
},
yAxis: {
type: 'value',
min: 0,
max: 100,
axisLabel: { color: 'rgba(255,255,255,0.6)', formatter: '{value}%' },
axisLine: { lineStyle: { color: 'rgba(255,255,255,0.16)' } },
splitLine: { lineStyle: { color: 'rgba(255,255,255,0.06)' } }
},
series: [
{
type: 'scatter',
name: '任务',
symbol: 'circle',
data: scatterData,
emphasis: {
scale: 1.1
}
}
]
})
}
const renderStatusChart = () => {
if (!statusChartRef.value) return
if (!statusChart) {
statusChart = echarts.init(statusChartRef.value)
}
const distribution = overview.value?.statusDistribution || {}
const entries = Object.entries(distribution)
if (!entries.length) {
statusChart.clear()
return
}
statusChart.setOption({
backgroundColor: 'transparent',
tooltip: { trigger: 'item' },
legend: { show: false },
series: [
{
type: 'pie',
radius: ['45%', '80%'],
startAngle: 220,
itemStyle: { borderColor: '#1e1e1e', borderWidth: 2 },
label: {
color: '#e5e5e5',
formatter: '{b}\n{d}%'
},
data: entries.map(([status, value]) => ({
name: statusLabel(status),
value,
itemStyle: { color: resolveStatusColor(status) }
}))
}
]
})
}
const renderTrendChart = () => {
if (!trendChartRef.value) return
if (!trendChart) {
trendChart = echarts.init(trendChartRef.value)
}
const trend = overview.value?.trend || []
if (!trend.length) {
trendChart.clear()
return
}
trendChart.setOption({
backgroundColor: 'transparent',
tooltip: { trigger: 'axis' },
legend: {
data: ['创建', '完结'],
textStyle: { color: '#e5e5e5' }
},
grid: { left: 30, right: 20, top: 30, bottom: 20 },
xAxis: {
type: 'category',
data: trend.map(item => item.date),
axisLabel: { color: 'rgba(255,255,255,0.6)' },
axisLine: { lineStyle: { color: 'rgba(255,255,255,0.16)' } }
},
yAxis: {
type: 'value',
axisLabel: { color: 'rgba(255,255,255,0.6)' },
splitLine: { lineStyle: { color: 'rgba(255,255,255,0.06)' } }
},
series: [
{
name: '创建',
type: 'line',
smooth: true,
showSymbol: false,
areaStyle: { color: 'rgba(49,240,139,0.12)' },
lineStyle: { color: '#31f08b' },
data: trend.map(item => item.createdCount)
},
{
name: '完结',
type: 'line',
smooth: true,
showSymbol: false,
areaStyle: { color: 'rgba(96,165,250,0.12)' },
lineStyle: { color: '#60a5fa' },
data: trend.map(item => item.completedCount)
}
]
})
}
const handleResize = () => {
galaxyChart?.resize()
statusChart?.resize()
trendChart?.resize()
}
onMounted(() => {
refreshData()
fetchDepartments()
window.addEventListener('resize', handleResize)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', handleResize)
disposeCharts()
})
</script>
<style lang="scss" scoped>
@import "@/styles/variables.scss";
.task-progress-page {
min-height: 100vh;
padding: $space-xl 0;
background: $bg-primary;
}
.dashboard-shell {
max-width: $container-max-width;
margin: 0 auto;
padding: 0 $space-xl $space-xxl;
}
.headline {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: $space-lg;
margin-bottom: $space-xl;
}
.eyebrow {
color: $text-secondary;
font-size: $font-size-sm;
margin-bottom: $space-xs;
}
.headline h1 {
margin: 0;
font-size: $font-size-2xl;
color: $text-white;
}
.subtitle {
margin-top: $space-xs;
font-size: $font-size-base;
color: $text-secondary;
max-width: 700px;
}
.headline-actions {
display: flex;
align-items: center;
gap: $space-sm;
}
.snapshot-chip {
padding: 10px 14px;
border-radius: $radius-md;
border: 1px solid $border-primary;
background: $bg-card;
min-width: 180px;
}
.snapshot-chip span {
color: $text-secondary;
font-size: $font-size-sm;
}
.snapshot-chip strong {
display: block;
color: $text-primary;
font-size: $font-size-lg;
}
.filter-row {
background: $bg-card;
border: 1px solid $border-primary;
border-radius: $radius-lg;
padding: $space-md;
display: flex;
justify-content: space-between;
align-items: center;
gap: $space-md;
margin-bottom: $space-lg;
}
.filter-left {
display: flex;
align-items: center;
gap: $space-sm;
flex-wrap: wrap;
}
.filter-select {
width: 160px;
}
.kpi-grid {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: $space-sm;
margin-bottom: $space-lg;
}
.kpi-card {
background: $bg-card;
border: 1px solid $border-primary;
border-radius: $radius-lg;
padding: $space-md;
}
.kpi-label {
color: $text-secondary;
font-size: $font-size-sm;
}
.kpi-value {
margin-top: 2px;
font-size: $font-size-xl;
font-weight: $font-weight-semibold;
color: $text-primary;
}
.kpi-meta {
margin-top: $space-xs;
color: $text-tertiary;
font-size: $font-size-xs;
}
.hero-grid,
.secondary-grid {
display: grid;
grid-template-columns: 2fr 1fr;
gap: $space-md;
margin-bottom: $space-md;
}
.secondary-grid {
grid-template-columns: 1fr 1fr;
}
.panel {
background: $bg-card;
border: 1px solid $border-primary;
border-radius: $radius-lg;
padding: $space-md;
}
.task-panel {
margin-top: $space-md;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: $space-sm;
}
.panel-header h3 {
margin: 0;
font-size: $font-size-lg;
color: $text-primary;
}
.panel-header p {
margin: 2px 0 0;
font-size: $font-size-sm;
color: $text-secondary;
}
.chart-layer {
position: relative;
height: 280px;
}
.galaxy-panel .chart-layer {
height: 360px;
}
.panel-empty {
display: flex;
justify-content: center;
align-items: center;
min-height: 220px;
}
.panel-empty.flat {
min-height: 120px;
}
.summary-panel {
display: grid;
gap: $space-sm;
}
.stage-list {
display: grid;
gap: $space-sm;
}
.stage-item {
border: 1px solid rgba(255, 255, 255, 0.06);
background: $bg-secondary;
border-radius: $radius-md;
padding: $space-sm $space-md;
}
.stage-label {
display: flex;
justify-content: space-between;
font-size: $font-size-sm;
}
.stage-bar {
height: 6px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
margin: 6px 0;
}
.stage-bar-fill {
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, $brand-primary, #60a5fa);
}
.stage-percentage {
font-size: $font-size-xs;
color: $text-secondary;
}
.insight-list {
margin: $space-md 0 0;
padding-left: 16px;
color: $text-secondary;
font-size: $font-size-sm;
}
.risk-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: $space-sm;
}
.risk-card {
border: 1px solid $border-primary;
background: $bg-secondary;
border-radius: $radius-md;
padding: $space-sm $space-md;
}
.risk-card h4 {
margin: $space-xs 0;
font-size: $font-size-base;
color: $text-primary;
}
.risk-card p {
margin: 0;
font-size: $font-size-sm;
color: $text-secondary;
line-height: 1.5;
}
.risk-card .risk-reason {
margin-top: $space-xs;
}
.risk-card.danger {
border-color: rgba(239, 68, 68, 0.45);
}
.risk-card.warning {
border-color: rgba(245, 158, 11, 0.4);
}
.risk-meta {
display: flex;
justify-content: space-between;
align-items: center;
font-size: $font-size-xs;
color: $text-secondary;
}
.ghost-link {
background: none;
border: none;
color: $brand-primary;
cursor: pointer;
font-size: $font-size-xs;
}
.dept-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: $space-sm;
}
.dept-card {
border-radius: $radius-md;
border: 1px solid $border-primary;
padding: $space-sm $space-md;
}
.dept-title {
display: flex;
justify-content: space-between;
align-items: center;
}
.dept-title h4 {
margin: 0;
font-size: $font-size-base;
color: $text-primary;
}
.badge {
background: rgba(49, 240, 139, 0.14);
color: $brand-primary;
border-radius: 999px;
padding: 2px 8px;
font-size: $font-size-xs;
}
.dept-metrics {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: $space-sm;
margin-top: $space-sm;
}
.dept-metrics span {
display: block;
color: $text-secondary;
font-size: $font-size-xs;
}
.dept-metrics strong {
display: block;
color: $text-primary;
font-size: $font-size-lg;
}
.task-title {
display: flex;
flex-direction: column;
gap: 2px;
}
.task-no {
color: $text-secondary;
font-size: $font-size-xs;
cursor: pointer;
&:hover {
color: $brand-primary;
}
}
.status-pill {
display: inline-flex;
align-items: center;
padding: 2px 10px;
border-radius: 999px;
color: $text-white;
font-size: $font-size-xs;
}
.progress-bar {
position: relative;
height: 6px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
}
.progress-bar-fill {
position: absolute;
inset: 0 auto 0 0;
border-radius: inherit;
background: $brand-primary;
}
.progress-bar span {
position: absolute;
right: 0;
top: -18px;
font-size: $font-size-xs;
color: $text-secondary;
}
.priority-pill,
.risk-flag {
font-size: $font-size-xs;
padding: 2px 8px;
border-radius: $radius-sm;
}
.priority-pill.danger,
.risk-flag.danger {
color: #ff6b6b;
background: rgba(255, 107, 107, 0.14);
}
.priority-pill.warning,
.risk-flag.warning {
color: #f6b454;
background: rgba(245, 158, 11, 0.14);
}
.priority-pill.info {
color: #60a5fa;
background: rgba(96, 165, 250, 0.14);
}
.priority-pill.muted,
.risk-flag.muted {
color: $text-secondary;
background: rgba(255, 255, 255, 0.08);
}
.dark-table {
:deep(.ant-table) {
background: transparent !important;
}
:deep(.ant-table-thead > tr > th) {
background: rgba(0, 0, 0, 0.32) !important;
border: none !important;
color: rgba(255, 255, 255, 0.66) !important;
font-size: 13px;
font-weight: 500;
}
:deep(.ant-table-tbody > tr > td) {
border: none !important;
color: rgba(255, 255, 255, 0.86) !important;
font-size: 13px;
background: transparent !important;
}
:deep(.ant-table-tbody > tr:nth-child(even) > td) {
background: rgba(255, 255, 255, 0.015) !important;
}
:deep(.ant-table-tbody > tr:hover > td) {
background: rgba(49, 240, 139, 0.06) !important;
}
}
@media (max-width: 1200px) {
.kpi-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.hero-grid,
.secondary-grid {
grid-template-columns: 1fr;
}
.dept-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: $breakpoint-mobile) {
.dashboard-shell {
padding: 0 $space-md $space-xxl;
}
.headline {
flex-direction: column;
margin-bottom: $space-lg;
}
.headline h1 {
font-size: $font-size-xl;
}
.headline-actions {
width: 100%;
justify-content: space-between;
}
.filter-row {
padding: $space-sm;
}
.kpi-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.risk-grid,
.dept-grid {
grid-template-columns: 1fr;
}
}
</style>