1450 lines
48 KiB
Vue
1450 lines
48 KiB
Vue
<template>
|
||
<div class="dashboard-page">
|
||
<div class="page-header" style="display: flex; justify-content: space-between; align-items: flex-end;">
|
||
<div>
|
||
<h1>全局统计看板</h1>
|
||
<p>多维度查看工时数据:销售视角 / 研发视角 / 项目视角</p>
|
||
</div>
|
||
<div style="display: flex; gap: 12px; align-items: center;">
|
||
<el-date-picker v-model="allExportDateRange" type="daterange" range-separator="至"
|
||
start-placeholder="全部时间起" end-placeholder="全部时间止" value-format="YYYY-MM-DD" size="large" />
|
||
<el-button type="success" @click="exportData('all')" :loading="exportingAll" size="large">
|
||
📊 总导出 (All in One)
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tabs -->
|
||
<el-tabs v-model="activeTab" type="border-card" class="dashboard-tabs">
|
||
|
||
<!-- ==================== Sales Perspective ==================== -->
|
||
<el-tab-pane label="📈 销售视角" name="sales">
|
||
<div class="filter-bar">
|
||
<el-date-picker v-model="salesFilter.dateRange" type="daterange" range-separator="至"
|
||
start-placeholder="开始日期" end-placeholder="结束日期" value-format="YYYY-MM-DD" size="default" />
|
||
|
||
<el-button type="primary" @click="loadSalesData">查询</el-button>
|
||
<el-button @click="exportData('sales')">📥 导出该页</el-button>
|
||
</div>
|
||
|
||
<div class="two-col-layout">
|
||
<!-- Sales Table -->
|
||
<div class="table-col">
|
||
<div class="section-title">📋 销售人员汇总 (点击展开查看研发明细)</div>
|
||
<el-table :data="salesSummary" stripe v-loading="salesLoading" style="width: 100%" row-key="sales_name">
|
||
<el-table-column type="expand">
|
||
<template #default="props">
|
||
<div style="padding: 10px 40px; background: rgba(0,0,0,0.2);">
|
||
<div style="margin-bottom: 8px; font-weight: bold; color: var(--text-secondary);">
|
||
[ {{ props.row.sales_name }} ] 的研发工时消耗明细:
|
||
</div>
|
||
<el-table :data="props.row.rdList" border style="width: 100%" size="small">
|
||
<el-table-column prop="rd_name" label="研发人员" width="150" />
|
||
<el-table-column prop="total_hours" label="消耗工时(h)" width="120" />
|
||
<el-table-column prop="work_days" label="工作天数" width="120" />
|
||
</el-table>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="sales_name" label="销售人员" min-width="140" />
|
||
<el-table-column prop="project_count" label="项目数" min-width="100" />
|
||
<el-table-column prop="rd_count" label="涉及研发" min-width="120" />
|
||
<el-table-column prop="total_hours" label="总消耗工时(h)" min-width="130" sortable />
|
||
</el-table>
|
||
</div>
|
||
<!-- Sales Charts -->
|
||
<div class="chart-col">
|
||
<div class="chart-card">
|
||
<div class="chart-title">销售占用工时占比(饼图)</div>
|
||
<div ref="salesChartRef" class="big-chart"></div>
|
||
</div>
|
||
<div class="chart-card" style="margin-top: 16px">
|
||
<div class="chart-title">各销售工时对比(柱状图)</div>
|
||
<div ref="salesBarRef" class="big-chart"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</el-tab-pane>
|
||
|
||
<!-- ==================== R&D Perspective ==================== -->
|
||
<el-tab-pane label="👨💻 研发视角" name="rd">
|
||
<div class="filter-bar">
|
||
<el-date-picker v-model="rdFilter.dateRange" type="daterange" range-separator="至"
|
||
start-placeholder="开始日期" end-placeholder="结束日期" value-format="YYYY-MM-DD" size="default" />
|
||
<el-select v-model="rdFilter.user_id" placeholder="全部研发" clearable size="default" style="width: 150px">
|
||
<el-option v-for="u in rdUsers" :key="u.id" :label="u.realname" :value="u.id" />
|
||
</el-select>
|
||
<el-button type="primary" @click="loadRdData">查询</el-button>
|
||
<el-button @click="exportData('rd')">📥 导出该页</el-button>
|
||
</div>
|
||
|
||
<div class="two-col-layout">
|
||
<!-- R&D Table -->
|
||
<div class="table-col">
|
||
<div class="section-title">📋 研发人员填报明细</div>
|
||
<el-table :data="rdData" stripe v-loading="rdLoading" style="width: 100%">
|
||
<el-table-column prop="user_name" label="研发人员" min-width="120" />
|
||
<el-table-column prop="client_name" label="客户/项目" min-width="180" />
|
||
<el-table-column prop="work_date" label="日期" min-width="120" />
|
||
<el-table-column prop="hours" label="工时(h)" min-width="100" />
|
||
<el-table-column prop="remark" label="备注" min-width="180" />
|
||
</el-table>
|
||
</div>
|
||
<!-- R&D Charts -->
|
||
<div class="chart-col">
|
||
<div class="chart-card">
|
||
<div class="chart-title">研发人员工时产出占比(饼图)</div>
|
||
<div ref="rdChartRef" class="big-chart"></div>
|
||
</div>
|
||
<div class="chart-card" style="margin-top: 16px">
|
||
<div class="chart-title">各研发人员工时对比(柱状图)</div>
|
||
<div ref="rdBarRef" class="big-chart"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</el-tab-pane>
|
||
|
||
<!-- ==================== Project Perspective ==================== -->
|
||
<el-tab-pane label="🏢 项目视角" name="project">
|
||
<div class="filter-bar">
|
||
<el-date-picker v-model="projFilter.dateRange" type="daterange" range-separator="至"
|
||
start-placeholder="开始日期" end-placeholder="结束日期" value-format="YYYY-MM-DD" size="default" style="margin-right: 12px;" />
|
||
<el-select v-model="projFilter.sales_id" clearable placeholder="全部销售" style="width: 140px; margin-right: 12px;">
|
||
<el-option v-for="u in salesUsers" :key="u.id" :label="u.realname" :value="u.id" />
|
||
</el-select>
|
||
<el-button type="primary" @click="loadProjectData">查询</el-button>
|
||
<el-button @click="exportData('project')">📥 导出该页</el-button>
|
||
</div>
|
||
|
||
<div class="two-col-layout">
|
||
<!-- Project Table -->
|
||
<div class="table-col">
|
||
<div class="section-title">📋 项目总览 (点击展开查看研发明细)</div>
|
||
<el-table :data="projectData" stripe v-loading="projLoading" style="width: 100%" row-key="client_id">
|
||
<el-table-column type="expand">
|
||
<template #default="props">
|
||
<div style="padding: 10px 40px; background: rgba(0,0,0,0.2);">
|
||
<div style="margin-bottom: 8px; font-weight: bold; color: var(--text-secondary);">
|
||
[ {{ props.row.client_name }} ] 的参与研发明细:
|
||
</div>
|
||
<el-table :data="props.row.rdList" border style="width: 100%" size="small">
|
||
<el-table-column prop="rd_name" label="研发人员" width="150" />
|
||
<el-table-column prop="total_hours" label="消耗工时(h)" width="120" sortable />
|
||
<el-table-column prop="work_days" label="工作天数" width="120" />
|
||
<el-table-column prop="first_date" label="首次介入" width="120" />
|
||
<el-table-column prop="last_date" label="最近介入" width="120" />
|
||
</el-table>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="client_name" label="客户/项目" min-width="200" />
|
||
<el-table-column prop="sales_name" label="所属销售" min-width="120" />
|
||
<el-table-column prop="client_status" label="状态" min-width="90">
|
||
<template #default="{ row }">
|
||
<el-tag :type="row.client_status === 1 ? 'success' : 'info'" effect="dark" round>
|
||
{{ row.client_status === 1 ? '进行中' : '已结算' }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="total_hours" label="总工时(h)" min-width="110" sortable />
|
||
<el-table-column prop="rd_count" label="研发人数" min-width="100" />
|
||
</el-table>
|
||
</div>
|
||
<!-- Project Charts -->
|
||
<div class="chart-col">
|
||
<div class="chart-card">
|
||
<div class="chart-title">项目工时分布(饼图)</div>
|
||
<div ref="projectChartRef" class="big-chart"></div>
|
||
</div>
|
||
<div class="chart-card" style="margin-top: 16px">
|
||
<div class="chart-title">各项目工时对比(柱状图)</div>
|
||
<div ref="projectBarRef" class="big-chart"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</el-tab-pane>
|
||
|
||
<!-- ==================== Matrix Summary ==================== -->
|
||
<el-tab-pane label="📊 研发工时汇总" name="matrix">
|
||
<div class="filter-bar">
|
||
<el-date-picker v-model="matrixFilter.dateRange" type="daterange" range-separator="至"
|
||
start-placeholder="开始日期" end-placeholder="结束日期" value-format="YYYY-MM-DD" size="default" />
|
||
<el-button type="primary" @click="loadMatrixData">查询</el-button>
|
||
<el-button @click="exportMatrix">📥 导出该页</el-button>
|
||
</div>
|
||
<div class="content-card" style="margin-top: 16px; overflow-x: auto;" v-loading="matrixLoading">
|
||
<table class="matrix-table" v-if="matrixRows.length">
|
||
<thead>
|
||
<tr>
|
||
<th class="row-header">研发人员</th>
|
||
<th v-for="col in matrixCols" :key="col">{{ col }}</th>
|
||
<th class="total-col">合计</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="(row, ri) in matrixRows" :key="ri" :class="{ 'total-row': ri === matrixRows.length - 1 }">
|
||
<td class="row-header">{{ row.label }}</td>
|
||
<td v-for="col in matrixCols" :key="col">{{ row.cells[col] || '-' }}</td>
|
||
<td class="total-col">{{ row.total }}</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
<el-empty v-else description="暂无工时数据" />
|
||
</div>
|
||
</el-tab-pane>
|
||
|
||
<!-- ==================== Fill-out Calendar ==================== -->
|
||
<el-tab-pane label="📅 填报日历" name="calendar">
|
||
<div class="filter-bar">
|
||
<el-date-picker v-model="calendarFilter.month" type="month" placeholder="选择月份"
|
||
value-format="YYYY-MM" size="default" :clearable="false" @change="handleCalendarFilterChange" style="width: 140px; margin-right: 12px;" />
|
||
|
||
<el-radio-group v-model="calendarViewMode" size="default" style="margin-right: 12px;">
|
||
<el-radio-button value="grid">全员概览</el-radio-button>
|
||
<el-radio-button value="individual">个人日历</el-radio-button>
|
||
</el-radio-group>
|
||
|
||
<el-select v-if="calendarViewMode === 'individual'" v-model="calendarFilter.user_id" placeholder="选择研发人员" size="default" style="width: 150px" @change="loadIndividualCalendarData">
|
||
<el-option v-for="u in rdUsers" :key="u.id" :label="u.realname" :value="u.id" />
|
||
</el-select>
|
||
</div>
|
||
|
||
<!-- 1. All Staff Heatmap Grid (全员概览) -->
|
||
<div v-if="calendarViewMode === 'grid'" class="content-card" style="margin-top: 16px; overflow-x: auto;" v-loading="calendarLoading">
|
||
<div class="card-title">
|
||
<span>👥 R&D 全员填报状态矩阵 ({{ calendarFilter.month }})</span>
|
||
<div class="calendar-legends">
|
||
<span class="legend-item"><span class="dot green"></span>已满8h</span>
|
||
<span class="legend-item"><span class="dot yellow"></span>未满8h</span>
|
||
<span class="legend-item"><span class="dot red"></span>漏填报</span>
|
||
<span class="legend-item"><span class="dot gray"></span>周末/未到期</span>
|
||
</div>
|
||
</div>
|
||
<table class="grid-table" v-if="gridData.length">
|
||
<thead>
|
||
<tr>
|
||
<th class="name-header">姓名</th>
|
||
<th v-for="day in daysInSelectedMonth" :key="day" class="day-col">
|
||
{{ day }}
|
||
<span class="day-weekday">{{ getDayLabel(day) }}</span>
|
||
</th>
|
||
<th class="total-header">累计工时</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="row in gridData" :key="row.user_id" class="grid-row">
|
||
<td class="name-header clickable" @click="switchToIndividual(row.user_id)">
|
||
{{ row.user_name }}
|
||
<span class="arrow-tip">📅</span>
|
||
</td>
|
||
<td v-for="day in daysInSelectedMonth" :key="day"
|
||
:class="['status-cell', getGridCellClass(row, day)]"
|
||
:title="getGridCellTitle(row, day)">
|
||
<span class="cell-hours">{{ getGridCellHours(row, day) }}</span>
|
||
</td>
|
||
<td class="total-cell">{{ row.total_hours }}h</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
<el-empty v-else description="暂无研发人员数据" />
|
||
</div>
|
||
|
||
<!-- 2. Individual Calendar View (个人日历) -->
|
||
<div v-else class="two-col-layout" style="margin-top: 16px;" v-loading="calendarLoading">
|
||
<!-- Calendar Card -->
|
||
<div class="table-col" style="flex: 1.5;">
|
||
<div class="content-card" style="margin-bottom: 0;">
|
||
<div class="card-title">
|
||
<span>📅 {{ selectedDeveloperName }} 的个人日历 ({{ calendarFilter.month }})</span>
|
||
<div class="calendar-legends">
|
||
<span class="legend-item"><span class="dot green"></span>已满8h</span>
|
||
<span class="legend-item"><span class="dot yellow"></span>未满8h</span>
|
||
<span class="legend-item"><span class="dot red"></span>漏填</span>
|
||
<span class="legend-item"><span class="dot gray"></span>周末/未到期</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Inline Calendar -->
|
||
<el-calendar v-model="adminCalendarDate" :key="adminCalendarKey">
|
||
<template #date-cell="{ data }">
|
||
<div class="day-cell-content" @click="handleAdminSelectDate(data.day)">
|
||
<div class="day-header">
|
||
<span class="day-num">{{ data.day.split('-')[2] }}</span>
|
||
<span v-if="data.type === 'current-month' && isRestDay(data.day)" class="weekend-tag">休</span>
|
||
</div>
|
||
<div class="day-body" v-if="data.type === 'current-month'">
|
||
<div v-if="getAdminDayHours(data.day) >= 8" class="status-badge filled">
|
||
{{ getAdminDayHours(data.day) }}h
|
||
</div>
|
||
<div v-else-if="getAdminDayHours(data.day) > 0" class="status-badge partial">
|
||
{{ getAdminDayHours(data.day) }}h
|
||
</div>
|
||
<div v-else-if="isAdminPastUnfilled(data.day)" class="status-badge unfilled">
|
||
未填报
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</el-calendar>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Side Details Card -->
|
||
<div class="chart-col" style="flex: 1; min-width: 320px;">
|
||
<!-- Month Summary Card -->
|
||
<div class="content-card">
|
||
<div class="card-title">📊 月度数据汇总</div>
|
||
<div class="mini-stats-grid">
|
||
<div class="mini-stat-item green-glow">
|
||
<div class="label">满额填报</div>
|
||
<div class="value green-text">{{ individualStats.fullDays }}<span class="unit">天</span></div>
|
||
</div>
|
||
<div class="mini-stat-item yellow-glow">
|
||
<div class="label">部分填报</div>
|
||
<div class="value yellow-text">{{ individualStats.partialDays }}<span class="unit">天</span></div>
|
||
</div>
|
||
<div class="mini-stat-item red-glow">
|
||
<div class="label">漏填工作日</div>
|
||
<div class="value red-text">{{ individualStats.unfilledDays }}<span class="unit">天</span></div>
|
||
</div>
|
||
<div class="mini-stat-item purple-glow">
|
||
<div class="label">累计总工时</div>
|
||
<div class="value purple-text">{{ individualStats.totalHours }}<span class="unit">小时</span></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Date records card -->
|
||
<div class="content-card" style="margin-top: 16px;">
|
||
<div class="card-title">📋 {{ adminSelectedDate }} 工时明细</div>
|
||
<el-table :data="adminSelectedDateRecords" stripe style="width: 100%" size="small">
|
||
<el-table-column prop="client_name" label="客户/项目" min-width="130" />
|
||
<el-table-column prop="hours" label="工时" width="70" />
|
||
<el-table-column prop="remark" label="备注" min-width="120" show-overflow-tooltip />
|
||
</el-table>
|
||
<el-empty v-if="!adminSelectedDateRecords.length" description="当天暂无工时记录" :image-size="50" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</el-tab-pane>
|
||
|
||
</el-tabs>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, reactive, computed, onMounted, nextTick, watch, onBeforeUnmount } from 'vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import { adminApi } from '../../api'
|
||
import * as echarts from 'echarts'
|
||
|
||
const activeTab = ref('sales')
|
||
const salesUsers = ref([])
|
||
const rdUsers = ref([])
|
||
|
||
const salesChartRef = ref(null)
|
||
const rdChartRef = ref(null)
|
||
const projectChartRef = ref(null)
|
||
const salesBarRef = ref(null)
|
||
const rdBarRef = ref(null)
|
||
const projectBarRef = ref(null)
|
||
|
||
let salesChartInstance = null
|
||
let rdChartInstance = null
|
||
let projectChartInstance = null
|
||
let salesBarInstance = null
|
||
let rdBarInstance = null
|
||
let projectBarInstance = null
|
||
const exportingAll = ref(false)
|
||
|
||
const getRecentMonthRange = () => {
|
||
const end = new Date()
|
||
const start = new Date()
|
||
start.setMonth(start.getMonth() - 1)
|
||
const pad = (n) => String(n).padStart(2, '0')
|
||
const format = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||
return [format(start), format(end)]
|
||
}
|
||
const defaultRange = getRecentMonthRange()
|
||
|
||
const allExportDateRange = ref(defaultRange)
|
||
|
||
// ====== Sales ======
|
||
const salesFilter = reactive({ dateRange: defaultRange })
|
||
const salesRawData = ref([])
|
||
const salesDetailData = ref([])
|
||
const salesLoading = ref(false)
|
||
|
||
const salesSummary = computed(() => {
|
||
const map = {}
|
||
|
||
// Aggregate detail row by rd_id into the sales object
|
||
salesDetailData.value.forEach(row => {
|
||
if (!map[row.sales_id]) {
|
||
map[row.sales_id] = {
|
||
sales_id: row.sales_id,
|
||
sales_name: row.sales_name,
|
||
projects: new Set(),
|
||
rdMap: {},
|
||
total_hours: 0
|
||
}
|
||
}
|
||
const sales = map[row.sales_id]
|
||
if (!sales.rdMap[row.rd_id]) {
|
||
sales.rdMap[row.rd_id] = { rd_name: row.rd_name, total_hours: 0, work_days: 0 }
|
||
}
|
||
sales.rdMap[row.rd_id].total_hours += row.total_hours
|
||
sales.rdMap[row.rd_id].work_days += row.work_days
|
||
})
|
||
|
||
// Add project counts & total hours from raw overviews to base object
|
||
salesRawData.value.forEach(row => {
|
||
if (!map[row.sales_id]) {
|
||
map[row.sales_id] = {
|
||
sales_id: row.sales_id,
|
||
sales_name: row.sales_name,
|
||
projects: new Set(),
|
||
rdMap: {},
|
||
total_hours: 0
|
||
}
|
||
}
|
||
map[row.sales_id].projects.add(row.client_id)
|
||
map[row.sales_id].total_hours += row.total_hours
|
||
})
|
||
|
||
return Object.values(map).map(m => {
|
||
return {
|
||
sales_name: m.sales_name,
|
||
project_count: m.projects.size,
|
||
rd_count: Object.keys(m.rdMap).length,
|
||
total_hours: Math.round(m.total_hours * 10) / 10,
|
||
rdList: Object.values(m.rdMap).sort((a,b)=> b.total_hours - a.total_hours)
|
||
}
|
||
})
|
||
})
|
||
|
||
async function loadSalesData() {
|
||
salesLoading.value = true
|
||
try {
|
||
const params = {}
|
||
if (salesFilter.dateRange) {
|
||
params.start_date = salesFilter.dateRange[0]
|
||
params.end_date = salesFilter.dateRange[1]
|
||
}
|
||
|
||
const [overview, detail] = await Promise.all([
|
||
adminApi.getSalesStats(params),
|
||
adminApi.getSalesDetail(params)
|
||
])
|
||
salesRawData.value = overview.data
|
||
salesDetailData.value = detail.data
|
||
await nextTick()
|
||
const pieData = salesSummary.value.filter(s => s.total_hours > 0).map(s => ({name: s.sales_name, value: s.total_hours}))
|
||
renderPieChart(salesChartRef, salesChartInstance, 'sales', pieData)
|
||
const salesBarData = salesSummary.value.filter(s => s.total_hours > 0)
|
||
renderBarChart(salesBarRef, salesBarInstance, 'sales',
|
||
salesBarData.map(s => s.sales_name),
|
||
salesBarData.map(s => s.total_hours)
|
||
)
|
||
} finally { salesLoading.value = false }
|
||
}
|
||
|
||
// ====== R&D ======
|
||
const rdFilter = reactive({ dateRange: defaultRange, user_id: '' })
|
||
const rdData = ref([])
|
||
const rdLoading = ref(false)
|
||
|
||
const rdSummary = computed(() => {
|
||
const map = {}
|
||
rdData.value.forEach(row => {
|
||
if (!map[row.user_id]) {
|
||
map[row.user_id] = { name: row.user_name, value: 0 }
|
||
}
|
||
map[row.user_id].value += row.hours
|
||
})
|
||
return Object.values(map).filter(r => r.value > 0)
|
||
})
|
||
|
||
async function loadRdData() {
|
||
rdLoading.value = true
|
||
try {
|
||
const params = {}
|
||
if (rdFilter.dateRange) {
|
||
params.start_date = rdFilter.dateRange[0]
|
||
params.end_date = rdFilter.dateRange[1]
|
||
}
|
||
if (rdFilter.user_id) params.user_id = rdFilter.user_id
|
||
const res = await adminApi.getRdStats(params)
|
||
rdData.value = res.data
|
||
await nextTick()
|
||
renderPieChart(rdChartRef, rdChartInstance, 'rd', rdSummary.value)
|
||
renderBarChart(rdBarRef, rdBarInstance, 'rd',
|
||
rdSummary.value.map(r => r.name),
|
||
rdSummary.value.map(r => r.value)
|
||
)
|
||
} finally { rdLoading.value = false }
|
||
}
|
||
|
||
// ====== Project ======
|
||
const projFilter = reactive({ dateRange: defaultRange, sales_id: '' })
|
||
const projectData = ref([])
|
||
const projLoading = ref(false)
|
||
const projectDetailData = ref([])
|
||
|
||
async function loadProjectData() {
|
||
projLoading.value = true
|
||
try {
|
||
const params = {}
|
||
if (projFilter.dateRange) {
|
||
params.start_date = projFilter.dateRange[0]
|
||
params.end_date = projFilter.dateRange[1]
|
||
}
|
||
if (projFilter.sales_id) params.sales_id = projFilter.sales_id
|
||
const [overview, detail] = await Promise.all([
|
||
adminApi.getProjectStats(params),
|
||
adminApi.getProjectDetail(params)
|
||
])
|
||
|
||
// Group details by project
|
||
const detailMap = {}
|
||
detail.data.forEach(row => {
|
||
if (!detailMap[row.client_id]) detailMap[row.client_id] = []
|
||
detailMap[row.client_id].push(row)
|
||
})
|
||
|
||
// Attach rdList to each project
|
||
projectData.value = overview.data.map(p => {
|
||
p.rdList = detailMap[p.client_id] || []
|
||
return p
|
||
})
|
||
|
||
await nextTick()
|
||
const projPieData = projectData.value.filter(p => p.total_hours > 0).map(p => ({name: p.client_name, value: p.total_hours}))
|
||
renderPieChart(projectChartRef, projectChartInstance, 'project', projPieData)
|
||
const projBarData = projectData.value.filter(p => p.total_hours > 0)
|
||
renderBarChart(projectBarRef, projectBarInstance, 'project',
|
||
projBarData.map(p => p.client_name),
|
||
projBarData.map(p => p.total_hours)
|
||
)
|
||
} finally { projLoading.value = false }
|
||
}
|
||
|
||
// ====== Charts ======
|
||
function renderPieChart(domRef, instanceRef, type, dataArray) {
|
||
if (!domRef.value || domRef.value.clientWidth === 0) return
|
||
if (instanceRef) instanceRef.dispose()
|
||
let inst = echarts.init(domRef.value, 'dark')
|
||
if (type === 'sales') salesChartInstance = inst
|
||
if (type === 'rd') rdChartInstance = inst
|
||
if (type === 'project') projectChartInstance = inst
|
||
inst.setOption({
|
||
backgroundColor: 'transparent',
|
||
tooltip: { trigger: 'item', formatter: '{b}: {c}h ({d}%)', confine: true },
|
||
legend: { type: 'scroll', bottom: 10, textStyle: { color: '#94a3b8' } },
|
||
series: [{
|
||
type: 'pie', radius: ['35%', '65%'], center: ['50%', '45%'],
|
||
avoidLabelOverlap: true,
|
||
itemStyle: { borderRadius: 8, borderColor: '#1e293b', borderWidth: 2 },
|
||
label: { color: '#f1f5f9', fontSize: 13 },
|
||
emphasis: {
|
||
label: { show: true, fontSize: 16, fontWeight: 'bold' },
|
||
itemStyle: { shadowBlur: 10, shadowOffsetX: 0, shadowColor: 'rgba(0,0,0,0.5)' }
|
||
},
|
||
data: dataArray
|
||
}]
|
||
})
|
||
}
|
||
|
||
function renderBarChart(domRef, instanceRef, type, categories, values) {
|
||
if (!domRef.value || domRef.value.clientWidth === 0) return
|
||
if (instanceRef) instanceRef.dispose()
|
||
let inst = echarts.init(domRef.value, 'dark')
|
||
if (type === 'sales') salesBarInstance = inst
|
||
if (type === 'rd') rdBarInstance = inst
|
||
if (type === 'project') projectBarInstance = inst
|
||
inst.setOption({
|
||
backgroundColor: 'transparent',
|
||
tooltip: { trigger: 'axis', formatter: (p) => `${p[0].name}: ${p[0].value}h`, confine: true },
|
||
grid: { left: '3%', right: '4%', bottom: '15%', containLabel: true },
|
||
xAxis: {
|
||
type: 'category', data: categories,
|
||
axisLabel: { color: '#94a3b8', interval: 0, rotate: categories.length > 5 ? 30 : 0, fontSize: 12 }
|
||
},
|
||
yAxis: { type: 'value', name: '工时(h)', axisLabel: { color: '#94a3b8' } },
|
||
series: [{
|
||
type: 'bar', barMaxWidth: 50, data: values,
|
||
itemStyle: {
|
||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||
{ offset: 0, color: '#818cf8' }, { offset: 1, color: '#4f46e5' }
|
||
]),
|
||
borderRadius: [4, 4, 0, 0]
|
||
},
|
||
label: { show: true, position: 'top', color: '#f1f5f9', fontSize: 12, formatter: '{c}h' },
|
||
markLine: type === 'rd' && values.length > 0 ? {
|
||
data: [{ type: 'average', name: '平均值' }],
|
||
lineStyle: { type: 'dashed', color: '#f59e0b', width: 2 },
|
||
label: { formatter: '平均: {c}h', position: 'end', color: '#f59e0b', fontWeight: 'bold' }
|
||
} : undefined
|
||
}]
|
||
})
|
||
}
|
||
|
||
function handleResize() {
|
||
salesChartInstance?.resize()
|
||
rdChartInstance?.resize()
|
||
projectChartInstance?.resize()
|
||
salesBarInstance?.resize()
|
||
rdBarInstance?.resize()
|
||
projectBarInstance?.resize()
|
||
}
|
||
|
||
// ====== Export ======
|
||
async function exportData(type) {
|
||
try {
|
||
if (type === 'all') {
|
||
exportingAll.value = true
|
||
let params = {}
|
||
let start = '开始', end = '至今'
|
||
if (allExportDateRange.value && allExportDateRange.value.length === 2) {
|
||
params.start_date = allExportDateRange.value[0]
|
||
params.end_date = allExportDateRange.value[1]
|
||
start = params.start_date
|
||
end = params.end_date
|
||
}
|
||
const response = await adminApi.exportAllExcel(params)
|
||
downloadBlob(response.data, `总报表_${start}至${end}.xlsx`)
|
||
ElMessage.success('全导成功')
|
||
exportingAll.value = false
|
||
return
|
||
}
|
||
|
||
let params = {}
|
||
let start = '开始', end = '至今', prefix = ''
|
||
if (type === 'sales') {
|
||
prefix = '销售视角'
|
||
if (salesFilter.dateRange && salesFilter.dateRange.length === 2) {
|
||
params.start_date = salesFilter.dateRange[0]; params.end_date = salesFilter.dateRange[1];
|
||
start = params.start_date; end = params.end_date;
|
||
}
|
||
} else if (type === 'rd') {
|
||
prefix = '研发视角'
|
||
if (rdFilter.dateRange && rdFilter.dateRange.length === 2) {
|
||
params.start_date = rdFilter.dateRange[0]; params.end_date = rdFilter.dateRange[1];
|
||
start = params.start_date; end = params.end_date;
|
||
}
|
||
} else if (type === 'project') {
|
||
prefix = '项目视角'
|
||
if (projFilter.dateRange && projFilter.dateRange.length === 2) {
|
||
params.start_date = projFilter.dateRange[0]; params.end_date = projFilter.dateRange[1];
|
||
start = params.start_date; end = params.end_date;
|
||
}
|
||
if (projFilter.sales_id) params.sales_id = projFilter.sales_id;
|
||
}
|
||
const response = await adminApi.exportExcel(type, params)
|
||
downloadBlob(response.data, `${prefix}_${start}至${end}.xlsx`)
|
||
ElMessage.success('导出成功')
|
||
} catch (e) {
|
||
exportingAll.value = false
|
||
ElMessage.error('导出失败')
|
||
}
|
||
}
|
||
|
||
function downloadBlob(data, filename) {
|
||
const url = URL.createObjectURL(new Blob([data]))
|
||
const link = document.createElement('a')
|
||
link.href = url
|
||
link.download = filename
|
||
link.click()
|
||
URL.revokeObjectURL(url)
|
||
}
|
||
|
||
watch(activeTab, (tab) => {
|
||
nextTick(() => {
|
||
if (tab === 'sales') {
|
||
const filtered = salesSummary.value.filter(s => s.total_hours > 0)
|
||
renderPieChart(salesChartRef, salesChartInstance, 'sales', filtered.map(s => ({name: s.sales_name, value: s.total_hours})))
|
||
renderBarChart(salesBarRef, salesBarInstance, 'sales', filtered.map(s => s.sales_name), filtered.map(s => s.total_hours))
|
||
} else if (tab === 'rd') {
|
||
renderPieChart(rdChartRef, rdChartInstance, 'rd', rdSummary.value)
|
||
renderBarChart(rdBarRef, rdBarInstance, 'rd', rdSummary.value.map(r => r.name), rdSummary.value.map(r => r.value))
|
||
} else if (tab === 'project') {
|
||
const filtered = projectData.value.filter(p => p.total_hours > 0)
|
||
renderPieChart(projectChartRef, projectChartInstance, 'project', filtered.map(p => ({name: p.client_name, value: p.total_hours})))
|
||
renderBarChart(projectBarRef, projectBarInstance, 'project', filtered.map(p => p.client_name), filtered.map(p => p.total_hours))
|
||
} else if (tab === 'matrix') {
|
||
loadMatrixData()
|
||
} else if (tab === 'calendar') {
|
||
if (rdUsers.value.length && !calendarFilter.user_id) {
|
||
calendarFilter.user_id = rdUsers.value[0].id
|
||
}
|
||
loadCalendarData().then(() => {
|
||
if (calendarViewMode.value === 'individual') {
|
||
loadIndividualCalendarData()
|
||
}
|
||
})
|
||
}
|
||
})
|
||
})
|
||
|
||
onMounted(async () => {
|
||
window.addEventListener('resize', handleResize)
|
||
try {
|
||
const [s, r] = await Promise.all([adminApi.getSalesUsers(), adminApi.getRdUsers()])
|
||
salesUsers.value = s.data
|
||
rdUsers.value = r.data
|
||
if (rdUsers.value.length && !calendarFilter.user_id) {
|
||
calendarFilter.user_id = rdUsers.value[0].id
|
||
}
|
||
} catch (e) {}
|
||
loadSalesData()
|
||
loadRdData()
|
||
loadProjectData()
|
||
loadMatrixData()
|
||
loadCalendarData().then(() => {
|
||
if (calendarViewMode.value === 'individual') {
|
||
loadIndividualCalendarData()
|
||
}
|
||
})
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
window.removeEventListener('resize', handleResize)
|
||
salesChartInstance?.dispose()
|
||
rdChartInstance?.dispose()
|
||
projectChartInstance?.dispose()
|
||
salesBarInstance?.dispose()
|
||
rdBarInstance?.dispose()
|
||
projectBarInstance?.dispose()
|
||
})
|
||
|
||
// ====== Calendar (填报日历) ======
|
||
const calendarViewMode = ref('grid')
|
||
const calendarLoading = ref(false)
|
||
const todayStr = ref(new Date().toLocaleDateString('sv-SE', { timeZone: 'Asia/Shanghai' }))
|
||
const calendarFilter = reactive({
|
||
month: new Date().toLocaleDateString('sv-SE', { timeZone: 'Asia/Shanghai' }).substring(0, 7),
|
||
user_id: ''
|
||
})
|
||
const adminCalendarDate = ref(new Date())
|
||
const adminSelectedDate = ref(new Date().toLocaleDateString('sv-SE', { timeZone: 'Asia/Shanghai' }))
|
||
|
||
const calendarRawRecords = ref([])
|
||
const gridData = ref([])
|
||
const adminRecordsMap = ref({})
|
||
const adminCalendarKey = ref(0)
|
||
|
||
const selectedDeveloperName = computed(() => {
|
||
const found = rdUsers.value.find(u => u.id === calendarFilter.user_id)
|
||
return found ? found.realname : ''
|
||
})
|
||
|
||
const daysInSelectedMonth = computed(() => {
|
||
const [year, month] = calendarFilter.month.split('-').map(Number)
|
||
const daysCount = new Date(year, month, 0).getDate()
|
||
return Array.from({ length: daysCount }, (_, i) => i + 1)
|
||
})
|
||
|
||
const getDayLabel = (day) => {
|
||
const [year, month] = calendarFilter.month.split('-').map(Number)
|
||
const d = new Date(year, month - 1, day)
|
||
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
|
||
return weekdays[d.getDay()]
|
||
}
|
||
|
||
const getGridCellHours = (row, day) => {
|
||
const dStr = `${calendarFilter.month}-${String(day).padStart(2, '0')}`
|
||
const hours = row.dates[dStr]?.hours || 0
|
||
return hours > 0 ? hours : ''
|
||
}
|
||
|
||
const holidaysMap = ref({})
|
||
const loadedHolidayYear = ref(null)
|
||
|
||
async function loadHolidays(year) {
|
||
try {
|
||
const res = await adminApi.getHolidays(year)
|
||
holidaysMap.value = res.data || {}
|
||
adminCalendarKey.value++
|
||
} catch (e) {
|
||
console.error('Failed to load holidays:', e)
|
||
}
|
||
}
|
||
|
||
const getGridCellClass = (row, day) => {
|
||
const dStr = `${calendarFilter.month}-${String(day).padStart(2, '0')}`
|
||
const hours = row.dates[dStr]?.hours || 0
|
||
if (hours >= 8) {
|
||
return 'cell-filled'
|
||
} else if (hours > 0) {
|
||
return 'cell-partial'
|
||
} else {
|
||
if (dStr < todayStr.value && !isRestDay(dStr)) {
|
||
return 'cell-unfilled'
|
||
}
|
||
return 'cell-neutral'
|
||
}
|
||
}
|
||
|
||
const getGridCellTitle = (row, day) => {
|
||
const dStr = `${calendarFilter.month}-${String(day).padStart(2, '0')}`
|
||
const hours = row.dates[dStr]?.hours || 0
|
||
if (hours > 0) {
|
||
return `${row.user_name} 在 ${dStr} 已填报 ${hours}h`
|
||
} else {
|
||
if (dStr < todayStr.value && !isRestDay(dStr)) {
|
||
return `${row.user_name} 在 ${dStr} 漏填报`
|
||
}
|
||
return `${row.user_name} 在 ${dStr} 无需填报/未到期`
|
||
}
|
||
}
|
||
|
||
const getDayOfWeek = (dayStr) => {
|
||
const d = new Date(dayStr + 'T00:00:00')
|
||
return d.getDay()
|
||
}
|
||
|
||
const isWorkday = (dayStr) => {
|
||
const mmDd = dayStr.substring(5)
|
||
if (holidaysMap.value && holidaysMap.value[mmDd]) {
|
||
return !holidaysMap.value[mmDd].holiday
|
||
}
|
||
const day = getDayOfWeek(dayStr)
|
||
return day !== 0 && day !== 6
|
||
}
|
||
|
||
const isRestDay = (dayStr) => {
|
||
return !isWorkday(dayStr)
|
||
}
|
||
|
||
const getAdminDayHours = (dayStr) => {
|
||
return adminRecordsMap.value[dayStr]?.hours || 0
|
||
}
|
||
|
||
const isAdminPastUnfilled = (dayStr) => {
|
||
if (dayStr >= todayStr.value) return false
|
||
if (isRestDay(dayStr)) return false
|
||
return getAdminDayHours(dayStr) === 0
|
||
}
|
||
|
||
const adminSelectedDateRecords = computed(() => {
|
||
return adminRecordsMap.value[adminSelectedDate.value]?.list || []
|
||
})
|
||
|
||
async function loadCalendarData() {
|
||
calendarLoading.value = true
|
||
try {
|
||
const [yearStr, monthStr] = calendarFilter.month.split('-')
|
||
const year = parseInt(yearStr)
|
||
const month = parseInt(monthStr)
|
||
const daysCount = new Date(year, month, 0).getDate()
|
||
const start_date = `${calendarFilter.month}-01`
|
||
const end_date = `${calendarFilter.month}-${String(daysCount).padStart(2, '0')}`
|
||
|
||
// Fetch holidays if year changes
|
||
if (loadedHolidayYear.value !== year) {
|
||
loadedHolidayYear.value = year
|
||
await loadHolidays(year)
|
||
}
|
||
|
||
const res = await adminApi.getRdStats({ start_date, end_date })
|
||
calendarRawRecords.value = res.data || []
|
||
buildGridData()
|
||
} catch (e) {
|
||
ElMessage.error('获取填报日历数据失败')
|
||
} finally {
|
||
calendarLoading.value = false
|
||
}
|
||
}
|
||
|
||
function buildGridData() {
|
||
const userMap = {}
|
||
rdUsers.value.forEach(u => {
|
||
userMap[u.id] = {
|
||
user_id: u.id,
|
||
user_name: u.realname,
|
||
dates: {},
|
||
total_hours: 0
|
||
}
|
||
})
|
||
|
||
calendarRawRecords.value.forEach(r => {
|
||
const uid = r.user_id
|
||
if (!userMap[uid]) {
|
||
userMap[uid] = {
|
||
user_id: uid,
|
||
user_name: r.user_name || '未知研发',
|
||
dates: {},
|
||
total_hours: 0
|
||
}
|
||
}
|
||
const dateStr = r.work_date
|
||
if (!userMap[uid].dates[dateStr]) {
|
||
userMap[uid].dates[dateStr] = { hours: 0, records: [] }
|
||
}
|
||
userMap[uid].dates[dateStr].hours += r.hours
|
||
userMap[uid].dates[dateStr].records.push(r)
|
||
userMap[uid].total_hours += r.hours
|
||
})
|
||
|
||
gridData.value = Object.values(userMap)
|
||
.map(u => {
|
||
u.total_hours = Math.round(u.total_hours * 10) / 10
|
||
return u
|
||
})
|
||
.filter(u => u.total_hours > 0)
|
||
}
|
||
|
||
function handleCalendarFilterChange() {
|
||
loadCalendarData().then(() => {
|
||
if (calendarViewMode.value === 'individual') {
|
||
loadIndividualCalendarData()
|
||
}
|
||
})
|
||
}
|
||
|
||
function switchToIndividual(userId) {
|
||
calendarFilter.user_id = userId
|
||
calendarViewMode.value = 'individual'
|
||
loadIndividualCalendarData()
|
||
}
|
||
|
||
function loadIndividualCalendarData() {
|
||
if (!calendarFilter.user_id) return
|
||
const map = {}
|
||
calendarRawRecords.value.forEach(r => {
|
||
if (r.user_id === calendarFilter.user_id) {
|
||
if (!map[r.work_date]) {
|
||
map[r.work_date] = { hours: 0, list: [] }
|
||
}
|
||
map[r.work_date].hours += r.hours
|
||
map[r.work_date].list.push(r)
|
||
}
|
||
})
|
||
adminRecordsMap.value = map
|
||
adminCalendarKey.value++
|
||
|
||
const [cy, cm] = calendarFilter.month.split('-').map(Number)
|
||
const currentCalDate = adminCalendarDate.value
|
||
if (currentCalDate.getFullYear() !== cy || (currentCalDate.getMonth() + 1) !== cm) {
|
||
adminCalendarDate.value = new Date(cy, cm - 1, 1)
|
||
}
|
||
|
||
updateAdminSelectedDate()
|
||
}
|
||
|
||
function handleAdminSelectDate(dayStr) {
|
||
adminSelectedDate.value = dayStr
|
||
}
|
||
|
||
function updateAdminSelectedDate() {
|
||
const today = new Date().toLocaleDateString('sv-SE', { timeZone: 'Asia/Shanghai' })
|
||
if (today.startsWith(calendarFilter.month)) {
|
||
adminSelectedDate.value = today
|
||
} else {
|
||
adminSelectedDate.value = `${calendarFilter.month}-01`
|
||
}
|
||
}
|
||
|
||
watch(adminCalendarDate, (newDate) => {
|
||
if (newDate && calendarViewMode.value === 'individual') {
|
||
const year = newDate.getFullYear()
|
||
const month = String(newDate.getMonth() + 1).padStart(2, '0')
|
||
const yyyyMm = `${year}-${month}`
|
||
if (calendarFilter.month !== yyyyMm) {
|
||
calendarFilter.month = yyyyMm
|
||
loadCalendarData().then(() => {
|
||
loadIndividualCalendarData()
|
||
})
|
||
}
|
||
}
|
||
})
|
||
|
||
watch(rdUsers, () => {
|
||
buildGridData()
|
||
})
|
||
|
||
watch(calendarViewMode, (newMode) => {
|
||
if (newMode === 'individual') {
|
||
if (!calendarFilter.user_id && rdUsers.value.length) {
|
||
calendarFilter.user_id = rdUsers.value[0].id
|
||
}
|
||
loadIndividualCalendarData()
|
||
}
|
||
})
|
||
|
||
const individualStats = computed(() => {
|
||
const [year, month] = calendarFilter.month.split('-').map(Number)
|
||
const daysCount = new Date(year, month, 0).getDate()
|
||
|
||
let fullDays = 0
|
||
let partialDays = 0
|
||
let unfilledDays = 0
|
||
let totalHours = 0
|
||
|
||
for (let day = 1; day <= daysCount; day++) {
|
||
const dStr = `${calendarFilter.month}-${String(day).padStart(2, '0')}`
|
||
const hours = getAdminDayHours(dStr)
|
||
totalHours += hours
|
||
|
||
if (hours >= 8) {
|
||
fullDays++
|
||
} else if (hours > 0) {
|
||
partialDays++
|
||
} else {
|
||
if (dStr < todayStr.value && !isRestDay(dStr)) {
|
||
unfilledDays++
|
||
}
|
||
}
|
||
}
|
||
|
||
return {
|
||
fullDays,
|
||
partialDays,
|
||
unfilledDays,
|
||
totalHours: Math.round(totalHours * 10) / 10
|
||
}
|
||
})
|
||
|
||
// ====== Matrix (研发工时汇总) ======
|
||
const matrixFilter = reactive({ dateRange: defaultRange })
|
||
const matrixLoading = ref(false)
|
||
const matrixCols = ref([]) // 项目-子项目 labels
|
||
const matrixRows = ref([]) // { label, cells:{colName:hours}, total }
|
||
|
||
async function loadMatrixData() {
|
||
matrixLoading.value = true
|
||
try {
|
||
const params = {}
|
||
if (matrixFilter.dateRange && matrixFilter.dateRange.length === 2) {
|
||
params.start_date = matrixFilter.dateRange[0]
|
||
params.end_date = matrixFilter.dateRange[1]
|
||
}
|
||
const res = await adminApi.getSummaryMatrix(params)
|
||
const raw = res.data
|
||
// Build cols = unique 项目-子项目 labels
|
||
const colSet = new Set(raw.map(r => r.sub_project ? `${r.client_name}-${r.sub_project}` : r.client_name))
|
||
matrixCols.value = [...colSet].sort()
|
||
// Build row keys = 研发人员
|
||
const rowKeyMap = {}
|
||
raw.forEach(r => {
|
||
const ck = r.sub_project ? `${r.client_name}-${r.sub_project}` : r.client_name
|
||
if (!rowKeyMap[r.user_name]) rowKeyMap[r.user_name] = {}
|
||
rowKeyMap[r.user_name][ck] = (rowKeyMap[r.user_name][ck] || 0) + r.hours
|
||
})
|
||
const dataRows = Object.entries(rowKeyMap).map(([label, cells]) => {
|
||
const total = Object.values(cells).reduce((s, v) => s + v, 0)
|
||
return { label, cells, total: Math.round(total * 10) / 10 }
|
||
})
|
||
// Totals row
|
||
const totalCells = {}
|
||
matrixCols.value.forEach(c => {
|
||
totalCells[c] = Math.round(dataRows.reduce((s, r) => s + (r.cells[c] || 0), 0) * 10) / 10
|
||
})
|
||
const grandTotal = Math.round(dataRows.reduce((s, r) => s + r.total, 0) * 10) / 10
|
||
dataRows.push({ label: '合计', cells: totalCells, total: grandTotal })
|
||
matrixRows.value = dataRows
|
||
} finally { matrixLoading.value = false }
|
||
}
|
||
|
||
async function exportMatrix() {
|
||
const params = {}
|
||
let start = '开始', end = '至今'
|
||
if (matrixFilter.dateRange && matrixFilter.dateRange.length === 2) {
|
||
params.start_date = matrixFilter.dateRange[0]
|
||
params.end_date = matrixFilter.dateRange[1]
|
||
start = params.start_date; end = params.end_date
|
||
}
|
||
const response = await adminApi.exportMatrixExcel(params)
|
||
downloadBlob(response.data, `研发工时汇总_${start}至${end}.xlsx`)
|
||
ElMessage.success('导出成功')
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
/* Tabs Overrides */
|
||
.dashboard-page :deep(.el-tabs--border-card) {
|
||
background: var(--bg-card) !important;
|
||
border-color: var(--border-color) !important;
|
||
border-radius: var(--radius) !important;
|
||
overflow: visible !important;
|
||
}
|
||
.dashboard-page :deep(.el-tabs__content) {
|
||
overflow: visible !important;
|
||
padding: 20px !important;
|
||
}
|
||
.dashboard-page :deep(.el-tab-pane) {
|
||
overflow: visible !important;
|
||
}
|
||
.dashboard-page :deep(.el-tabs__header) {
|
||
background: rgba(51, 65, 85, 0.3) !important;
|
||
border-bottom-color: var(--border-color) !important;
|
||
}
|
||
.dashboard-page :deep(.el-tabs__item) { color: var(--text-secondary) !important; }
|
||
.dashboard-page :deep(.el-tabs__item.is-active) {
|
||
color: var(--primary-light) !important;
|
||
background: var(--bg-card) !important;
|
||
}
|
||
.dashboard-page :deep(.el-tabs__item:hover) { color: var(--text-primary) !important; }
|
||
|
||
/* Table Overrides for Expandable Rows */
|
||
.dashboard-page :deep(.el-table__expanded-cell) {
|
||
background-color: rgba(0,0,0,0.1) !important;
|
||
padding: 0 !important;
|
||
}
|
||
.dashboard-page :deep(.el-table__expanded-cell:hover) {
|
||
background-color: rgba(0,0,0,0.1) !important;
|
||
}
|
||
|
||
.section-title {
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
color: var(--text-primary);
|
||
margin-bottom: 14px;
|
||
padding-bottom: 8px;
|
||
border-bottom: 1px solid var(--border-color);
|
||
}
|
||
|
||
.two-col-layout {
|
||
display: flex;
|
||
gap: 24px;
|
||
align-items: flex-start;
|
||
margin-top: 16px;
|
||
}
|
||
.table-col {
|
||
flex: 2;
|
||
min-width: 0;
|
||
}
|
||
.chart-col {
|
||
flex: 1;
|
||
min-width: 350px;
|
||
}
|
||
|
||
.chart-card {
|
||
background: rgba(30, 41, 59, 0.5);
|
||
border: 1px solid var(--border-color);
|
||
border-radius: var(--radius);
|
||
padding: 20px;
|
||
}
|
||
.chart-title {
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
color: var(--text-primary);
|
||
margin-bottom: 16px;
|
||
text-align: center;
|
||
}
|
||
.big-chart {
|
||
width: 100%;
|
||
height: 400px;
|
||
}
|
||
|
||
/* Matrix Table */
|
||
.matrix-table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
font-size: 13px;
|
||
}
|
||
.matrix-table th, .matrix-table td {
|
||
border: 1px solid var(--border-color);
|
||
padding: 8px 12px;
|
||
text-align: center;
|
||
white-space: nowrap;
|
||
}
|
||
.matrix-table thead th {
|
||
background: rgba(99, 102, 241, 0.15);
|
||
color: var(--primary-light);
|
||
font-weight: 600;
|
||
}
|
||
.matrix-table .row-header {
|
||
text-align: left;
|
||
font-weight: 500;
|
||
background: rgba(255,255,255,0.03);
|
||
min-width: 160px;
|
||
}
|
||
.matrix-table .total-col {
|
||
font-weight: 700;
|
||
color: #818cf8;
|
||
background: rgba(99,102,241,0.08);
|
||
}
|
||
.matrix-table .total-row td {
|
||
font-weight: 700;
|
||
background: rgba(99,102,241,0.12);
|
||
color: #c7d2fe;
|
||
}
|
||
.matrix-table tbody tr:hover td {
|
||
background: rgba(255,255,255,0.04);
|
||
}
|
||
|
||
/* Calendar tab custom styles */
|
||
.calendar-legends {
|
||
display: flex;
|
||
gap: 12px;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.legend-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
.legend-item .dot {
|
||
width: 10px;
|
||
height: 10px;
|
||
border-radius: 50%;
|
||
}
|
||
|
||
.legend-item .dot.green { background: var(--success); }
|
||
.legend-item .dot.yellow { background: var(--warning); }
|
||
.legend-item .dot.red { background: var(--danger); }
|
||
.legend-item .dot.gray { background: var(--text-muted); }
|
||
|
||
/* All staff grid table */
|
||
.grid-table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.grid-table th, .grid-table td {
|
||
border: 1px solid var(--border-color);
|
||
padding: 6px 4px;
|
||
text-align: center;
|
||
vertical-align: middle;
|
||
}
|
||
|
||
.grid-table thead th {
|
||
background: rgba(51, 65, 85, 0.5);
|
||
color: var(--text-secondary);
|
||
font-weight: 600;
|
||
padding: 8px 4px;
|
||
}
|
||
|
||
.grid-table .name-header {
|
||
text-align: left;
|
||
font-weight: 600;
|
||
color: var(--text-primary);
|
||
min-width: 100px;
|
||
padding-left: 12px;
|
||
background: var(--bg-card);
|
||
position: sticky;
|
||
left: 0;
|
||
z-index: 5;
|
||
border-right: 2px solid var(--border-color);
|
||
}
|
||
|
||
.grid-table .name-header.clickable {
|
||
cursor: pointer;
|
||
transition: var(--transition);
|
||
}
|
||
|
||
.grid-table .name-header.clickable:hover {
|
||
background: var(--bg-hover);
|
||
color: var(--primary-light);
|
||
}
|
||
|
||
.arrow-tip {
|
||
font-size: 10px;
|
||
opacity: 0;
|
||
transition: var(--transition);
|
||
margin-left: 4px;
|
||
}
|
||
|
||
.grid-table tr:hover .arrow-tip {
|
||
opacity: 0.8;
|
||
}
|
||
|
||
.day-col {
|
||
min-width: 32px;
|
||
font-size: 11px;
|
||
}
|
||
|
||
.day-weekday {
|
||
display: block;
|
||
font-size: 9px;
|
||
color: var(--text-muted);
|
||
font-weight: normal;
|
||
margin-top: 2px;
|
||
}
|
||
|
||
.status-cell {
|
||
position: relative;
|
||
transition: var(--transition);
|
||
}
|
||
|
||
.cell-hours {
|
||
font-weight: 600;
|
||
font-size: 11px;
|
||
}
|
||
|
||
.status-cell.cell-filled {
|
||
background-color: rgba(16, 185, 129, 0.2) !important;
|
||
color: #34d399;
|
||
}
|
||
|
||
.status-cell.cell-partial {
|
||
background-color: rgba(245, 158, 11, 0.2) !important;
|
||
color: #fbbf24;
|
||
}
|
||
|
||
.status-cell.cell-unfilled {
|
||
background-color: rgba(239, 68, 68, 0.2) !important;
|
||
color: #f87171;
|
||
}
|
||
|
||
.status-cell.cell-neutral {
|
||
background-color: transparent;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.total-header, .total-cell {
|
||
font-weight: 700;
|
||
min-width: 70px;
|
||
background: rgba(99, 102, 241, 0.05);
|
||
}
|
||
|
||
.total-cell {
|
||
color: var(--primary-light);
|
||
}
|
||
|
||
/* Individual Calendar UI overrides */
|
||
:deep(.el-calendar) {
|
||
--el-calendar-border-color: var(--border-color);
|
||
background-color: var(--bg-card);
|
||
border: 1px solid var(--border-color);
|
||
border-radius: var(--radius);
|
||
}
|
||
|
||
:deep(.el-calendar__header) {
|
||
border-bottom: 1px solid var(--border-color);
|
||
padding: 12px 16px;
|
||
}
|
||
|
||
:deep(.el-calendar-table td) {
|
||
border-bottom: 1px solid var(--border-color);
|
||
border-right: 1px solid var(--border-color);
|
||
background-color: transparent !important;
|
||
}
|
||
|
||
:deep(.el-calendar-table .el-calendar-day) {
|
||
height: 60px;
|
||
padding: 4px;
|
||
transition: var(--transition);
|
||
}
|
||
|
||
:deep(.el-calendar-table .el-calendar-day:hover) {
|
||
background-color: var(--bg-hover) !important;
|
||
}
|
||
|
||
:deep(.el-calendar-table td.is-selected) {
|
||
background-color: rgba(99, 102, 241, 0.15) !important;
|
||
}
|
||
|
||
.day-cell-content {
|
||
display: flex;
|
||
flex-direction: column;
|
||
height: 100%;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.day-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
|
||
.day-num {
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.weekend-tag {
|
||
font-size: 9px;
|
||
color: var(--text-muted);
|
||
background: rgba(255, 255, 255, 0.05);
|
||
padding: 0 3px;
|
||
border-radius: 2px;
|
||
}
|
||
|
||
.day-body {
|
||
margin-top: 2px;
|
||
display: flex;
|
||
justify-content: center;
|
||
}
|
||
|
||
.status-badge {
|
||
font-size: 9px;
|
||
padding: 0px 4px;
|
||
border-radius: 3px;
|
||
font-weight: 500;
|
||
text-align: center;
|
||
width: 90%;
|
||
}
|
||
|
||
.status-badge.filled {
|
||
background: rgba(16, 185, 129, 0.15);
|
||
color: #34d399;
|
||
border: 1px solid rgba(16, 185, 129, 0.2);
|
||
}
|
||
|
||
.status-badge.partial {
|
||
background: rgba(245, 158, 11, 0.15);
|
||
color: #fbbf24;
|
||
border: 1px solid rgba(245, 158, 11, 0.2);
|
||
}
|
||
|
||
.status-badge.unfilled {
|
||
background: rgba(239, 68, 68, 0.15);
|
||
color: #f87171;
|
||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||
}
|
||
|
||
/* Stats mini grid for Admin calendar */
|
||
.mini-stats-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, 1fr);
|
||
gap: 12px;
|
||
}
|
||
|
||
.mini-stat-item {
|
||
background: rgba(30, 41, 59, 0.4);
|
||
border: 1px solid var(--border-color);
|
||
padding: 12px;
|
||
border-radius: var(--radius-sm);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
transition: var(--transition);
|
||
}
|
||
|
||
.mini-stat-item:hover {
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
.green-glow:hover { border-color: rgba(16, 185, 129, 0.4); }
|
||
.yellow-glow:hover { border-color: rgba(245, 158, 11, 0.4); }
|
||
.red-glow:hover { border-color: rgba(239, 68, 68, 0.4); }
|
||
.purple-glow:hover { border-color: rgba(99, 102, 241, 0.4); }
|
||
|
||
.mini-stat-item .label {
|
||
font-size: 11px;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.mini-stat-item .value {
|
||
font-size: 18px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.mini-stat-item .unit {
|
||
font-size: 10px;
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
.green-text { color: #10b981; }
|
||
.yellow-text { color: #f59e0b; }
|
||
.red-text { color: #ef4444; }
|
||
.purple-text { color: #818cf8; }
|
||
</style>
|