@@ -16,10 +16,10 @@ import { RequestFence, beijingTime, contextParams } from '../state'
import type { DailyResponse , Overview } from '../types'
import { overviewRange , overviewRangeOptions , beijingDate } from '../overviewRange'
import type { RangePreset } from '../types'
import type { TurnoverQuery , TurnoverResponse , TurnoverRow , TurnoverSort , TurnoverWindow } from '../turnoverTypes'
import type { TurnoverQuery , TurnoverResponse , TurnoverRow , TurnoverSort , TurnoverWindow , TurnoverTotal , TurnoverVolume } from '../turnoverTypes'
import { turnoverRoute , validateTurnover , yesterdayBeijing } from '../turnoverNavigation'
import { turnoverSortColumns , turnoverSortOptions } from '../turnoverSorting'
import { totalText , turnoverTotalNote , volumeNote , dimensionSourceText , inventoryCollectionNotice , turnoverChart , turnoverNumber , windowText , turnoverReasonsText as reasonsText } from '../turnoverPresentation'
import { totalText , turnoverTotalNote , volumeText , volumeNote , dimensionSourceText , inventoryCollectionNotice , turnoverChart , turnoverNumber , windowText , turnoverReasonsText as reasonsText } from '../turnoverPresentation'
import AnalyticsChart from './AnalyticsChart.vue'
import ChartDetailsDialog from './ChartDetailsDialog.vue'
import SkuTrend from './SkuTrend.vue'
@@ -34,6 +34,7 @@ const runtime = ref<Overview | null>(null), data = ref<TurnoverResponse | null>(
const error = ref < ApiErrorDetails | null > ( null ) , inputError = ref ( '' ) , notice = ref ( '' ) , loading = ref ( false )
const filters = ref < QueryModel > ( { warehouseCode : '' , endDate : yesterdayBeijing ( ) , baseWindowDays : 30 , rangePreset : 'LAST_30_DAYS' , customRange : [ ] , groupBy : 'SKU' , sku : '' , customerCode : '' , focus : 'ALL' , coverageDaysThreshold : '' , turnoverStatus : '' , sortBy : 'PHYSICAL_DESC' } )
const page = ref ( 1 ) , size = ref ( 20 ) , expanded = ref ( false ) , chartsOpen = ref ( true )
const rightChart = ref < 'volume' | 'daily' > ( 'volume' )
const fence = new RequestFence ( ) , runtimeFence = new RequestFence ( ) , trendFence = new RequestFence ( )
const trendOpen = ref ( false ) , trendLoading = ref ( false ) , daily = ref < DailyResponse | null > ( null ) , trendError = ref < ApiErrorDetails | null > ( null )
const evidence = ref < TurnoverRow | null > ( null ) , evidenceOpen = ref ( false )
@@ -51,7 +52,7 @@ const fields = computed<QueryField[]>(() => [
... ( filters . value . groupBy === 'SKU' ? [ { key : 'sku' , label : 'SKU' , type : 'input' as const , width : 175 , placeholder : 'SKU精确查询' } ] : [ ] ) ,
{ key : 'focus' , label : '关注条件' , type : 'select' , width : 165 , props : { clearable : false } , options : [ { value : 'ALL' , label : '全部' } , { value : 'NO_OUTBOUND_IN_WINDOW' , label : '所选时段无出库' } , { value : 'COVERAGE_GT' , label : '消化较慢' } , { value : 'COVERAGE_LE' , label : '消化较快' } , { value : 'FROZEN' , label : '有冻结库存' } , { value : 'DEFECTIVE' , label : '有次品库存' } , { value : 'UNASSESSABLE' , label : '待评估' } ] } ,
{ key : 'turnoverStatus' , label : '周转样本' , type : 'select' , width : 160 , options : [ { value : '' , label : '全部样本状态' } , { value : 'INSUFFICIENT' , label : '周转样本不足' } ] } ,
{ key : 'sortBy' , label : '排序' , type : 'select' , width : 190 , props : { clearable : false } , options : turnoverSortOptions . filter ( ( { value } ) => filters . value . groupBy === 'OWNER' ? ! value . startsWith ( 'COVERAGE_' ) : value !== 'NO_OUTBOUND_PHYSICAL_DESC' ) } ,
{ key : 'sortBy' , label : '排序' , type : 'select' , width : 190 , props : { clearable : false } , options : turnoverSortOptions . filter ( ( { value } ) => filters . value . groupBy === 'OWNER' ? ! value . startsWith ( 'COVERAGE_' ) && ! value . startsWith ( 'UNIT_VOLUME_' ) : value !== 'NO_OUTBOUND_PHYSICAL_DESC' ) } ,
] )
const ownerMode = computed ( ( ) => applied . value ? . groupBy === 'OWNER' )
const columns = computed < DataTableColumn [ ] > ( ( ) => [
@@ -59,9 +60,9 @@ const columns = computed<DataTableColumn[]>(() => [
{ prop : 'customerCode' , label : '所属货主' , slotName : 'owner' , minWidth : 160 } ,
... ( ownerMode . value ? [ { prop : 'physicalSkuCount' , label : '有实物SKU数' , minWidth : 125 } ] : [ ] ) ,
{ prop : 'physicalQuantity' , label : '实物件数' , slotName : 'physicalQuantity' , minWidth : 145 } , { prop : 'availableQuantity' , label : '正品可售' , slotName : 'availableQuantity' , minWidth : 145 } ,
{ prop : 'inventoryVolumeM3' , label : '库存立方数( m³) ' , slotName : 'inventoryVolume' , minWidth : 200 } ,
{ prop : 'outboundVolumesM3' , label : ` ${ applied . value ? . baseWindowDays ? ? 30 } 日出库立方数( m³,估算) ` , slotName : 'outboundVolume' , minWidth : 220 } ,
... ( ownerMode . value ? [ ] : [ { prop : 'unitVolumeM3' , label : '单件体积( m³) ' , slotName : 'unitVolume' , minWidth : 155 } ] ) ,
{ prop : 'inventoryVolumeM3' , label : '库存立方数' , slotName : 'inventoryVolume' , minWidth : 145 } ,
{ prop : 'outboundVolumesM3' , label : ` ${ applied . value ? . baseWindowDays ? ? 30 } 日出库立方数 ` , slotName : 'outboundVolume' , minWidth : 170 } ,
... ( ownerMode . value ? [ ] : [ { prop : 'unitVolumeM3' , label : '单件体积' , slotName : 'unitVolume' , minWidth : 135 } ] ) ,
{ prop : 'transitQuantity' , label : '在途' , slotName : 'transitQuantity' , minWidth : 125 } ,
... ( [ 7 , 15 , 30 ] as const ) . map ( n => ( { prop : ` outbound ${ n } ` , label : ` ${ n } 日出库 ` , slotName : ` outbound ${ n } ` , minWidth : 160 } ) ) ,
{ prop : 'averageDailyOutboundQuantity' , label : ` ${ applied . value ? . baseWindowDays ? ? 30 } 日日均 ` , slotName : 'daily' , minWidth : 110 } ,
@@ -81,15 +82,26 @@ async function sortColumn(prop: string) {
filters . value = { ... filters . value , sortBy }
await search ( )
}
const cards = computed ( ( ) => data . value ? . summary ? [
{ name : '实物件数' , value : data . value . summary . physicalQuantity } , { name : '正品可售件数' , value : data . value . summary . availableQuantity } ,
{ name : '所选时段无出库SKU' , value : data . value . summary . noOutboundSkuCount } , { name : '待评估SKU' , value : data . value . summary . unassessableSkuCount } ,
] : [ ] )
const incompleteDailyWindows = computed ( ( ) => data . value ? . charts ? . averageDaily . filter ( w => w . dataStatus !== 'COMPLETE' || w . averageDailyOutboundQuantity == null ) ? ? [ ] )
const volumeCards = computed ( ( ) => data . value ? . summary ? [
{ name : '库存立方数(m³)' , value : data . value . summary . inventoryVolumeM3 , outbound : false } ,
{ name : ` 所选 ${ applied . value ? . baseWindowDays ? ? 30 } 日出库立方数(m³,估算) ` , value : data . value . summary . outboundVolumesM3 . find ( w => w . days === applied . value ? . baseWindowDays ) ? . volume , outbound : true } ,
] : [ ] )
const summaryGroups = computed ( ( ) => {
const summary = data . value ? . summary
if ( ! summary ) return [ ]
const quantity = ( key : string , name : string , value : TurnoverTotal , tone : string ) => ( { key , name , kind : 'quantity' , tone , text : turnoverNumber ( value . value ) , note : turnoverTotalNote ( value ) , title : undefined as string | undefined } )
const volume = ( key : string , name : string , value : TurnoverVolume | undefined , outbound : boolean , tone : string ) => ( { key , name , kind : 'volume' , tone , text : turnoverNumber ( value ? . value , 2 ) , note : volumeNote ( value , outbound , 2 ) , title : value ? . value != null ? volumeText ( value . value ) : undefined } )
const days = applied . value ? . baseWindowDays ? ? 30
return [
{ key : 'stock' , name : '当前库存' , note : '使用当前库存快照,不随统计时段变化' , cards : [
quantity ( 'physical' , '实物件数' , summary . physicalQuantity , 'primary' ) ,
quantity ( 'available' , '正品可售件数' , summary . availableQuantity , 'green' ) ,
volume ( 'inventory-volume' , '库存立方数(m³)' , summary . inventoryVolumeM3 , false , 'purple' ) ,
] } ,
{ key : 'period' , name : '所选时段分析' , note : ` ${ days } 天 · 截至 ${ applied . value ? . endDate ? ? '—' } ` , cards : [
quantity ( 'no-outbound' , '所选时段无出库SKU' , summary . noOutboundSkuCount , 'orange' ) ,
volume ( 'outbound-volume' , ` ${ days } 日出库立方数(m³) ` , summary . outboundVolumesM3 . find ( w => w . days === days ) ? . volume , true , 'green' ) ,
quantity ( 'unassessable' , '待评估SKU' , summary . unassessableSkuCount , 'purple' ) ,
] } ,
]
} )
const runtimeError = ref ( '' )
const inventoryRuntime = computed ( ( ) => runtime . value ? . warehouses . find ( w => w . warehouseCode === applied . value ? . warehouseCode && w . dataFamily === 'INVENTORY' ) )
const collectionNotice = computed ( ( ) => ! data . value || runtimeError . value ? '' : inventoryCollectionNotice ( inventoryRuntime . value ) )
@@ -252,14 +264,43 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }
< p v-else-if = "data?.context.analyticsContext.inventoryStatus === 'UNKNOWN'" class="turnover-context" role="status" > 当前尚无可用库存版本 , 请查看数据采集 。 < / p >
< p v-if = "collectionNotice" class="turnover-context" role="status">{{ collectionNotice }};采集状态截至 {{ beijingTime(inventoryRuntime?.observedAt) }}。<el-button link @click="router.push('/self-operated/collection')" > 查看数据采集 < / el -button > < / p >
< p v-if = "runtimeError" class="turnover-context" role="status" > {{ runtimeError }} < / p >
< div v-if = "card s.length" class="turnover-cards"><article v-for="card in cards" :key="card.name" > < span > { { card . name } } < / span > < strong > { { turnoverNumber ( card . value . value ) } } < / strong > < small > { { turnoverTotalNote ( card . value ) } } < / small > < / article > < / div >
< div v-if = "volumeCards.length" class="turnover-cards turnover-volume-cards"><article v-for="card in volumeCards" :key="card.name" > < span > { { card . name } } < / span > < strong > { { turnoverNumber ( card . value ? . value , 6 ) } } < / strong > < small > { { volumeNote ( card . value , card . outbound ) } } < / small > < / article > < / div >
< div v-if = "summaryGroup s.length" class="turnover-summary" >
< section v-for = "group in summaryGroups" :key="group.key" class="turnover-summary-group" :data-group="group.key" :aria-label="group.name" >
< header > < h4 > { { group . name } } < / h4 > < span > { { group . note } } < / span > < / header >
< div class = "turnover-cards" >
< article v-for = "card in group.cards" :key="card.key" :data-metric="card.key" :data-kind="card.kind" :data-tone="card.tone" >
< span > { { card . name } } < / span > < strong :title = "card.title" > { { card . text } } < / strong > < small > { { card . note } } < / small >
< / article >
< / div >
< / section >
< / div >
< p v-if = "data" class="turnover-context" > 体积优先采用完整仓库实测尺寸 , 缺失时按商品尺寸估算 ; 库存按当前全品质实物件数计算 , 历史出库按当前商品尺寸估算 , 不含在途及库位空隙 。 缺尺寸或数量未知时展示已知小计 。 < / p >
< template v-if = "data?.charts" >
< div class = "turnover-context" > < el-button link @click ="chartsOpen = !chartsOpen" > {{ chartsOpen ? ' 收起图表 ' : ' 展开图表 ' }} < / el -button > < span > 图表与指标为 { { applied ? . customerCode ? '本货主' : '本仓' } } 整体 , 不随列表SKU或关注筛选改变 < / span > < / div >
< div v-if = "chartsOpen" class="turnover-charts" >
< section > < header class = "turnover-chart-header" > < h3 > 货主实物件数 Top10 < / h3 > < ChartDetailsDialog inline button -label = " 查看明细 " title = "货主实物件数明细" :reset-on = "data.context.snapshotContext" > < table > < thead > < tr > < th > 货主 < / th > < th > 实物件数 < / th > < / tr > < / thead > < tbody > < tr v-for = "owner in data.charts.ownerPhysicalTop" :key="owner.customerCode"><td><el-button link @click="selectOwner(owner.customerCode)">{{ owner.customerName || owner.customerCode }}</el-button></td><td>{{ turnoverNumber(owner.physicalQuantity) }}</td></tr></tbody></table></ChartDetailsDialog></header><AnalyticsChart v-if="data.charts.ownerPhysicalTop.length" class="turnover-chart" :option="turnoverChart(data.charts, 'owners')" @select="event => { const owner = data?.charts?.ownerPhysicalTop[event.dataIndex]; if (owner) selectOwner(owner.customerCode) }" /><div v-else class="turnover-chart-empty"><strong>暂无可排名的库存数据</strong><span>库存数量尚未完整确认,确认后展示货主排名</span></div><p v-if="data.charts.unknownOwnerCount" class="turnover-chart-note" > {{ data.charts.unknownOwnerCount }} 个货主未能完整计算 < / p > < / section >
< section > < header class = "turnover-chart-header" > < h3 > 日均出库对比 ( 含所选时段 ) < / h3 > < ChartDetailsDialog inline button -label = " 查看明细 " title = "日均出库与覆盖明细" :reset-on = "data.context.snapshotContext" > < p v-for = "window in data.charts.averageDaily" :key="window.days">{{ window.startDate }} 至 {{ window.endDateExclusive }}(不含):{{ windowText(window) }}; {{ reasonsText(window.reasonCodes) }}</p></ChartDetailsDialog></header><p v-if="data.context.analyticsContext.rebuildRequired" class="turnover-chart-note" role="status">日均出库按 {{ beijingTime(data.context.analyticsContext.salesPublishedAt) }} 已发布销量版本计算;新采集数据待重算,发布后请整体刷新。SKU库存评估仍等待重算完成。</p><AnalyticsChart v-if="data.charts.averageDaily.some(w => w.dataStatus === 'COMPLETE' && w.averageDailyOutboundQuantity != null)" class="turnover-chart" :option="turnoverChart(data.charts, 'daily')" /><p v-else class="turnover-chart-empty">暂无完整窗口的日均出库数据,覆盖情况如下。</p><div v-if="incompleteDailyWindows.length" class="turnover-window-gaps" role="status"><span v-for="window in incompleteDailyWindows" :key="window.days" > {{ window.days }} 日 : {{ window.dataStatus = = = ' COMPLETE ' ? ' 日均暂不可用 ' : ' 覆盖不足 ' }} ( 完整 {{ window.completeDayCount }} / {{ window.days }} 日 ) < / span > < / div > < / section >
< section aria -label = " 库存体积与出库对比 " >
< header class = "turnover-chart-header" >
< div class = "density-status-tabs" role = "group" aria -label = " 图表指标 " >
< button type = "button" : class = "{ 'is-active': rightChart === 'volume' }" : aria -pressed = " rightChart = = = ' volume ' " @click ="rightChart = 'volume'" > 货主库存立方数 Top10 < / button >
< button type = "button" : class = "{ 'is-active': rightChart === 'daily' }" : aria -pressed = " rightChart = = = ' daily ' " @click ="rightChart = 'daily'" > 日均出库对比 < / button >
< / div >
< ChartDetailsDialog v-if = "rightChart === 'volume'" key="volume" inline button-label="查看明细" title="货主库存立方数明细" :reset-on="data.context.snapshotContext" >
< p > 当前库存 , 不随统计时段变化 ; 按完整体积排名 , 不含在途及库位空隙 。 < / p >
< p v-if = "data.charts.unknownVolumeOwnerCount" > {{ data.charts.unknownVolumeOwnerCount }} 个货主因缺尺寸或库存数量未知 , 未参与排名 。 < / p >
< table class = "density-table" > < thead > < tr > < th > 货主 < / th > < th > 库存立方数 < / th > < th > 计算说明 < / th > < / tr > < / thead > < tbody > < tr v-for = "owner in data.charts.ownerVolumeTop" :key="owner.customerCode"><td><el-button link @click="selectOwner(owner.customerCode)">{{ owner.customerName || owner.customerCode }}</el-button></td><td :title="volumeText(owner.inventoryVolumeM3.value)" > {{ volumeText ( owner.inventoryVolumeM3.value , 2 ) }} < / td > < td > { { volumeNote ( owner . inventoryVolumeM3 ) } } < / td > < / tr > < / tbody > < / table >
< / ChartDetailsDialog >
< ChartDetailsDialog v-else key = "daily" inline button -label = " 查看明细 " title = "日均出库与覆盖明细" :reset-on = "data.context.snapshotContext" > < p v-for = "window in data.charts.averageDaily" :key="window.days" > {{ window.startDate }} 至 {{ window.endDateExclusive }} ( 不含 ) : {{ windowText ( window ) }} ; {{ reasonsText ( window.reasonCodes ) }} < / p > < / ChartDetailsDialog >
< / header >
< template v-if = "rightChart === 'volume'" >
< AnalyticsChart v-if = "data.charts.ownerVolumeTop.length" key="volume" class="turnover-chart" :option="turnoverChart(data.charts, 'volume')" @select="event => { const owner = data?.charts?.ownerVolumeTop[event.dataIndex]; if (owner) selectOwner(owner.customerCode) }" / >
< div v-else class = "turnover-chart-empty" > < strong > 暂无可排名的库存体积数据 < / strong > < span > 需有可用库存及有效尺寸 ; 未知体积不按零参与排名 < / span > < / div >
< p class = "turnover-chart-note" > 当前库存 , 不随统计时段变化 ; 尺寸口径见明细 。 < span v-if = "data.charts.unknownVolumeOwnerCount" > {{ data.charts.unknownVolumeOwnerCount }} 个货主因缺尺寸或库存数量未知 , 未参与排名 。 < / span > < / p >
< / template >
< template v-else >
< p v-if = "data.context.analyticsContext.rebuildRequired" class="turnover-chart-note" role="status">日均出库按 {{ beijingTime(data.context.analyticsContext.salesPublishedAt) }} 已发布销量版本计算;新采集数据待重算,发布后请整体刷新。SKU库存评估仍等待重算完成。</p><AnalyticsChart v-if="data.charts.averageDaily.some(w => w.dataStatus === 'COMPLETE' && w.averageDailyOutboundQuantity != null)" class="turnover-chart" :option="turnoverChart(data.charts, 'daily')" /><p v-else class="turnover-chart-empty">暂无完整窗口的日均出库数据,覆盖情况如下。</p><div v-if="incompleteDailyWindows.length" class="turnover-window-gaps" role="status"><span v-for="window in incompleteDailyWindows" :key="window.days" > {{ window.days }} 日 : {{ window.dataStatus = = = ' COMPLETE ' ? ' 日均暂不可用 ' : ' 覆盖不足 ' }} ( 完整 {{ window.completeDayCount }} / {{ window.days }} 日 ) < / span > < / div >
< / template >
< / section >
< / div >
< / template >
< nav class = "turnover-focus" aria -label = " 库存关注快捷筛选 " >
@@ -283,9 +324,9 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }
< template # owner = "{ row }" > < el-button v-if = "ownerMode" link type="primary" @click="selectOwner(castRow(row).customerCode)" > {{ castRow ( row ) .customerName | | castRow ( row ) .customerCode }} < / el -button > < span v-else > {{ castRow ( row ) .customerName | | castRow ( row ) .customerCode }} < / span > < / template >
< template v-for = "key in ['physicalQuantity', 'availableQuantity', 'transitQuantity', 'frozenQuantity', 'defectiveQuantity'] as const" :key="key" #[key]="{ row }" > {{ totalText ( castRow ( row ) [ key ] ) }} < / template >
< template v-for = "n in [7, 15, 30] as TurnoverWindow[]" :key="n" #[`outbound${n}`]="{ row }" > {{ windowText ( castRow ( row ) .windows.find ( w = > w . days === n ) ) } } < / template >
< template # inventoryVolume = "{ row }" > { { turnoverNumber ( castRow ( row ) . inventoryVolumeM3 . value , 6 ) } } < small > { { volumeNote ( castRow ( row ) . inventoryVolumeM3 ) } } < / small > < / template >
< template # outboundVolume = "{ row }" > { { turnoverNumber ( castRow ( row ) . outboundVolumesM3 . find ( w => w . days === applied ? . baseWindowDays ) ? . volume . value , 6 ) } } < small > { { volumeNote ( castRow ( row ) . outboundVolumesM3 . find ( w => w . days === applied ? . baseWindowDays ) ? . volume , true ) } } < / small > < / template >
< template # unitVolume = "{ row }" > { { turnoverNumber ( castRow ( row ) . unitVolumeM3 , 9 ) } } < small > { { dimensionSourceText ( castRow ( row ) . dimensionSource ) } } < / small > < / template >
< template # inventoryVolume = "{ row }" > { { volumeText ( castRow ( row ) . inventoryVolumeM3 . value ) } } < small > { { volumeNote ( castRow ( row ) . inventoryVolumeM3 ) } } < / small > < / template >
< template # outboundVolume = "{ row }" > { { volumeText ( castRow ( row ) . outboundVolumesM3 . find ( w => w . days === applied ? . baseWindowDays ) ? . volume . value ) } } < small > { { volumeNote ( castRow ( row ) . outboundVolumesM3 . find ( w => w . days === applied ? . baseWindowDays ) ? . volume , true ) } } < / small > < / template >
< template # unitVolume = "{ row }" > { { volumeText ( castRow ( row ) . unitVolumeM3 , 9 ) } } < small > { { dimensionSourceText ( castRow ( row ) . dimensionSource ) } } < / small > < / template >
< template # daily = "{ row }" > { { turnoverNumber ( castRow ( row ) . averageDailyOutboundQuantity ) } } < / template >
< template # coverage = "{ row }" > { { turnoverNumber ( castRow ( row ) . inventoryCoverageDays , 1 ) } } < small > { { reasonsText ( [ castRow ( row ) . coverageStatus ] ) } } < / small > < / template >
< template # status = "{ row }" > < small > { { reasonsText ( castRow ( row ) . attentionReasons ) } } < / small > < span v-if = "ownerMode">待评估 {{ castRow(row).unassessableSkuCount }} SKU</span><el-button link @click="evidence = castRow(row); evidenceOpen = true" > 日样本 {{ castRow ( row ) .qualifiedSampleDays }} / {{ castRow ( row ) .requiredSampleDays }} < / el -button > < / template >
@@ -312,15 +353,19 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }
. turnover - title { margin : 0 ; font - size : var ( -- admin - font - section ) ; display : flex ; align - items : center ; gap : 16 px ; }
. turnover - title span { font - size : var ( -- admin - font - caption ) ; font - weight : 400 ; color : var ( -- el - text - color - secondary ) ; }
. turnover - context { display : flex ; gap : 16 px ; align - items : center ; flex - wrap : wrap ; padding : 10 px 0 ; color : var ( -- el - text - color - secondary ) ; font - size : var ( -- admin - font - caption ) ; }
. turnover - cards { display : grid ; grid - template - columns : repeat ( 4 , minmax ( 0 , 1 fr ) ) ; gap : 12 px ; padding : 4 px 0 12 px ; }
. turnover - volume - cards { grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr ) ) ; }
. turnover - cards article { position : relative ; overflow : hidden ; padding : 16 px 20 px ; border : 1 px solid var ( -- el - color - primary - light - 8 ) ; background : linear - gradient ( 120 deg , # fff , var ( -- el - color - primary - light - 9 ) ) ; border - radius : 10 px ; color : var ( -- el - color - primary ) ; }
. turnover - cards article : nth - child ( 2 ) { color : # 248260 ; border - color : # dbeee5 ; background : linear - gradient ( 120 deg , # fff , # edf8f1 ) ; }
. turnover - cards article : nth - child ( 3 ) { color : # b37a22 ; border - color : # f4e7d3 ; background : linear - gradient ( 120 deg , # fff , # fff6e8 ) ; }
. turnover - cards article : nth - child ( 4 ) { color : # 8 a62bf ; border - color : # e9e0f5 ; background : linear - gradient ( 120 deg , # fff , # f6f0fc ) ; }
. turnover - summary { display : grid ; grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr ) ) ; gap : 16 px ; padding : 4 px 0 12 px ; }
. turnover - summary - group { min - width : 0 ; display : flex ; flex - direction : column ; }
. turnover - summary - group > header { display : flex ; align - items : baseline ; flex - wrap : wrap ; gap : 4 px 12 px ; margin - bottom : 8 px ; }
. turnover - summary - group h4 { margin : 0 ; font - size : var ( -- admin - font - body ) ; }
. turnover - summary - group > header span { color : var ( -- el - text - color - secondary ) ; font - size : var ( -- admin - font - caption ) ; }
. turnover - cards { display : grid ; grid - template - columns : repeat ( 3 , minmax ( 0 , 1 fr ) ) ; gap : 10 px ; flex : 1 ; }
. turnover - cards article { position : relative ; overflow : hidden ; min - width : 0 ; padding : 12 px ; border : 1 px solid var ( -- el - color - primary - light - 8 ) ; background : linear - gradient ( 120 deg , # fff , var ( -- el - color - primary - light - 9 ) ) ; border - radius : 10 px ; color : var ( -- el - color - primary ) ; }
. turnover - cards article [ data - tone = "green" ] { color : # 248260 ; border - color : # dbeee5 ; background : linear - gradient ( 120 deg , # fff , # edf8f1 ) ; }
. turnover - cards article [ data - tone = "orange" ] { color : # b37a22 ; border - color : # f4e7d3 ; background : linear - gradient ( 120 deg , # fff , # fff6e8 ) ; }
. turnover - cards article [ data - tone = "purple" ] { color : # 8 a62bf ; border - color : # e9e0f5 ; background : linear - gradient ( 120 deg , # fff , # f6f0fc ) ; }
. turnover - cards article : : after { content : '' ; position : absolute ; width : 88 px ; height : 88 px ; border - radius : 50 % ; right : - 22 px ; top : - 30 px ; background : currentColor ; opacity : .07 ; pointer - events : none ; }
. turnover - cards article > span { color : var ( -- el - text - color - regular ) ; }
. turnover - cards strong { display : block ; font - size : 28 px ; margin : 8 px 0 4 px ; font - variant - numeric : tabular - nums ; }
. turnover - cards strong { display : block ; font - size : 24 px ; margin : 6 px 0 4 px ; font - variant - numeric : tabular - nums ; }
. turnover - charts { display : grid ; grid - template - columns : 1 fr 1 fr ; gap : 16 px ; padding : 0 0 12 px ; align - items : stretch ; }
. turnover - charts section { height : 360 px ; box - sizing : border - box ; display : flex ; flex - direction : column ; min - width : 0 ; background : var ( -- el - bg - color ) ; border : 1 px solid var ( -- el - border - color - light ) ; padding : 16 px ; border - radius : 10 px ; }
. turnover - chart { flex : 1 1 0 ; height : 0 ; min - height : 120 px ; width : 100 % ; }