Some checks failed
CD Pipeline / deploy (push) Failing after 59s
- 建立 Gitea Actions CD pipeline (.gitea/workflows/cd.yaml) - 部署模式: rsync Python 檔案至 188 → docker restart (volume mount) - Dockerfile/requirements 變動時自動重建 Docker image - 部署通知: Telegram (開始/成功/失敗) - 健康檢查: https://mo.wooo.work/health (最多 5 次重試) - 同步最新 CLAUDE.md / ADR-008 / memory (2026-04-19) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1794 lines
75 KiB
HTML
1794 lines
75 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-TW">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||
<title>缺貨管理 - 廠商缺貨系統</title>
|
||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||
<style>
|
||
body {
|
||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||
background: linear-gradient(135deg, #f5f7fa 0%, #e8ecf1 100%);
|
||
min-height: 100vh;
|
||
padding-top: 70px;
|
||
}
|
||
|
||
/* Custom Dark Gray Navbar */
|
||
.navbar.bg-custom-dark {
|
||
background: linear-gradient(135deg, #1f2937 0%, #374151 100%);
|
||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||
border-bottom: 1px solid rgba(255,255,255,0.1);
|
||
}
|
||
|
||
.navbar.bg-custom-dark .navbar-brand {
|
||
color: #ffffff;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.navbar.bg-custom-dark .navbar-nav .nav-link {
|
||
color: rgba(255, 255, 255, 0.85);
|
||
font-weight: 500;
|
||
}
|
||
|
||
.navbar.bg-custom-dark .navbar-nav .nav-link:hover {
|
||
color: #ffffff;
|
||
}
|
||
|
||
.navbar.bg-custom-dark .navbar-nav .nav-link.active {
|
||
color: #ffffff;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.navbar.bg-custom-dark .navbar-text {
|
||
color: rgba(255, 255, 255, 0.75);
|
||
}
|
||
|
||
/* 主要標籤樣式 */
|
||
.main-tabs {
|
||
background: white;
|
||
border-radius: 16px;
|
||
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
|
||
margin-bottom: 1.5rem;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.main-tabs .nav-tabs {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.main-tabs .nav-link {
|
||
border: none;
|
||
border-radius: 0;
|
||
padding: 1rem 2rem;
|
||
font-weight: 600;
|
||
color: #6c757d;
|
||
transition: all 0.3s;
|
||
position: relative;
|
||
}
|
||
|
||
.main-tabs .nav-link.active {
|
||
background: linear-gradient(135deg, #27ae60 0%, #2ecc71 100%);
|
||
color: white;
|
||
}
|
||
|
||
.main-tabs .nav-link:hover:not(.active) {
|
||
background: #f8f9fa;
|
||
}
|
||
|
||
.main-tabs .nav-link .badge {
|
||
position: absolute;
|
||
top: 5px;
|
||
right: 5px;
|
||
font-size: 0.7rem;
|
||
}
|
||
|
||
/* 狀態子標籤 */
|
||
.status-tabs {
|
||
background: #f8f9fa;
|
||
border-radius: 12px;
|
||
padding: 0.5rem;
|
||
margin-bottom: 1.5rem;
|
||
}
|
||
|
||
.status-tabs .nav-link {
|
||
border: none;
|
||
border-radius: 8px;
|
||
padding: 0.75rem 1.5rem;
|
||
font-weight: 500;
|
||
color: #6c757d;
|
||
transition: all 0.3s;
|
||
margin-right: 0.5rem;
|
||
}
|
||
|
||
.status-tabs .nav-link.active {
|
||
background: white;
|
||
color: #2c3e50;
|
||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||
}
|
||
|
||
.status-tabs .nav-link.tab-pending.active {
|
||
border-left: 3px solid #f39c12;
|
||
}
|
||
|
||
.status-tabs .nav-link.tab-sent.active {
|
||
border-left: 3px solid #27ae60;
|
||
}
|
||
|
||
.table-container {
|
||
background: white;
|
||
border-radius: 16px;
|
||
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.filter-card {
|
||
background: white;
|
||
border-radius: 16px;
|
||
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
|
||
padding: 1.5rem;
|
||
margin-bottom: 1.5rem;
|
||
}
|
||
|
||
.table {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.table thead th {
|
||
background: linear-gradient(135deg, #27ae60 0%, #2ecc71 100%);
|
||
color: white;
|
||
border: none;
|
||
font-weight: 600;
|
||
padding: 1rem;
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 10;
|
||
}
|
||
|
||
.table thead th.th-pending {
|
||
background: linear-gradient(135deg, #f39c12 0%, #f1c40f 100%);
|
||
}
|
||
|
||
.table thead th.th-sent {
|
||
background: linear-gradient(135deg, #27ae60 0%, #2ecc71 100%);
|
||
}
|
||
|
||
.table tbody tr {
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.table tbody tr:hover {
|
||
background-color: #f8f9fa;
|
||
transform: translateX(5px);
|
||
}
|
||
|
||
.badge {
|
||
padding: 0.5rem 0.8rem;
|
||
font-size: 0.85rem;
|
||
}
|
||
|
||
.status-pending {
|
||
background-color: #ffc107;
|
||
color: #000;
|
||
}
|
||
|
||
.status-sent {
|
||
background-color: #28a745;
|
||
}
|
||
|
||
.status-failed {
|
||
background-color: #dc3545;
|
||
}
|
||
|
||
.btn-action {
|
||
padding: 0.4rem 0.8rem;
|
||
font-size: 0.875rem;
|
||
}
|
||
|
||
.stats-card {
|
||
background: white;
|
||
border-radius: 12px;
|
||
padding: 1.5rem;
|
||
text-align: center;
|
||
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
|
||
margin-bottom: 1.5rem;
|
||
transition: transform 0.2s;
|
||
border-top: 4px solid #3498db;
|
||
}
|
||
|
||
.stats-card:hover {
|
||
transform: translateY(-5px);
|
||
}
|
||
|
||
.stats-card.card-total { border-top-color: #3498db; }
|
||
.stats-card.card-pending { border-top-color: #f39c12; }
|
||
.stats-card.card-sent { border-top-color: #27ae60; }
|
||
.stats-card.card-vendor { border-top-color: #9b59b6; }
|
||
|
||
.stats-number {
|
||
font-size: 2rem;
|
||
font-weight: bold;
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
|
||
.stats-label {
|
||
color: #6c757d;
|
||
font-size: 0.9rem;
|
||
}
|
||
|
||
.upload-area {
|
||
border: 3px dashed #3498db;
|
||
border-radius: 20px;
|
||
padding: 3rem;
|
||
text-align: center;
|
||
background: white;
|
||
transition: all 0.3s ease;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.upload-area:hover {
|
||
border-color: #2980b9;
|
||
background: #f8f9fa;
|
||
transform: scale(1.02);
|
||
}
|
||
|
||
.upload-area.dragover {
|
||
border-color: #27ae60;
|
||
background: #d5f4e6;
|
||
}
|
||
|
||
.upload-icon {
|
||
font-size: 5rem;
|
||
color: #3498db;
|
||
margin-bottom: 1rem;
|
||
}
|
||
|
||
.btn-upload {
|
||
padding: 1rem 3rem;
|
||
font-size: 1.1rem;
|
||
border-radius: 50px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.progress {
|
||
height: 30px;
|
||
border-radius: 15px;
|
||
}
|
||
|
||
.result-card {
|
||
border-left: 5px solid #3498db;
|
||
}
|
||
|
||
/* 流程提示樣式 */
|
||
.workflow-guide {
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
color: white;
|
||
border-radius: 16px;
|
||
padding: 1.5rem;
|
||
margin-bottom: 1.5rem;
|
||
}
|
||
|
||
.workflow-guide h5 {
|
||
margin-bottom: 1rem;
|
||
}
|
||
|
||
.workflow-steps {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
|
||
.workflow-step {
|
||
display: flex;
|
||
align-items: center;
|
||
flex-direction: column;
|
||
text-align: center;
|
||
}
|
||
|
||
.workflow-step .step-icon {
|
||
width: 50px;
|
||
height: 50px;
|
||
border-radius: 50%;
|
||
background: rgba(255,255,255,0.2);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
margin-bottom: 0.5rem;
|
||
font-size: 1.2rem;
|
||
}
|
||
|
||
.workflow-step .step-icon.active {
|
||
background: white;
|
||
color: #667eea;
|
||
}
|
||
|
||
.workflow-step .step-text {
|
||
font-size: 0.85rem;
|
||
}
|
||
|
||
.workflow-arrow {
|
||
font-size: 1.5rem;
|
||
opacity: 0.5;
|
||
}
|
||
|
||
/* 重送確認 Modal 特殊樣式 */
|
||
.resend-warning {
|
||
background: #fff3cd;
|
||
border: 1px solid #ffc107;
|
||
border-radius: 8px;
|
||
padding: 1rem;
|
||
margin-bottom: 1rem;
|
||
}
|
||
|
||
.resend-warning i {
|
||
color: #856404;
|
||
}
|
||
|
||
/* 批次操作區塊改進 */
|
||
.batch-actions-card {
|
||
background: white;
|
||
border-radius: 12px;
|
||
padding: 1rem 1.5rem;
|
||
margin-bottom: 1rem;
|
||
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||
}
|
||
|
||
.batch-actions-card.pending-actions {
|
||
border-left: 4px solid #f39c12;
|
||
}
|
||
|
||
.batch-actions-card.sent-actions {
|
||
border-left: 4px solid #27ae60;
|
||
}
|
||
|
||
/* 使用者下拉選單 */
|
||
.user-dropdown .dropdown-toggle::after {
|
||
display: none;
|
||
}
|
||
|
||
.user-avatar {
|
||
width: 32px;
|
||
height: 32px;
|
||
border-radius: 50%;
|
||
background: rgba(255,255,255,0.2);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: white;
|
||
font-weight: 600;
|
||
}
|
||
|
||
/* 空狀態提示 */
|
||
.empty-state {
|
||
padding: 4rem 2rem;
|
||
text-align: center;
|
||
}
|
||
|
||
.empty-state i {
|
||
font-size: 4rem;
|
||
color: #dee2e6;
|
||
margin-bottom: 1rem;
|
||
}
|
||
|
||
.empty-state h5 {
|
||
color: #6c757d;
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
|
||
.empty-state p {
|
||
color: #adb5bd;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<!-- 導航列 (使用共用組件) -->
|
||
{% include 'components/_navbar.html' %}
|
||
|
||
<div class="container-fluid" style="padding: 2rem;">
|
||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||
<h2 class="mb-0">
|
||
<i class="fas fa-box-open me-2" style="color: #27ae60;"></i>缺貨管理
|
||
</h2>
|
||
<a href="/vendor-stockout" class="btn btn-outline-secondary">
|
||
<i class="fas fa-home me-2"></i>返回主頁
|
||
</a>
|
||
</div>
|
||
|
||
<!-- 流程提示 -->
|
||
<div class="workflow-guide" id="workflowGuide">
|
||
<h5><i class="fas fa-route me-2"></i>操作流程</h5>
|
||
<div class="workflow-steps">
|
||
<div class="workflow-step">
|
||
<div class="step-icon" id="step1Icon"><i class="fas fa-file-upload"></i></div>
|
||
<div class="step-text">1. 匯入 Excel</div>
|
||
</div>
|
||
<div class="workflow-arrow"><i class="fas fa-chevron-right"></i></div>
|
||
<div class="workflow-step">
|
||
<div class="step-icon" id="step2Icon"><i class="fas fa-check-square"></i></div>
|
||
<div class="step-text">2. 勾選待發送</div>
|
||
</div>
|
||
<div class="workflow-arrow"><i class="fas fa-chevron-right"></i></div>
|
||
<div class="workflow-step">
|
||
<div class="step-icon" id="step3Icon"><i class="fas fa-paper-plane"></i></div>
|
||
<div class="step-text">3. 發送郵件</div>
|
||
</div>
|
||
<div class="workflow-arrow"><i class="fas fa-chevron-right"></i></div>
|
||
<div class="workflow-step">
|
||
<div class="step-icon" id="step4Icon"><i class="fas fa-history"></i></div>
|
||
<div class="step-text">4. 查看記錄</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 主分頁標籤 -->
|
||
<div class="main-tabs">
|
||
<ul class="nav nav-tabs" id="mainTabs" role="tablist">
|
||
<li class="nav-item" role="presentation">
|
||
<button class="nav-link active" id="list-tab" data-bs-toggle="tab" data-bs-target="#list" type="button">
|
||
<i class="fas fa-list me-2"></i>缺貨清單
|
||
</button>
|
||
</li>
|
||
<li class="nav-item" role="presentation">
|
||
<button class="nav-link" id="import-tab" data-bs-toggle="tab" data-bs-target="#import" type="button">
|
||
<i class="fas fa-file-upload me-2"></i>Excel 匯入
|
||
</button>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div class="tab-content" id="mainTabContent">
|
||
<!-- 缺貨清單 Tab -->
|
||
<div class="tab-pane fade show active" id="list" role="tabpanel">
|
||
<!-- 統計卡片 -->
|
||
<div class="row mb-4">
|
||
<div class="col-md-3">
|
||
<div class="stats-card card-total">
|
||
<div class="stats-number text-primary" id="totalCount">0</div>
|
||
<div class="stats-label">總缺貨筆數</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-3">
|
||
<div class="stats-card card-pending">
|
||
<div class="stats-number text-warning" id="pendingCount">0</div>
|
||
<div class="stats-label">待發送</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-3">
|
||
<div class="stats-card card-sent">
|
||
<div class="stats-number text-success" id="sentCount">0</div>
|
||
<div class="stats-label">已發送</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-3">
|
||
<div class="stats-card card-vendor">
|
||
<div class="stats-number text-purple" id="vendorCount" style="color: #9b59b6;">0</div>
|
||
<div class="stats-label">涉及廠商數</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 狀態子標籤(待發送/已發送) -->
|
||
<div class="status-tabs">
|
||
<ul class="nav" id="statusTabs" role="tablist">
|
||
<li class="nav-item" role="presentation">
|
||
<button class="nav-link tab-pending active" id="pending-tab" data-status="pending" type="button">
|
||
<i class="fas fa-clock me-2"></i>待發送
|
||
<span class="badge bg-warning text-dark ms-2" id="pendingBadge">0</span>
|
||
</button>
|
||
</li>
|
||
<li class="nav-item" role="presentation">
|
||
<button class="nav-link tab-sent" id="sent-tab" data-status="sent" type="button">
|
||
<i class="fas fa-check-circle me-2"></i>已發送
|
||
<span class="badge bg-success ms-2" id="sentBadge">0</span>
|
||
</button>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<!-- 篩選區域 -->
|
||
<div class="filter-card">
|
||
<h5 class="mb-3"><i class="fas fa-filter me-2 text-success"></i>篩選條件</h5>
|
||
<div class="row">
|
||
<div class="col-md-3">
|
||
<label class="form-label">批次選擇</label>
|
||
<select class="form-select" id="filterBatch">
|
||
<option value="">全部批次</option>
|
||
</select>
|
||
</div>
|
||
<div class="col-md-3">
|
||
<label class="form-label">廠商代碼/名稱</label>
|
||
<input type="text" class="form-control" id="filterVendor" placeholder="輸入廠商代碼或名稱">
|
||
</div>
|
||
<div class="col-md-3">
|
||
<label class="form-label">排序方式</label>
|
||
<select class="form-select" id="sortBy">
|
||
<option value="created_at_desc">最新匯入</option>
|
||
<option value="created_at_asc">最早匯入</option>
|
||
<option value="vendor_code_asc">廠商代碼</option>
|
||
<option value="stockout_days_desc">缺貨天數</option>
|
||
</select>
|
||
</div>
|
||
<div class="col-md-3">
|
||
<label class="form-label"> </label>
|
||
<button class="btn btn-success w-100" id="applyFilter">
|
||
<i class="fas fa-search me-2"></i>套用篩選
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 待發送批次操作 -->
|
||
<div class="batch-actions-card pending-actions" id="pendingActions">
|
||
<div class="d-flex justify-content-between align-items-center">
|
||
<div>
|
||
<input type="checkbox" id="selectAllPending" class="form-check-input me-2">
|
||
<label for="selectAllPending" class="form-check-label">
|
||
全選待發送 (<span id="selectedPendingCount">0</span> 筆)
|
||
</label>
|
||
</div>
|
||
<div>
|
||
<button class="btn btn-outline-secondary btn-action me-2" onclick="openEmailTemplateModal()">
|
||
<i class="fas fa-cog me-1"></i>郵件模板
|
||
</button>
|
||
<button class="btn btn-primary btn-action me-2" id="batchSendGrouped" disabled>
|
||
<i class="fas fa-paper-plane me-1"></i>按廠商發送
|
||
</button>
|
||
<button class="btn btn-info btn-action me-2" id="batchSendSingle" disabled>
|
||
<i class="fas fa-envelope me-1"></i>按商品發送
|
||
</button>
|
||
<button class="btn btn-outline-success btn-action me-2" id="batchMarkSent" disabled>
|
||
<i class="fas fa-check me-1"></i>標記已發送
|
||
</button>
|
||
<button class="btn btn-outline-danger btn-action" id="batchDeletePending" disabled>
|
||
<i class="fas fa-trash me-1"></i>刪除
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 已發送批次操作(預設隱藏) -->
|
||
<div class="batch-actions-card sent-actions" id="sentActions" style="display: none;">
|
||
<div class="d-flex justify-content-between align-items-center">
|
||
<div>
|
||
<input type="checkbox" id="selectAllSent" class="form-check-input me-2">
|
||
<label for="selectAllSent" class="form-check-label">
|
||
全選已發送 (<span id="selectedSentCount">0</span> 筆)
|
||
</label>
|
||
</div>
|
||
<div>
|
||
<button class="btn btn-warning btn-action me-2" id="batchResend" disabled>
|
||
<i class="fas fa-redo me-1"></i>重新發送
|
||
</button>
|
||
<button class="btn btn-outline-warning btn-action me-2" id="batchMarkPending" disabled>
|
||
<i class="fas fa-undo me-1"></i>重設為待發送
|
||
</button>
|
||
<button class="btn btn-outline-danger btn-action" id="batchDeleteSent" disabled>
|
||
<i class="fas fa-trash me-1"></i>刪除
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 資料表格 -->
|
||
<div class="table-container">
|
||
<div class="table-responsive">
|
||
<table class="table table-hover">
|
||
<thead id="tableHead">
|
||
<tr>
|
||
<th style="width: 50px;"></th>
|
||
<th>批次</th>
|
||
<th>廠商代碼</th>
|
||
<th>廠商名稱</th>
|
||
<th>商品ID</th>
|
||
<th>商品名稱</th>
|
||
<th>缺貨天數</th>
|
||
<th>日均銷量</th>
|
||
<th>可賣量</th>
|
||
<th>狀態</th>
|
||
<th>匯入時間</th>
|
||
<th>操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="dataTable">
|
||
<tr>
|
||
<td colspan="12" class="text-center py-5">
|
||
<i class="fas fa-spinner fa-spin fa-2x text-muted mb-3"></i>
|
||
<p class="text-muted">載入中...</p>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 分頁 -->
|
||
<div class="d-flex justify-content-between align-items-center mt-4">
|
||
<div class="text-muted">
|
||
顯示 <span id="pageInfo">0-0 / 0</span> 筆
|
||
</div>
|
||
<nav>
|
||
<ul class="pagination mb-0" id="pagination">
|
||
<li class="page-item disabled"><a class="page-link" href="#">上一頁</a></li>
|
||
<li class="page-item active"><a class="page-link" href="#">1</a></li>
|
||
<li class="page-item disabled"><a class="page-link" href="#">下一頁</a></li>
|
||
</ul>
|
||
</nav>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Excel 匯入 Tab -->
|
||
<div class="tab-pane fade" id="import" role="tabpanel">
|
||
<div class="row justify-content-center">
|
||
<div class="col-md-10">
|
||
<!-- 頁面說明 -->
|
||
<div class="card mb-4" style="border-radius: 16px;">
|
||
<div class="card-body">
|
||
<h4 class="mb-3">
|
||
<i class="fas fa-file-excel text-primary me-3"></i>Excel 匯入
|
||
</h4>
|
||
<p class="text-muted mb-0">上傳包含缺貨商品資料的 Excel 檔案,系統會自動檢測並匯入</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 上傳區域 -->
|
||
<div class="card mb-4" style="border-radius: 16px;">
|
||
<div class="card-body">
|
||
<div class="upload-area" id="uploadArea">
|
||
<input type="file" id="fileInput" accept=".xlsx,.xls" style="display: none;">
|
||
<div class="upload-icon">
|
||
<i class="fas fa-cloud-upload-alt"></i>
|
||
</div>
|
||
<h4 class="mb-3">拖曳檔案到此處或點擊選擇檔案</h4>
|
||
<p class="text-muted">支援格式: Excel (.xlsx, .xls)</p>
|
||
<button type="button" class="btn btn-primary btn-upload mt-3" id="selectFileBtn">
|
||
<i class="fas fa-folder-open me-2"></i>選擇檔案
|
||
</button>
|
||
</div>
|
||
|
||
<!-- 檔案資訊 -->
|
||
<div id="fileInfo" class="mt-4" style="display: none;">
|
||
<div class="alert alert-info">
|
||
<i class="fas fa-file me-2"></i>
|
||
<strong>已選擇檔案:</strong> <span id="fileName"></span>
|
||
<span class="badge bg-primary ms-2" id="fileSize"></span>
|
||
</div>
|
||
<button type="button" class="btn btn-success w-100 btn-upload" id="uploadBtn">
|
||
<i class="fas fa-upload me-2"></i>開始匯入
|
||
</button>
|
||
</div>
|
||
|
||
<!-- 進度條 -->
|
||
<div id="progressArea" class="mt-4" style="display: none;">
|
||
<div class="progress">
|
||
<div class="progress-bar progress-bar-striped progress-bar-animated"
|
||
role="progressbar" style="width: 100%">
|
||
匯入中...
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 匯入結果 -->
|
||
<div id="resultArea" style="display: none;">
|
||
<div class="card result-card" style="border-radius: 16px;">
|
||
<div class="card-body">
|
||
<h5 class="card-title">
|
||
<i class="fas fa-check-circle text-success me-2"></i>匯入完成
|
||
</h5>
|
||
<div class="row mt-4">
|
||
<div class="col-md-3">
|
||
<div class="text-center">
|
||
<h3 class="text-primary" id="importTotalCount">0</h3>
|
||
<small class="text-muted">總筆數</small>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-3">
|
||
<div class="text-center">
|
||
<h3 class="text-success" id="importSuccessCount">0</h3>
|
||
<small class="text-muted">成功匯入</small>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-3">
|
||
<div class="text-center">
|
||
<h3 class="text-warning" id="importDuplicateCount">0</h3>
|
||
<small class="text-muted">重複項目</small>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-3">
|
||
<div class="text-center">
|
||
<h3 class="text-danger" id="importFailedCount">0</h3>
|
||
<small class="text-muted">失敗</small>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="mt-4">
|
||
<p class="mb-2">
|
||
<i class="fas fa-barcode me-2"></i>
|
||
<strong>批次編號:</strong> <code id="batchId"></code>
|
||
</p>
|
||
</div>
|
||
<div class="mt-4 text-center">
|
||
<button type="button" class="btn btn-primary me-2" onclick="switchToListTab()">
|
||
<i class="fas fa-list me-2"></i>查看缺貨清單
|
||
</button>
|
||
<button type="button" class="btn btn-outline-secondary" id="importAgainBtn">
|
||
<i class="fas fa-redo me-2"></i>再次匯入
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 錯誤訊息 -->
|
||
<div id="errorArea" style="display: none;">
|
||
<div class="alert alert-danger">
|
||
<h5><i class="fas fa-exclamation-circle me-2"></i>匯入失敗</h5>
|
||
<p class="mb-0" id="errorMessage"></p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 編輯 Modal -->
|
||
<div class="modal fade" id="editModal" tabindex="-1">
|
||
<div class="modal-dialog modal-lg">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h5 class="modal-title"><i class="fas fa-edit me-2"></i>編輯缺貨資料</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<input type="hidden" id="editId">
|
||
<div class="row">
|
||
<div class="col-md-6 mb-3">
|
||
<label class="form-label">廠商代碼</label>
|
||
<input type="text" class="form-control" id="editVendorCode" readonly>
|
||
</div>
|
||
<div class="col-md-6 mb-3">
|
||
<label class="form-label">廠商名稱</label>
|
||
<input type="text" class="form-control" id="editVendorName" readonly>
|
||
</div>
|
||
<div class="col-md-6 mb-3">
|
||
<label class="form-label">商品ID</label>
|
||
<input type="text" class="form-control" id="editProductCode" readonly>
|
||
</div>
|
||
<div class="col-md-6 mb-3">
|
||
<label class="form-label">商品名稱</label>
|
||
<input type="text" class="form-control" id="editProductName">
|
||
</div>
|
||
<div class="col-md-4 mb-3">
|
||
<label class="form-label">缺貨天數</label>
|
||
<input type="number" class="form-control" id="editStockoutDays">
|
||
</div>
|
||
<div class="col-md-4 mb-3">
|
||
<label class="form-label">日均銷量</label>
|
||
<input type="number" step="0.01" class="form-control" id="editDailyAvgSales">
|
||
</div>
|
||
<div class="col-md-4 mb-3">
|
||
<label class="form-label">可賣量</label>
|
||
<input type="number" class="form-control" id="editCurrentStock">
|
||
</div>
|
||
<div class="col-md-12 mb-3">
|
||
<label class="form-label">備註</label>
|
||
<textarea class="form-control" id="editNotes" rows="3"></textarea>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
|
||
<button type="button" class="btn btn-success" id="saveEdit">
|
||
<i class="fas fa-save me-2"></i>儲存
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 郵件模板設定 Modal -->
|
||
<div class="modal fade" id="emailTemplateModal" tabindex="-1">
|
||
<div class="modal-dialog modal-xl">
|
||
<div class="modal-content">
|
||
<div class="modal-header bg-primary text-white">
|
||
<h5 class="modal-title"><i class="fas fa-cog me-2"></i>郵件模板設定</h5>
|
||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<!-- 說明 -->
|
||
<div class="alert alert-info mb-3">
|
||
<i class="fas fa-info-circle me-2"></i>
|
||
設定郵件模板內容,之後所有廠商缺貨通知郵件都會套用此模板。
|
||
</div>
|
||
|
||
<!-- 可用變數提示 -->
|
||
<div class="mb-3 p-3 bg-light rounded">
|
||
<div class="mb-2">
|
||
<i class="fas fa-lightbulb text-warning me-1"></i>
|
||
<strong>可用變數(會自動替換為實際值):</strong>
|
||
</div>
|
||
<div class="row">
|
||
<div class="col-md-4">
|
||
<code class="text-primary">{{vendor_name}}</code>
|
||
<small class="text-muted ms-1">廠商名稱</small>
|
||
</div>
|
||
<div class="col-md-4">
|
||
<code class="text-primary">{{vendor_code}}</code>
|
||
<small class="text-muted ms-1">廠商代碼</small>
|
||
</div>
|
||
<div class="col-md-4">
|
||
<code class="text-primary">{{item_count}}</code>
|
||
<small class="text-muted ms-1">缺貨商品數量</small>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="row">
|
||
<div class="col-md-6">
|
||
<!-- 郵件主旨 -->
|
||
<div class="mb-3">
|
||
<label class="form-label fw-bold">
|
||
<i class="fas fa-heading me-1"></i>郵件主旨
|
||
</label>
|
||
<input type="text" class="form-control" id="emailSubjectInput"
|
||
oninput="refreshEmailPreview()">
|
||
</div>
|
||
|
||
<!-- 郵件內容 -->
|
||
<div class="mb-3">
|
||
<label class="form-label fw-bold">
|
||
<i class="fas fa-file-alt me-1"></i>郵件內容 (HTML)
|
||
</label>
|
||
<textarea class="form-control" id="emailBodyInput" rows="12"
|
||
oninput="refreshEmailPreview()"></textarea>
|
||
<div class="form-text">支援 HTML 格式標籤,如 <p>、<br>、<b> 等</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<!-- 預覽區 -->
|
||
<div class="mb-3">
|
||
<label class="form-label fw-bold">
|
||
<i class="fas fa-eye me-1"></i>郵件預覽(範例)
|
||
</label>
|
||
<div class="border rounded p-3 bg-white" style="min-height: 350px; max-height: 400px; overflow-y: auto;">
|
||
<div class="mb-2 pb-2 border-bottom">
|
||
<strong>主旨:</strong>
|
||
<span id="emailPreviewSubject" class="text-primary"></span>
|
||
</div>
|
||
<div id="emailPreviewBody"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn btn-outline-warning" onclick="resetEmailTemplate()">
|
||
<i class="fas fa-undo me-2"></i>恢復預設
|
||
</button>
|
||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
|
||
<button type="button" class="btn btn-primary" onclick="saveEmailTemplate()">
|
||
<i class="fas fa-save me-2"></i>儲存模板
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 重新發送確認 Modal -->
|
||
<div class="modal fade" id="resendConfirmModal" tabindex="-1">
|
||
<div class="modal-dialog">
|
||
<div class="modal-content">
|
||
<div class="modal-header bg-warning">
|
||
<h5 class="modal-title"><i class="fas fa-exclamation-triangle me-2"></i>確認重新發送</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="resend-warning">
|
||
<i class="fas fa-info-circle me-2"></i>
|
||
<strong>注意:</strong>這些項目已經發送過郵件通知給廠商。
|
||
</div>
|
||
<p>您確定要重新發送嗎?</p>
|
||
<ul class="mb-3">
|
||
<li>選取的 <strong id="resendCount">0</strong> 筆記錄將會重新發送郵件</li>
|
||
<li>廠商將會收到重複的通知</li>
|
||
</ul>
|
||
<div class="form-check">
|
||
<input class="form-check-input" type="checkbox" id="confirmResend">
|
||
<label class="form-check-label" for="confirmResend">
|
||
我了解並確認要重新發送
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
|
||
<button type="button" class="btn btn-warning" id="confirmResendBtn" disabled>
|
||
<i class="fas fa-paper-plane me-2"></i>確認重新發送
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||
<script>
|
||
// ====== Global Variables ======
|
||
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
|
||
let currentPage = 1;
|
||
let pageSize = 50;
|
||
let totalCount = 0;
|
||
let allData = [];
|
||
let selectedIds = new Set();
|
||
let editModal;
|
||
let emailTemplateModal;
|
||
let resendConfirmModal;
|
||
let selectedFile = null;
|
||
let currentStatus = 'pending'; // 當前檢視的狀態:pending 或 sent
|
||
|
||
// ====== Initialize ======
|
||
document.addEventListener('DOMContentLoaded', async () => {
|
||
editModal = new bootstrap.Modal(document.getElementById('editModal'));
|
||
emailTemplateModal = new bootstrap.Modal(document.getElementById('emailTemplateModal'));
|
||
resendConfirmModal = new bootstrap.Modal(document.getElementById('resendConfirmModal'));
|
||
await loadBatches();
|
||
await loadData();
|
||
bindEvents();
|
||
updateWorkflowGuide();
|
||
});
|
||
|
||
// ====== Bind Events ======
|
||
function bindEvents() {
|
||
// 狀態標籤切換
|
||
document.querySelectorAll('#statusTabs .nav-link').forEach(tab => {
|
||
tab.addEventListener('click', (e) => {
|
||
document.querySelectorAll('#statusTabs .nav-link').forEach(t => t.classList.remove('active'));
|
||
e.target.classList.add('active');
|
||
currentStatus = e.target.dataset.status;
|
||
currentPage = 1;
|
||
selectedIds.clear();
|
||
loadData();
|
||
updateUIForStatus();
|
||
});
|
||
});
|
||
|
||
// 篩選與全選
|
||
document.getElementById('applyFilter').addEventListener('click', () => {
|
||
currentPage = 1;
|
||
loadData();
|
||
});
|
||
|
||
// 待發送全選
|
||
document.getElementById('selectAllPending').addEventListener('change', (e) => {
|
||
handleSelectAll(e.target.checked);
|
||
});
|
||
|
||
// 已發送全選
|
||
document.getElementById('selectAllSent').addEventListener('change', (e) => {
|
||
handleSelectAll(e.target.checked);
|
||
});
|
||
|
||
// 批次操作按鈕
|
||
document.getElementById('batchDeletePending').addEventListener('click', batchDelete);
|
||
document.getElementById('batchDeleteSent').addEventListener('click', batchDelete);
|
||
document.getElementById('batchMarkSent').addEventListener('click', batchMarkSent);
|
||
document.getElementById('batchMarkPending').addEventListener('click', batchMarkPending);
|
||
document.getElementById('batchSendGrouped').addEventListener('click', batchSendGrouped);
|
||
document.getElementById('batchSendSingle').addEventListener('click', batchSendSingle);
|
||
document.getElementById('batchResend').addEventListener('click', openResendConfirm);
|
||
document.getElementById('saveEdit').addEventListener('click', saveEdit);
|
||
|
||
// 重送確認
|
||
document.getElementById('confirmResend').addEventListener('change', (e) => {
|
||
document.getElementById('confirmResendBtn').disabled = !e.target.checked;
|
||
});
|
||
document.getElementById('confirmResendBtn').addEventListener('click', executeResend);
|
||
|
||
// Excel 匯入相關
|
||
bindImportEvents();
|
||
}
|
||
|
||
// 更新 UI 根據當前狀態
|
||
function updateUIForStatus() {
|
||
const isPending = currentStatus === 'pending';
|
||
|
||
// 顯示/隱藏對應的操作區塊
|
||
document.getElementById('pendingActions').style.display = isPending ? 'block' : 'none';
|
||
document.getElementById('sentActions').style.display = isPending ? 'none' : 'block';
|
||
|
||
// 更新表頭顏色
|
||
const headers = document.querySelectorAll('#tableHead th');
|
||
headers.forEach(th => {
|
||
th.classList.remove('th-pending', 'th-sent');
|
||
th.classList.add(isPending ? 'th-pending' : 'th-sent');
|
||
});
|
||
|
||
// 重置選取
|
||
document.getElementById('selectAllPending').checked = false;
|
||
document.getElementById('selectAllSent').checked = false;
|
||
updateBatchButtons();
|
||
}
|
||
|
||
// 全選處理
|
||
function handleSelectAll(checked) {
|
||
document.querySelectorAll('.row-checkbox').forEach(cb => {
|
||
cb.checked = checked;
|
||
if (checked) {
|
||
selectedIds.add(parseInt(cb.value));
|
||
} else {
|
||
selectedIds.delete(parseInt(cb.value));
|
||
}
|
||
});
|
||
updateBatchButtons();
|
||
}
|
||
|
||
// ====== 缺貨清單功能 ======
|
||
|
||
// 載入批次清單
|
||
async function loadBatches() {
|
||
try {
|
||
const response = await fetch('/vendor-stockout/api/stockout/batches');
|
||
const result = await response.json();
|
||
if (result.success) {
|
||
const select = document.getElementById('filterBatch');
|
||
result.data.forEach(batch => {
|
||
const option = document.createElement('option');
|
||
option.value = batch.batch_number;
|
||
option.textContent = `${batch.batch_number} (${batch.count} 筆)`;
|
||
select.appendChild(option);
|
||
});
|
||
}
|
||
} catch (error) {
|
||
console.error('載入批次失敗:', error);
|
||
}
|
||
}
|
||
|
||
// 載入資料
|
||
async function loadData() {
|
||
try {
|
||
// 建立查詢參數
|
||
const params = new URLSearchParams({
|
||
page: currentPage,
|
||
page_size: pageSize,
|
||
status: currentStatus
|
||
});
|
||
|
||
const batch = document.getElementById('filterBatch').value;
|
||
const vendor = document.getElementById('filterVendor').value;
|
||
const sortBy = document.getElementById('sortBy').value;
|
||
|
||
if (batch) params.append('batch_number', batch);
|
||
if (vendor) params.append('vendor', vendor);
|
||
if (sortBy) params.append('sort_by', sortBy);
|
||
|
||
const response = await fetch(`/vendor-stockout/api/stockout/list?${params}`);
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
allData = result.data.records;
|
||
totalCount = result.data.total;
|
||
renderTable(allData);
|
||
updateStats(result.data.stats);
|
||
updatePagination();
|
||
updateUIForStatus();
|
||
}
|
||
} catch (error) {
|
||
console.error('載入資料失敗:', error);
|
||
document.getElementById('dataTable').innerHTML = `
|
||
<tr>
|
||
<td colspan="12" class="text-center py-5 text-danger">
|
||
<i class="fas fa-exclamation-circle fa-2x mb-3"></i>
|
||
<p>載入失敗: ${error.message}</p>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
}
|
||
}
|
||
|
||
// 渲染表格
|
||
function renderTable(data) {
|
||
const tbody = document.getElementById('dataTable');
|
||
const isPending = currentStatus === 'pending';
|
||
|
||
if (data.length === 0) {
|
||
const emptyMessage = isPending
|
||
? '暫無待發送的缺貨資料<br><small class="text-muted">請先匯入 Excel 檔案或從「已發送」重設項目</small>'
|
||
: '暫無已發送的記錄<br><small class="text-muted">發送郵件後會顯示在此處</small>';
|
||
|
||
tbody.innerHTML = `
|
||
<tr>
|
||
<td colspan="12">
|
||
<div class="empty-state">
|
||
<i class="fas ${isPending ? 'fa-inbox' : 'fa-paper-plane'}"></i>
|
||
<h5>${emptyMessage}</h5>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
tbody.innerHTML = data.map(record => `
|
||
<tr>
|
||
<td>
|
||
<input type="checkbox" class="form-check-input row-checkbox" value="${record.id}"
|
||
${selectedIds.has(record.id) ? 'checked' : ''}>
|
||
</td>
|
||
<td><span class="badge bg-info">${record.batch_number || '-'}</span></td>
|
||
<td><strong>${record.vendor_code}</strong></td>
|
||
<td>${record.vendor_name}</td>
|
||
<td><code>${record.product_code}</code></td>
|
||
<td>${record.product_name || '-'}</td>
|
||
<td><span class="badge bg-danger">${record.stockout_days || 0} 天</span></td>
|
||
<td>${record.daily_avg_sales ? record.daily_avg_sales.toFixed(1) : '-'}</td>
|
||
<td>${record.current_stock || 0}</td>
|
||
<td>${getStatusBadge(record.send_status)}</td>
|
||
<td><small>${formatDateTime(record.created_at)}</small></td>
|
||
<td>
|
||
<button class="btn btn-sm btn-outline-primary me-1" onclick="editRecord(${record.id})">
|
||
<i class="fas fa-edit"></i>
|
||
</button>
|
||
<button class="btn btn-sm btn-outline-danger" onclick="deleteRecord(${record.id})">
|
||
<i class="fas fa-trash"></i>
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
`).join('');
|
||
|
||
// 綁定勾選事件
|
||
document.querySelectorAll('.row-checkbox').forEach(cb => {
|
||
cb.addEventListener('change', handleRowSelect);
|
||
});
|
||
}
|
||
|
||
// 更新統計
|
||
function updateStats(stats) {
|
||
document.getElementById('totalCount').textContent = stats.total || 0;
|
||
document.getElementById('pendingCount').textContent = stats.pending || 0;
|
||
document.getElementById('sentCount').textContent = stats.sent || 0;
|
||
document.getElementById('vendorCount').textContent = stats.vendor_count || 0;
|
||
|
||
// 更新標籤上的徽章
|
||
document.getElementById('pendingBadge').textContent = stats.pending || 0;
|
||
document.getElementById('sentBadge').textContent = stats.sent || 0;
|
||
}
|
||
|
||
// 更新分頁
|
||
function updatePagination() {
|
||
const totalPages = Math.ceil(totalCount / pageSize);
|
||
const start = (currentPage - 1) * pageSize + 1;
|
||
const end = Math.min(currentPage * pageSize, totalCount);
|
||
|
||
document.getElementById('pageInfo').textContent = totalCount > 0 ? `${start}-${end} / ${totalCount}` : '0 筆';
|
||
|
||
const pagination = document.getElementById('pagination');
|
||
let html = '';
|
||
|
||
// 上一頁
|
||
html += `<li class="page-item ${currentPage === 1 ? 'disabled' : ''}">
|
||
<a class="page-link" href="#" onclick="changePage(${currentPage - 1}); return false;">上一頁</a>
|
||
</li>`;
|
||
|
||
// 頁碼
|
||
for (let i = 1; i <= totalPages; i++) {
|
||
if (i === 1 || i === totalPages || (i >= currentPage - 2 && i <= currentPage + 2)) {
|
||
html += `<li class="page-item ${i === currentPage ? 'active' : ''}">
|
||
<a class="page-link" href="#" onclick="changePage(${i}); return false;">${i}</a>
|
||
</li>`;
|
||
} else if (i === currentPage - 3 || i === currentPage + 3) {
|
||
html += `<li class="page-item disabled"><a class="page-link">...</a></li>`;
|
||
}
|
||
}
|
||
|
||
// 下一頁
|
||
html += `<li class="page-item ${currentPage === totalPages || totalPages === 0 ? 'disabled' : ''}">
|
||
<a class="page-link" href="#" onclick="changePage(${currentPage + 1}); return false;">下一頁</a>
|
||
</li>`;
|
||
|
||
pagination.innerHTML = html;
|
||
}
|
||
|
||
// 切換頁面
|
||
function changePage(page) {
|
||
const totalPages = Math.ceil(totalCount / pageSize);
|
||
if (page < 1 || page > totalPages) return;
|
||
currentPage = page;
|
||
loadData();
|
||
}
|
||
|
||
// 處理行勾選
|
||
function handleRowSelect(e) {
|
||
const id = parseInt(e.target.value);
|
||
if (e.target.checked) {
|
||
selectedIds.add(id);
|
||
} else {
|
||
selectedIds.delete(id);
|
||
// 取消全選勾選
|
||
if (currentStatus === 'pending') {
|
||
document.getElementById('selectAllPending').checked = false;
|
||
} else {
|
||
document.getElementById('selectAllSent').checked = false;
|
||
}
|
||
}
|
||
updateBatchButtons();
|
||
}
|
||
|
||
// 更新批次按鈕狀態
|
||
function updateBatchButtons() {
|
||
const count = selectedIds.size;
|
||
|
||
if (currentStatus === 'pending') {
|
||
document.getElementById('selectedPendingCount').textContent = count;
|
||
document.getElementById('batchDeletePending').disabled = count === 0;
|
||
document.getElementById('batchMarkSent').disabled = count === 0;
|
||
document.getElementById('batchSendGrouped').disabled = count === 0;
|
||
document.getElementById('batchSendSingle').disabled = count === 0;
|
||
} else {
|
||
document.getElementById('selectedSentCount').textContent = count;
|
||
document.getElementById('batchDeleteSent').disabled = count === 0;
|
||
document.getElementById('batchMarkPending').disabled = count === 0;
|
||
document.getElementById('batchResend').disabled = count === 0;
|
||
}
|
||
}
|
||
|
||
// 編輯記錄
|
||
async function editRecord(id) {
|
||
const record = allData.find(r => r.id === id);
|
||
if (!record) return;
|
||
|
||
document.getElementById('editId').value = record.id;
|
||
document.getElementById('editVendorCode').value = record.vendor_code;
|
||
document.getElementById('editVendorName').value = record.vendor_name;
|
||
document.getElementById('editProductCode').value = record.product_code;
|
||
document.getElementById('editProductName').value = record.product_name || '';
|
||
document.getElementById('editStockoutDays').value = record.stockout_days || 0;
|
||
document.getElementById('editDailyAvgSales').value = record.daily_avg_sales || 0;
|
||
document.getElementById('editCurrentStock').value = record.current_stock || 0;
|
||
document.getElementById('editNotes').value = record.notes || '';
|
||
|
||
editModal.show();
|
||
}
|
||
|
||
// 儲存編輯
|
||
async function saveEdit() {
|
||
const id = document.getElementById('editId').value;
|
||
const data = {
|
||
product_name: document.getElementById('editProductName').value,
|
||
stockout_days: parseInt(document.getElementById('editStockoutDays').value) || 0,
|
||
daily_avg_sales: parseFloat(document.getElementById('editDailyAvgSales').value) || 0,
|
||
current_stock: parseInt(document.getElementById('editCurrentStock').value) || 0,
|
||
notes: document.getElementById('editNotes').value
|
||
};
|
||
|
||
try {
|
||
const response = await fetch(`/vendor-stockout/api/stockout/${id}`, {
|
||
method: 'PUT',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-CSRFToken': csrfToken
|
||
},
|
||
body: JSON.stringify(data)
|
||
});
|
||
|
||
const result = await response.json();
|
||
if (result.success) {
|
||
editModal.hide();
|
||
await loadData();
|
||
alert('更新成功!');
|
||
} else {
|
||
alert('更新失敗: ' + result.message);
|
||
}
|
||
} catch (error) {
|
||
alert('更新失敗: ' + error.message);
|
||
}
|
||
}
|
||
|
||
// 刪除記錄
|
||
async function deleteRecord(id) {
|
||
if (!confirm('確定要刪除這筆記錄嗎?')) return;
|
||
|
||
try {
|
||
const response = await fetch(`/vendor-stockout/api/stockout/${id}`, {
|
||
method: 'DELETE',
|
||
headers: {
|
||
'X-CSRFToken': csrfToken
|
||
}
|
||
});
|
||
|
||
const result = await response.json();
|
||
if (result.success) {
|
||
await loadData();
|
||
alert('刪除成功!');
|
||
} else {
|
||
alert('刪除失敗: ' + result.message);
|
||
}
|
||
} catch (error) {
|
||
alert('刪除失敗: ' + error.message);
|
||
}
|
||
}
|
||
|
||
// 批次刪除
|
||
async function batchDelete() {
|
||
if (selectedIds.size === 0) return;
|
||
if (!confirm(`確定要刪除選取的 ${selectedIds.size} 筆記錄嗎?`)) return;
|
||
|
||
try {
|
||
const response = await fetch('/vendor-stockout/api/stockout/batch/delete', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-CSRFToken': csrfToken
|
||
},
|
||
body: JSON.stringify({
|
||
ids: Array.from(selectedIds)
|
||
})
|
||
});
|
||
|
||
const result = await response.json();
|
||
if (result.success) {
|
||
selectedIds.clear();
|
||
await loadData();
|
||
alert(`成功刪除 ${result.data.deleted_count} 筆記錄!`);
|
||
} else {
|
||
alert('刪除失敗: ' + result.message);
|
||
}
|
||
} catch (error) {
|
||
alert('刪除失敗: ' + error.message);
|
||
}
|
||
}
|
||
|
||
// 批次標記已發送
|
||
async function batchMarkSent() {
|
||
if (selectedIds.size === 0) return;
|
||
if (!confirm(`確定要將選取的 ${selectedIds.size} 筆記錄標記為已發送嗎?\n(這不會發送實際郵件)`)) return;
|
||
|
||
try {
|
||
const response = await fetch('/vendor-stockout/api/stockout/batch/mark-sent', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-CSRFToken': csrfToken
|
||
},
|
||
body: JSON.stringify({
|
||
ids: Array.from(selectedIds)
|
||
})
|
||
});
|
||
|
||
const result = await response.json();
|
||
if (result.success) {
|
||
selectedIds.clear();
|
||
await loadData();
|
||
alert(`成功更新 ${result.data.updated_count} 筆記錄!`);
|
||
} else {
|
||
alert('更新失敗: ' + result.message);
|
||
}
|
||
} catch (error) {
|
||
alert('更新失敗: ' + error.message);
|
||
}
|
||
}
|
||
|
||
// 批次標記為待發送(從已發送重設)
|
||
async function batchMarkPending() {
|
||
if (selectedIds.size === 0) return;
|
||
if (!confirm(`確定要將選取的 ${selectedIds.size} 筆記錄重設為「待發送」狀態嗎?`)) return;
|
||
|
||
try {
|
||
const response = await fetch('/vendor-stockout/api/stockout/batch/mark-pending', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-CSRFToken': csrfToken
|
||
},
|
||
body: JSON.stringify({
|
||
ids: Array.from(selectedIds)
|
||
})
|
||
});
|
||
|
||
const result = await response.json();
|
||
if (result.success) {
|
||
selectedIds.clear();
|
||
await loadData();
|
||
alert(`成功更新 ${result.data.updated_count} 筆記錄!`);
|
||
} else {
|
||
alert('更新失敗: ' + result.message);
|
||
}
|
||
} catch (error) {
|
||
alert('更新失敗: ' + error.message);
|
||
}
|
||
}
|
||
|
||
// 開啟重送確認對話框
|
||
function openResendConfirm() {
|
||
if (selectedIds.size === 0) return;
|
||
document.getElementById('resendCount').textContent = selectedIds.size;
|
||
document.getElementById('confirmResend').checked = false;
|
||
document.getElementById('confirmResendBtn').disabled = true;
|
||
resendConfirmModal.show();
|
||
}
|
||
|
||
// 執行重新發送
|
||
async function executeResend() {
|
||
resendConfirmModal.hide();
|
||
await batchSendGrouped();
|
||
}
|
||
|
||
// 按廠商分組發送郵件
|
||
async function batchSendGrouped() {
|
||
if (selectedIds.size === 0) return;
|
||
|
||
const vendorCount = new Set(allData.filter(r => selectedIds.has(r.id)).map(r => r.vendor_code)).size;
|
||
|
||
// 如果是從已發送重送,不需要再次確認(已經在 Modal 確認過)
|
||
if (currentStatus === 'pending') {
|
||
if (!confirm(`確定要按廠商分組發送嗎?\n\n將發送給 ${vendorCount} 個廠商\n選取的 ${selectedIds.size} 個商品將按廠商整合為一封郵件`)) return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch('/vendor-stockout/api/email/send/grouped', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-CSRFToken': csrfToken
|
||
},
|
||
body: JSON.stringify({
|
||
stockout_ids: Array.from(selectedIds)
|
||
})
|
||
});
|
||
|
||
const result = await response.json();
|
||
if (result.success) {
|
||
selectedIds.clear();
|
||
await loadData();
|
||
|
||
// 顯示詳細結果
|
||
let message = result.message + '\n\n';
|
||
if (result.data.success_vendors && result.data.success_vendors.length > 0) {
|
||
message += '✅ 成功發送:\n';
|
||
result.data.success_vendors.forEach(v => {
|
||
message += ` - ${v.vendor_name} (${v.vendor_code}): ${v.item_count} 個商品\n`;
|
||
});
|
||
}
|
||
if (result.data.failed_vendors && result.data.failed_vendors.length > 0) {
|
||
message += '\n❌ 發送失敗:\n';
|
||
result.data.failed_vendors.forEach(v => {
|
||
message += ` - ${v.vendor_name} (${v.vendor_code}): ${v.reason}\n`;
|
||
});
|
||
}
|
||
alert(message);
|
||
updateWorkflowGuide();
|
||
} else {
|
||
alert('發送失敗: ' + result.message);
|
||
}
|
||
} catch (error) {
|
||
alert('發送失敗: ' + error.message);
|
||
}
|
||
}
|
||
|
||
// 按商品單筆發送郵件
|
||
async function batchSendSingle() {
|
||
if (selectedIds.size === 0) return;
|
||
|
||
if (!confirm(`確定要按商品單筆發送嗎?\n\n將為 ${selectedIds.size} 個商品各發送一封獨立郵件`)) return;
|
||
|
||
try {
|
||
const response = await fetch('/vendor-stockout/api/email/send/single', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-CSRFToken': csrfToken
|
||
},
|
||
body: JSON.stringify({
|
||
stockout_ids: Array.from(selectedIds)
|
||
})
|
||
});
|
||
|
||
const result = await response.json();
|
||
if (result.success) {
|
||
selectedIds.clear();
|
||
await loadData();
|
||
|
||
// 顯示詳細結果
|
||
let message = result.message + '\n\n';
|
||
if (result.data.success_items && result.data.success_items.length > 0) {
|
||
message += `✅ 成功發送 ${result.data.success_items.length} 封郵件\n`;
|
||
}
|
||
if (result.data.failed_items && result.data.failed_items.length > 0) {
|
||
message += '\n❌ 發送失敗:\n';
|
||
result.data.failed_items.forEach(item => {
|
||
message += ` - ${item.product_name} (${item.product_code}): ${item.reason}\n`;
|
||
});
|
||
}
|
||
alert(message);
|
||
updateWorkflowGuide();
|
||
} else {
|
||
alert('發送失敗: ' + result.message);
|
||
}
|
||
} catch (error) {
|
||
alert('發送失敗: ' + error.message);
|
||
}
|
||
}
|
||
|
||
// 更新流程提示
|
||
function updateWorkflowGuide() {
|
||
// 根據目前狀態更新流程圖標
|
||
const pendingCount = parseInt(document.getElementById('pendingCount').textContent) || 0;
|
||
const sentCount = parseInt(document.getElementById('sentCount').textContent) || 0;
|
||
|
||
// Step 1: 匯入
|
||
document.getElementById('step1Icon').classList.toggle('active', true);
|
||
|
||
// Step 2: 勾選
|
||
document.getElementById('step2Icon').classList.toggle('active', pendingCount > 0);
|
||
|
||
// Step 3: 發送
|
||
document.getElementById('step3Icon').classList.toggle('active', sentCount > 0);
|
||
|
||
// Step 4: 查看記錄
|
||
document.getElementById('step4Icon').classList.toggle('active', sentCount > 0);
|
||
}
|
||
|
||
// ====== Excel 匯入功能 ======
|
||
|
||
function bindImportEvents() {
|
||
const uploadArea = document.getElementById('uploadArea');
|
||
const fileInput = document.getElementById('fileInput');
|
||
const selectFileBtn = document.getElementById('selectFileBtn');
|
||
|
||
uploadArea.addEventListener('click', () => fileInput.click());
|
||
selectFileBtn.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
fileInput.click();
|
||
});
|
||
|
||
fileInput.addEventListener('change', (e) => handleFile(e.target.files[0]));
|
||
|
||
uploadArea.addEventListener('dragover', (e) => {
|
||
e.preventDefault();
|
||
uploadArea.classList.add('dragover');
|
||
});
|
||
|
||
uploadArea.addEventListener('dragleave', () => {
|
||
uploadArea.classList.remove('dragover');
|
||
});
|
||
|
||
uploadArea.addEventListener('drop', (e) => {
|
||
e.preventDefault();
|
||
uploadArea.classList.remove('dragover');
|
||
handleFile(e.dataTransfer.files[0]);
|
||
});
|
||
|
||
document.getElementById('uploadBtn').addEventListener('click', uploadExcel);
|
||
document.getElementById('importAgainBtn').addEventListener('click', resetImport);
|
||
}
|
||
|
||
function handleFile(file) {
|
||
if (!file) return;
|
||
|
||
if (!file.name.match(/\.(xlsx|xls)$/i)) {
|
||
alert('請選擇 Excel 檔案 (.xlsx 或 .xls)');
|
||
return;
|
||
}
|
||
|
||
selectedFile = file;
|
||
document.getElementById('fileName').textContent = file.name;
|
||
document.getElementById('fileSize').textContent = formatFileSize(file.size);
|
||
document.getElementById('fileInfo').style.display = 'block';
|
||
document.getElementById('resultArea').style.display = 'none';
|
||
document.getElementById('errorArea').style.display = 'none';
|
||
}
|
||
|
||
async function uploadExcel() {
|
||
if (!selectedFile) return;
|
||
|
||
const formData = new FormData();
|
||
formData.append('file', selectedFile);
|
||
|
||
document.getElementById('fileInfo').style.display = 'none';
|
||
document.getElementById('progressArea').style.display = 'block';
|
||
document.getElementById('errorArea').style.display = 'none';
|
||
document.getElementById('resultArea').style.display = 'none';
|
||
|
||
try {
|
||
const response = await fetch('/vendor-stockout/api/import/excel', {
|
||
method: 'POST',
|
||
headers: {
|
||
'X-CSRFToken': csrfToken
|
||
},
|
||
body: formData
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
document.getElementById('progressArea').style.display = 'none';
|
||
|
||
if (result.success) {
|
||
// 顯示成功結果
|
||
document.getElementById('importTotalCount').textContent = result.data.total_count;
|
||
document.getElementById('importSuccessCount').textContent = result.data.success_count;
|
||
document.getElementById('importDuplicateCount').textContent = result.data.duplicate_count;
|
||
document.getElementById('importFailedCount').textContent = result.data.failed_count;
|
||
document.getElementById('batchId').textContent = result.data.batch_id;
|
||
document.getElementById('resultArea').style.display = 'block';
|
||
} else {
|
||
// 顯示錯誤
|
||
document.getElementById('errorMessage').textContent = result.message;
|
||
document.getElementById('errorArea').style.display = 'block';
|
||
}
|
||
} catch (error) {
|
||
document.getElementById('progressArea').style.display = 'none';
|
||
document.getElementById('errorMessage').textContent = '網路錯誤: ' + error.message;
|
||
document.getElementById('errorArea').style.display = 'block';
|
||
}
|
||
}
|
||
|
||
function resetImport() {
|
||
selectedFile = null;
|
||
document.getElementById('fileInput').value = '';
|
||
document.getElementById('fileInfo').style.display = 'none';
|
||
document.getElementById('resultArea').style.display = 'none';
|
||
document.getElementById('errorArea').style.display = 'none';
|
||
}
|
||
|
||
function switchToListTab() {
|
||
const listTab = new bootstrap.Tab(document.getElementById('list-tab'));
|
||
listTab.show();
|
||
loadData();
|
||
}
|
||
|
||
// ====== 工具函數 ======
|
||
|
||
function getStatusBadge(status) {
|
||
const badges = {
|
||
'pending': '<span class="badge status-pending"><i class="fas fa-clock me-1"></i>待發送</span>',
|
||
'sent': '<span class="badge status-sent"><i class="fas fa-check me-1"></i>已發送</span>',
|
||
'failed': '<span class="badge status-failed"><i class="fas fa-times me-1"></i>失敗</span>'
|
||
};
|
||
return badges[status] || badges['pending'];
|
||
}
|
||
|
||
function formatDateTime(dateStr) {
|
||
if (!dateStr) return '-';
|
||
const date = new Date(dateStr);
|
||
return date.toLocaleString('zh-TW', {
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
});
|
||
}
|
||
|
||
function formatFileSize(bytes) {
|
||
if (bytes < 1024) return bytes + ' B';
|
||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||
}
|
||
|
||
// ====== 郵件模板設定功能 ======
|
||
|
||
// 開啟郵件模板設定 Modal
|
||
async function openEmailTemplateModal() {
|
||
await loadEmailTemplate();
|
||
emailTemplateModal.show();
|
||
}
|
||
|
||
// 載入郵件模板
|
||
async function loadEmailTemplate() {
|
||
try {
|
||
const response = await fetch('/vendor-stockout/api/email/template');
|
||
const result = await response.json();
|
||
if (result.success) {
|
||
document.getElementById('emailSubjectInput').value = result.data.subject || '';
|
||
document.getElementById('emailBodyInput').value = result.data.body || '';
|
||
refreshEmailPreview();
|
||
} else {
|
||
alert('載入模板失敗: ' + result.message);
|
||
}
|
||
} catch (error) {
|
||
alert('載入模板失敗: ' + error.message);
|
||
}
|
||
}
|
||
|
||
// 儲存郵件模板
|
||
async function saveEmailTemplate() {
|
||
const subject = document.getElementById('emailSubjectInput').value.trim();
|
||
const body = document.getElementById('emailBodyInput').value.trim();
|
||
|
||
if (!subject || !body) {
|
||
alert('請填寫郵件主旨和內容');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch('/vendor-stockout/api/email/template', {
|
||
method: 'PUT',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-CSRFToken': csrfToken
|
||
},
|
||
body: JSON.stringify({ subject, body })
|
||
});
|
||
|
||
const result = await response.json();
|
||
if (result.success) {
|
||
alert('郵件模板已儲存!之後發送的郵件都會套用此模板。');
|
||
emailTemplateModal.hide();
|
||
} else {
|
||
alert('儲存失敗: ' + result.message);
|
||
}
|
||
} catch (error) {
|
||
alert('儲存失敗: ' + error.message);
|
||
}
|
||
}
|
||
|
||
// 恢復預設模板
|
||
async function resetEmailTemplate() {
|
||
if (!confirm('確定要恢復為預設郵件模板嗎?')) return;
|
||
|
||
try {
|
||
const response = await fetch('/vendor-stockout/api/email/template/reset', {
|
||
method: 'POST',
|
||
headers: {
|
||
'X-CSRFToken': csrfToken
|
||
}
|
||
});
|
||
|
||
const result = await response.json();
|
||
if (result.success) {
|
||
document.getElementById('emailSubjectInput').value = result.data.subject || '';
|
||
document.getElementById('emailBodyInput').value = result.data.body || '';
|
||
refreshEmailPreview();
|
||
alert('已恢復為預設模板');
|
||
} else {
|
||
alert('恢復失敗: ' + result.message);
|
||
}
|
||
} catch (error) {
|
||
alert('恢復失敗: ' + error.message);
|
||
}
|
||
}
|
||
|
||
// 重新整理郵件預覽
|
||
function refreshEmailPreview() {
|
||
const subject = document.getElementById('emailSubjectInput').value;
|
||
const body = document.getElementById('emailBodyInput').value;
|
||
|
||
// 範例資料
|
||
const sampleData = {
|
||
vendor_name: '範例廠商',
|
||
vendor_code: 'V001',
|
||
item_count: '5'
|
||
};
|
||
|
||
// 替換變數
|
||
let previewSubject = subject;
|
||
let previewBody = body;
|
||
|
||
for (const [key, value] of Object.entries(sampleData)) {
|
||
const regex = new RegExp(`\\{\\{${key}\\}\\}`, 'g');
|
||
previewSubject = previewSubject.replace(regex, value);
|
||
previewBody = previewBody.replace(regex, value);
|
||
}
|
||
|
||
document.getElementById('emailPreviewSubject').textContent = previewSubject;
|
||
document.getElementById('emailPreviewBody').innerHTML = previewBody;
|
||
}
|
||
</script>
|
||
<script>
|
||
// 更新導航列時鐘
|
||
function updateNavTime() {
|
||
const now = new Date();
|
||
const elem = document.getElementById('nav-time');
|
||
if (elem) {
|
||
elem.textContent = now.toLocaleString('zh-TW', {
|
||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||
hour: '2-digit', minute: '2-digit', second: '2-digit'
|
||
});
|
||
}
|
||
}
|
||
updateNavTime();
|
||
setInterval(updateNavTime, 1000);
|
||
</script>
|
||
</body>
|
||
</html>
|