This commit is contained in:
@@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
|
||||
# ==========================================
|
||||
# 系統版本與路徑
|
||||
# ==========================================
|
||||
SYSTEM_VERSION = "V10.584"
|
||||
SYSTEM_VERSION = "V10.585"
|
||||
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
|
||||
public_url = PUBLIC_URL # 用於模板顯示
|
||||
|
||||
|
||||
@@ -407,6 +407,13 @@ def prepare_daily_charts(df, selected_date, days=30):
|
||||
else:
|
||||
daily_agg['avg_price'] = 0
|
||||
|
||||
if col_amount and col_amount in daily_agg.columns:
|
||||
daily_agg['margin_rate'] = (
|
||||
daily_agg['profit'] / daily_agg[col_amount].replace(0, pd.NA) * 100
|
||||
).replace([float('inf'), float('-inf')], 0).fillna(0)
|
||||
else:
|
||||
daily_agg['margin_rate'] = 0
|
||||
|
||||
# DoD
|
||||
if col_amount and col_amount in daily_agg.columns:
|
||||
daily_agg['dod_revenue'] = daily_agg[col_amount].pct_change() * 100
|
||||
@@ -448,6 +455,7 @@ def prepare_daily_charts(df, selected_date, days=30):
|
||||
'revenue': daily_agg[col_amount].tolist() if col_amount and col_amount in daily_agg.columns and not daily_agg.empty else [],
|
||||
'cost': daily_agg[col_cost].tolist() if col_cost and col_cost in daily_agg.columns and not daily_agg.empty else [],
|
||||
'profit': daily_agg['profit'].tolist() if 'profit' in daily_agg.columns and not daily_agg.empty else [],
|
||||
'margin_rate': daily_agg['margin_rate'].tolist() if 'margin_rate' in daily_agg.columns and not daily_agg.empty else [],
|
||||
'qty': daily_agg[col_qty].tolist() if col_qty and col_qty in daily_agg.columns and not daily_agg.empty else [],
|
||||
'avg_price': daily_agg['avg_price'].tolist() if 'avg_price' in daily_agg.columns and not daily_agg.empty else [],
|
||||
'dod_revenue': daily_agg['dod_revenue'].fillna(0).tolist() if 'dod_revenue' in daily_agg.columns and not daily_agg.empty else [],
|
||||
@@ -537,6 +545,52 @@ def prepare_category_summary(df, date_str=None, is_month_view=False, month_start
|
||||
return category_df.to_dict('records')
|
||||
|
||||
|
||||
def prepare_category_chart(category_records, limit=12):
|
||||
"""將分類明細壓成圖表需要的前 N 分類,避免前端再做重資料整理。"""
|
||||
if not category_records:
|
||||
return {
|
||||
'labels': [],
|
||||
'revenue': [],
|
||||
'profit': [],
|
||||
'margin_rate': [],
|
||||
'qty': [],
|
||||
}
|
||||
|
||||
category_df = pd.DataFrame(category_records)
|
||||
if category_df.empty or 'category' not in category_df.columns:
|
||||
return {
|
||||
'labels': [],
|
||||
'revenue': [],
|
||||
'profit': [],
|
||||
'margin_rate': [],
|
||||
'qty': [],
|
||||
}
|
||||
|
||||
for column in ['revenue', 'profit', 'qty']:
|
||||
if column not in category_df.columns:
|
||||
category_df[column] = 0
|
||||
category_df[column] = pd.to_numeric(category_df[column], errors='coerce').fillna(0)
|
||||
|
||||
grouped = (
|
||||
category_df.groupby('category', dropna=False)
|
||||
.agg({'revenue': 'sum', 'profit': 'sum', 'qty': 'sum'})
|
||||
.reset_index()
|
||||
.sort_values(by='revenue', ascending=False)
|
||||
.head(limit)
|
||||
)
|
||||
grouped['margin_rate'] = (
|
||||
grouped['profit'] / grouped['revenue'].replace(0, pd.NA) * 100
|
||||
).replace([float('inf'), float('-inf')], 0).fillna(0)
|
||||
|
||||
return {
|
||||
'labels': grouped['category'].fillna('未分類').astype(str).tolist(),
|
||||
'revenue': grouped['revenue'].tolist(),
|
||||
'profit': grouped['profit'].tolist(),
|
||||
'margin_rate': grouped['margin_rate'].tolist(),
|
||||
'qty': grouped['qty'].tolist(),
|
||||
}
|
||||
|
||||
|
||||
# ==========================================
|
||||
# 頁面路由
|
||||
# ==========================================
|
||||
@@ -558,6 +612,7 @@ def daily_sales():
|
||||
error="尚未匯入當日業績資料,請先至系統設定頁面匯入 Excel。",
|
||||
selected_date=None, available_dates=[], current=None, dod=None, wow=None,
|
||||
chart_data=None, categories=None, calendar_data=None, selected_month=None,
|
||||
category_chart=None,
|
||||
datetime_now=datetime_now_str, active_page='daily_sales')
|
||||
|
||||
available_dates, current_fingerprint = _get_daily_sales_metadata(engine, table_name)
|
||||
@@ -566,6 +621,7 @@ def daily_sales():
|
||||
error="資料表為空,請先匯入當日業績資料。",
|
||||
selected_date=None, available_dates=[], current=None, dod=None, wow=None,
|
||||
chart_data=None, categories=None, calendar_data=None, selected_month=None,
|
||||
category_chart=None,
|
||||
datetime_now=datetime_now_str, active_page='daily_sales')
|
||||
|
||||
available_dates_str = [d.strftime('%Y-%m-%d') for d in available_dates]
|
||||
@@ -622,6 +678,7 @@ def daily_sales():
|
||||
error="所選日期區間沒有業績資料。",
|
||||
selected_date=None, available_dates=available_dates_str, current=None, dod=None, wow=None,
|
||||
chart_data=None, categories=None, calendar_data=None, selected_month=None,
|
||||
category_chart=None,
|
||||
datetime_now=datetime_now_str, active_page='daily_sales')
|
||||
|
||||
df = preprocess_daily_sales_data(df)
|
||||
@@ -678,6 +735,7 @@ def daily_sales():
|
||||
month_start=month_start if is_month_view else None,
|
||||
month_end=month_end if is_month_view else None
|
||||
)
|
||||
category_chart = prepare_category_chart(category_list, limit=12)
|
||||
category_total_count = len(category_list)
|
||||
if show_all_categories:
|
||||
visible_categories = category_list
|
||||
@@ -703,6 +761,7 @@ def daily_sales():
|
||||
'chart_data': chart_data,
|
||||
'competitor_intel': competitor_intel,
|
||||
'categories': visible_categories,
|
||||
'category_chart': category_chart,
|
||||
'category_total_count': category_total_count,
|
||||
'category_visible_count': len(visible_categories),
|
||||
'category_table_limit': _CATEGORY_TABLE_DEFAULT_LIMIT,
|
||||
@@ -734,6 +793,7 @@ def daily_sales():
|
||||
is_month_view=False,
|
||||
chart_data=None,
|
||||
categories=None,
|
||||
category_chart=None,
|
||||
calendar_data=None,
|
||||
marketing_data=None,
|
||||
selected_month=None,
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#}
|
||||
|
||||
{% set _active_page = active_page|default('') %}
|
||||
{% set _is_price_workbench = _active_page == 'dashboard' and request is defined and request.args.get('filter') == 'pchome_review' %}
|
||||
{% set _next_run = next_run|default(None) %}
|
||||
{% set _session_username = session.get('username') if session is defined else None %}
|
||||
{% set _session_role = session.get('role') if session is defined else None %}
|
||||
@@ -47,15 +48,20 @@
|
||||
{# 群組 1: 監控(caramel) #}
|
||||
<div class="momo-nav-group">
|
||||
<div class="momo-nav-group-title">監控</div>
|
||||
<a class="momo-nav-link {% if _active_page == 'dashboard' %}is-active{% endif %}" href="/">
|
||||
<a class="momo-nav-link {% if _active_page == 'dashboard' and not _is_price_workbench %}is-active{% endif %}" href="/">
|
||||
<span class="momo-nav-icon"><i class="fas fa-border-all"></i></span>
|
||||
<span class="momo-nav-label">商品看板</span>
|
||||
<span class="momo-nav-code">01</span>
|
||||
</a>
|
||||
<a class="momo-nav-link {% if _is_price_workbench %}is-active{% endif %}" href="/?filter=pchome_review&review_status=all&sort_by=pchome_review&order=desc">
|
||||
<span class="momo-nav-icon"><i class="fas fa-scale-balanced"></i></span>
|
||||
<span class="momo-nav-label">比價工作台</span>
|
||||
<span class="momo-nav-code">02</span>
|
||||
</a>
|
||||
<a class="momo-nav-link {% if _active_page in ['edm', 'campaigns'] %}is-active{% endif %}" href="/edm">
|
||||
<span class="momo-nav-icon"><i class="fas fa-bullhorn"></i></span>
|
||||
<span class="momo-nav-label">活動看板</span>
|
||||
<span class="momo-nav-code">02</span>
|
||||
<span class="momo-nav-code">03</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -318,6 +318,28 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ Restored / Decision Charts ============ -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-xl-4 col-lg-6 mb-4 mb-xl-0">
|
||||
<div class="card chart-card">
|
||||
<div class="card-header"><i class="fas fa-percent"></i> 毛利率趨勢</div>
|
||||
<div class="card-body"><div class="chart-container has-html-chart"><canvas id="marginChart"></canvas>{{ chart_snapshot(chart_data.labels, chart_data.margin_rate, 'pct') }}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xl-4 col-lg-6 mb-4 mb-xl-0">
|
||||
<div class="card chart-card">
|
||||
<div class="card-header"><i class="fas fa-receipt"></i> 客單價 × 銷量</div>
|
||||
<div class="card-body"><div class="chart-container has-html-chart"><canvas id="avgQtyChart"></canvas>{{ chart_snapshot(chart_data.labels, chart_data.avg_price, 'currency') }}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xl-4 col-lg-12">
|
||||
<div class="card chart-card">
|
||||
<div class="card-header"><i class="fas fa-layer-group"></i> 分類業績 Top 12</div>
|
||||
<div class="card-body"><div class="chart-container has-html-chart"><canvas id="categoryRevenueChart"></canvas>{{ chart_snapshot(category_chart.labels if category_chart else [], category_chart.revenue if category_chart else [], 'currency', 12) }}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ Marketing ============ -->
|
||||
{% if competitor_intel %}
|
||||
{% set comp_trend = competitor_intel.trend %}
|
||||
@@ -338,68 +360,55 @@
|
||||
</div>
|
||||
<div class="col-lg-4">
|
||||
<div class="card chart-card">
|
||||
<div class="card-header">
|
||||
<i class="fas fa-triangle-exclamation"></i> 競價覆蓋與風險
|
||||
</div>
|
||||
<header class="card-header card-header--split">
|
||||
<span><i class="fas fa-triangle-exclamation"></i> 比價決策覆蓋</span>
|
||||
<a class="btn btn-sm btn-table-toggle" href="{{ url_for('dashboard.index', filter='pchome_review', review_status='all', sort_by='pchome_review', order='desc') }}">
|
||||
<i class="fas fa-arrow-up-right-from-square"></i> 比價工作台
|
||||
</a>
|
||||
</header>
|
||||
<div class="card-body">
|
||||
<div class="chart-container chart-container--sm has-html-chart">
|
||||
<canvas id="competitorCoverageChart"></canvas>
|
||||
</div>
|
||||
<div class="daily-competitor-summary">
|
||||
<div>
|
||||
<span>決策支援覆蓋率</span>
|
||||
<strong class="momo-mono">{{ comp_coverage.decision_support_rate | default(comp_coverage.decision_ready_rate | default(0)) }}%</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>有效身份配對</span>
|
||||
<strong class="momo-mono">{{ comp_coverage.valid_matches | default(0) | number_format }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>精準可告警覆蓋</span>
|
||||
<strong class="momo-mono">{{ comp_coverage.decision_ready_rate | default(0) }}%</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>身份配對</span>
|
||||
<strong class="momo-mono">{{ comp_coverage.valid_matches | default(0) | number_format }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>身份覆蓋率</span>
|
||||
<strong class="momo-mono">{{ comp_coverage.match_rate | default(0) }}%</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>價格新鮮</span>
|
||||
<strong class="momo-mono">{{ comp_coverage.decision_ready_matches | default(comp_coverage.fresh_matches | default(0)) | number_format }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>價格過期</span>
|
||||
<span>待刷新</span>
|
||||
<strong class="momo-mono">{{ comp_coverage.stale_matches | default(0) | number_format }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>未知新鮮度</span>
|
||||
<strong class="momo-mono">{{ comp_coverage.unknown_freshness_matches | default(0) | number_format }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>未形成有效身份配對</span>
|
||||
<strong class="momo-mono">{{ comp_coverage.pending | default(0) | number_format }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>需單位價覆核</span>
|
||||
<strong class="momo-mono">{{ comp_coverage.unit_comparable_count | default(0) | number_format }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>型錄/任選可比</span>
|
||||
<strong class="momo-mono">{{ comp_coverage.catalog_comparable_count | default(0) | number_format }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>重算待人工覆核</span>
|
||||
<strong class="momo-mono">{{ comp_coverage.rescore_accepted_count | default(0) | number_format }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>人工採用</span>
|
||||
<strong class="momo-mono">{{ comp_coverage.manual_accept_count | default(0) | number_format }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>人工否決</span>
|
||||
<strong class="momo-mono">{{ comp_coverage.manual_reject_count | default(0) | number_format }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>人工單位價</span>
|
||||
<strong class="momo-mono">{{ comp_coverage.manual_unit_price_count | default(0) | number_format }}</strong>
|
||||
<span>型錄/任選可比</span>
|
||||
<strong class="momo-mono">{{ comp_coverage.catalog_comparable_count | default(0) | number_format }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="daily-competitor-closure momo-mono" aria-label="人工比價閉環">
|
||||
<span>人工採用 {{ comp_coverage.manual_accept_count | default(0) | number_format }}</span>
|
||||
<span>人工否決 {{ comp_coverage.manual_reject_count | default(0) | number_format }}</span>
|
||||
<span>人工單位價 {{ comp_coverage.manual_unit_price_count | default(0) | number_format }}</span>
|
||||
</div>
|
||||
{% if competitor_intel.review_queue %}
|
||||
<ol class="daily-competitor-risk-list daily-competitor-risk-list--review">
|
||||
{% for item in competitor_intel.review_queue[:3] %}
|
||||
@@ -536,6 +545,7 @@
|
||||
{% set daily_sales_payload = {
|
||||
'chartData': chart_data,
|
||||
'competitor': competitor_intel,
|
||||
'categoryChart': category_chart,
|
||||
'marketing': {
|
||||
'discount': {
|
||||
'labels': marketing_data.discount | map(attribute='name') | list,
|
||||
|
||||
@@ -16,17 +16,15 @@
|
||||
<span class="meta momo-mono">KPI · 最新有效價格 {{ overview.last_pchome_crawled or '待刷新' }}</span>
|
||||
</div>
|
||||
<div class="dashboard-kpi-grid">
|
||||
<div class="dashboard-kpi">
|
||||
<div class="dashboard-kpi dashboard-kpi--coverage">
|
||||
<div class="dashboard-kpi-label momo-mono">決策支援覆蓋率</div>
|
||||
<div class="dashboard-kpi-value momo-mono">{{ overview.decision_support_rate | default(overview.decision_ready_rate | default(0)) }}%</div>
|
||||
<div class="dashboard-kpi-sub momo-mono">
|
||||
{{ overview.decision_support_count | default(overview.decision_ready_count | default(0)) | number_format }} / {{ overview.total_active | default(total_products) | number_format }} ACTIVE
|
||||
· 精準可用 {{ overview.decision_ready_rate | default(0) }}%
|
||||
· 型錄可比 {{ overview.catalog_comparable_count | default(0) | number_format }}
|
||||
· 單位價 {{ overview.unit_comparable_count | default(0) | number_format }}
|
||||
· 身份 {{ overview.identity_coverage_rate | default(overview.match_rate | default(0)) }}%
|
||||
· 過期 {{ overview.stale_match_count | default(0) | number_format }}
|
||||
· 未設到期 {{ overview.unknown_freshness_count | default(0) | number_format }}
|
||||
<div class="dashboard-kpi-sub">可直接支援調價、挑品、簡報的有效比價資料</div>
|
||||
<div class="dashboard-kpi-metrics momo-mono" aria-label="決策支援組成">
|
||||
<span><em>支援</em><strong>{{ overview.decision_support_count | default(overview.decision_ready_count | default(0)) | number_format }}</strong></span>
|
||||
<span><em>ACTIVE</em><strong>{{ overview.total_active | default(total_products) | number_format }}</strong></span>
|
||||
<span><em>身份</em><strong>{{ overview.identity_coverage_rate | default(overview.match_rate | default(0)) }}%</strong></span>
|
||||
<span><em>型錄</em><strong>{{ overview.catalog_comparable_count | default(0) | number_format }}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dashboard-kpi is-accent">
|
||||
@@ -46,23 +44,14 @@
|
||||
<a class="dashboard-kpi-sub-link" href="{{ url_for('dashboard.index', filter='ai_picks', category=current_category, q=search_query, sort_by='timestamp', order='desc') }}">查看 {{ ai_pick_list_limit }} 品清單</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dashboard-kpi">
|
||||
<div class="dashboard-kpi dashboard-kpi--ops">
|
||||
<div class="dashboard-kpi-label momo-mono">比價覆核</div>
|
||||
<div class="dashboard-kpi-value momo-mono is-warning">{{ overview.review_queue_count | default(0) | number_format }}</div>
|
||||
<div class="dashboard-kpi-sub momo-mono">
|
||||
<a class="dashboard-kpi-sub-link" href="{{ url_for('dashboard.index', filter='pchome_review', category=current_category, q=search_query, review_status='rescore_accepted', sort_by='pchome_review', order='desc') }}">重算待覆核 {{ overview.rescore_accepted_count | default(0) | number_format }}</a>
|
||||
· <a class="dashboard-kpi-sub-link" href="{{ url_for('dashboard.index', filter='pchome_review', category=current_category, q=search_query, review_status='unit_comparable', sort_by='pchome_review', order='desc') }}">需單位價 {{ overview.unit_comparable_count | default(0) | number_format }}</a>
|
||||
· 人工閉環 {{ overview.manual_closed_count | default(0) | number_format }}
|
||||
· 待補抓 {{ overview.pending_match_count | default(0) | number_format }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="dashboard-kpi">
|
||||
<div class="dashboard-kpi-label momo-mono">最新有效價格抓取</div>
|
||||
<div class="dashboard-kpi-value momo-mono is-small">{{ overview.last_pchome_crawled or '待刷新' }}</div>
|
||||
<div class="dashboard-kpi-sub momo-mono">
|
||||
新鮮率 {{ overview.fresh_match_rate | default(0) }}%
|
||||
· 待刷新 {{ overview.stale_match_count | default(0) | number_format }}
|
||||
· 未設到期 {{ overview.unknown_freshness_count | default(0) | number_format }}
|
||||
<div class="dashboard-kpi-metrics momo-mono" aria-label="比價覆核組成">
|
||||
<a href="{{ url_for('dashboard.index', filter='pchome_review', category=current_category, q=search_query, review_status='rescore_accepted', sort_by='pchome_review', order='desc') }}" title="重算待覆核 {{ overview.rescore_accepted_count | default(0) | number_format }}"><em>重算待覆核</em><strong>{{ overview.rescore_accepted_count | default(0) | number_format }}</strong></a>
|
||||
<a href="{{ url_for('dashboard.index', filter='pchome_review', category=current_category, q=search_query, review_status='unit_comparable', sort_by='pchome_review', order='desc') }}" title="需單位價 {{ overview.unit_comparable_count | default(0) | number_format }}"><em>需單位價</em><strong>{{ overview.unit_comparable_count | default(0) | number_format }}</strong></a>
|
||||
<span><em>待補抓</em><strong>{{ overview.pending_match_count | default(0) | number_format }}</strong></span>
|
||||
<span><em>新鮮</em><strong>{{ overview.fresh_match_rate | default(0) }}%</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -75,8 +64,13 @@
|
||||
<div class="dashboard-backfill-main">
|
||||
<div class="dashboard-backfill-label momo-mono">PCHOME MATCH BACKFILL</div>
|
||||
<div class="dashboard-backfill-title">PChome 比價補強產線</div>
|
||||
<div class="dashboard-backfill-meta momo-mono">
|
||||
待刷新 {{ overview.stale_match_count | default(0) | number_format }} · 未設到期 {{ overview.unknown_freshness_count | default(0) | number_format }} · 待補抓 {{ overview.pending_match_count | default(0) | number_format }} · 待處理覆核 {{ overview.review_queue_count | default(0) | number_format }} · 人工閉環 {{ overview.manual_closed_count | default(0) | number_format }} · 單位價 {{ overview.unit_comparable_count | default(0) | number_format }}
|
||||
<div class="dashboard-backfill-meta">刷新過期、補抓未配對、清理近門檻候選,目標是提高可決策覆蓋率。</div>
|
||||
<div class="dashboard-backfill-pills momo-mono" aria-label="補強產線隊列">
|
||||
<span title="待刷新 {{ overview.stale_match_count | default(0) | number_format }}">待刷新 <strong>{{ overview.stale_match_count | default(0) | number_format }}</strong></span>
|
||||
<span>未設到期 <strong>{{ overview.unknown_freshness_count | default(0) | number_format }}</strong></span>
|
||||
<span>待補抓 <strong>{{ overview.pending_match_count | default(0) | number_format }}</strong></span>
|
||||
<span>覆核 <strong>{{ overview.review_queue_count | default(0) | number_format }}</strong></span>
|
||||
<span>單位價 <strong>{{ overview.unit_comparable_count | default(0) | number_format }}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dashboard-backfill-progress" aria-hidden="true">
|
||||
@@ -101,6 +95,41 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dashboard-decision-workbench" aria-label="提升決策支援覆蓋率工作流">
|
||||
<div class="dashboard-decision-workbench__head">
|
||||
<span class="dashboard-backfill-label momo-mono">COVERAGE WORKFLOW</span>
|
||||
<strong>提升決策支援覆蓋率</strong>
|
||||
<em>先處理最能轉成可決策資料的隊列,避免盲目降低門檻。</em>
|
||||
</div>
|
||||
<div class="dashboard-decision-lanes">
|
||||
<button class="dashboard-decision-lane"
|
||||
type="button"
|
||||
data-pchome-refresh-stale-trigger
|
||||
data-limit="120">
|
||||
<span class="momo-mono">01</span>
|
||||
<strong>刷新過期</strong>
|
||||
<em>{{ overview.stale_match_count | default(0) | number_format }} 筆價格需要重新確認</em>
|
||||
</button>
|
||||
<button class="dashboard-decision-lane"
|
||||
type="button"
|
||||
data-pchome-backfill-trigger
|
||||
data-limit="60">
|
||||
<span class="momo-mono">02</span>
|
||||
<strong>補抓未配對</strong>
|
||||
<em>{{ overview.pending_match_count | default(0) | number_format }} 筆尚未形成有效身份配對</em>
|
||||
</button>
|
||||
<a class="dashboard-decision-lane" href="{{ url_for('dashboard.index', filter='pchome_review', category=current_category, q=search_query, review_status='unit_comparable', sort_by='pchome_review', order='desc') }}">
|
||||
<span class="momo-mono">03</span>
|
||||
<strong>單位價覆核</strong>
|
||||
<em>{{ overview.unit_comparable_count | default(0) | number_format }} 筆需判斷容量、入數、組合價</em>
|
||||
</a>
|
||||
<a class="dashboard-decision-lane" href="{{ url_for('dashboard.index', filter='pchome_review', category=current_category, q=search_query, review_status='rescore_accepted', sort_by='pchome_review', order='desc') }}">
|
||||
<span class="momo-mono">04</span>
|
||||
<strong>採用/否決候選</strong>
|
||||
<em>{{ overview.rescore_accepted_count | default(0) | number_format }} 筆近門檻候選等待確認</em>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-family: var(--momo-font-family);
|
||||
font-size: 26px;
|
||||
font-size: 1.8rem;
|
||||
font-weight: 800;
|
||||
color: var(--momo-page-ink, var(--momo-text-primary));
|
||||
line-height: var(--momo-line-height-tight);
|
||||
@@ -576,6 +576,11 @@
|
||||
background: color-mix(in srgb, var(--momo-text-primary) 5%, var(--momo-bg-surface));
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.chart-container,
|
||||
.chart-container--sm {
|
||||
height: 280px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
@@ -843,6 +848,27 @@
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.daily-competitor-closure {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin: -4px 0 12px;
|
||||
}
|
||||
|
||||
.daily-competitor-closure span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 24px;
|
||||
padding: 3px 8px;
|
||||
color: var(--momo-text-secondary);
|
||||
background: var(--momo-bg-paper);
|
||||
border: 1px solid var(--momo-border-light);
|
||||
border-radius: var(--momo-radius-pill);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.daily-competitor-risk-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
@@ -944,6 +970,11 @@
|
||||
height: 320px;
|
||||
}
|
||||
|
||||
.chart-container--sm {
|
||||
height: 210px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.chart-container canvas {
|
||||
display: block;
|
||||
width: 100% !important;
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
|
||||
.dashboard-kpi-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
overflow: hidden;
|
||||
background: var(--momo-bg-surface);
|
||||
border: 1px solid var(--momo-border-light);
|
||||
@@ -120,7 +120,7 @@
|
||||
.dashboard-kpi-value {
|
||||
margin-bottom: 8px;
|
||||
color: var(--momo-text-primary);
|
||||
font-size: 34px;
|
||||
font-size: 1.85rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0;
|
||||
line-height: 1;
|
||||
@@ -153,6 +153,59 @@
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.dashboard-kpi-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.dashboard-kpi-metrics span,
|
||||
.dashboard-kpi-metrics a {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
padding: 6px 8px;
|
||||
color: var(--momo-text-secondary);
|
||||
background: color-mix(in srgb, var(--momo-bg-paper) 86%, transparent);
|
||||
border: 1px solid var(--momo-border-light);
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.dashboard-kpi-metrics em {
|
||||
overflow: hidden;
|
||||
color: var(--momo-text-tertiary);
|
||||
font-size: 9px;
|
||||
font-style: normal;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dashboard-kpi-metrics strong {
|
||||
overflow: hidden;
|
||||
color: var(--momo-text-primary);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dashboard-kpi.is-accent .dashboard-kpi-metrics span,
|
||||
.dashboard-kpi.is-accent .dashboard-kpi-metrics a {
|
||||
color: rgba(250, 247, 240, 0.76);
|
||||
background: rgba(250, 247, 240, 0.08);
|
||||
border-color: rgba(250, 247, 240, 0.16);
|
||||
}
|
||||
|
||||
.dashboard-kpi.is-accent .dashboard-kpi-metrics em,
|
||||
.dashboard-kpi.is-accent .dashboard-kpi-metrics strong {
|
||||
color: rgba(250, 247, 240, 0.86);
|
||||
}
|
||||
|
||||
.dashboard-kpi-sub-link {
|
||||
color: inherit;
|
||||
font-weight: 800;
|
||||
@@ -216,6 +269,33 @@
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.dashboard-backfill-pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.dashboard-backfill-pills span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-height: 22px;
|
||||
padding: 3px 7px;
|
||||
color: var(--momo-text-secondary);
|
||||
background: var(--momo-bg-paper);
|
||||
border: 1px solid var(--momo-border-light);
|
||||
border-radius: var(--momo-radius-pill);
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dashboard-backfill-pills strong {
|
||||
color: var(--momo-text-primary);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.dashboard-backfill-progress {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
@@ -230,13 +310,13 @@
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: 0%;
|
||||
background: linear-gradient(90deg, var(--momo-warm-caramel), var(--momo-success));
|
||||
background: var(--momo-warm-caramel);
|
||||
transition: width 240ms ease;
|
||||
}
|
||||
|
||||
.dashboard-backfill-card[data-status="failed"] .dashboard-backfill-progress span,
|
||||
.dashboard-backfill-card[data-status="stale"] .dashboard-backfill-progress span {
|
||||
background: linear-gradient(90deg, var(--momo-danger), var(--momo-warm-rust));
|
||||
background: var(--momo-danger);
|
||||
}
|
||||
|
||||
.dashboard-backfill-status {
|
||||
@@ -252,6 +332,97 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-decision-workbench {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 0.8fr) minmax(0, 2.2fr);
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
padding: 14px 16px;
|
||||
background: var(--momo-bg-surface);
|
||||
border: 1px solid var(--momo-border-light);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.dashboard-decision-workbench__head {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
align-content: start;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-decision-workbench__head strong {
|
||||
color: var(--momo-text-primary);
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.dashboard-decision-workbench__head em {
|
||||
color: var(--momo-text-secondary);
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.dashboard-decision-lanes {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-decision-lane {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto 1fr;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
min-height: 112px;
|
||||
padding: 10px;
|
||||
color: var(--momo-text-primary);
|
||||
background: var(--momo-bg-paper);
|
||||
border: 1px solid var(--momo-border-light);
|
||||
border-radius: 8px;
|
||||
text-align: left;
|
||||
text-decoration: none;
|
||||
transition: var(--momo-transition-base);
|
||||
}
|
||||
|
||||
button.dashboard-decision-lane {
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dashboard-decision-lane:hover {
|
||||
color: var(--momo-text-primary);
|
||||
border-color: rgba(190, 106, 45, 0.38);
|
||||
background: color-mix(in srgb, var(--momo-warm-caramel) 8%, var(--momo-bg-paper));
|
||||
}
|
||||
|
||||
.dashboard-decision-lane span {
|
||||
color: var(--momo-text-tertiary);
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.dashboard-decision-lane strong {
|
||||
color: var(--momo-text-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.dashboard-decision-lane em {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
color: var(--momo-text-secondary);
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
line-height: 1.45;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.dashboard-focus-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
@@ -1188,6 +1359,14 @@
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.dashboard-decision-workbench {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dashboard-decision-lanes {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.dashboard-ai-summary-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
@@ -1236,6 +1415,11 @@
|
||||
}
|
||||
|
||||
.dashboard-kpi:nth-last-child(-n + 2) {
|
||||
border-bottom: 1px solid var(--momo-border-light);
|
||||
}
|
||||
|
||||
.dashboard-kpi:last-child {
|
||||
grid-column: 1 / -1;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
@@ -1254,6 +1438,21 @@
|
||||
color: var(--momo-text-primary);
|
||||
}
|
||||
|
||||
.dashboard-kpi.is-accent .dashboard-kpi-metrics span,
|
||||
.dashboard-kpi.is-accent .dashboard-kpi-metrics a {
|
||||
color: var(--momo-text-secondary);
|
||||
background: var(--momo-bg-paper);
|
||||
border-color: var(--momo-border-light);
|
||||
}
|
||||
|
||||
.dashboard-kpi.is-accent .dashboard-kpi-metrics em {
|
||||
color: var(--momo-text-tertiary);
|
||||
}
|
||||
|
||||
.dashboard-kpi.is-accent .dashboard-kpi-metrics strong {
|
||||
color: var(--momo-text-primary);
|
||||
}
|
||||
|
||||
.dashboard-kpi-label {
|
||||
margin-bottom: 7px;
|
||||
font-size: 9px;
|
||||
@@ -1288,6 +1487,14 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dashboard-decision-lanes {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dashboard-decision-lane {
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.dashboard-search,
|
||||
.dashboard-select,
|
||||
.dashboard-segmented {
|
||||
|
||||
@@ -78,10 +78,17 @@
|
||||
const cd = dailySalesData.chartData || {};
|
||||
const competitor = dailySalesData.competitor || {};
|
||||
const competitorTrend = competitor.trend || {};
|
||||
const competitorCoverage = competitor.coverage || {};
|
||||
const categoryChart = dailySalesData.categoryChart || {};
|
||||
const safe = {
|
||||
labels: cd.labels || [],
|
||||
revenue: cd.revenue || [],
|
||||
profit: cd.profit || [],
|
||||
margin_rate: cd.margin_rate || (cd.revenue || []).map((revenue, index) => {
|
||||
const rev = Number(revenue || 0);
|
||||
if (!rev) return 0;
|
||||
return Number((cd.profit || [])[index] || 0) / rev * 100;
|
||||
}),
|
||||
avg_price: cd.avg_price || [],
|
||||
qty: cd.qty || [],
|
||||
dod_revenue: cd.dod_revenue || [],
|
||||
@@ -214,6 +221,19 @@
|
||||
limit: 14
|
||||
});
|
||||
}
|
||||
renderHtmlBars('marginChart', safe.labels, safe.margin_rate, { mode: 'pct', limit: 14 });
|
||||
renderHtmlBars('avgQtyChart', safe.labels, safe.avg_price, { mode: 'currency', limit: 14 });
|
||||
if (categoryChart.labels) {
|
||||
renderHtmlBars('categoryRevenueChart', categoryChart.labels, categoryChart.revenue, {
|
||||
mode: 'currency',
|
||||
horizontal: true
|
||||
});
|
||||
}
|
||||
const coverage = buildCoverageFunnel();
|
||||
renderHtmlBars('competitorCoverageChart', coverage.labels, coverage.values, {
|
||||
mode: 'number',
|
||||
horizontal: true
|
||||
});
|
||||
}
|
||||
|
||||
function hasSeriesData(labels, ...seriesList) {
|
||||
@@ -264,6 +284,20 @@
|
||||
};
|
||||
}
|
||||
|
||||
function buildCoverageFunnel() {
|
||||
return {
|
||||
labels: ['決策支援', '精準告警', '身份配對', '待刷新', '單位價', '待覆核'],
|
||||
values: [
|
||||
competitorCoverage.decision_support_count ?? competitorCoverage.decision_ready_count ?? 0,
|
||||
competitorCoverage.decision_ready_matches ?? competitorCoverage.fresh_matches ?? 0,
|
||||
competitorCoverage.valid_matches ?? 0,
|
||||
competitorCoverage.stale_matches ?? competitorCoverage.stale_match_count ?? 0,
|
||||
competitorCoverage.unit_comparable_count ?? 0,
|
||||
competitorCoverage.rescore_accepted_count ?? competitorCoverage.review_queue_count ?? 0
|
||||
].map(value => Number(value || 0))
|
||||
};
|
||||
}
|
||||
|
||||
// -- Chart 1: trend (multi-line) --------------------------------------
|
||||
function renderTrend() {
|
||||
const el = document.getElementById('trendChart');
|
||||
@@ -440,7 +474,175 @@
|
||||
}));
|
||||
}
|
||||
|
||||
// -- Chart 5: Competitor gap pressure --------------------------------
|
||||
// -- Chart 5: Margin rate trend --------------------------------------
|
||||
function renderMarginRate() {
|
||||
const el = document.getElementById('marginChart');
|
||||
if (!el) return;
|
||||
if (!hasSeriesData(safe.labels, safe.margin_rate)) {
|
||||
renderChartEmpty('marginChart', '目前沒有可計算的毛利率序列。');
|
||||
return;
|
||||
}
|
||||
|
||||
rememberChart(new Chart(el, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: safe.labels,
|
||||
datasets: [
|
||||
makeLineDataset('毛利率', safe.margin_rate, palette.olive),
|
||||
{
|
||||
label: '30 日均線',
|
||||
data: safe.margin_rate.map((_, index, values) => {
|
||||
const start = Math.max(0, index - 6);
|
||||
const sample = values.slice(start, index + 1).map(Number).filter(Number.isFinite);
|
||||
return sample.length ? sample.reduce((sum, value) => sum + value, 0) / sample.length : 0;
|
||||
}),
|
||||
borderColor: palette.honey,
|
||||
backgroundColor: rgba(palette.honey, 0.1),
|
||||
borderWidth: 1.8,
|
||||
borderDash: [5, 5],
|
||||
tension: 0.28,
|
||||
pointRadius: 0
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
plugins: {
|
||||
legend: { position: isCompact() ? 'bottom' : 'top' },
|
||||
tooltip: { callbacks: { label: ctx => `${ctx.dataset.label}: ${formatMetric(ctx.parsed.y, 'pct')}` } }
|
||||
},
|
||||
scales: {
|
||||
x: { grid: { display: false }, ticks: { maxTicksLimit: isCompact() ? 5 : 10 } },
|
||||
y: axisPercent('毛利率')
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
// -- Chart 6: AOV x Qty ----------------------------------------------
|
||||
function renderAvgQty() {
|
||||
const el = document.getElementById('avgQtyChart');
|
||||
if (!el) return;
|
||||
if (!hasSeriesData(safe.labels, safe.avg_price, safe.qty)) {
|
||||
renderChartEmpty('avgQtyChart', '目前沒有客單價或銷量序列。');
|
||||
return;
|
||||
}
|
||||
|
||||
rememberChart(new Chart(el, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: safe.labels,
|
||||
datasets: [
|
||||
{
|
||||
label: '銷量',
|
||||
data: safe.qty,
|
||||
backgroundColor: rgba(palette.olive, 0.24),
|
||||
borderColor: palette.olive,
|
||||
borderWidth: 1,
|
||||
maxBarThickness: 24,
|
||||
yAxisID: 'y'
|
||||
},
|
||||
{
|
||||
label: '客單價',
|
||||
data: safe.avg_price,
|
||||
type: 'line',
|
||||
borderColor: palette.mahogany,
|
||||
backgroundColor: rgba(palette.mahogany, 0.12),
|
||||
borderWidth: 2.2,
|
||||
tension: 0.32,
|
||||
pointRadius: 2,
|
||||
yAxisID: 'y1'
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
plugins: {
|
||||
legend: { position: isCompact() ? 'bottom' : 'top' },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: ctx => {
|
||||
const mode = ctx.dataset.yAxisID === 'y1' ? 'currency' : 'number';
|
||||
return `${ctx.dataset.label}: ${formatMetric(ctx.parsed.y, mode)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: { grid: { display: false }, ticks: { maxTicksLimit: isCompact() ? 5 : 10 } },
|
||||
y: { beginAtZero: true, title: { display: !isCompact(), text: '銷量' } },
|
||||
y1: {
|
||||
position: 'right',
|
||||
grid: { drawOnChartArea: false },
|
||||
...axisMoney('客單價')
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
// -- Chart 7: Category revenue ---------------------------------------
|
||||
function renderCategoryRevenue() {
|
||||
const el = document.getElementById('categoryRevenueChart');
|
||||
if (!el) return;
|
||||
if (!hasSeriesData(categoryChart.labels, categoryChart.revenue, categoryChart.profit)) {
|
||||
renderChartEmpty('categoryRevenueChart', '目前沒有分類業績彙總可繪製。');
|
||||
return;
|
||||
}
|
||||
|
||||
rememberChart(new Chart(el, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: categoryChart.labels || [],
|
||||
datasets: [
|
||||
{
|
||||
label: '業績',
|
||||
data: categoryChart.revenue || [],
|
||||
backgroundColor: rgba(palette.caramel, 0.5),
|
||||
borderColor: palette.caramel,
|
||||
borderWidth: 1,
|
||||
maxBarThickness: 22
|
||||
},
|
||||
{
|
||||
label: '毛利',
|
||||
data: categoryChart.profit || [],
|
||||
backgroundColor: rgba(palette.olive, 0.42),
|
||||
borderColor: palette.olive,
|
||||
borderWidth: 1,
|
||||
maxBarThickness: 22
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
indexAxis: 'y',
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { position: isCompact() ? 'bottom' : 'top' },
|
||||
tooltip: { callbacks: { label: ctx => `${ctx.dataset.label}: ${formatMetric(ctx.parsed.x, 'currency')}` } }
|
||||
},
|
||||
scales: {
|
||||
x: axisMoney('金額'),
|
||||
y: {
|
||||
grid: { display: false },
|
||||
ticks: {
|
||||
autoSkip: false,
|
||||
callback: function (value) {
|
||||
const label = this.getLabelForValue(value);
|
||||
return label.length > 14 ? `${label.slice(0, 14)}…` : label;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
// -- Chart 8: Competitor gap pressure --------------------------------
|
||||
function renderCompetitorGap() {
|
||||
const el = document.getElementById('competitorGapChart');
|
||||
if (!el) return;
|
||||
@@ -507,6 +709,59 @@
|
||||
}));
|
||||
}
|
||||
|
||||
// -- Chart 9: Competitor decision coverage ---------------------------
|
||||
function renderCompetitorCoverage() {
|
||||
const el = document.getElementById('competitorCoverageChart');
|
||||
if (!el) return;
|
||||
const coverage = buildCoverageFunnel();
|
||||
if (!hasSeriesData(coverage.labels, coverage.values)) {
|
||||
renderChartEmpty('competitorCoverageChart', '目前尚未形成可繪製的比價覆蓋資料。');
|
||||
return;
|
||||
}
|
||||
|
||||
rememberChart(new Chart(el, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: coverage.labels,
|
||||
datasets: [{
|
||||
label: 'SKU 數',
|
||||
data: coverage.values,
|
||||
backgroundColor: [
|
||||
rgba(palette.caramel, 0.62),
|
||||
rgba(palette.olive, 0.54),
|
||||
rgba(palette.honey, 0.54),
|
||||
rgba(palette.rust, 0.38),
|
||||
rgba(palette.mahogany, 0.32),
|
||||
rgba(palette.muted, 0.24)
|
||||
],
|
||||
borderColor: [
|
||||
palette.caramel,
|
||||
palette.olive,
|
||||
palette.honey,
|
||||
palette.rust,
|
||||
palette.mahogany,
|
||||
palette.muted
|
||||
],
|
||||
borderWidth: 1,
|
||||
maxBarThickness: 18
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
indexAxis: 'y',
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: { callbacks: { label: ctx => `${ctx.dataset.label}: ${formatMetric(ctx.parsed.x, 'number')}` } }
|
||||
},
|
||||
scales: {
|
||||
x: { beginAtZero: true, ticks: { precision: 0 } },
|
||||
y: { grid: { display: false } }
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
// -- Marketing charts ----------------------------------------------
|
||||
function renderMarketingBar(elId, marketing, color) {
|
||||
const el = document.getElementById(elId);
|
||||
@@ -684,7 +939,11 @@
|
||||
renderDod();
|
||||
renderWow();
|
||||
renderTop10();
|
||||
renderMarginRate();
|
||||
renderAvgQty();
|
||||
renderCategoryRevenue();
|
||||
renderCompetitorGap();
|
||||
renderCompetitorCoverage();
|
||||
|
||||
const mk = dailySalesData.marketing || {};
|
||||
if (mk.discount) renderMarketingBar('discountChart', mk.discount, palette.caramel);
|
||||
|
||||
Reference in New Issue
Block a user