[FEAT] 新增商品銷售排行欄位與尊榮徽章,並更新資料匯出引擎
1. 於商品成本分析報表(電腦版 Table)最左側新增「排行」欄位,完美對齊圓角卡片樣式。 2. 針對前三名(Top 3)銷售商品設計尊榮徽章:第 1 名黃金徽章、第 2 名白銀徽章、第 3 名青銅徽章,其餘名次採用精緻 Mono 灰字。 3. 行動版 Card 移除原先通用包裹圖示,替換為智能偵測之獎牌徽章(🥇、🥈、🥉)或排行數字。 4. 全面升級資料複製(TSV)、CSV、Excel 與 PDF 列印等四大導出引擎,將「排行」業務指標同步注入首欄,並處理 colspan 對齊。 5. 補強多語系翻譯,於 zh_TW.json、en.json 與 ja.json 中完美新增「Rank / 排行 / 順位」鍵值。
This commit is contained in:
parent
1dc79f2cec
commit
90a77404fe
@ -1265,6 +1265,7 @@
|
|||||||
"Quick Select": "Quick Select",
|
"Quick Select": "Quick Select",
|
||||||
"Quick replenishment from this view": "Quick replenishment from this view",
|
"Quick replenishment from this view": "Quick replenishment from this view",
|
||||||
"Quick search...": "Quick search...",
|
"Quick search...": "Quick search...",
|
||||||
|
"Rank": "Rank",
|
||||||
"Real-time OEE analysis awaits": "Real-time OEE analysis awaits",
|
"Real-time OEE analysis awaits": "Real-time OEE analysis awaits",
|
||||||
"Real-time Operation Logs (Last 50)": "Real-time Operation Logs (Last 50)",
|
"Real-time Operation Logs (Last 50)": "Real-time Operation Logs (Last 50)",
|
||||||
"Real-time fleet efficiency and OEE metrics": "Real-time fleet efficiency and OEE metrics",
|
"Real-time fleet efficiency and OEE metrics": "Real-time fleet efficiency and OEE metrics",
|
||||||
|
|||||||
@ -1265,6 +1265,7 @@
|
|||||||
"Quick Select": "クイック選択",
|
"Quick Select": "クイック選択",
|
||||||
"Quick replenishment from this view": "この画面からクイック補充",
|
"Quick replenishment from this view": "この画面からクイック補充",
|
||||||
"Quick search...": "クイック検索...",
|
"Quick search...": "クイック検索...",
|
||||||
|
"Rank": "順位",
|
||||||
"Real-time OEE analysis awaits": "リアルタイムOEE分析待機中",
|
"Real-time OEE analysis awaits": "リアルタイムOEE分析待機中",
|
||||||
"Real-time Operation Logs (Last 50)": "リアルタイム操作ログ (直近50件)",
|
"Real-time Operation Logs (Last 50)": "リアルタイム操作ログ (直近50件)",
|
||||||
"Real-time fleet efficiency and OEE metrics": "全機器のリアルタイム効率とOEE指標",
|
"Real-time fleet efficiency and OEE metrics": "全機器のリアルタイム効率とOEE指標",
|
||||||
|
|||||||
@ -1284,6 +1284,7 @@
|
|||||||
"Quick Select": "快速選取",
|
"Quick Select": "快速選取",
|
||||||
"Quick replenishment from this view": "從此畫面快速補貨",
|
"Quick replenishment from this view": "從此畫面快速補貨",
|
||||||
"Quick search...": "快速搜尋...",
|
"Quick search...": "快速搜尋...",
|
||||||
|
"Rank": "排行",
|
||||||
"Real-time OEE analysis awaits": "即時 OEE 分析預備中",
|
"Real-time OEE analysis awaits": "即時 OEE 分析預備中",
|
||||||
"Real-time Operation Logs (Last 50)": "即時操作日誌 (最後 50 筆)",
|
"Real-time Operation Logs (Last 50)": "即時操作日誌 (最後 50 筆)",
|
||||||
"Real-time fleet efficiency and OEE metrics": "全機台即時效率與 OEE 指標",
|
"Real-time fleet efficiency and OEE metrics": "全機台即時效率與 OEE 指標",
|
||||||
|
|||||||
@ -262,7 +262,12 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr class="bg-slate-50/50 dark:bg-slate-900/10">
|
<tr class="bg-slate-50/50 dark:bg-slate-900/10">
|
||||||
<!-- Sortable columns -->
|
<!-- Sortable columns -->
|
||||||
<th @click="sortBy('barcode')" class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 cursor-pointer hover:bg-slate-100/50 dark:hover:bg-slate-800/20 transition-colors rounded-l-2xl">
|
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 rounded-l-2xl w-24">
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<span>{{ __('Rank') }}</span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th @click="sortBy('barcode')" class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 cursor-pointer hover:bg-slate-100/50 dark:hover:bg-slate-800/20 transition-colors">
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<span>{{ __('Product Barcode') }}</span>
|
<span>{{ __('Product Barcode') }}</span>
|
||||||
<template x-if="sortField === 'barcode'">
|
<template x-if="sortField === 'barcode'">
|
||||||
@ -321,8 +326,22 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-slate-100 dark:divide-slate-800/80">
|
<tbody class="divide-y divide-slate-100 dark:divide-slate-800/80">
|
||||||
<template x-for="row in paginatedTableData" :key="row.product_id || row.barcode || row.name">
|
<template x-for="(row, index) in paginatedTableData" :key="row.product_id || row.barcode || row.name">
|
||||||
<tr class="group hover:bg-slate-50/50 dark:hover:bg-white/[0.02] transition-colors duration-200">
|
<tr class="group hover:bg-slate-50/50 dark:hover:bg-white/[0.02] transition-colors duration-200">
|
||||||
|
<td class="px-6 py-4 text-xs font-mono font-black text-slate-400 dark:text-slate-500">
|
||||||
|
<template x-if="((currentPage - 1) * pageSize) + index + 1 === 1">
|
||||||
|
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-amber-500/10 text-amber-500 border border-amber-500/20 font-black shadow-sm">1</span>
|
||||||
|
</template>
|
||||||
|
<template x-if="((currentPage - 1) * pageSize) + index + 1 === 2">
|
||||||
|
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-slate-400/10 text-slate-400 border border-slate-400/20 font-black shadow-sm">2</span>
|
||||||
|
</template>
|
||||||
|
<template x-if="((currentPage - 1) * pageSize) + index + 1 === 3">
|
||||||
|
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-amber-700/10 text-amber-700 border border-amber-700/20 font-black shadow-sm">3</span>
|
||||||
|
</template>
|
||||||
|
<template x-if="((currentPage - 1) * pageSize) + index + 1 > 3">
|
||||||
|
<span x-text="((currentPage - 1) * pageSize) + index + 1" class="pl-2"></span>
|
||||||
|
</template>
|
||||||
|
</td>
|
||||||
<td class="px-6 py-4 text-xs font-black font-mono text-slate-400 dark:text-slate-500 tracking-widest" x-text="row.barcode || '-'"></td>
|
<td class="px-6 py-4 text-xs font-black font-mono text-slate-400 dark:text-slate-500 tracking-widest" x-text="row.barcode || '-'"></td>
|
||||||
<td class="px-6 py-4 text-sm font-extrabold text-slate-700 dark:text-slate-200" x-text="row.name"></td>
|
<td class="px-6 py-4 text-sm font-extrabold text-slate-700 dark:text-slate-200" x-text="row.name"></td>
|
||||||
<td class="px-6 py-4 text-sm font-black text-right text-slate-600 dark:text-slate-300 font-mono" x-text="Number(row.total_quantity).toLocaleString() + ' 件'"></td>
|
<td class="px-6 py-4 text-sm font-black text-right text-slate-600 dark:text-slate-300 font-mono" x-text="Number(row.total_quantity).toLocaleString() + ' 件'"></td>
|
||||||
@ -340,7 +359,7 @@
|
|||||||
<!-- Empty State Table -->
|
<!-- Empty State Table -->
|
||||||
<template x-if="paginatedTableData.length === 0">
|
<template x-if="paginatedTableData.length === 0">
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="7" class="py-12 text-center">
|
<td colspan="8" class="py-12 text-center">
|
||||||
<div class="w-16 h-16 mx-auto bg-slate-100 dark:bg-slate-800 rounded-full flex items-center justify-center text-slate-300 mb-4 scale-animation">
|
<div class="w-16 h-16 mx-auto bg-slate-100 dark:bg-slate-800 rounded-full flex items-center justify-center text-slate-300 mb-4 scale-animation">
|
||||||
<svg class="w-8 h-8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
<svg class="w-8 h-8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
<path d="M15 15l6 6m-6-6l-6-6m6 6l6-6m-6 6l-6 6" />
|
<path d="M15 15l6 6m-6-6l-6-6m6 6l6-6m-6 6l-6 6" />
|
||||||
@ -356,14 +375,25 @@
|
|||||||
|
|
||||||
<!-- Mobile List (xl:hidden) -->
|
<!-- Mobile List (xl:hidden) -->
|
||||||
<div class="xl:hidden grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div class="xl:hidden grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<template x-for="row in paginatedTableData" :key="row.product_id || row.barcode || row.name">
|
<template x-for="(row, index) in paginatedTableData" :key="row.product_id || row.barcode || row.name">
|
||||||
<div class="luxury-card p-6 rounded-[2rem] border border-slate-100 dark:border-slate-800 bg-white/50 dark:bg-slate-900/50 transition-all duration-300 group">
|
<div class="luxury-card p-6 rounded-[2rem] border border-slate-100 dark:border-slate-800 bg-white/50 dark:bg-slate-900/50 transition-all duration-300 group">
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="flex items-start justify-between gap-4 mb-6">
|
<div class="flex items-start justify-between gap-4 mb-6">
|
||||||
<div class="flex items-center gap-4 min-w-0">
|
<div class="flex items-center gap-4 min-w-0">
|
||||||
<div class="w-14 h-14 rounded-2xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 border border-slate-200 dark:border-slate-700 group-hover:bg-cyan-500 group-hover:text-white transition-all duration-300 overflow-hidden shadow-sm shrink-0">
|
<div class="w-14 h-14 rounded-2xl flex items-center justify-center border transition-all duration-300 overflow-hidden shadow-sm shrink-0 font-black text-base"
|
||||||
📦
|
:class="
|
||||||
|
((currentPage - 1) * pageSize) + index + 1 === 1 ? 'bg-amber-500/10 text-amber-500 border-amber-500/20' :
|
||||||
|
(((currentPage - 1) * pageSize) + index + 1 === 2 ? 'bg-slate-400/10 text-slate-400 border-slate-400/20' :
|
||||||
|
(((currentPage - 1) * pageSize) + index + 1 === 3 ? 'bg-amber-700/10 text-amber-700 border-amber-700/20' :
|
||||||
|
'bg-slate-100 dark:bg-slate-800 text-slate-400 border-slate-200 dark:border-slate-700 group-hover:bg-cyan-500 group-hover:text-white group-hover:border-cyan-500'))
|
||||||
|
">
|
||||||
|
<template x-if="((currentPage - 1) * pageSize) + index + 1 === 1"><span>🥇</span></template>
|
||||||
|
<template x-if="((currentPage - 1) * pageSize) + index + 1 === 2"><span>🥈</span></template>
|
||||||
|
<template x-if="((currentPage - 1) * pageSize) + index + 1 === 3"><span>🥉</span></template>
|
||||||
|
<template x-if="((currentPage - 1) * pageSize) + index + 1 > 3">
|
||||||
|
<span x-text="((currentPage - 1) * pageSize) + index + 1" class="text-sm font-black font-mono"></span>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<h3 class="text-base font-extrabold text-slate-800 dark:text-slate-100 truncate hover:text-cyan-600 dark:hover:text-cyan-400 transition-colors tracking-tight" x-text="row.name"></h3>
|
<h3 class="text-base font-extrabold text-slate-800 dark:text-slate-100 truncate hover:text-cyan-600 dark:hover:text-cyan-400 transition-colors tracking-tight" x-text="row.name"></h3>
|
||||||
@ -750,10 +780,10 @@
|
|||||||
|
|
||||||
// Exporter Module: Copy formatted TSV data into system Clipboard
|
// Exporter Module: Copy formatted TSV data into system Clipboard
|
||||||
copyTableData() {
|
copyTableData() {
|
||||||
let tsv = '商品條碼\t商品名稱\t銷售數量\t銷售金額\t商品成本\t銷售利潤\t毛利率\n';
|
let tsv = '排行\t商品條碼\t商品名稱\t銷售數量\t銷售金額\t商品成本\t銷售利潤\t毛利率\n';
|
||||||
this.filteredTableData.forEach(row => {
|
this.filteredTableData.forEach((row, idx) => {
|
||||||
const margin = row.total_sales > 0 ? ((row.total_profit / row.total_sales) * 100).toFixed(1) + '%' : '0.0%';
|
const margin = row.total_sales > 0 ? ((row.total_profit / row.total_sales) * 100).toFixed(1) + '%' : '0.0%';
|
||||||
tsv += `${row.barcode || '-'}\t${row.name}\t${row.total_quantity}\t${row.total_sales}\t${row.total_cost}\t${row.total_profit}\t${margin}\n`;
|
tsv += `${idx + 1}\t${row.barcode || '-'}\t${row.name}\t${row.total_quantity}\t${row.total_sales}\t${row.total_cost}\t${row.total_profit}\t${margin}\n`;
|
||||||
});
|
});
|
||||||
|
|
||||||
navigator.clipboard.writeText(tsv).then(() => {
|
navigator.clipboard.writeText(tsv).then(() => {
|
||||||
@ -766,11 +796,11 @@
|
|||||||
|
|
||||||
// Exporter Module: Export UTF-8 BOM CSV for direct MS Excel compatibility
|
// Exporter Module: Export UTF-8 BOM CSV for direct MS Excel compatibility
|
||||||
exportCSV() {
|
exportCSV() {
|
||||||
let csv = '商品條碼,商品名稱,銷售數量,銷售金額,商品成本,銷售利潤,毛利率\n';
|
let csv = '排行,商品條碼,商品名稱,銷售數量,銷售金額,商品成本,銷售利潤,毛利率\n';
|
||||||
this.filteredTableData.forEach(row => {
|
this.filteredTableData.forEach((row, idx) => {
|
||||||
const margin = row.total_sales > 0 ? ((row.total_profit / row.total_sales) * 100).toFixed(1) + '%' : '0.0%';
|
const margin = row.total_sales > 0 ? ((row.total_profit / row.total_sales) * 100).toFixed(1) + '%' : '0.0%';
|
||||||
const escapedName = `"${(row.name || '').replace(/"/g, '""')}"`;
|
const escapedName = `"${(row.name || '').replace(/"/g, '""')}"`;
|
||||||
csv += `${row.barcode || '-'},${escapedName},${row.total_quantity},${row.total_sales},${row.total_cost},${row.total_profit},${margin}\n`;
|
csv += `${idx + 1},${row.barcode || '-'},${escapedName},${row.total_quantity},${row.total_sales},${row.total_cost},${row.total_profit},${margin}\n`;
|
||||||
});
|
});
|
||||||
|
|
||||||
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' });
|
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' });
|
||||||
@ -791,16 +821,16 @@
|
|||||||
html += `<head><meta charset="utf-8"/><style>table { border-collapse: collapse; } th, td { border: 1px solid #cbd5e1; padding: 8px 12px; text-align: left; } th { background-color: #f1f5f9; font-weight: bold; }</style></head><body>`;
|
html += `<head><meta charset="utf-8"/><style>table { border-collapse: collapse; } th, td { border: 1px solid #cbd5e1; padding: 8px 12px; text-align: left; } th { background-color: #f1f5f9; font-weight: bold; }</style></head><body>`;
|
||||||
html += `<h2>商品成本分析報表</h2>`;
|
html += `<h2>商品成本分析報表</h2>`;
|
||||||
html += `<p>統計期間:${this.dateRange}</p>`;
|
html += `<p>統計期間:${this.dateRange}</p>`;
|
||||||
html += `<table><thead><tr><th>商品條碼</th><th>商品名稱</th><th>銷售數量</th><th>銷售金額</th><th>商品成本</th><th>銷售利潤</th><th>毛利率</th></tr></thead><tbody>`;
|
html += `<table><thead><tr><th>排行</th><th>商品條碼</th><th>商品名稱</th><th>銷售數量</th><th>銷售金額</th><th>商品成本</th><th>銷售利潤</th><th>毛利率</th></tr></thead><tbody>`;
|
||||||
|
|
||||||
this.filteredTableData.forEach(row => {
|
this.filteredTableData.forEach((row, idx) => {
|
||||||
const margin = row.total_sales > 0 ? ((row.total_profit / row.total_sales) * 100).toFixed(1) + '%' : '0.0%';
|
const margin = row.total_sales > 0 ? ((row.total_profit / row.total_sales) * 100).toFixed(1) + '%' : '0.0%';
|
||||||
html += `<tr><td>${row.barcode || '-'}</td><td>${row.name}</td><td>${row.total_quantity}</td><td>${row.total_sales}</td><td>${row.total_cost}</td><td>${row.total_profit}</td><td>${margin}</td></tr>`;
|
html += `<tr><td>${idx + 1}</td><td>${row.barcode || '-'}</td><td>${row.name}</td><td>${row.total_quantity}</td><td>${row.total_sales}</td><td>${row.total_cost}</td><td>${row.total_profit}</td><td>${margin}</td></tr>`;
|
||||||
});
|
});
|
||||||
|
|
||||||
const totalMargin = this.summary.total_sales > 0 ? ((this.summary.total_profit / this.summary.total_sales) * 100).toFixed(1) + '%' : '0.0%';
|
const totalMargin = this.summary.total_sales > 0 ? ((this.summary.total_profit / this.summary.total_sales) * 100).toFixed(1) + '%' : '0.0%';
|
||||||
html += `<tr style="font-weight: bold; background-color: #f8fafc;">
|
html += `<tr style="font-weight: bold; background-color: #f8fafc;">
|
||||||
<td colspan="2">總計 (Total)</td>
|
<td colspan="3">總計 (Total)</td>
|
||||||
<td>${this.summary.total_quantity}</td>
|
<td>${this.summary.total_quantity}</td>
|
||||||
<td>${this.summary.total_sales}</td>
|
<td>${this.summary.total_sales}</td>
|
||||||
<td>${this.summary.total_cost}</td>
|
<td>${this.summary.total_cost}</td>
|
||||||
@ -842,12 +872,13 @@
|
|||||||
</style></head><body>`;
|
</style></head><body>`;
|
||||||
html += `<h1>商品成本分析報表</h1>`;
|
html += `<h1>商品成本分析報表</h1>`;
|
||||||
html += `<div class="subtitle">統計區間:${this.dateRange}</div>`;
|
html += `<div class="subtitle">統計區間:${this.dateRange}</div>`;
|
||||||
html += `<table><thead><tr><th>商品條碼</th><th>商品名稱</th><th class="text-right">銷售數量</th><th class="text-right">銷售金額</th><th class="text-right">商品成本</th><th class="text-right">銷售利潤</th><th class="text-right">毛利率</th></tr></thead><tbody>`;
|
html += `<table><thead><tr><th>排行</th><th>商品條碼</th><th>商品名稱</th><th class="text-right">銷售數量</th><th class="text-right">銷售金額</th><th class="text-right">商品成本</th><th class="text-right">銷售利潤</th><th class="text-right">毛利率</th></tr></thead><tbody>`;
|
||||||
|
|
||||||
this.filteredTableData.forEach(row => {
|
this.filteredTableData.forEach((row, idx) => {
|
||||||
const margin = row.total_sales > 0 ? ((row.total_profit / row.total_sales) * 100).toFixed(1) + '%' : '0.0%';
|
const margin = row.total_sales > 0 ? ((row.total_profit / row.total_sales) * 100).toFixed(1) + '%' : '0.0%';
|
||||||
const profitClass = row.total_profit >= 0 ? 'text-success' : 'text-danger';
|
const profitClass = row.total_profit >= 0 ? 'text-success' : 'text-danger';
|
||||||
html += `<tr>
|
html += `<tr>
|
||||||
|
<td>${idx + 1}</td>
|
||||||
<td>${row.barcode || '-'}</td>
|
<td>${row.barcode || '-'}</td>
|
||||||
<td>${row.name}</td>
|
<td>${row.name}</td>
|
||||||
<td class="text-right">${row.total_quantity} 件</td>
|
<td class="text-right">${row.total_quantity} 件</td>
|
||||||
@ -860,7 +891,7 @@
|
|||||||
|
|
||||||
const totalMargin = this.summary.total_sales > 0 ? ((this.summary.total_profit / this.summary.total_sales) * 100).toFixed(1) + '%' : '0.0%';
|
const totalMargin = this.summary.total_sales > 0 ? ((this.summary.total_profit / this.summary.total_sales) * 100).toFixed(1) + '%' : '0.0%';
|
||||||
html += `<tr style="font-weight: bold; background-color: #f8fafc;">
|
html += `<tr style="font-weight: bold; background-color: #f8fafc;">
|
||||||
<td colspan="2">總計 (Total)</td>
|
<td colspan="3">總計 (Total)</td>
|
||||||
<td class="text-right">${this.summary.total_quantity} 件</td>
|
<td class="text-right">${this.summary.total_quantity} 件</td>
|
||||||
<td class="text-right">NT$ ${Number(this.summary.total_sales).toLocaleString()}</td>
|
<td class="text-right">NT$ ${Number(this.summary.total_sales).toLocaleString()}</td>
|
||||||
<td class="text-right">NT$ ${Number(this.summary.total_cost).toLocaleString()}</td>
|
<td class="text-right">NT$ ${Number(this.summary.total_cost).toLocaleString()}</td>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user