This commit is contained in:
hero920103 committed 2026-11-11 08:53:14 +08:00
1 parent d9a50e23dd
commit 4f1acaa451
25 files changed
+1265 -335

No files matched your search

+1 -1
View File
@@ -3,7 +3,7 @@ import request from "@/utils/request";
/**
* CRM 业务管线接口:线索 → 商机 → 项目,回访贯穿全流程。
*
* 关联类型 related_type:1=线索 2=商机 3=项目
* 关联类型 related_type:1=线索 2=商机 3=项目 4=投标 5=招标 6=合同
*/
/* --------------------------------- 线索 --------------------------------- */
@@ -110,8 +110,8 @@ const props = defineProps({
const emit = defineEmits(["update:visible", "refresh", "edit"]);
// 合同操作日志与项目共用关联类型 3
const RELATED_TYPE = 3;
// 合同操作日志独立关联类型 6(与项目 3 解耦,避免 ID 撞车时日志串数据)
const RELATED_TYPE = 6;
// 电子合同附件专属关联类型(与线索 1 / 商机 2 / 项目 3 区分)
const E_CONTRACT_TYPE = 4;
// 发票管理附件专属关联类型
+22 -5
View File
@@ -60,6 +60,18 @@
</div>
</div>
<!-- 状态 Tab 栏:按业务流程顺序展示 -->
<div class="status-tabs">
<el-tabs v-model="activeStatusTab" @tab-change="handleStatusTabChange">
<el-tab-pane label="全部" name="" />
<el-tab-pane label="草稿" name="1" />
<el-tab-pane label="已完成" name="2" />
<el-tab-pane label="履约中" name="4" />
<el-tab-pane label="异常" name="5" />
<el-tab-pane label="已作废" name="3" />
</el-tabs>
</div>
<div class="filter-bar">
<el-form :inline="true" :model="filters" @submit.prevent>
<el-form-item>
@@ -88,11 +100,6 @@
<el-option label="无头合同" value="headless" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" clearable placeholder="全部" style="width: 110px">
<el-option v-for="i in CONTRACT_STATUS_OPTIONS" :key="i.value" :label="i.label" :value="i.value" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button>
<el-button :icon="Refresh" @click="resetFilters">重置</el-button>
@@ -245,6 +252,15 @@ const filters = reactive({
});
const pagination = reactive({ page: 1, pageSize: 20, total: 0 });
/** 状态 Tab 绑定值,空字符串表示"全部" */
const activeStatusTab = ref("");
/** 切换状态 Tab:同步到 filters.status 并刷新列表 */
function handleStatusTabChange(name) {
filters.status = name;
handleSearch();
}
/**
* 统计周期:all(全部)/ month / quarter / year / custom(自定义选区间)。
* 默认「本年」——列表与统计都按签订日期落在本年内归类,打开即看当年合同。
@@ -401,6 +417,7 @@ function resetFilters() {
filters.contract_category = "";
filters.project_type = "";
filters.status = "";
activeStatusTab.value = "";
handleSearch();
}
+23 -5
View File
@@ -11,6 +11,19 @@
</div>
</div>
<!-- 状态 Tab 栏 -->
<div class="status-tabs">
<el-tabs v-model="activeStatusTab" @tab-change="handleStatusTabChange">
<el-tab-pane label="全部" name="" />
<el-tab-pane
v-for="i in PROJECT_STATUS_OPTIONS"
:key="i.value"
:label="i.label"
:name="i.value"
/>
</el-tabs>
</div>
<div class="filter-bar">
<el-form :inline="true" :model="filters" @submit.prevent>
<el-form-item>
@@ -23,11 +36,6 @@
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item label="项目状态">
<el-select v-model="filters.status" clearable placeholder="全部" style="width: 130px">
<el-option v-for="i in PROJECT_STATUS_OPTIONS" :key="i.value" :label="i.label" :value="i.value" />
</el-select>
</el-form-item>
<!-- 年度筛选:按项目开始时间(关联合同汇总)所在年份过滤,选项由后端按现有项目汇总 -->
<el-form-item label="年度">
<el-select v-model="filters.year" clearable placeholder="全部" style="width: 120px">
@@ -179,6 +187,15 @@ const pagination = reactive({ page: 1, pageSize: 20, total: 0 });
/** 年度筛选项:由后端按现有项目的开始时间汇总返回(倒序),避免前端写死年份范围 */
const yearOptions = ref([]);
/** 状态 Tab 绑定值,空字符串表示"全部" */
const activeStatusTab = ref("");
/** 切换状态 Tab:同步到 filters.status 并刷新列表 */
function handleStatusTabChange(name) {
filters.status = name;
handleSearch();
}
onMounted(() => {
fetchList();
});
@@ -221,6 +238,7 @@ function resetFilters() {
filters.keyword = "";
filters.status = "";
filters.year = "";
activeStatusTab.value = "";
handleSearch();
}
@@ -240,7 +240,7 @@ function tagType(type) {
padding-left: 10px;
font-size: 15px;
font-weight: 600;
color: #303133;
color: var(--el-text-color-primary);
&::before {
content: "";
@@ -251,7 +251,7 @@ function tagType(type) {
width: 3px;
height: 14px;
border-radius: 2px;
background: #2563eb;
background: var(--el-color-primary);
}
}
@@ -300,7 +300,7 @@ function tagType(type) {
width: 16px;
height: 16px;
border-radius: 8px;
border: 2px solid #fff;
border: 2px solid var(--el-bg-color);
display: flex;
align-items: center;
justify-content: center;
@@ -327,7 +327,7 @@ function tagType(type) {
flex: 1;
width: 0;
margin-top: 6px;
border-left: 1px dashed #cbd5e1;
border-left: 1px dashed var(--el-border-color);
}
.flow-main {
@@ -350,28 +350,28 @@ function tagType(type) {
.flow-name {
font-size: 14px;
font-weight: 600;
color: #303133;
color: var(--el-text-color-primary);
}
.flow-sub {
margin-top: 2px;
font-size: 12px;
color: #909399;
color: var(--el-text-color-secondary);
}
.flow-time {
margin-top: 4px;
font-size: 12px;
color: #909399;
color: var(--el-text-color-secondary);
}
.flow-comment {
margin-top: 8px;
padding: 8px 10px;
background: #f1f5f9;
background: var(--el-fill-color-light);
border-radius: 6px;
font-size: 13px;
color: #606266;
color: var(--el-text-color-regular);
line-height: 1.6;
}
@@ -170,7 +170,7 @@ onMounted(loadList);
<style lang="less" scoped>
.approval-page {
background: #f5f7fa;
background: var(--el-bg-color-page);
padding: 20px;
}
@@ -182,13 +182,13 @@ onMounted(loadList);
h2 {
margin: 0 0 8px;
color: #303133;
color: var(--el-text-color-primary);
font-size: 22px;
}
p {
margin: 0;
color: #909399;
color: var(--el-text-color-secondary);
font-size: 13px;
}
}
@@ -202,10 +202,10 @@ onMounted(loadList);
.filter-card,
.table-card {
background: #fff;
border-radius: 6px;
background: var(--el-bg-color);
border-radius: var(--el-border-radius-base);
padding: 18px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
border: 1px solid var(--el-border-color-lighter);
}
.filter-card {
@@ -181,7 +181,7 @@ onMounted(loadList);
<style lang="less" scoped>
.approval-page {
background: #f5f7fa;
background: var(--el-bg-color-page);
padding: 20px;
}
@@ -193,23 +193,23 @@ onMounted(loadList);
h2 {
margin: 0 0 8px;
color: #303133;
color: var(--el-text-color-primary);
font-size: 22px;
}
p {
margin: 0;
color: #909399;
color: var(--el-text-color-secondary);
font-size: 13px;
}
}
.filter-card,
.table-card {
background: #fff;
border-radius: 6px;
background: var(--el-bg-color);
border-radius: var(--el-border-radius-base);
padding: 18px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
border: 1px solid var(--el-border-color-lighter);
}
.filter-card {
@@ -60,7 +60,7 @@ onMounted(loadFlows);
<style lang="less" scoped>
.approval-settings-page {
background: #f5f7fa;
background: var(--el-bg-color-page);
padding: 20px;
}
@@ -69,23 +69,23 @@ onMounted(loadFlows);
h2 {
margin: 0 0 8px;
color: #303133;
color: var(--el-text-color-primary);
font-size: 22px;
}
p {
margin: 0;
color: #909399;
color: var(--el-text-color-secondary);
font-size: 13px;
}
}
.reference-card {
margin-top: 16px;
background: #fff;
border-radius: 6px;
background: var(--el-bg-color);
border-radius: var(--el-border-radius-base);
padding: 18px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
border: 1px solid var(--el-border-color-lighter);
}
.section-title {
@@ -94,7 +94,7 @@ onMounted(loadFlows);
padding-left: 10px;
font-size: 15px;
font-weight: 600;
color: #303133;
color: var(--el-text-color-primary);
&::before {
content: "";
@@ -105,13 +105,13 @@ onMounted(loadFlows);
width: 3px;
height: 14px;
border-radius: 2px;
background: #2563eb;
background: var(--el-color-primary);
}
}
.hint {
margin: 0 0 12px;
font-size: 13px;
color: #909399;
color: var(--el-text-color-secondary);
}
</style>
+440 -141
View File
@@ -121,8 +121,138 @@
</div>
</div>
<!-- 右侧 30%:通知公告 + 工作日程提醒 -->
<!-- 右侧 30%:今日概览 + 通知公告 + 工作日程提醒 -->
<div class="col-right">
<!-- 今日概览卡片(顶部,与左侧 6 张指标卡区域等高) -->
<div class="today-card">
<div class="tc-greet">
<div class="tc-hello">Hi,{{ displayName }}</div>
<div class="tc-sub">
{{ timeGreeting }},今天是 {{ todayWeekday }} {{ todayDay }}
</div>
</div>
<div class="tc-tiles">
<div class="tc-tile">
<div class="tc-tile-head">
<span class="tc-tile-label">天气</span>
<el-icon
class="tc-refresh"
:class="{ spinning: weatherLoading }"
@click="refreshWeather"
>
<Refresh />
</el-icon>
</div>
<div class="tc-weather" v-if="weather.available">
<el-icon :size="26" class="tc-weather-icon">
<component :is="weather.icon" />
</el-icon>
<div class="tc-weather-text">
<div class="tc-weather-temp">{{ weather.temp }}°</div>
<div class="tc-weather-desc">
{{ weather.text }}<span v-if="weather.city"> · {{ weather.city }}</span>
</div>
</div>
</div>
<div class="tc-weather tc-weather-empty" v-else>
<el-icon :size="22"><Cloudy /></el-icon>
<span>暂未获取</span>
</div>
</div>
<div class="tc-tile">
<div class="tc-tile-head">
<span class="tc-tile-label">今日排班</span>
</div>
<div class="tc-shift" :class="todaySchedule.status">
<span class="tc-shift-dot" />
<span class="tc-shift-text">{{ todaySchedule.label }}</span>
</div>
<div class="tc-shift-meta">
<span v-if="todaySchedule.shiftName">{{ todaySchedule.shiftName }}</span>
<span v-if="todaySchedule.time">{{ todaySchedule.time }}</span>
</div>
</div>
</div>
<div class="tc-schedule">
<div class="tc-schedule-head">
<span class="tc-section-title">工作日程</span>
<el-button link type="primary" @click="goSchedule">查看全部</el-button>
</div>
<el-tabs v-model="scheduleRange" class="schedule-tabs">
<el-tab-pane
v-for="tab in scheduleRanges"
:key="tab.value"
:name="tab.value"
>
<template #label>
<span class="tab-label">{{ tab.label }}</span>
</template>
</el-tab-pane>
</el-tabs>
<div v-loading="scheduleLoading" class="schedule-list">
<div
v-for="item in filteredScheduleReminders"
:key="item.id"
class="schedule-item"
:class="{ done: item.status === 1 }"
:style="{ '--item-color': item.color }"
@click="openScheduleDetail(item)"
>
<span
class="item-status-dot"
:class="{ done: item.status === 1 }"
/>
<div class="schedule-item-main">
<div
class="schedule-item-title"
:class="{ done: item.status === 1 }"
>
<i v-if="hasImages(item)" class="fa-solid fa-image schedule-image-icon"></i>
{{ item.title }}
<el-tag
v-if="item.priority > 0"
:type="item.priority === 2 ? 'danger' : 'warning'"
size="small"
effect="light"
>
{{ item.priority === 2 ? "紧急" : "重要" }}
</el-tag>
<el-tag
v-if="item.source_id > 0 && item.status === 0"
type="warning"
size="small"
effect="plain"
>
延期第 {{ item.carry_count + 1 }} 天
</el-tag>
</div>
<div
class="schedule-item-time"
:class="{ done: item.status === 1 }"
>
<span :class="{ overdue: isOverdueSchedule(item) }">{{
scheduleDateTag(item)
}}</span>
<span>{{ scheduleTimeText(item) }}</span>
</div>
</div>
</div>
<el-empty
v-if="!filteredScheduleReminders.length && !scheduleLoading"
:description="
scheduleRange === 'today'
? '今日暂无日程安排'
: '该时间范围内暂无日程'
"
:image-size="60"
/>
</div>
</div>
</div>
<div class="chart-card notice-card">
<div class="chart-title notice-title">
<span>通知公告</span>
@@ -156,84 +286,6 @@
/>
</div>
</div>
<div class="chart-card schedule-card">
<div class="chart-title schedule-title">
<span>工作日程提醒</span>
<el-button link type="primary" @click="goSchedule"
>查看全部</el-button
>
</div>
<el-tabs v-model="scheduleRange" class="schedule-tabs">
<el-tab-pane
v-for="tab in scheduleRanges"
:key="tab.value"
:name="tab.value"
>
<template #label>
<span class="tab-label">{{ tab.label }}</span>
</template>
</el-tab-pane>
</el-tabs>
<div v-loading="scheduleLoading" class="schedule-list">
<div
v-for="item in filteredScheduleReminders"
:key="item.id"
class="schedule-item"
:class="{ done: item.status === 1 }"
:style="{ '--item-color': item.color }"
@click="openScheduleDetail(item)"
>
<span
class="item-status-dot"
:class="{ done: item.status === 1 }"
/>
<div class="schedule-item-main">
<div
class="schedule-item-title"
:class="{ done: item.status === 1 }"
>
<i v-if="hasImages(item)" class="fa-solid fa-image schedule-image-icon"></i>
{{ item.title }}
<el-tag
v-if="item.priority > 0"
:type="item.priority === 2 ? 'danger' : 'warning'"
size="small"
effect="light"
>
{{ item.priority === 2 ? "紧急" : "重要" }}
</el-tag>
<el-tag
v-if="item.source_id > 0 && item.status === 0"
type="warning"
size="small"
effect="plain"
>
延期第 {{ item.carry_count + 1 }} 天
</el-tag>
</div>
<div
class="schedule-item-time"
:class="{ done: item.status === 1 }"
>
<span :class="{ overdue: isOverdueSchedule(item) }">{{
scheduleDateTag(item)
}}</span>
<span>{{ scheduleTimeText(item) }}</span>
</div>
</div>
</div>
<el-empty
v-if="!filteredScheduleReminders.length && !scheduleLoading"
:description="
scheduleRange === 'today'
? '今日暂无日程安排'
: '该时间范围内暂无日程'
"
:image-size="60"
/>
</div>
</div>
</div>
</div>
@@ -290,17 +342,35 @@ import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue"
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import * as echarts from "echarts";
import { Refresh } from "@element-plus/icons-vue";
import {
Refresh,
Sunny,
Cloudy,
PartlyCloudy,
Moon,
OfficeBuilding,
Lightning,
Pouring,
Drizzling
} from "@element-plus/icons-vue";
import request from "@/utils/request";
import { getReimbursementDashboard } from "@/api/reimburse";
import {
getNoticeDetail,
getNoticePortal
} from "@/api/oaNotice";
import { finishSchedule, getScheduleList, getScheduleStats } from "@/api/oaSchedule";
import { getMyScheduleBoard } from "@/api/oaScheduling";
import ScheduleDetail from "../schedule/components/detail.vue";
import SchedulePostpone from "../schedule/components/postponeDialog.vue";
// 时间显示统一为:YYYY-MM-DD HH:mm:ss
import { formatDateTime } from "@/utils/datetime";
import { useAuthStore } from "@/stores/auth";
const authStore = useAuthStore();
const displayName = computed(
() => authStore.user?.name || authStore.user?.account || "朋友"
);
const emptyStat = () => ({ total: 0, paid: 0, unpaid: 0, count: 0 });
const dashboard = reactive({
@@ -318,6 +388,102 @@ const statCards = computed(() => [
{ key: "year", label: "本年报销", data: dashboard.year },
]);
// ---------- 今日概览 ----------
const todayDate = new Date();
const WEEKDAYS = ["星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六"];
const todayWeekday = WEEKDAYS[todayDate.getDay()];
const todayDay = `${todayDate.getMonth() + 1}月${todayDate.getDate()}日`;
const timeGreeting = (() => {
const h = new Date().getHours();
if (h < 6) return "凌晨好";
if (h < 9) return "早上好";
if (h < 12) return "上午好";
if (h < 14) return "中午好";
if (h < 18) return "下午好";
return "晚上好";
})();
const pad2 = (n) => String(n).padStart(2, "0");
const todayStr = `${todayDate.getFullYear()}-${pad2(todayDate.getMonth() + 1)}-${pad2(todayDate.getDate())}`;
const todayScheduleEntry = ref(null);
const todaySchedule = computed(() => {
const entry = todayScheduleEntry.value;
const isRest = !entry || !entry.shift_id || Number(entry.shift_id) === 0;
const workStart = entry?.work_start || "";
const workEnd = entry?.work_end || "";
const time = workStart && workEnd ? `${workStart} - ${workEnd}` : "";
return {
status: isRest ? "rest" : "work",
label: isRest ? "今日休息" : "今日上班",
icon: isRest ? Moon : OfficeBuilding,
time,
shiftName: entry?.shift_name || "",
};
});
const weather = ref({
available: false,
temp: "",
text: "",
city: "",
icon: PartlyCloudy,
});
// 天气图标:按中国天气网的天气现象文字(晴/多云/阴/小雨…)匹配
const weatherIconOf = (desc) => {
const s = String(desc || "");
if (s.includes("雷")) return Lightning;
if (s.includes("暴雨") || s.includes("大雨")) return Pouring;
if (s.includes("雨")) return Drizzling;
if (s.includes("雪")) return Cloudy;
if (s.includes("阴")) return Cloudy;
if (s.includes("云")) return PartlyCloudy;
if (s.includes("晴")) return Sunny;
return PartlyCloudy;
};
const loadTodaySchedule = async () => {
try {
const res = await getMyScheduleBoard({ start_date: todayStr, end_date: todayStr });
const list = res?.data?.list || [];
todayScheduleEntry.value = list.length ? list[0] : null;
} catch (e) {
console.warn("加载今日排班失败:", e?.message, e);
todayScheduleEntry.value = null;
}
};
const WEATHER_CITY = "连云港";
const weatherLoading = ref(false);
const loadWeather = async () => {
if (weatherLoading.value) return;
weatherLoading.value = true;
try {
const res = await request.get("/backend/weather/now", {
params: { city: WEATHER_CITY },
});
const d = res?.data || {};
if (!d.temp && !d.text) throw new Error(res?.msg || "天气数据为空");
weather.value = {
available: true,
temp: d.temp || "",
text: d.text || "",
city: d.city || WEATHER_CITY,
icon: weatherIconOf(d.text),
};
} catch (e) {
console.warn("加载天气失败:", e?.message);
weather.value = { ...weather.value, available: false };
} finally {
weatherLoading.value = false;
}
};
// 手动刷新天气(点击卡片右上角刷新图标)
const refreshWeather = async () => {
await loadWeather();
if (weather.value.available) ElMessage.success("天气已更新");
};
// 趋势图
const trendChartRef = ref(null);
let trendChart = null;
@@ -423,7 +589,6 @@ const scheduleRanges = [
];
const scheduleRange = ref("today");
const pad2 = (n) => String(n).padStart(2, "0");
const fmtDate = (d) =>
`${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
@@ -660,6 +825,11 @@ const loadScheduleStats = async () => {
};
const loadDashboard = async () => {
// 今日概览与经营看板并行拉取
await Promise.allSettled([
loadTodaySchedule(),
loadWeather(),
]);
try {
const data = responseData(await getReimbursementDashboard());
dashboard.week = data.week || emptyStat();
@@ -730,11 +900,8 @@ onBeforeUnmount(() => {
}
.stat-row {
margin-bottom: 16px;
.el-col {
margin-bottom: 8px;
}
/* 行内换行/窄屏堆叠时的行间距;与下一张卡的间距由 .col-left 的 gap 统一控制 */
row-gap: 16px;
}
.stat-card {
@@ -782,7 +949,6 @@ onBeforeUnmount(() => {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin-bottom: 16px;
}
.todo-stat-card {
@@ -828,12 +994,12 @@ onBeforeUnmount(() => {
min-width: 0;
}
.col-left > .chart-card {
margin-bottom: 16px;
&:last-child {
margin-bottom: 0;
}
/* 统一卡片间距:两栏内部全部用 16px 的 flex gap,卡片自身不再写 margin */
.col-left,
.col-right {
display: flex;
flex-direction: column;
gap: 16px;
}
.chart-card {
@@ -869,13 +1035,182 @@ onBeforeUnmount(() => {
}
}
.type-card {
margin-bottom: 0;
/* 今日概览卡片:与左侧 6 张指标卡区域等高 */
.today-card {
display: flex;
flex-direction: column;
gap: 14px;
padding: 18px;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.04);
min-height: 250px;
/* 打招呼 */
.tc-greet {
.tc-hello {
font-size: 20px;
font-weight: 700;
line-height: 1.3;
color: var(--el-text-color-primary);
}
.tc-sub {
margin-top: 4px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
/* 天气 / 排班 两块信息 */
.tc-tiles {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.tc-tile {
min-width: 0;
padding: 10px;
background: var(--el-fill-color-light);
border-radius: 8px;
.tc-tile-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
margin-bottom: 6px;
min-height: 16px;
}
.tc-tile-label {
font-size: 11px;
color: var(--el-text-color-placeholder);
}
.tc-refresh {
font-size: 12px;
color: var(--el-text-color-placeholder);
cursor: pointer;
transition: color 0.2s;
&:hover {
color: var(--el-color-primary);
}
&.spinning {
animation: tc-spin 1s linear infinite;
color: var(--el-color-primary);
}
}
}
.tc-weather {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
.tc-weather-icon {
flex-shrink: 0;
color: #f59e0b;
}
.tc-weather-text {
min-width: 0;
}
.tc-weather-temp {
font-size: 18px;
font-weight: 600;
line-height: 1.1;
color: var(--el-text-color-primary);
}
.tc-weather-desc {
margin-top: 2px;
font-size: 11px;
color: var(--el-text-color-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&.tc-weather-empty {
gap: 6px;
font-size: 12px;
color: var(--el-text-color-placeholder);
}
}
.tc-shift {
display: flex;
align-items: center;
gap: 6px;
.tc-shift-dot {
width: 7px;
height: 7px;
flex-shrink: 0;
border-radius: 50%;
background: var(--el-text-color-placeholder);
}
.tc-shift-text {
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
}
&.work {
.tc-shift-dot {
background: var(--el-color-success);
}
.tc-shift-text {
color: var(--el-color-success);
}
}
}
.tc-shift-meta {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 4px;
font-size: 11px;
color: var(--el-text-color-secondary);
}
/* 工作日程提醒(与今日概览合并展示) */
.tc-schedule {
margin-top: 2px;
padding-top: 12px;
border-top: 1px dashed var(--el-border-color-lighter);
.tc-schedule-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
}
.tc-section-title {
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-primary);
}
}
}
@keyframes tc-spin {
to {
transform: rotate(360deg);
}
}
.notice-card {
margin-bottom: 16px;
.notice-title {
display: flex;
align-items: center;
@@ -957,43 +1292,9 @@ onBeforeUnmount(() => {
overflow-y: auto;
}
.schedule-card {
display: flex;
flex-direction: column;
/*min-height: 340px; */
height: 600px;
.schedule-title {
display: flex;
align-items: center;
justify-content: space-between;
}
.schedule-tabs {
margin-bottom: 20px;
:deep(.el-tabs__header) {
margin: 0;
}
:deep(.el-tabs__nav-wrap::after) {
height: 1px;
}
:deep(.el-tabs__item) {
height: 36px;
line-height: 36px;
}
}
.schedule-list {
flex: 1;
}
}
.schedule-tabs {
:deep(.el-tabs__header) {
margin: 0 0 10px;
margin: 0 0 8px;
}
/* 列表由下方 .schedule-list 承载,tabs 只作切换栏,隐藏空的面板容器 */
@@ -1025,16 +1326,12 @@ onBeforeUnmount(() => {
text-align: center;
}
.status-card {
margin-bottom: 16px;
}
.schedule-list {
display: flex;
flex-direction: column;
gap: 8px;
min-height: 120px;
max-height: 520px;
max-height: 300px;
overflow-y: auto;
}
@@ -1229,10 +1526,6 @@ onBeforeUnmount(() => {
flex: 1 1 auto;
width: 100%;
}
.col-right {
margin-top: 16px;
}
}
@media (max-width: 768px) {
@@ -1261,6 +1554,12 @@ html.dark & {
background: var(--el-bg-color-page);
}
.today-card {
background: var(--el-bg-color);
border-color: var(--el-border-color);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.35);
}
.page-header {
h2 {
color: var(--el-text-color-primary);
@@ -436,13 +436,20 @@ const dateRangeText = (row) => {
return `${startDate || '?'} ~ ${endDate || '至今'}`;
};
/** 合同状态:以结束日期判断履行中 / 已到期 */
/**
* 合同档案状态:按起止日期自动推导三态。
* - 过期:结束日期已过
* - 废弃:开始日期尚未到来(签订后未生效)
* - 正常:履行中 / 无固定期限 / 仅一端有日期等其余场景
*/
const contractStatus = (row) => {
const startDate = formatDateOnly(row.start_date);
const endDate = formatDateOnly(row.end_date);
if (!endDate) return { text: '长期', type: 'success' };
return endDate >= new Date().toISOString().slice(0, 10)
? { text: '履行中', type: 'success' }
: { text: '已到期', type: 'warning' };
const today = new Date().toISOString().slice(0, 10);
if (endDate && endDate < today) return { text: '过期', type: 'warning' };
if (startDate && startDate > today) return { text: '废弃', type: 'info' };
return { text: '正常', type: 'success' };
};
const isImage = (url) => /\.(png|jpe?g|gif|webp|bmp)(\?.*)?$/i.test(String(url || ''));
@@ -161,7 +161,7 @@ const uploadAccept = computed(() => (
));
const uploadTip = computed(() => (
meta.value.type === 3
? '支持图片、PDF、Word、Excel,大小不超过 20MB'
? '支持图片、PDF、Word、Excel,大小不超过 30MB'
: '支持图片 / PDF,大小不超过 10MB'
));
@@ -218,7 +218,7 @@ const beforeUpload = (file) => {
? ['application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet']
: ['application/pdf'];
const allowed = file.type.startsWith('image/') || allowedTypes.includes(file.type);
const limitMB = isContract ? 20 : 10;
const limitMB = isContract ? 30 : 10;
const withinLimit = file.size / 1024 / 1024 < limitMB;
if (!allowed) ElMessage.error(isContract ? '仅支持图片、PDF、Word 或 Excel 格式' : '仅支持图片或 PDF 格式');
if (!withinLimit) ElMessage.error(`文件大小不能超过 ${limitMB}MB`);
@@ -5,11 +5,16 @@
<span v-if="overdueDays > 0" class="postpone-overdue"
>该日程已逾期 {{ overdueDays }} 天。</span
>
延期后「{{ schedule.title }}」在 {{ schedule.schedule_date }}
当天标记为已完成,并在目标日期自动生成一条待办继续跟进。
「{{ schedule.title }}」在 {{ schedule.schedule_date }} 当天会标记为已完成。延期到会仅在目标日期创建一条待办;延期至会在次日至目标日期的每一天分别创建一条待办。
</p>
<el-form label-position="top">
<el-form-item label="延期到">
<el-form-item label="延期方式">
<el-radio-group v-model="postponeMode">
<el-radio-button label="to">延期到(点对点)</el-radio-button>
<el-radio-button label="through">延期至(逐日创建)</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item :label="postponeMode === 'to' ? '延期到' : '延期至'">
<el-date-picker
v-model="targetDate"
type="date"
@@ -118,6 +123,7 @@ const overdueDays = computed(() => {
});
const targetDate = ref("");
const postponeMode = ref("to");
const saving = ref(false);
// 打开时给出默认日期:逾期任务默认补到今天,未到期任务默认顺延到下一天
@@ -148,10 +154,11 @@ async function submit() {
saving.value = true;
try {
const res = await carrySchedule(props.schedule.id, {
target_date: targetDate.value
target_date: targetDate.value,
mode: postponeMode.value
});
if (res?.code === 200) {
ElMessage.success(`已延期到 ${targetDate.value}`);
ElMessage.success(postponeMode.value === "to" ? `已延期到 ${targetDate.value}` : `已延期至 ${targetDate.value}`);
visible.value = false;
emit("saved");
} else {
+78 -103
View File
@@ -113,17 +113,7 @@
<span v-else-if="cell.tag === 'work'" class="holiday-tag is-work"
>班</span
>
<span v-if="cell.isToday" class="today-tag">今</span>
<span v-else-if="cell.day === 1" class="month-tag"
>{{ cell.month }}月</span
>
<span
v-if="cell.myEntry"
class="my-shift-chip"
:style="{ background: cell.myEntry.color }"
:title="`${cell.myEntry.shift_name} ${cell.myEntry.work_start}-${cell.myEntry.work_end}`"
>{{ cell.myEntry.short_name || cell.myEntry.shift_name }}</span
>
<span v-if="cell.day === 1" class="month-tag">{{ cell.month }}月</span>
</div>
<div v-if="cell.holidayName" class="cell-holiday" :title="cell.holidayName">
{{ cell.holidayName }}
@@ -158,6 +148,8 @@
还有 {{ cell.items.length - 3 }} 项
</div>
</div>
<div v-if="cell.myTag === 'work'" class="cell-sched-badge is-work">班</div>
<div v-else-if="cell.myTag === 'rest'" class="cell-sched-badge is-rest">休</div>
</div>
</div>
</div>
@@ -1372,6 +1364,7 @@ onMounted(() => {
}
.calendar-cell {
position: relative;
border-right: 1px solid #f2f4f8;
border-bottom: 1px solid #f2f4f8;
padding: 6px;
@@ -1393,12 +1386,6 @@ onMounted(() => {
background: #fafbfc;
}
.calendar-cell.selected {
background: #ecf5ff;
box-shadow: inset 0 0 0 2px #409eff;
border-radius: 4px;
}
.cell-date {
display: flex;
align-items: center;
@@ -1411,18 +1398,6 @@ onMounted(() => {
font-weight: 500;
}
.calendar-cell.today .day-num {
background: #409eff;
color: #fff;
border-radius: 50%;
width: 22px;
height: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.today-tag,
.month-tag {
font-size: 11px;
color: #409eff;
@@ -1468,73 +1443,90 @@ onMounted(() => {
background: #fff;
}
/* 我的排班背景:上班浅绿 / 休息浅橙。排班是实际作息,覆盖节假日底色,
但保留休/班角标与节日名;today / selected 在后面定义,优先级仍最高 */
/* 我的排班背景:上班绿 / 休息橙。排班是实际作息,覆盖节假日底色,
但保留休/班角标与节日名;today / selected 在后面定义,优先级仍最高。
仅靠提高底色饱和度区分,不加边框/竖条 */
.calendar-cell.is-my-work {
background: #f0f9eb;
background: #e2f6d2;
}
.calendar-cell.is-my-work:hover {
background: #e6f5db;
background: #d3f0bd;
}
/* 休息日:斜纹底 + 右上角「休」角标。
斜纹比纯色更像「非工作日」,角标保证文字含义仍在,且不压住日程条目 */
.calendar-cell.is-my-rest {
background: #fdf6ec;
background: repeating-linear-gradient(
135deg,
#fdf3e6 0,
#fdf3e6 7px,
#fbe6cd 7px,
#fbe6cd 14px
);
}
.calendar-cell.is-my-rest:hover {
background: #faecd8;
background: repeating-linear-gradient(
135deg,
#fbeadb 0,
#fbeadb 7px,
#f8dcbb 7px,
#f8dcbb 14px
);
}
/* 格内班次角标:用班次定义色做实底小色块 */
.my-shift-chip {
display: inline-flex;
align-items: center;
/* 排班角标:上班「班」绿 / 休息「休」橙,统一放右上角,与日期行互不干扰 */
.cell-sched-badge {
position: absolute;
top: 5px;
right: 5px;
width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 3px;
border-radius: 50%;
color: #fff;
font-size: 10px;
line-height: 1;
color: #fff;
flex-shrink: 0;
max-width: 56px;
overflow: hidden;
white-space: nowrap;
font-weight: 600;
line-height: 16px;
text-align: center;
pointer-events: none;
user-select: none;
&.is-work {
background: #67c23a;
box-shadow: 0 1px 2px rgba(103, 194, 58, 0.35);
}
&.is-rest {
background: #e6a23c;
box-shadow: 0 1px 2px rgba(230, 162, 60, 0.35);
}
}
/* 今天:整格实蓝底,最醒目。放在 is-off / is-work 之后,
保证今天即使逢节假日也保留蓝色标识。
实底较深,格内文字统一转白(或白底反白)以保证对比度。 */
/* 选中态优先级高于排班底色:蓝色斜条纹 + 主色描边 */
.calendar-cell.selected {
background: repeating-linear-gradient(
135deg,
#ecf5ff 0,
#ecf5ff 6px,
#d9ecff 6px,
#d9ecff 12px
);
box-shadow: inset 0 0 0 2px #409eff;
border-radius: 4px;
}
/* 今天:不再用实底蓝,改为绿色描边框,保留排班底色(上班绿 / 休息斜纹)不变。
放在 is-off / is-work / is-my-* 之后,保证框始终可见。 */
.calendar-cell.today {
background: #409eff;
box-shadow: inset 0 0 0 2px #67c23a;
border-radius: 4px;
}
.calendar-cell.today:hover {
background: #66b1ff;
}
/* 日期数字改为白底蓝字,避免在蓝底上"蓝圆叠蓝底"糊成一片 */
/* 今天的日期数字用绿色加粗强调,替代原来的「今」字标签 */
.calendar-cell.today .day-num {
background: #fff;
color: #409eff;
}
.calendar-cell.today .cell-holiday,
.calendar-cell.today .today-tag {
color: #fff;
}
/* 休/班角标改为白底,保持原有红/灰字色可辨识 */
.calendar-cell.today .holiday-tag.is-off {
background: #fff;
color: #f56c6c;
border-color: #fff;
}
.calendar-cell.today .holiday-tag.is-work {
background: #fff;
color: #909399;
border-color: #fff;
color: #529b2e;
font-weight: 700;
}
/* 格内节日名称 */
@@ -1845,11 +1837,16 @@ html.dark & {
background: var(--el-fill-color-light);
}
&.selected {
background: var(--el-color-primary-light-9);
background: repeating-linear-gradient(
135deg,
rgba(64, 158, 255, 0.14) 0,
rgba(64, 158, 255, 0.14) 6px,
rgba(64, 158, 255, 0.3) 6px,
rgba(64, 158, 255, 0.3) 12px
);
box-shadow: inset 0 0 0 2px var(--el-color-primary);
}
}
.today-tag,
.month-tag {
color: var(--el-color-primary);
}
@@ -1888,34 +1885,12 @@ html.dark & {
background: rgba(230, 162, 60, 0.16);
}
}
/* 今天:整格主色实底,与浅色主题保持一致 */
/* 今天:与浅色主题一致,只用绿色描边框,不覆盖排班底色 */
.calendar-cell.today {
background: var(--el-color-primary);
&:hover {
background: var(--el-color-primary-light-3);
}
box-shadow: inset 0 0 0 2px #67c23a;
.day-num {
background: #fff;
color: var(--el-color-primary);
}
.cell-holiday,
.today-tag {
color: #fff;
}
.holiday-tag.is-off {
background: #fff;
color: #f56c6c;
border-color: #fff;
}
.holiday-tag.is-work {
background: #fff;
color: #909399;
border-color: #fff;
color: #95d475;
}
}
.cell-holiday {
+358 -10
View File
@@ -31,16 +31,69 @@
<span class="period-range">{{ rangeText }}</span>
</div>
<!-- 主指标:本期购销、库存与应收 -->
<div class="stats-grid">
<div v-for="item in primaryStats" :key="item.label" class="stat-card" :class="item.tone">
<div class="stat-icon">
<el-icon :size="24"><component :is="item.icon" /></el-icon>
<!-- 主指标:本期购销、库存与应收 + 右侧今日概览 -->
<div class="top-row">
<div class="top-row-left">
<div class="stats-grid">
<div v-for="item in primaryStats" :key="item.label" class="stat-card" :class="item.tone">
<div class="stat-icon">
<el-icon :size="24"><component :is="item.icon" /></el-icon>
</div>
<div class="stat-body">
<div class="stat-label">{{ item.label }}</div>
<div class="stat-value">{{ item.value }}</div>
<div class="stat-hint">{{ item.hint }}</div>
</div>
</div>
</div>
<div class="stat-body">
<div class="stat-label">{{ item.label }}</div>
<div class="stat-value">{{ item.value }}</div>
<div class="stat-hint">{{ item.hint }}</div>
</div>
<!-- 今日概览卡片:星期几、是否上班、天气 -->
<div class="today-card">
<div class="today-header">
<div class="today-date">
<div class="today-weekday">{{ todayWeekday }}</div>
<div class="today-day">{{ todayDay }}</div>
</div>
<div class="today-schedule" :class="todaySchedule.status">
<el-icon :size="18">
<component :is="todaySchedule.icon" />
</el-icon>
<div class="schedule-info">
<span class="schedule-label">{{ todaySchedule.label }}</span>
<span class="schedule-time" v-if="todaySchedule.time">{{ todaySchedule.time }}</span>
</div>
</div>
</div>
<div class="today-weather" v-if="weather.available">
<div class="weather-main">
<el-icon :size="36"><component :is="weather.icon" /></el-icon>
<div class="weather-temp">{{ weather.temp }}°</div>
</div>
<div class="weather-detail">
<span>{{ weather.text }}</span>
<span v-if="weather.city" class="weather-city">{{ weather.city }}</span>
</div>
</div>
<div class="today-weather weather-fallback" v-else>
<el-icon :size="32"><Cloudy /></el-icon>
<div class="weather-detail">
<span>天气暂未获取</span>
</div>
</div>
<div class="today-extra">
<div class="extra-item">
<span class="extra-label">是否上班</span>
<span class="extra-value" :class="todaySchedule.status === 'work' ? 'text-success' : 'text-muted'">
{{ todaySchedule.status === 'work' ? '上班' : '休息' }}
</span>
</div>
<div class="extra-item" v-if="todaySchedule.shiftName">
<span class="extra-label">班次</span>
<span class="extra-value">{{ todaySchedule.shiftName }}</span>
</div>
</div>
</div>
</div>
@@ -144,9 +197,16 @@ import {
TrendCharts,
Coin,
Refresh,
Sunny,
Cloudy,
PartlyCloudy,
Moon,
Sunset,
OfficeBuilding,
} from '@element-plus/icons-vue';
import { getErpDashboard } from '@/api/erpReport';
import { getDocumentList } from '@/api/erpDocument';
import { getMyScheduleBoard } from '@/api/oaScheduling';
import {
docTypeLabel,
docStatusText,
@@ -207,6 +267,89 @@ const netChart = shallowRef<echarts.ECharts | null>(null);
const displayName = computed(() => authStore.user.name || authStore.user.account || '管理员');
const tenantName = computed(() => authStore.user.tenant_name || '');
/** 今日日期信息 */
const today = new Date();
const WEEKDAYS = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'];
const todayWeekday = WEEKDAYS[today.getDay()];
const todayDay = `${today.getMonth() + 1}月${today.getDate()}日`;
const todayStr = today.toISOString().slice(0, 10);
/** 今日排班(来自后端 /backend/oa/scheduling/my,仅今日) */
const todayScheduleEntry = ref<any>(null);
const SCHEDULE_STATUS_HOLIDAYS = [0]; // shift_id=0 或无记录 => 休息
const todaySchedule = computed(() => {
const entry = todayScheduleEntry.value;
const isRest = !entry || !entry.shift_id || Number(entry.shift_id) === 0;
const workStart = entry?.work_start || '';
const workEnd = entry?.work_end || '';
const time = workStart && workEnd ? `${workStart} - ${workEnd}` : '';
return {
status: isRest ? 'rest' : 'work',
label: isRest ? '今日休息' : '今日上班',
icon: isRest ? Moon : OfficeBuilding,
time,
shiftName: entry?.shift_name || '',
};
});
/** 天气:用 wttr.in 免费接口(无需 key),失败时优雅降级 */
type WeatherInfo = {
available: boolean;
temp: string;
text: string;
city: string;
icon: any;
};
const weather = ref<WeatherInfo>({
available: false,
temp: '',
text: '',
city: '',
icon: PartlyCloudy,
});
const WEATHER_ICON_MAP: Record<string, any> = {
Sunny, Clear: Sunny, Sun: Sunny,
Cloudy, Overcast: Cloudy,
'Partly cloudy': PartlyCloudy,
Rain: Cloudy, Snow: Cloudy, Stormy: Cloudy,
};
const weatherIconOf = (desc: string) => {
const key = String(desc || '').trim();
return WEATHER_ICON_MAP[key] || PartlyCloudy;
};
const loadTodaySchedule = async () => {
try {
const res = await getMyScheduleBoard({ start_date: todayStr, end_date: todayStr });
const list = res?.data?.list || [];
todayScheduleEntry.value = list.length ? list[0] : null;
} catch {
todayScheduleEntry.value = null;
}
};
const loadWeather = async () => {
try {
const res = await fetch('https://wttr.in/?format=j1', { mode: 'cors' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const cur = data?.current_condition?.[0] || {};
const area = data?.nearest_area?.[0] || {};
const areaName =
area?.areaName?.[0]?.value || area?.region?.[0]?.value || area?.country?.[0]?.value || '';
const desc = cur.lang_zh?.[0]?.value || cur.weatherDesc?.[0]?.value || '';
weather.value = {
available: true,
temp: cur.temp_C ?? '',
text: desc,
city: areaName,
icon: weatherIconOf(desc),
};
} catch {
weather.value = { ...weather.value, available: false };
}
};
const currentDate = computed(() => {
const date = new Date();
const weekdays = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'];
@@ -454,7 +597,14 @@ const loadAll = async () => {
loading.value = true;
noErp.value = false;
try {
const status = await loadDashboard();
// 今日概览(排班 + 天气)和经营看板并行拉取
const [dashboardStatus, scheduleRes, weatherPromise] = await Promise.allSettled([
loadDashboard(),
loadTodaySchedule(),
loadWeather(),
]);
const status = dashboardStatus.status === 'fulfilled' ? dashboardStatus.value : 'fail';
if (status === 'denied') return;
const [approvingRes, draftRes, latestRes] = await Promise.allSettled([
@@ -578,6 +728,24 @@ onUnmounted(() => {
}
}
// 主指标卡 + 右侧今日概览双列
.top-row {
display: grid;
grid-template-columns: 1fr 320px;
gap: 16px;
margin-bottom: 16px;
align-items: stretch;
.top-row-left {
display: flex;
min-width: 0;
}
.stats-grid {
flex: 1;
}
}
// 主指标卡
.stats-grid {
display: grid;
@@ -666,6 +834,163 @@ onUnmounted(() => {
}
}
// 今日概览卡片:与左侧 stats-grid 等高
.today-card {
display: flex;
flex-direction: column;
gap: 14px;
padding: 20px;
background: linear-gradient(135deg, #f0f6ff 0%, #fef6f0 100%);
border: 1px solid var(--el-border-color-lighter);
border-radius: 12px;
min-height: 0;
.today-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
.today-date {
.today-weekday {
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-secondary);
line-height: 1.3;
}
.today-day {
margin-top: 2px;
font-size: 22px;
font-weight: 700;
color: var(--el-text-color-primary);
line-height: 1.2;
}
}
.today-schedule {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 8px;
font-size: 12px;
flex-shrink: 0;
&.work {
background: rgba(57, 115, 255, 0.1);
color: var(--el-color-primary);
}
&.rest {
background: rgba(134, 144, 156, 0.12);
color: var(--el-text-color-secondary);
}
.schedule-info {
display: flex;
flex-direction: column;
line-height: 1.3;
.schedule-time {
font-size: 11px;
opacity: 0.8;
}
}
}
}
.today-weather {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
background: rgba(255, 255, 255, 0.65);
border-radius: 10px;
.weather-main {
display: flex;
align-items: center;
gap: 8px;
color: #f59e0b;
.weather-temp {
font-size: 26px;
font-weight: 600;
color: var(--el-text-color-primary);
line-height: 1;
}
}
.weather-detail {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
font-size: 12px;
color: var(--el-text-color-secondary);
text-align: right;
.weather-city {
font-size: 11px;
opacity: 0.7;
}
}
&.weather-fallback {
justify-content: center;
color: var(--el-text-color-placeholder);
.weather-main {
color: var(--el-text-color-placeholder);
flex-direction: column;
gap: 4px;
}
.weather-temp {
display: none;
}
}
}
.today-extra {
margin-top: auto;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
.extra-item {
display: flex;
flex-direction: column;
gap: 2px;
.extra-label {
font-size: 11px;
color: var(--el-text-color-placeholder);
}
.extra-value {
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
&.text-success {
color: var(--el-color-success);
}
&.text-muted {
color: var(--el-text-color-secondary);
}
}
}
}
}
html.dark {
.today-card {
background: linear-gradient(135deg, #1e2a44 0%, #2a1e18 100%);
border-color: #3a3a3a;
.today-weather {
background: rgba(255, 255, 255, 0.06);
}
}
}
// 次级指标
.mini-grid {
display: grid;
@@ -839,6 +1164,29 @@ onUnmounted(() => {
// 响应式
@media (max-width: 1200px) {
.top-row {
grid-template-columns: 1fr;
.today-card {
flex-direction: row;
gap: 18px;
.today-header {
flex: 1;
min-width: 0;
}
.today-weather {
flex-shrink: 0;
min-width: 180px;
}
.today-extra {
margin-top: 0;
grid-template-columns: 1fr;
flex-shrink: 0;
}
}
}
.charts-section,
.lists-section {
grid-template-columns: 1fr;