فهرست منبع

Merge branch 'master' of http://1.94.244.160:3000/shuzhihua/pms-iot-vue

Zimo 1 هفته پیش
والد
کامیت
8d5e13710b
4فایلهای تغییر یافته به همراه678 افزوده شده و 47 حذف شده
  1. 6 1
      src/api/pms/qhse/index.ts
  2. 15 18
      src/views/pms/qhse/factor/index.vue
  3. 283 27
      src/views/pms/qhse/hazard/index.vue
  4. 374 1
      src/views/pms/qhse/safety/index.vue

+ 6 - 1
src/api/pms/qhse/index.ts

@@ -192,6 +192,11 @@ export const IotDangerApi = {
   // 导出危险源 Excel
   exportDanger: async (params) => {
     return await request.download({ url: `/rq/iot-danger-source/export-excel`, params })
+  },
+
+  // 统计
+  getDangerStatistics: async (id) => {
+    return await request.get({ url: `/rq/iot-danger-source/stat?deptId=${id}` })
   }
 }
 
@@ -296,7 +301,7 @@ export const IotHiddenApi = {
   },
   // 统计
   getHiddenStatistics: async (id) => {
-    return await request.get({ url: `/rq/iot-hazard-type?deptId=${id}` })
+    return await request.get({ url: `/rq/iot-hazard/stat?deptId=${id}` })
   }
 }
 

+ 15 - 18
src/views/pms/qhse/factor/index.vue

@@ -11,8 +11,7 @@
               placeholder="请输入工序"
               clearable
               @keyup.enter="handleQuery"
-              class="!w-150px"
-            />
+              class="!w-150px" />
           </el-form-item>
           <el-form-item label="步骤分解" prop="stepBreak">
             <el-input
@@ -20,8 +19,7 @@
               placeholder="请输入步骤分解"
               clearable
               @keyup.enter="handleQuery"
-              class="!w-150px"
-            />
+              class="!w-150px" />
           </el-form-item>
           <el-form-item label="环境因素" prop="environmentElement">
             <el-input
@@ -29,8 +27,7 @@
               placeholder="请输入环境因素"
               clearable
               @keyup.enter="handleQuery"
-              class="!w-150px"
-            />
+              class="!w-150px" />
           </el-form-item>
           <el-form-item>
             <el-button @click="handleQuery">
@@ -57,8 +54,13 @@
               {{ scope.$index + 1 }}
             </template>
           </zm-table-column>
-          <zm-table-column label="工序" prop="process" align="center" fixed="left" />
-          <zm-table-column label="步骤分解" prop="stepBreak" align="center" />
+          <zm-table-column
+            label="工序"
+            prop="process"
+            align="center"
+            fixed="left"
+            min-width="150" />
+          <zm-table-column label="步骤分解" min-width="150" prop="stepBreak" align="center" />
           <zm-table-column label="环境因素" prop="environmentElement" align="center" />
 
           <zm-table-column label="时态" align="center">
@@ -112,8 +114,7 @@
                   type="success"
                   style="border: none"
                   plain
-                  v-if="row.statusException"
-                >
+                  v-if="row.statusException">
                   <span class="text-[#259644]">
                     {{ '✔' }}
                   </span>
@@ -242,8 +243,7 @@
             layout="total, sizes, prev, pager, next"
             @size-change="handleSizeChange"
             @current-change="handleCurrentChange"
-            background
-          />
+            background />
         </div>
       </ContentWrap>
     </el-col>
@@ -255,8 +255,7 @@
         :model="formData"
         :rules="formRules"
         label-width="120px"
-        v-loading="formLoading"
-      >
+        v-loading="formLoading">
         <el-row :gutter="20">
           <el-col :span="12">
             <el-form-item label="工序" prop="process">
@@ -330,8 +329,7 @@
               <el-input
                 type="textarea"
                 v-model="formData.controlMethod"
-                placeholder="请输入控制措施"
-              />
+                placeholder="请输入控制措施" />
             </el-form-item>
           </el-col>
         </el-row>
@@ -347,8 +345,7 @@
                 check-strictly
                 node-key="id"
                 filterable
-                placeholder="请选择所在部门"
-              />
+                placeholder="请选择所在部门" />
             </el-form-item>
           </el-col>
           <el-col :span="12">

+ 283 - 27
src/views/pms/qhse/hazard/index.vue

@@ -10,14 +10,12 @@
               v-model="queryParams.riskGrade"
               placeholder="请选择风险等级"
               clearable
-              style="width: 200px"
-            >
+              style="width: 200px">
               <el-option
                 v-for="dict in getStrDictOptions(DICT_TYPE.DANGER_GRADE)"
                 :key="dict.value"
                 :label="dict.label"
-                :value="dict.value"
-              />
+                :value="dict.value" />
             </el-select>
           </el-form-item>
 
@@ -39,14 +37,43 @@
       </ContentWrap>
 
       <ContentWrap style="border: 0">
+        <div v-loading="!staticData.length" class="stats-cards">
+          <div
+            v-for="item in statsDisplayCards"
+            :key="item.key"
+            class="stats-card"
+            :style="getStatsCardStyle(item.accent, item.glow)">
+            <div
+              class="stats-card__decor stats-card__decor--left"
+              :style="{ background: item.glow }"></div>
+            <div
+              class="stats-card__decor stats-card__decor--right"
+              :style="{ background: item.glow }"></div>
+            <div class="stats-card__header">
+              <div class="stats-card__icon-wrap">
+                <div class="stats-card__icon" :style="{ color: item.accent }">
+                  <Icon :icon="item.icon" />
+                </div>
+              </div>
+              <div class="stats-card__title">{{ item.label }}</div>
+            </div>
+            <div class="stats-card__body">
+              <CountTo
+                :duration="2600"
+                :end-val="item.count"
+                :start-val="0"
+                class="stats-card__count"
+                :style="{ color: item.accent }" />
+            </div>
+          </div>
+        </div>
         <zm-table
           :data="tableData"
           border
           style="width: 100%"
           :header-cell-style="{ background: '#f5f7fa', color: '#333' }"
           :cell-style="{ padding: '12px 8px' }"
-          height="70.5vh"
-        >
+          height="52.7vh">
           <!-- 区域/位置 列(已合并) -->
           <zm-table-column prop="region" label="区域/位置" align="center" fixed="left" />
 
@@ -87,8 +114,7 @@
             prop="controlMethod"
             label="控制措施"
             show-overflow-tooltip
-            align="center"
-          />
+            align="center" />
           <zm-table-column prop="charge" label="责任人" align="center" />
           <zm-table-column label="操作" width="150" align="center" fixed="right" action>
             <template #default="{ row }">
@@ -97,8 +123,7 @@
                   :underline="false"
                   size="small"
                   type="primary"
-                  @click="openForm('edit', row)"
-                >
+                  @click="openForm('edit', row)">
                   编辑
                 </el-link>
                 <el-link :underline="false" size="small" type="danger" @click="deleteRow(row)">
@@ -118,8 +143,7 @@
             layout="total, sizes, prev, pager, next, jumper"
             @size-change="handleSizeChange"
             @current-change="handleCurrentChange"
-            background
-          />
+            background />
         </div>
       </ContentWrap>
     </el-col>
@@ -150,8 +174,7 @@
               v-model="formData.maybeResult"
               placeholder="请输入可能导致的后果"
               type="textarea"
-              :rows="1"
-            />
+              :rows="1" />
           </el-form-item>
         </el-col>
         <el-col :span="12">
@@ -159,8 +182,7 @@
             <el-input-number
               v-model="formData.evalKn"
               controls-position="right"
-              style="width: 100%"
-            />
+              style="width: 100%" />
           </el-form-item>
         </el-col>
       </el-row>
@@ -172,8 +194,7 @@
             <el-input-number
               v-model="formData.evalYz"
               controls-position="right"
-              style="width: 100%"
-            />
+              style="width: 100%" />
           </el-form-item>
         </el-col>
         <el-col :span="12">
@@ -191,14 +212,12 @@
               v-model="formData.riskGrade"
               placeholder="请选择风险等级"
               clearable
-              style="width: 100%"
-            >
+              style="width: 100%">
               <el-option
                 v-for="dict in getStrDictOptions(DICT_TYPE.DANGER_GRADE)"
                 :key="dict.value"
                 :label="dict.label"
-                :value="dict.value"
-              />
+                :value="dict.value" />
             </el-select>
           </el-form-item>
         </el-col>
@@ -216,8 +235,7 @@
               v-model="formData.remark"
               type="textarea"
               placeholder="请输入备注"
-              :rows="1"
-            />
+              :rows="1" />
           </el-form-item>
         </el-col>
       </el-row>
@@ -230,8 +248,7 @@
               v-model="formData.controlMethod"
               type="textarea"
               :rows="4"
-              placeholder="请输入控制措施"
-            />
+              placeholder="请输入控制措施" />
           </el-form-item>
         </el-col>
       </el-row>
@@ -245,11 +262,13 @@
 </template>
 
 <script setup>
-import { ref, reactive, watch, onMounted } from 'vue'
+import { ref, reactive, watch, onMounted, computed } from 'vue'
 import { IotDangerApi } from '@/api/pms/qhse/index'
 import { DICT_TYPE, getStrDictOptions } from '@/utils/dict'
 import DeptTree from '@/views/system/user/HazardTree.vue'
+import { useUserStore } from '@/store/modules/user'
 
+const userStore = useUserStore()
 // 查询参数
 const queryParams = reactive({
   riskGrade: '',
@@ -300,12 +319,14 @@ watch(
 const handleQuery = () => {
   pagination.pageNo = 1 // 搜索后回到第一页
   loadTableData()
+  getStatic()
 }
 
 const handleDeptNodeClick = async (row) => {
   queryParams.deptId = row.id
   pagination.pageNo = 1
   loadTableData()
+  getStatic()
 }
 
 const downloadFile = (response) => {
@@ -361,6 +382,7 @@ const resetQuery = () => {
   queryParams.riskGrade = '' // 清空风险等级筛选
   pagination.pageNo = 1 // 重置为第一页
   loadTableData()
+  getStatic()
 }
 
 // 删除确认
@@ -514,9 +536,91 @@ const loadTableData = async () => {
   }
 }
 
+let staticData = ref([])
+const totalRiskCount = computed(() =>
+  staticData.value.reduce((sum, item) => sum + (Number(item.count) || 0), 0)
+)
+
+const getStatsCardMeta = (classify) => {
+  const value = String(classify || '')
+  if (value.includes('重大') || value.includes('閲嶅ぇ')) {
+    return {
+      accent: '#ff5b61',
+      glow: 'radial-gradient(circle, rgba(255, 91, 97, 0.22) 0%, rgba(255, 91, 97, 0) 72%)',
+      icon: 'ep:warning-filled'
+    }
+  }
+  if (value.includes('较大') || value.includes('杈冨ぇ')) {
+    return {
+      accent: '#ff9827',
+      glow: 'radial-gradient(circle, rgba(255, 152, 39, 0.24) 0%, rgba(255, 152, 39, 0) 72%)',
+      icon: 'ep:opportunity'
+    }
+  }
+  if (value.includes('一般') || value.includes('涓€鑸')) {
+    return {
+      accent: '#3d7cff',
+      glow: 'radial-gradient(circle, rgba(61, 124, 255, 0.2) 0%, rgba(61, 124, 255, 0) 72%)',
+      icon: 'ep:info-filled'
+    }
+  }
+  if (value.includes('低') || value.includes('浣')) {
+    return {
+      accent: '#25b36a',
+      glow: 'radial-gradient(circle, rgba(37, 179, 106, 0.22) 0%, rgba(37, 179, 106, 0) 72%)',
+      icon: 'ep:success-filled'
+    }
+  }
+  return {
+    accent: '#5f7da8',
+    glow: 'radial-gradient(circle, rgba(95, 125, 168, 0.18) 0%, rgba(95, 125, 168, 0) 72%)',
+    icon: 'ep:data-analysis'
+  }
+}
+
+const statsDisplayCards = computed(() =>
+  staticData.value.map((item, index) => {
+    const meta = getStatsCardMeta(item.classify)
+    const count = Number(item.count) || 0
+    const rate = totalRiskCount.value ? ((count / totalRiskCount.value) * 100).toFixed(1) : '0.0'
+    return {
+      key: `${item.classify}-${index}`,
+      label: item.classify,
+      count,
+      note: `占比:${rate}%`,
+      ...meta
+    }
+  })
+)
+
+const getStatsCardStyle = (accent, glow) => ({
+  '--stats-accent': accent,
+  '--stats-glow': glow
+})
+
+const getStatsCardClass = (classify) => {
+  const value = String(classify || '')
+  if (value.includes('重大')) return 'stats-card--major'
+  if (value.includes('较大')) return 'stats-card--high'
+  if (value.includes('一般')) return 'stats-card--medium'
+  if (value.includes('低')) return 'stats-card--low'
+  return 'stats-card--default'
+}
+
+async function getStatic() {
+  if (queryParams.deptId) {
+    const res = await IotDangerApi.getDangerStatistics(queryParams.deptId)
+    staticData.value = res.classify
+  } else {
+    const res = await IotDangerApi.getDangerStatistics(userStore.user.deptId)
+    staticData.value = res.classify
+  }
+}
+
 // 页面挂载后加载数据
 onMounted(() => {
   loadTableData()
+  getStatic()
 })
 </script>
 
@@ -568,4 +672,156 @@ onMounted(() => {
   padding: 6px 12px;
   border-radius: 4px;
 }
+
+.stats-cards {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+  gap: 12px;
+  margin-bottom: 16px;
+}
+
+.stats-card {
+  position: relative;
+  overflow: hidden;
+  min-height: 132px;
+  padding: 18px 18px 16px;
+  border-radius: 22px;
+  background: radial-gradient(
+      circle at 18% 22%,
+      rgb(255 255 255 / 92%) 0%,
+      rgb(255 255 255 / 0%) 20%
+    ),
+    radial-gradient(circle at 88% 80%, rgb(255 215 158 / 22%) 0%, rgb(255 215 158 / 0%) 16%),
+    linear-gradient(135deg, rgb(239 245 255 / 96%) 0%, rgb(217 230 248 / 88%) 100%);
+  border: 1px solid rgb(255 255 255 / 62%);
+  box-shadow:
+    inset 0 1px 0 rgb(255 255 255 / 86%),
+    0 14px 30px rgb(116 146 191 / 12%);
+}
+
+.stats-card__decor {
+  position: absolute;
+  border-radius: 999px;
+  pointer-events: none;
+  filter: blur(8px);
+  opacity: 0.95;
+}
+
+.stats-card__decor--left {
+  width: 72px;
+  height: 72px;
+  left: -10px;
+  top: -8px;
+}
+
+.stats-card__decor--right {
+  width: 88px;
+  height: 88px;
+  right: -18px;
+  bottom: -24px;
+}
+
+.stats-card__header {
+  position: relative;
+  z-index: 1;
+  display: flex;
+  align-items: center;
+  gap: 14px;
+}
+
+.stats-card__icon-wrap {
+  width: 48px;
+  height: 48px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border-radius: 14px;
+  box-shadow:
+    inset 0 1px 0 rgb(255 255 255 / 85%),
+    0 10px 24px rgb(118 144 187 / 10%);
+}
+
+.stats-card__icon {
+  font-size: 24px;
+  /* line-height: 1; */
+}
+
+.stats-card__title {
+  font-size: 16px;
+  font-weight: 700;
+  color: #324b72;
+  letter-spacing: 0;
+}
+
+.stats-card__body {
+  position: relative;
+  z-index: 1;
+  display: flex;
+  align-items: flex-end;
+  gap: 8px;
+  margin-top: 14px;
+  padding-left: 62px;
+}
+
+.stats-card__count {
+  display: block;
+  font-size: 38px !important;
+  font-weight: 800;
+  line-height: 0.92;
+  letter-spacing: 1px;
+  font-style: italic;
+  text-shadow: 0 8px 18px rgb(68 110 183 / 10%);
+}
+
+.stats-card__note {
+  padding-bottom: 4px;
+  font-size: 16px;
+  font-weight: 700;
+  line-height: 1;
+}
+
+@media (max-width: 768px) {
+  .stats-cards {
+    grid-template-columns: 1fr;
+  }
+
+  .stats-card {
+    min-height: 160px;
+    padding: 24px 22px 22px;
+    border-radius: 22px;
+  }
+
+  .stats-card__header {
+    gap: 18px;
+  }
+
+  .stats-card__icon-wrap {
+    width: 58px;
+    height: 58px;
+    border-radius: 16px;
+  }
+
+  .stats-card__icon {
+    font-size: 28px;
+  }
+
+  .stats-card__title {
+    font-size: 17px;
+  }
+
+  .stats-card__body {
+    margin-top: 18px;
+    padding-left: 76px;
+    gap: 10px;
+  }
+
+  .stats-card__count {
+    font-size: 46px !important;
+  }
+
+  .stats-card__note {
+    font-size: 18px;
+    padding-bottom: 6px;
+  }
+}
 </style>

+ 374 - 1
src/views/pms/qhse/safety/index.vue

@@ -57,11 +57,71 @@
 
       <!-- 列表 -->
       <ContentWrap class="flex-1 overflow-hidden mt-15px" style="border: none">
+        <div class="stats-cards">
+          <div
+            class="stats-card"
+            style="background: linear-gradient(180deg, #fff 0%, #e8f2ff 100%)">
+            <div class="stats-card__header">
+              <Icon icon="eos-icons:counting" color="#2563eb" />
+
+              <div class="stats-card__title">隐患总数</div>
+            </div>
+            <div class="stats-card__body py-10">
+              <CountTo
+                :duration="2600"
+                :end-val="hiddenCount"
+                :start-val="0"
+                class="stats-card__count text-3xl text-center! text-[#2563eb]!" />
+            </div>
+          </div>
+
+          <div
+            class="stats-card"
+            style="background: linear-gradient(180deg, #fff4f4 0%, #ffe8e8 100%)">
+            <div class="stats-card__header">
+              <Icon icon="ep:info-filled" color="#de3b3b" />
+              <div class="stats-card__title">未整改数</div>
+            </div>
+            <div class="stats-card__body py-10">
+              <CountTo
+                :duration="2600"
+                :end-val="todo"
+                :start-val="0"
+                class="stats-card__count text-3xl text-center! text-[#ff5b61]!" />
+            </div>
+          </div>
+
+          <div
+            class="stats-card"
+            style="background: linear-gradient(180deg, #fff 0%, #e8f2ff 100%)">
+            <div class="stats-card__header">
+              <Icon icon="material-symbols:android-cell-4-bar" color="#2563eb" />
+
+              <div class="stats-card__title">整改率</div>
+            </div>
+            <div class="stats-card__body py-10">
+              <CountTo
+                :duration="2600"
+                :end-val="((hiddenCount - todo) / hiddenCount) * 100 || 0"
+                :start-val="0"
+                class="stats-card__count text-3xl text-center! text-[#2563eb]!" />
+              <span class="text-[#2563eb]!">%</span>
+            </div>
+          </div>
+
+          <!-- 饼图 -->
+          <div
+            class="stats-card pie-chart-card"
+            style="background: linear-gradient(180deg, #fff 0%, #e8f2ff 100%)">
+            <div class="pie-chart-title">隐患来源分布</div>
+            <div ref="pieChartRef" class="pie-chart-container"></div>
+          </div>
+        </div>
         <zm-table
           :loading="loading"
           :data="list"
           :stripe="true"
-          height="calc(85vh - 135px)"
+          height="calc(44.6vh)"
           :show-overflow-tooltip="true">
           <zm-table-column :label="t('monitor.serial')" width="70" align="center">
             <template #default="scope">
@@ -299,6 +359,7 @@
 </template>
 
 <script setup lang="ts">
+import { ref, reactive, watch, nextTick } from 'vue'
 import { IotHiddenApi, IotHiddenTypeApi } from '@/api/pms/qhse/index'
 import DeptTree from '@/views/system/user/DeptTree2.vue'
 
@@ -311,9 +372,20 @@ import UploadFile from '@/components/UploadFile/src/UploadFile.vue'
 import FilePreviewDialog from '@/components/FilePreview/src/FilePreviewDialog.vue'
 import { DICT_TYPE, getStrDictOptions } from '@/utils/dict'
 
+import { useUserStore } from '@/store/modules/user'
+
+const userStore = useUserStore()
+
 import { useTableComponents } from '@/components/ZmTable/useTableComponents'
 const { ZmTable, ZmTableColumn } = useTableComponents()
 
+import * as echarts from 'echarts/core'
+import { PieChart } from 'echarts/charts'
+import { TooltipComponent, LegendComponent } from 'echarts/components'
+import { CanvasRenderer } from 'echarts/renderers'
+
+echarts.use([TooltipComponent, LegendComponent, PieChart, CanvasRenderer])
+
 defineOptions({ name: 'IotQHSECertificate' })
 
 const loading = ref(true) // 列表的加载中
@@ -407,6 +479,7 @@ const handleExport = async () => {
 const handleDeptNodeClick = async (row) => {
   queryParams.deptId = row.id
   await getList()
+  await getStatic()
 }
 
 /** 搜索按钮操作 */
@@ -623,10 +696,142 @@ const getTree = async () => {
   deptTree.value.push(dept)
   // firstLevelKeys.value = deptTree.value.map((node) => node.id)
 }
+
+let hiddenCount = ref(0)
+let todo = ref(0)
+let source = ref('')
+const pieChartRef = ref<HTMLDivElement>()
+let pieChart: echarts.ECharts | null = null
+async function getStatic() {
+  if (queryParams.deptId) {
+    const res = await IotHiddenApi.getHiddenStatistics(queryParams.deptId)
+    hiddenCount.value = res.total
+    todo.value = res.todo
+    source.value = res.source
+  } else {
+    const res = await IotHiddenApi.getHiddenStatistics(userStore.user.deptId)
+    hiddenCount.value = res.total
+    todo.value = res.todo
+    source.value = res.source
+  }
+
+  nextTick(() => {
+    initPieChart()
+  })
+}
+
+const initPieChart = () => {
+  if (!pieChartRef.value) return
+
+  if (pieChart) {
+    pieChart.dispose()
+  }
+
+  pieChart = echarts.init(pieChartRef.value)
+
+  const chartData = Array.isArray(source.value)
+    ? source.value.map((item: any) => ({
+        name: item.classify || '未知',
+        value: item.count || 0
+      }))
+    : []
+
+  const option = {
+    tooltip: {
+      trigger: 'item',
+      formatter: '{b}: {c} ({d}%)'
+    },
+    legend: {
+      orient: 'vertical',
+      left: 'left',
+      top: 'middle',
+      textStyle: {
+        fontSize: 14
+      },
+      itemWidth: 20,
+      itemHeight: 20
+    },
+    series: [
+      {
+        name: '隐患来源',
+        type: 'pie',
+        radius: ['35%', '60%'],
+        center: ['58%', '50%'],
+        avoidLabelOverlap: false,
+        itemStyle: {
+          borderRadius: 6,
+          borderColor: '#fff',
+          borderWidth: 2
+        },
+        label: {
+          show: false
+        },
+        emphasis: {
+          label: {
+            show: true,
+            fontSize: 12,
+            fontWeight: 'bold'
+          }
+        },
+        data: chartData
+      }
+    ]
+  }
+
+  pieChart.setOption(option)
+}
+
+const resizePieChart = () => {
+  pieChart?.resize()
+}
+
+let pieChartResizeObserver: ResizeObserver | null = null
+
 onMounted(async () => {
   getList()
   deptList2.value = handleTree(await DeptApi.getSimpleDeptList())
+
+  getStatic()
+
+  window.addEventListener('resize', resizePieChart)
+
+  if (pieChartRef.value) {
+    pieChartResizeObserver = new ResizeObserver(() => {
+      resizePieChart()
+    })
+    pieChartResizeObserver.observe(pieChartRef.value)
+  }
+})
+
+onUnmounted(() => {
+  if (pieChart) {
+    pieChart.dispose()
+    pieChart = null
+  }
+  window.removeEventListener('resize', resizePieChart)
+
+  if (pieChartResizeObserver && pieChartRef.value) {
+    pieChartResizeObserver.unobserve(pieChartRef.value)
+    pieChartResizeObserver = null
+  }
 })
+
+watch(
+  () => source.value,
+  () => {
+    nextTick(() => {
+      initPieChart()
+
+      if (pieChartRef.value && !pieChartResizeObserver) {
+        pieChartResizeObserver = new ResizeObserver(() => {
+          resizePieChart()
+        })
+        pieChartResizeObserver.observe(pieChartRef.value)
+      }
+    })
+  },
+  { deep: true }
+)
 </script>
 
 <style scoped>
@@ -641,4 +846,172 @@ onMounted(async () => {
   display: flex;
   justify-content: center;
 }
+
+.stats-cards {
+  display: grid;
+  grid-template-columns: repeat(3, minmax(180px, 1fr)) minmax(280px, 2fr);
+  gap: 12px;
+  margin-bottom: 16px;
+}
+
+.stats-card {
+  position: relative;
+  overflow: hidden;
+  min-height: 100px;
+  padding: 18px 18px 0px;
+
+  border: 1px solid #e4ecf7;
+  border-radius: 10px;
+  box-shadow: 0 4px 12px rgb(31 91 184 / 8%);
+}
+
+.stats-card__decor {
+  position: absolute;
+  border-radius: 999px;
+  pointer-events: none;
+  filter: blur(8px);
+  opacity: 0.95;
+}
+
+.stats-card__decor--left {
+  width: 72px;
+  height: 72px;
+  left: -10px;
+  top: -8px;
+}
+
+.stats-card__decor--right {
+  width: 88px;
+  height: 88px;
+  right: -18px;
+  bottom: -24px;
+}
+
+.stats-card__header {
+  position: relative;
+  z-index: 1;
+  display: flex;
+  align-items: center;
+  gap: 14px;
+}
+
+.stats-card__icon-wrap {
+  width: 48px;
+  height: 48px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border-radius: 14px;
+  box-shadow:
+    inset 0 1px 0 rgb(255 255 255 / 85%),
+    0 10px 24px rgb(118 144 187 / 10%);
+}
+
+.stats-card__icon {
+  font-size: 24px;
+  /* line-height: 1; */
+}
+
+.stats-card__title {
+  font-size: 16px;
+  font-weight: 700;
+  color: #324b72;
+  letter-spacing: 0;
+}
+
+.stats-card__body {
+  position: relative;
+  z-index: 1;
+  display: flex;
+  align-items: flex-end;
+  gap: 8px;
+  margin-top: 14px;
+  padding-left: 62px;
+}
+
+.stats-card__count {
+  display: block;
+  font-size: 38px !important;
+  font-weight: 800;
+  line-height: 0.92;
+  letter-spacing: 1px;
+  /* font-style: italic; */
+  text-shadow: 0 8px 18px rgb(68 110 183 / 10%);
+}
+
+.stats-card__note {
+  padding-bottom: 4px;
+  font-size: 16px;
+  font-weight: 700;
+  line-height: 1;
+}
+
+.pie-chart-card {
+  display: flex;
+  flex-direction: column;
+  min-height: 180px;
+}
+
+.pie-chart-title {
+  font-size: 13px;
+  font-weight: 600;
+  color: #324b72;
+  margin-bottom: 6px;
+  text-align: center;
+}
+
+.pie-chart-container {
+  flex: 1;
+  min-height: 50px;
+  width: 100%;
+}
+
+@media (max-width: 768px) {
+  .stats-cards {
+    grid-template-columns: 1fr;
+  }
+
+  .stats-card {
+    min-height: 160px;
+    padding: 24px 22px 22px;
+    border-radius: 22px;
+  }
+
+  .stats-card__header {
+    gap: 18px;
+  }
+
+  .stats-card__icon-wrap {
+    width: 58px;
+    height: 58px;
+    border-radius: 16px;
+  }
+
+  .stats-card__icon {
+    font-size: 28px;
+  }
+
+  .stats-card__title {
+    font-size: 17px;
+  }
+
+  .stats-card__body {
+    margin-top: 18px;
+    padding-left: 76px;
+    gap: 10px;
+  }
+
+  .stats-card__count {
+    font-size: 46px !important;
+  }
+
+  .stats-card__note {
+    font-size: 18px;
+    padding-bottom: 6px;
+  }
+
+  .pie-chart-container {
+    min-height: 220px;
+  }
+}
 </style>