|
|
@@ -33,9 +33,13 @@ interface ColumnAgg {
|
|
|
name: string;
|
|
|
ownerDept: string;
|
|
|
kpiText: string;
|
|
|
- actualText: string;
|
|
|
- statusText: string;
|
|
|
- statusTone: 'green' | 'yellow' | 'red' | 'pending' | 'mixed';
|
|
|
+ achievementDaysText: string;
|
|
|
+ achievementRateText: string;
|
|
|
+ statusTone: 'green' | 'yellow' | 'red' | 'pending';
|
|
|
+ isSingleOrder: boolean;
|
|
|
+ singleStatusLabel: string;
|
|
|
+ statusCounts: { green: number; yellow: number; red: number; pending: number };
|
|
|
+ statusTooltip: string;
|
|
|
}
|
|
|
|
|
|
function pickStage(order: SalesOrderExecution, key: OrderNodeKey): StageSnapshot | undefined {
|
|
|
@@ -48,13 +52,22 @@ function avg(values: number[]): number | null {
|
|
|
return sum / values.length;
|
|
|
}
|
|
|
|
|
|
+const STATUS_LABEL: Record<StageSnapshot['status'], string> = {
|
|
|
+ green: '正常',
|
|
|
+ yellow: '关注',
|
|
|
+ red: '严重',
|
|
|
+ pending: '未开始',
|
|
|
+};
|
|
|
+
|
|
|
function aggregateColumn(
|
|
|
key: OrderNodeKey,
|
|
|
name: string,
|
|
|
ownerDept: string,
|
|
|
orders: SalesOrderExecution[],
|
|
|
): ColumnAgg {
|
|
|
+ const isSingleOrder = orders.length === 1;
|
|
|
const stages = orders.map((o) => pickStage(o, key)).filter((s): s is StageSnapshot => !!s);
|
|
|
+ const emptyCounts = { green: 0, yellow: 0, red: 0, pending: 0 };
|
|
|
|
|
|
if (stages.length === 0) {
|
|
|
return {
|
|
|
@@ -62,60 +75,73 @@ function aggregateColumn(
|
|
|
name,
|
|
|
ownerDept,
|
|
|
kpiText: '--',
|
|
|
- actualText: '--',
|
|
|
- statusText: '暂无',
|
|
|
+ achievementDaysText: '--',
|
|
|
+ achievementRateText: '--',
|
|
|
statusTone: 'pending',
|
|
|
+ isSingleOrder,
|
|
|
+ singleStatusLabel: '暂无',
|
|
|
+ statusCounts: emptyCounts,
|
|
|
+ statusTooltip: '暂无样本',
|
|
|
};
|
|
|
}
|
|
|
|
|
|
const kpiVals: number[] = [];
|
|
|
const actualVals: number[] = [];
|
|
|
+ const validKpiPairs: Array<{ actual: number; kpi: number }> = [];
|
|
|
for (const s of stages) {
|
|
|
if (s.actualDays != null && s.nodeVarianceDays != null) {
|
|
|
- kpiVals.push(Number((s.actualDays - s.nodeVarianceDays).toFixed(2)));
|
|
|
+ const kpi = Number((s.actualDays - s.nodeVarianceDays).toFixed(2));
|
|
|
+ kpiVals.push(kpi);
|
|
|
+ if (s.status !== 'pending') {
|
|
|
+ validKpiPairs.push({ actual: s.actualDays, kpi });
|
|
|
+ }
|
|
|
}
|
|
|
if (s.actualDays != null) actualVals.push(s.actualDays);
|
|
|
}
|
|
|
|
|
|
const kpiAvg = avg(kpiVals);
|
|
|
const actualAvg = avg(actualVals);
|
|
|
-
|
|
|
- const statusCounts = { green: 0, yellow: 0, red: 0, pending: 0 };
|
|
|
+ const onTimeRate =
|
|
|
+ validKpiPairs.length === 0
|
|
|
+ ? null
|
|
|
+ : Math.round(
|
|
|
+ (validKpiPairs.filter((p) => p.actual <= p.kpi).length / validKpiPairs.length) * 100,
|
|
|
+ );
|
|
|
+
|
|
|
+ const statusCounts = { ...emptyCounts };
|
|
|
for (const s of stages) statusCounts[s.status]++;
|
|
|
|
|
|
- let statusText: string;
|
|
|
let statusTone: ColumnAgg['statusTone'];
|
|
|
- if (orders.length === 1) {
|
|
|
- const labels: Record<StageSnapshot['status'], string> = {
|
|
|
- green: '正常',
|
|
|
- yellow: '关注',
|
|
|
- red: '严重',
|
|
|
- pending: '未开始',
|
|
|
- };
|
|
|
+ if (isSingleOrder) {
|
|
|
statusTone = stages[0].status;
|
|
|
- statusText = labels[stages[0].status];
|
|
|
+ } else if (onTimeRate == null) {
|
|
|
+ statusTone = 'pending';
|
|
|
+ } else if (onTimeRate >= 95) {
|
|
|
+ statusTone = 'green';
|
|
|
+ } else if (onTimeRate >= 85) {
|
|
|
+ statusTone = 'yellow';
|
|
|
} else {
|
|
|
- const total = stages.length;
|
|
|
- const parts: string[] = [];
|
|
|
- if (statusCounts.green > 0) parts.push(`达成 ${statusCounts.green}/${total}`);
|
|
|
- if (statusCounts.yellow > 0) parts.push(`预警 ${statusCounts.yellow}`);
|
|
|
- if (statusCounts.red > 0) parts.push(`风险 ${statusCounts.red}`);
|
|
|
- if (statusCounts.pending > 0) parts.push(`待启 ${statusCounts.pending}`);
|
|
|
- statusText = parts.join(' · ') || '--';
|
|
|
- if (statusCounts.red > 0) statusTone = 'red';
|
|
|
- else if (statusCounts.yellow > 0) statusTone = 'yellow';
|
|
|
- else if (statusCounts.green === total) statusTone = 'green';
|
|
|
- else statusTone = 'mixed';
|
|
|
+ statusTone = 'red';
|
|
|
}
|
|
|
|
|
|
+ const tooltipParts: string[] = [];
|
|
|
+ if (statusCounts.green > 0) tooltipParts.push(`达成 ${statusCounts.green}`);
|
|
|
+ if (statusCounts.yellow > 0) tooltipParts.push(`预警 ${statusCounts.yellow}`);
|
|
|
+ if (statusCounts.red > 0) tooltipParts.push(`风险 ${statusCounts.red}`);
|
|
|
+ if (statusCounts.pending > 0) tooltipParts.push(`待启 ${statusCounts.pending}`);
|
|
|
+
|
|
|
return {
|
|
|
key,
|
|
|
name,
|
|
|
ownerDept,
|
|
|
kpiText: kpiAvg != null ? `${kpiAvg.toFixed(1)} 天` : '--',
|
|
|
- actualText: actualAvg != null ? `${actualAvg.toFixed(1)} 天` : '--',
|
|
|
- statusText,
|
|
|
+ achievementDaysText: actualAvg != null ? `${actualAvg.toFixed(1)} 天` : '--',
|
|
|
+ achievementRateText: onTimeRate != null ? `${onTimeRate}%` : '--',
|
|
|
statusTone,
|
|
|
+ isSingleOrder,
|
|
|
+ singleStatusLabel: STATUS_LABEL[stages[0].status],
|
|
|
+ statusCounts,
|
|
|
+ statusTooltip: tooltipParts.join(' / ') || '暂无',
|
|
|
};
|
|
|
}
|
|
|
|
|
|
@@ -201,7 +227,16 @@ function onClick(key: OrderNodeKey) {
|
|
|
:key="`a-${col.key}`"
|
|
|
:class="{ 'chain-matrix__cell--active': activeStageKey === col.key }"
|
|
|
>
|
|
|
- {{ col.actualText }}
|
|
|
+ <div class="chain-matrix__achievement">
|
|
|
+ <span class="chain-matrix__achievement-days">{{ col.achievementDaysText }}</span>
|
|
|
+ <span class="chain-matrix__achievement-sep">/</span>
|
|
|
+ <span
|
|
|
+ class="chain-matrix__achievement-rate"
|
|
|
+ :class="`chain-matrix__achievement-rate--${col.statusTone}`"
|
|
|
+ >
|
|
|
+ {{ col.achievementRateText }}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
</td>
|
|
|
</tr>
|
|
|
<tr>
|
|
|
@@ -211,10 +246,57 @@ function onClick(key: OrderNodeKey) {
|
|
|
:key="`s-${col.key}`"
|
|
|
:class="{ 'chain-matrix__cell--active': activeStageKey === col.key }"
|
|
|
>
|
|
|
- <span class="chain-matrix__status" :class="`chain-matrix__status--${col.statusTone}`">
|
|
|
+ <div
|
|
|
+ v-if="col.isSingleOrder"
|
|
|
+ class="chain-matrix__status chain-matrix__status--single"
|
|
|
+ :class="`chain-matrix__status--${col.statusTone}`"
|
|
|
+ :title="col.statusTooltip"
|
|
|
+ >
|
|
|
<span class="chain-matrix__status-dot" />
|
|
|
- {{ col.statusText }}
|
|
|
- </span>
|
|
|
+ <span>{{ col.singleStatusLabel }}</span>
|
|
|
+ </div>
|
|
|
+ <div v-else class="chain-matrix__dist" :title="col.statusTooltip">
|
|
|
+ <span
|
|
|
+ v-if="col.statusCounts.green > 0"
|
|
|
+ class="chain-matrix__dist-item chain-matrix__dist-item--green"
|
|
|
+ >
|
|
|
+ <span class="chain-matrix__status-dot" />
|
|
|
+ <span>{{ col.statusCounts.green }}</span>
|
|
|
+ </span>
|
|
|
+ <span
|
|
|
+ v-if="col.statusCounts.yellow > 0"
|
|
|
+ class="chain-matrix__dist-item chain-matrix__dist-item--yellow"
|
|
|
+ >
|
|
|
+ <span class="chain-matrix__status-dot" />
|
|
|
+ <span>{{ col.statusCounts.yellow }}</span>
|
|
|
+ </span>
|
|
|
+ <span
|
|
|
+ v-if="col.statusCounts.red > 0"
|
|
|
+ class="chain-matrix__dist-item chain-matrix__dist-item--red"
|
|
|
+ >
|
|
|
+ <span class="chain-matrix__status-dot" />
|
|
|
+ <span>{{ col.statusCounts.red }}</span>
|
|
|
+ </span>
|
|
|
+ <span
|
|
|
+ v-if="col.statusCounts.pending > 0"
|
|
|
+ class="chain-matrix__dist-item chain-matrix__dist-item--pending"
|
|
|
+ >
|
|
|
+ <span class="chain-matrix__status-dot" />
|
|
|
+ <span>{{ col.statusCounts.pending }}</span>
|
|
|
+ </span>
|
|
|
+ <span
|
|
|
+ v-if="
|
|
|
+ col.statusCounts.green === 0 &&
|
|
|
+ col.statusCounts.yellow === 0 &&
|
|
|
+ col.statusCounts.red === 0 &&
|
|
|
+ col.statusCounts.pending === 0
|
|
|
+ "
|
|
|
+ class="chain-matrix__dist-item chain-matrix__dist-item--pending"
|
|
|
+ >
|
|
|
+ <span class="chain-matrix__status-dot" />
|
|
|
+ <span>--</span>
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
</td>
|
|
|
</tr>
|
|
|
</tbody>
|
|
|
@@ -366,8 +448,8 @@ function onClick(key: OrderNodeKey) {
|
|
|
}
|
|
|
|
|
|
.chain-matrix__status-dot {
|
|
|
- width: 6px;
|
|
|
- height: 6px;
|
|
|
+ width: 7px;
|
|
|
+ height: 7px;
|
|
|
border-radius: 999px;
|
|
|
background: currentColor;
|
|
|
}
|
|
|
@@ -376,5 +458,53 @@ function onClick(key: OrderNodeKey) {
|
|
|
.chain-matrix__status--yellow { color: #ffc107; }
|
|
|
.chain-matrix__status--red { color: #ffb4ab; }
|
|
|
.chain-matrix__status--pending { color: var(--order-text-muted, #909097); }
|
|
|
-.chain-matrix__status--mixed { color: var(--order-accent, #7bd0ff); }
|
|
|
+
|
|
|
+.chain-matrix__achievement {
|
|
|
+ display: inline-flex;
|
|
|
+ align-items: baseline;
|
|
|
+ gap: 6px;
|
|
|
+ font-family: 'JetBrains Mono', 'Cascadia Code', Menlo, monospace;
|
|
|
+}
|
|
|
+
|
|
|
+.chain-matrix__achievement-days {
|
|
|
+ font-size: 13px;
|
|
|
+ color: var(--order-text-primary, #e1e2eb);
|
|
|
+ font-weight: 600;
|
|
|
+}
|
|
|
+
|
|
|
+.chain-matrix__achievement-sep {
|
|
|
+ color: var(--order-text-muted, #909097);
|
|
|
+}
|
|
|
+
|
|
|
+.chain-matrix__achievement-rate {
|
|
|
+ font-size: 13px;
|
|
|
+ font-weight: 700;
|
|
|
+}
|
|
|
+
|
|
|
+.chain-matrix__achievement-rate--green { color: #88fd54; }
|
|
|
+.chain-matrix__achievement-rate--yellow { color: #ffc107; }
|
|
|
+.chain-matrix__achievement-rate--red { color: #ffb4ab; }
|
|
|
+.chain-matrix__achievement-rate--pending { color: var(--order-text-muted, #909097); }
|
|
|
+
|
|
|
+.chain-matrix__dist {
|
|
|
+ display: inline-flex;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ justify-content: center;
|
|
|
+ gap: 8px;
|
|
|
+ font-family: 'JetBrains Mono', 'Cascadia Code', Menlo, monospace;
|
|
|
+ cursor: help;
|
|
|
+}
|
|
|
+
|
|
|
+.chain-matrix__dist-item {
|
|
|
+ display: inline-flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 4px;
|
|
|
+ font-size: 12px;
|
|
|
+ font-weight: 700;
|
|
|
+}
|
|
|
+
|
|
|
+.chain-matrix__dist-item--green { color: #88fd54; }
|
|
|
+.chain-matrix__dist-item--yellow { color: #ffc107; }
|
|
|
+.chain-matrix__dist-item--red { color: #ffb4ab; }
|
|
|
+.chain-matrix__dist-item--pending { color: var(--order-text-muted, #909097); }
|
|
|
</style>
|