Compare commits

...

4 Commits

Author SHA1 Message Date
Phuoc Nguyen
597c6a0e57 fix homepage news, invoice 2025-12-09 11:17:32 +07:00
Phuoc Nguyen
e0a9b3b9f4 point 2025-12-05 10:11:03 +07:00
Phuoc Nguyen
b9b6d91a87 update point 2025-12-04 11:43:27 +07:00
Phuoc Nguyen
d4de557662 fix update page 2025-12-04 10:39:47 +07:00
19 changed files with 2329 additions and 644 deletions

View File

@@ -8,78 +8,6 @@
<link rel="stylesheet" href="assets/css/style.css"> <link rel="stylesheet" href="assets/css/style.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
</head> </head>
<style>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.modal-content {
background: white;
border-radius: 12px;
width: 100%;
max-width: 500px;
animation: slideUp 0.3s ease;
}
@keyframes slideUp {
from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.modal-header {
padding: 20px;
border-bottom: 1px solid #e5e7eb;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-body {
padding: 20px;
}
.modal-footer {
padding: 20px;
border-top: 1px solid #e5e7eb;
display: flex;
gap: 12px;
}
.modal-close {
background: none;
border: none;
font-size: 20px;
color: #6b7280;
cursor: pointer;
}
@media (max-width: 768px) {
.document-card {
flex-direction: column;
align-items: stretch;
}
.download-btn {
width: 100%;
justify-content: center;
}
}
.tab-item.active {
background: var(--primary-blue);
color: var(--white);
}
</style>
<body> <body>
<div class="page-wrapper"> <div class="page-wrapper">
<!-- Header --> <!-- Header -->
@@ -88,15 +16,12 @@
<i class="fas fa-arrow-left"></i> <i class="fas fa-arrow-left"></i>
</a> </a>
<h1 class="header-title">Lịch sử điểm</h1> <h1 class="header-title">Lịch sử điểm</h1>
<!--<div style="width: 32px;"></div>--> <div style="width: 32px;"></div>
<button class="back-button" onclick="openInfoModal()">
<i class="fas fa-info-circle"></i>
</button>
</div> </div>
<div class="container"> <div class="container">
<!-- Filter Section --> <!-- Filter Section -->
<!--<div class="card mb-3"> <div class="card mb-3">
<div class="d-flex justify-between align-center"> <div class="d-flex justify-between align-center">
<h3 class="card-title">Bộ lọc</h3> <h3 class="card-title">Bộ lọc</h3>
<i class="fas fa-filter" style="color: var(--primary-blue);"></i> <i class="fas fa-filter" style="color: var(--primary-blue);"></i>
@@ -104,184 +29,221 @@
<p class="text-muted" style="font-size: 12px; margin-top: 8px;"> <p class="text-muted" style="font-size: 12px; margin-top: 8px;">
Thời gian hiệu lực: 01/01/2023 - 31/12/2023 Thời gian hiệu lực: 01/01/2023 - 31/12/2023
</p> </p>
</div>--> </div>
<!-- Points History List --> <!-- Points History List -->
<div class="points-history-list"> <div class="points-history-list">
<!-- Transaction Item 1 --> <!-- Transaction Card 1 -->
<div class="card mb-3"> <div class="transaction-card">
<div class="d-flex justify-between align-start mb-2"> <div class="card-header">
<div style="flex: 1;"> <span class="transaction-code">GD-00083</span>
<h4 style="color: var(--primary-blue); font-weight: 500; margin-bottom: 4px;"> <span class="transaction-date">28/09/2023</span>
Giao dịch mua hàng 00083 </div>
</h4> <div class="card-content">
<p class="text-muted" style="font-size: 12px;"> <div class="transaction-desc">
Thời gian: 28/09/2023 17:23:18 <div style="font-weight: 600; color: #333; margin-bottom: 4px;">Giao dịch mua hàng</div>
</p> <div style="font-size: 13px; color: #999;">Mã tham chiếu: #HD-2023-00083</div>
<p class="text-muted" style="font-size: 12px;"> </div>
Giao dịch: 100.000.000 VND <div class="transaction-points positive">
</p> +3 điểm
</div>
</div>
<div class="card-footer">
<span class="balance-after">Số dư sau: <strong>604 điểm</strong></span>
</div> </div>
<button class="btn-complaint" onclick="openComplaint(this)">
Khiếu nại
</button>
</div> </div>
<div class="d-flex justify-end align-center" style="margin-top: 12px;"> <!-- Transaction Card 2 -->
<div style="text-align: right;"> <div class="transaction-card">
<div style="color: var(--success-color); font-weight: 500;">+3</div> <div class="card-header">
<div style="color: var(--primary-blue); font-size: 12px;">Điểm mới: 604</div> <span class="transaction-code">GD-00081</span>
<span class="transaction-date">27/09/2023</span>
</div>
<div class="card-content">
<div class="transaction-desc">
<div style="font-weight: 600; color: #333; margin-bottom: 4px;">Giao dịch mua hàng</div>
<div style="font-size: 13px; color: #999;">Mã tham chiếu: #HD-2023-00081</div>
</div>
<div class="transaction-points neutral">
0 điểm
</div>
</div>
<div class="card-footer">
<span class="balance-after">Số dư sau: <strong>601 điểm</strong></span>
</div>
</div>
<!-- Transaction Card 3 -->
<div class="transaction-card">
<div class="card-header">
<span class="transaction-code">EXP-2023-001</span>
<span class="transaction-date">20/09/2023</span>
</div>
<div class="card-content">
<div class="transaction-desc">
<div style="font-weight: 600; color: #333; margin-bottom: 4px;">Điểm thưởng hết hạn</div>
<div style="font-size: 13px; color: #999;">Hết hạn sử dụng</div>
</div>
<div class="transaction-points negative">
-5 điểm
</div>
</div>
<div class="card-footer">
<span class="balance-after">Số dư sau: <strong>601 điểm</strong></span>
</div>
</div>
<!-- Transaction Card 4 -->
<div class="transaction-card">
<div class="card-header">
<span class="transaction-code">RDM-2023-042</span>
<span class="transaction-date">19/09/2023</span>
</div>
<div class="card-content">
<div class="transaction-desc">
<div style="font-weight: 600; color: #333; margin-bottom: 4px;">Đổi Voucher giảm giá</div>
<div style="font-size: 13px; color: #999;">Voucher 5.000.000đ - HSG</div>
</div>
<div class="transaction-points negative">
-500 điểm
</div>
</div>
<div class="card-footer">
<span class="balance-after">Số dư sau: <strong>606 điểm</strong></span>
</div>
</div>
<!-- Transaction Card 5 -->
<div class="transaction-card">
<div class="card-header">
<span class="transaction-code">REF-2023-128</span>
<span class="transaction-date">10/09/2023</span>
</div>
<div class="card-content">
<div class="transaction-desc">
<div style="font-weight: 600; color: #333; margin-bottom: 4px;">Giới thiệu thành công</div>
<div style="font-size: 13px; color: #999;">Giới thiệu: Nguyễn Văn A</div>
</div>
<div class="transaction-points positive">
+5 điểm
</div>
</div>
<div class="card-footer">
<span class="balance-after">Số dư sau: <strong>1.106 điểm</strong></span>
</div>
</div>
<!-- Transaction Card 6 -->
<div class="transaction-card">
<div class="card-header">
<span class="transaction-code">RDM-2023-038</span>
<span class="transaction-date">05/09/2023</span>
</div>
<div class="card-content">
<div class="transaction-desc">
<div style="font-weight: 600; color: #333; margin-bottom: 4px;">Đổi quà tặng</div>
<div style="font-size: 13px; color: #999;">Tai nghe Bluetooth Sony WH-1000XM4</div>
</div>
<div class="transaction-points negative">
-200 điểm
</div>
</div>
<div class="card-footer">
<span class="balance-after">Số dư sau: <strong>1.101 điểm</strong></span>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Transaction Item 2 --> <style>
<div class="card mb-3"> /* Modern Transaction Card Styles */
<div class="d-flex justify-between align-start mb-2"> .transaction-card {
<div style="flex: 1;"> background: white;
<h4 style="color: var(--primary-blue); font-weight: 500; margin-bottom: 4px;"> border-radius: 12px;
Giao dịch mua hàng 00081 box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
</h4> margin-bottom: 12px;
<p class="text-muted" style="font-size: 12px;"> overflow: hidden;
Thời gian: 27/09/2023 17:23:18 transition: all 0.2s;
</p> }
<p class="text-muted" style="font-size: 12px;">
Giao dịch: 200.000.000 VND
</p>
</div>
<button class="btn-complaint" onclick="openComplaint(this)">
Khiếu nại
</button>
</div>
<div class="d-flex justify-end align-center" style="margin-top: 12px;"> .transaction-card:hover {
<div style="text-align: right;"> box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
<div style="color: var(--text-dark); font-weight: 500;">0</div> transform: translateY(-2px);
<div style="color: var(--primary-blue); font-size: 12px;">Điểm mới: 604</div> }
</div>
</div>
</div>
<!-- Transaction Item 3 --> .transaction-card .card-header {
<div class="card mb-3"> display: flex;
<div class="d-flex justify-between align-start mb-2"> justify-content: space-between;
<div style="flex: 1;"> align-items: center;
<h4 style="color: var(--primary-blue); font-weight: 500; margin-bottom: 4px;"> padding: 12px 16px;
Điểm thưởng hết hạn background: #f9fafb;
</h4> border-bottom: 1px solid #e5e7eb;
<p class="text-muted" style="font-size: 12px;"> }
Thời gian: 20/09/2023 17:23:18
</p>
</div>
<button class="btn-complaint" onclick="openComplaint(this)">
Khiếu nại
</button>
</div>
<div class="d-flex justify-end align-center" style="margin-top: 12px;"> .transaction-code {
<div style="text-align: right;"> font-weight: 700;
<div style="color: var(--danger-color); font-weight: 500;">-5</div> font-size: 14px;
<div style="color: var(--primary-blue); font-size: 12px;">Điểm mới: 604</div> color: #005B9A;
</div> }
</div>
</div>
<!-- Transaction Item 4 --> .transaction-date {
<div class="card mb-3"> font-size: 12px;
<div class="d-flex justify-between align-start mb-2"> color: #9ca3af;
<div style="flex: 1;"> }
<h4 style="color: var(--primary-blue); font-weight: 500; margin-bottom: 4px;">
Đổi Voucher HSG
</h4>
<p class="text-muted" style="font-size: 12px;">
Thời gian: 19/09/2023 17:23:18
</p>
</div>
<button class="btn-complaint" onclick="openComplaint(this)">
Khiếu nại
</button>
</div>
<div class="d-flex justify-end align-center" style="margin-top: 12px;"> .transaction-card .card-content {
<div style="text-align: right;"> display: flex;
<div style="color: var(--danger-color); font-weight: 500;">-500</div> justify-content: space-between;
<div style="color: var(--primary-blue); font-size: 12px;">Điểm mới: 604</div> align-items: center;
</div> padding: 16px;
</div> }
</div>
<!-- Transaction Item 5 --> .transaction-desc {
<div class="card mb-3"> flex: 1;
<div class="d-flex justify-between align-start mb-2"> }
<div style="flex: 1;">
<h4 style="color: var(--primary-blue); font-weight: 500; margin-bottom: 4px;">
Giới thiệu người dùng
</h4>
<p class="text-muted" style="font-size: 12px;">
Thời gian: 10/09/2023 17:23:18
</p>
</div>
<button class="btn-complaint" onclick="openComplaint(this)">
Khiếu nại
</button>
</div>
<div class="d-flex justify-end align-center" style="margin-top: 12px;"> .transaction-points {
<div style="text-align: right;"> font-size: 24px;
<div style="color: var(--success-color); font-weight: 500;">+5</div> font-weight: 700;
<div style="color: var(--primary-blue); font-size: 12px;">Điểm mới: 604</div> text-align: right;
</div> margin-left: 16px;
</div> }
</div>
<!-- Transaction Item 6 --> .transaction-points.positive {
<div class="card mb-3"> color: #059669;
<div class="d-flex justify-between align-start mb-2"> }
<div style="flex: 1;">
<h4 style="color: var(--primary-blue); font-weight: 500; margin-bottom: 4px;">
Đổi quà
</h4>
<p class="text-muted" style="font-size: 12px;">
Thời gian: 19/09/2023 17:23:18
</p>
</div>
<button class="btn-complaint" onclick="openComplaint(this)">
Khiếu nại
</button>
</div>
<div class="d-flex justify-end align-center" style="margin-top: 12px;"> .transaction-points.negative {
<div style="text-align: right;"> color: #dc2626;
<div style="color: var(--danger-color); font-weight: 500;">-200</div> }
<div style="color: var(--primary-blue); font-size: 12px;">Điểm mới: 604</div>
</div> .transaction-points.neutral {
</div> color: #6b7280;
</div> }
</div>
</div> .transaction-card .card-footer {
<!-- Info Modal --> padding: 10px 16px;
<div id="infoModal" class="modal-overlay" style="display: none;"> background: #fafafa;
<div class="modal-content info-modal"> border-top: 1px solid #f0f0f0;
<div class="modal-header"> }
<h3 class="modal-title" style="font-weight: bold;">Hướng dẫn sử dụng</h3>
<button class="modal-close" onclick="closeInfoModal()"> .balance-after {
<i class="fas fa-times"></i> font-size: 13px;
</button> color: #666;
</div> }
<div class="modal-body">
<p>Đây là nội dung hướng dẫn sử dụng cho tính năng Lịch sử điểm:</p> .balance-after strong {
<ul class="list-disc ml-6 mt-3"> color: #005B9A;
<li>Đây là sao kê chi tiết tất cả các giao dịch cộng/trừ điểm của bạn.</li> font-weight: 600;
<li>Bạn có thể kiểm tra điểm được cộng từ đơn hàng, từ việc đăng ký công trình, hoặc điểm bị trừ khi đổi quà.</li> }
<li>Nếu phát hiện giao dịch bị sai sót, hãy bấm nút "Khiếu nại" trên dòng giao dịch đó để gửi yêu cầu hỗ trợ.</li>
</ul> @media (max-width: 480px) {
</div> .transaction-points {
<div class="modal-footer"> font-size: 20px;
<button class="btn btn-primary" onclick="closeInfoModal()">Đóng</button> }
</div> }
</div> </style>
</div>
</div>
<script> <script>
function openComplaint(buttonElement) { function openComplaint(buttonElement) {
@@ -302,21 +264,6 @@
window.location.href = `point-complaint.html?${params.toString()}`; window.location.href = `point-complaint.html?${params.toString()}`;
} }
function openInfoModal() {
document.getElementById('infoModal').style.display = 'flex';
}
function closeInfoModal() {
document.getElementById('infoModal').style.display = 'none';
}
// Close modal when clicking outside
document.addEventListener('click', function(e) {
if (e.target.classList.contains('modal-overlay')) {
e.target.style.display = 'none';
}
});
</script> </script>
</body> </body>
</html> </html>

View File

@@ -319,7 +319,124 @@
color: #0369a1; color: #0369a1;
} }
.product-row {
background: #f8f9fa;
border: 2px solid var(--border-color);
border-radius: 12px;
padding: 16px;
margin-bottom: 12px;
position: relative;
}
.product-row-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px dashed #ddd;
}
.product-row-title {
font-size: 14px;
font-weight: 600;
color: var(--primary-color);
}
.btn-remove-product {
background: #fee2e2;
color: #dc2626;
border: none;
padding: 6px 12px;
border-radius: 6px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
font-weight: 500;
}
.btn-remove-product:hover {
background: #fecaca;
}
.product-input-group {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 12px;
}
.product-input-full {
grid-column: 1 / -1;
}
.product-label {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 6px;
display: block;
}
.product-label.required::after {
content: " *";
color: var(--danger-color);
}
.product-subtotal {
background: #eff6ff;
border: 2px dashed #005B9A;
border-radius: 8px;
padding: 12px;
text-align: center;
}
.product-subtotal-label {
font-size: 12px;
color: #666;
margin-bottom: 4px;
}
.product-subtotal-value {
font-size: 18px;
font-weight: 700;
color: #005B9A;
}
.btn-add-product {
width: 100%;
background: white;
border: 2px dashed var(--primary-color);
color: var(--primary-color);
padding: 14px;
border-radius: 12px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.btn-add-product:hover {
background: #eff6ff;
border-color: var(--primary-dark);
}
.summary-card {
background: linear-gradient(135deg, #eff6ff, #dbeafe);
border: 2px solid #005B9A;
border-radius: 12px;
padding: 20px;
margin-bottom: 16px;
}
@media (max-width: 480px) { @media (max-width: 480px) {
.product-input-group {
grid-template-columns: 1fr;
}
.content { .content {
padding: 0.75rem; padding: 0.75rem;
} }
@@ -403,44 +520,9 @@
placeholder="Nhập số hóa đơn (nếu có)" placeholder="Nhập số hóa đơn (nếu có)"
onchange="validateForm()"> onchange="validateForm()">
</div> </div>
<!-- Total Amount -->
<div class="form-group">
<label class="form-label required">Tổng giá trị đơn hàng (VNĐ)</label>
<input type="number"
class="form-input"
id="totalAmount"
placeholder="0"
min="0"
step="1000"
required
oninput="calculatePoints(); validateForm()">
</div>
<!-- Points Estimate -->
<!--<div class="points-estimate" id="pointsEstimate">
<div class="estimate-title">Điểm dự kiến nhận được</div>
<div class="estimate-text" id="estimateText">0 điểm</div>
</div>-->
<!-- Products Purchased -->
<!--<div class="form-group">
<label class="form-label">Sản phẩm đã mua</label>
<textarea class="form-input form-textarea"
id="products"
placeholder="Mô tả các sản phẩm đã mua (tùy chọn)"
rows="3"></textarea>
</div>-->
<!-- Points Estimate -->
<div class="points-estimate" id="pointsEstimate">
<div class="estimate-title">Điểm dự kiến nhận được</div>
<div class="estimate-text" id="estimateText">0 điểm</div>
</div>
<!-- Company Information --> <!-- Company Information -->
<div class="form-group"> <div class="form-group">
<label class="form-label">Tên công ty</label> <label class="form-label">Tên đơn vị mua hàng</label>
<input type="text" <input type="text"
class="form-input" class="form-input"
id="companyName" id="companyName"
@@ -454,26 +536,41 @@
id="taxCode" id="taxCode"
placeholder="Nhập mã số thuế (nếu có)"> placeholder="Nhập mã số thuế (nếu có)">
</div> </div>
<!-- Dynamic Product List -->
<div class="form-group"> <div class="form-group">
<label class="form-label">Số lượng (m²) đã mua</label> <label class="form-label required">Danh sách sản phẩm</label>
<input type="number" <div id="productsList">
class="form-input" <!-- Product rows will be added here dynamically -->
id="squareMeters" </div>
placeholder="0" <button type="button" onclick="addProductRow()" class="btn-add-product">
min="0" <i class="fas fa-plus-circle"></i>
step="0.01"> Thêm sản phẩm
</button>
</div> </div>
<!-- Products Purchased --> <!-- Order Summary (Auto-calculated) -->
<div class="form-group"> <div class="summary-card">
<label class="form-label">Sản phẩm đã mua</label> <h3 style="font-size: 16px; font-weight: 600; color: #333; margin-bottom: 16px;">
<textarea class="form-input form-textarea" <i class="fas fa-calculator" style="color: #005B9A; margin-right: 8px;"></i>
id="products" Tổng kết đơn hàng
placeholder="Mô tả các sản phẩm đã mua (tùy chọn)" </h3>
rows="3"></textarea> <div style="display: flex; flex-direction: column; gap: 12px;">
<div style="display: flex; justify-content: space-between; padding-bottom: 12px; border-bottom: 1px dashed #e5e7eb;">
<span style="color: #666; font-size: 15px;">Tổng số lượng:</span>
<span id="totalSquareMeters" style="color: #333; font-weight: 600; font-size: 15px;">0 m²</span>
</div>
<div style="display: flex; justify-content: space-between;">
<span style="color: #666; font-size: 15px;">Tổng giá trị đơn hàng:</span>
<span id="totalAmount" style="color: #005B9A; font-weight: 700; font-size: 18px;">0 đ</span>
</div>
</div>
</div> </div>
<!-- Points Estimate -->
<div class="points-estimate" id="pointsEstimate">
<div class="estimate-title">Điểm dự kiến nhận được</div>
<div class="estimate-text" id="estimateText">0 điểm</div>
</div>
<!-- Invoice Images --> <!-- Invoice Images -->
<div class="form-group"> <div class="form-group">
@@ -504,13 +601,13 @@
</div> </div>
<!-- Additional Notes --> <!-- Additional Notes -->
<div class="form-group"> <!--<div class="form-group">
<label class="form-label">Ghi chú thêm</label> <label class="form-label">Ghi chú thêm</label>
<textarea class="form-input form-textarea" <textarea class="form-input form-textarea"
id="notes" id="notes"
placeholder="Ghi chú thêm về đơn hàng (tùy chọn)" placeholder="Ghi chú thêm về đơn hàng (tùy chọn)"
rows="3"></textarea> rows="3"></textarea>
</div> </div>-->
<!-- Submit Button --> <!-- Submit Button -->
<button type="submit" class="submit-button" id="submitButton" disabled> <button type="submit" class="submit-button" id="submitButton" disabled>
@@ -523,26 +620,153 @@
<script> <script>
let selectedFiles = []; let selectedFiles = [];
let productCounter = 0;
let products = [];
// Set max date to today // Set max date to today
document.getElementById('purchaseDate').max = new Date().toISOString().split('T')[0]; document.getElementById('purchaseDate').max = new Date().toISOString().split('T')[0];
// Add first product row on page load
window.addEventListener('DOMContentLoaded', function() {
addProductRow();
});
function goBack() { function goBack() {
window.history.back(); window.history.back();
} }
function addProductRow() {
productCounter++;
const productsList = document.getElementById('productsList');
const productRow = document.createElement('div');
productRow.className = 'product-row';
productRow.id = `product-${productCounter}`;
productRow.innerHTML = `
<div class="product-row-header">
<span class="product-row-title">
<i class="fas fa-box"></i> Sản phẩm #${productCounter}
</span>
<button type="button" onclick="removeProductRow(${productCounter})" class="btn-remove-product">
<i class="fas fa-trash"></i> Xóa
</button>
</div>
<div class="product-input-full">
<label class="product-label required">Tên sản phẩm</label>
<input type="text"
class="form-input"
id="productName-${productCounter}"
placeholder="Ví dụ: Gạch Granite 60x60"
required
onchange="updateCalculations()">
</div>
<div class="product-input-group">
<div>
<label class="product-label required">Số lượng (m²)</label>
<input type="number"
class="form-input"
id="productQty-${productCounter}"
placeholder="0"
min="0"
step="0.01"
required
oninput="updateCalculations()">
</div>
<div>
<label class="product-label required">Đơn giá (VNĐ/m²)</label>
<input type="number"
class="form-input"
id="productPrice-${productCounter}"
placeholder="0"
min="0"
step="1000"
required
oninput="updateCalculations()">
</div>
</div>
<div class="product-subtotal">
<div class="product-subtotal-label">Thành tiền</div>
<div class="product-subtotal-value" id="productSubtotal-${productCounter}">0 đ</div>
</div>
`;
productsList.appendChild(productRow);
products.push(productCounter);
updateCalculations();
}
function removeProductRow(id) {
if (products.length <= 1) {
alert('Cần ít nhất một sản phẩm!');
return;
}
const row = document.getElementById(`product-${id}`);
if (row) {
row.remove();
products = products.filter(p => p !== id);
updateCalculations();
}
}
function updateCalculations() {
let totalQty = 0;
let totalValue = 0;
products.forEach(id => {
const qtyInput = document.getElementById(`productQty-${id}`);
const priceInput = document.getElementById(`productPrice-${id}`);
const subtotalEl = document.getElementById(`productSubtotal-${id}`);
if (qtyInput && priceInput && subtotalEl) {
const qty = parseFloat(qtyInput.value) || 0;
const price = parseFloat(priceInput.value) || 0;
const subtotal = qty * price;
totalQty += qty;
totalValue += subtotal;
subtotalEl.textContent = formatCurrency(subtotal);
}
});
// Update summary
document.getElementById('totalSquareMeters').textContent = totalQty.toFixed(2) + ' m²';
document.getElementById('totalAmount').textContent = formatCurrency(totalValue);
// Calculate points
calculatePoints(totalValue);
validateForm();
}
function formatCurrency(value) {
return new Intl.NumberFormat('vi-VN', {
style: 'currency',
currency: 'VND',
minimumFractionDigits: 0
}).format(value);
}
function validateForm() { function validateForm() {
const purchaseDate = document.getElementById('purchaseDate').value; const purchaseDate = document.getElementById('purchaseDate').value;
const storeLocation = document.getElementById('storeLocation').value;
const totalAmount = document.getElementById('totalAmount').value;
const hasFiles = selectedFiles.length > 0; const hasFiles = selectedFiles.length > 0;
const isValid = purchaseDate && storeLocation && totalAmount && hasFiles; // Check if at least one product is filled
let hasValidProduct = false;
products.forEach(id => {
const name = document.getElementById(`productName-${id}`).value;
const qty = document.getElementById(`productQty-${id}`).value;
const price = document.getElementById(`productPrice-${id}`).value;
if (name && qty && price) {
hasValidProduct = true;
}
});
const isValid = purchaseDate && hasValidProduct && hasFiles;
document.getElementById('submitButton').disabled = !isValid; document.getElementById('submitButton').disabled = !isValid;
} }
function calculatePoints() { function calculatePoints(totalAmount) {
const totalAmount = document.getElementById('totalAmount').value;
const pointsEstimate = document.getElementById('pointsEstimate'); const pointsEstimate = document.getElementById('pointsEstimate');
const estimateText = document.getElementById('estimateText'); const estimateText = document.getElementById('estimateText');

View File

@@ -632,6 +632,7 @@
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "DBIZ Partner"; INFOPLIST_KEY_CFBundleDisplayName = "DBIZ Partner";
IPHONEOS_DEPLOYMENT_TARGET = 15;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@@ -934,6 +935,7 @@
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "DBIZ Partner"; INFOPLIST_KEY_CFBundleDisplayName = "DBIZ Partner";
IPHONEOS_DEPLOYMENT_TARGET = 15;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@@ -959,6 +961,7 @@
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "DBIZ Partner"; INFOPLIST_KEY_CFBundleDisplayName = "DBIZ Partner";
IPHONEOS_DEPLOYMENT_TARGET = 15;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",

View File

@@ -28,6 +28,7 @@ import 'package:worker/features/chat/presentation/pages/chat_list_page.dart';
import 'package:worker/features/favorites/presentation/pages/favorites_page.dart'; import 'package:worker/features/favorites/presentation/pages/favorites_page.dart';
import 'package:worker/features/loyalty/presentation/pages/loyalty_page.dart'; import 'package:worker/features/loyalty/presentation/pages/loyalty_page.dart';
import 'package:worker/features/loyalty/presentation/pages/points_history_page.dart'; import 'package:worker/features/loyalty/presentation/pages/points_history_page.dart';
import 'package:worker/features/loyalty/presentation/pages/points_record_create_page.dart';
import 'package:worker/features/loyalty/presentation/pages/points_records_page.dart'; import 'package:worker/features/loyalty/presentation/pages/points_records_page.dart';
import 'package:worker/features/loyalty/presentation/pages/rewards_page.dart'; import 'package:worker/features/loyalty/presentation/pages/rewards_page.dart';
import 'package:worker/features/main/presentation/pages/main_scaffold.dart'; import 'package:worker/features/main/presentation/pages/main_scaffold.dart';
@@ -323,6 +324,14 @@ final routerProvider = Provider<GoRouter>((ref) {
MaterialPage(key: state.pageKey, child: const PointsRecordsPage()), MaterialPage(key: state.pageKey, child: const PointsRecordsPage()),
), ),
// Points Record Create Route
GoRoute(
path: RouteNames.pointsRecordCreate,
name: 'loyalty_points_record_create',
pageBuilder: (context, state) =>
MaterialPage(key: state.pageKey, child: const PointsRecordCreatePage()),
),
// Orders Route // Orders Route
GoRoute( GoRoute(
path: RouteNames.orders, path: RouteNames.orders,
@@ -651,6 +660,7 @@ class RouteNames {
static const String rewards = '$loyalty/rewards'; static const String rewards = '$loyalty/rewards';
static const String pointsHistory = '$loyalty/points-history'; static const String pointsHistory = '$loyalty/points-history';
static const String pointsRecords = '$loyalty/points-records'; static const String pointsRecords = '$loyalty/points-records';
static const String pointsRecordCreate = '$loyalty/points-records/create';
static const String myGifts = '$loyalty/gifts'; static const String myGifts = '$loyalty/gifts';
static const String referral = '$loyalty/referral'; static const String referral = '$loyalty/referral';

View File

@@ -131,7 +131,8 @@ class _HomePageState extends ConsumerState<HomePage> {
promotions: promotions, promotions: promotions,
onPromotionTap: (promotion) { onPromotionTap: (promotion) {
// Navigate to promotion details // Navigate to promotion details
context.push('/promotions/${promotion.id}'); context.push('/news/${promotion.id}');
}, },
) )
: const SizedBox.shrink(), : const SizedBox.shrink(),

View File

@@ -87,6 +87,7 @@ class BuyerInfoModel {
final String? wardCode; final String? wardCode;
final String? cityName; final String? cityName;
final String? wardName; final String? wardName;
final String? customerName;
const BuyerInfoModel({ const BuyerInfoModel({
this.name, this.name,
@@ -100,6 +101,7 @@ class BuyerInfoModel {
this.wardCode, this.wardCode,
this.cityName, this.cityName,
this.wardName, this.wardName,
this.customerName,
}); });
factory BuyerInfoModel.fromJson(Map<String, dynamic> json) { factory BuyerInfoModel.fromJson(Map<String, dynamic> json) {
@@ -115,6 +117,7 @@ class BuyerInfoModel {
wardCode: json['ward_code'] as String?, wardCode: json['ward_code'] as String?,
cityName: json['city_name'] as String?, cityName: json['city_name'] as String?,
wardName: json['ward_name'] as String?, wardName: json['ward_name'] as String?,
customerName: json['customer_name'] as String?,
); );
} }
@@ -130,6 +133,7 @@ class BuyerInfoModel {
'ward_code': wardCode, 'ward_code': wardCode,
'city_name': cityName, 'city_name': cityName,
'ward_name': wardName, 'ward_name': wardName,
'customer_name': customerName,
}; };
BuyerInfo toEntity() => BuyerInfo( BuyerInfo toEntity() => BuyerInfo(
@@ -144,6 +148,7 @@ class BuyerInfoModel {
wardCode: wardCode, wardCode: wardCode,
cityName: cityName, cityName: cityName,
wardName: wardName, wardName: wardName,
customerName: customerName,
); );
} }

View File

@@ -75,6 +75,7 @@ class BuyerInfo extends Equatable {
final String? wardCode; final String? wardCode;
final String? cityName; final String? cityName;
final String? wardName; final String? wardName;
final String? customerName;
const BuyerInfo({ const BuyerInfo({
this.name, this.name,
@@ -88,6 +89,7 @@ class BuyerInfo extends Equatable {
this.wardCode, this.wardCode,
this.cityName, this.cityName,
this.wardName, this.wardName,
this.customerName
}); });
/// Get formatted full address /// Get formatted full address
@@ -118,6 +120,7 @@ class BuyerInfo extends Equatable {
wardCode, wardCode,
cityName, cityName,
wardName, wardName,
customerName
]; ];
} }

View File

@@ -294,10 +294,10 @@ class InvoiceDetailPage extends ConsumerWidget {
List<_InfoLine> _buildBuyerInfoLines(Invoice invoice) { List<_InfoLine> _buildBuyerInfoLines(Invoice invoice) {
return [ return [
if (invoice.buyerInfo!.name != null) if (invoice.buyerInfo!.customerName != null)
_InfoLine(label: 'Người mua hàng', value: invoice.buyerInfo!.name!), _InfoLine(label: 'Người mua hàng', value: invoice.buyerInfo!.customerName!),
if (invoice.customerName != null) if (invoice.buyerInfo!.addressTitle != null)
_InfoLine(label: 'Tên đơn vị', value: invoice.customerName!), _InfoLine(label: 'Tên đơn vị', value: invoice.buyerInfo!.addressTitle!),
if (invoice.buyerInfo!.taxCode != null) if (invoice.buyerInfo!.taxCode != null)
_InfoLine(label: 'Mã số thuế', value: invoice.buyerInfo!.taxCode!), _InfoLine(label: 'Mã số thuế', value: invoice.buyerInfo!.taxCode!),
if (invoice.buyerInfo!.fullAddress.isNotEmpty) if (invoice.buyerInfo!.fullAddress.isNotEmpty)

View File

@@ -93,7 +93,7 @@ class PointsHistoryLocalDataSource {
complaintStatus: ComplaintStatus.none, complaintStatus: ComplaintStatus.none,
balanceAfter: 604, balanceAfter: 604,
expiryDate: now.add(const Duration(days: 365)), expiryDate: now.add(const Duration(days: 365)),
timestamp: DateTime(2023, 9, 28, 17, 23, 18), timestamp: DateTime(2025, 9, 28, 17, 23, 18),
), ),
LoyaltyPointEntryModel( LoyaltyPointEntryModel(
entryId: 'entry_002', entryId: 'entry_002',
@@ -108,7 +108,7 @@ class PointsHistoryLocalDataSource {
complaintStatus: ComplaintStatus.none, complaintStatus: ComplaintStatus.none,
balanceAfter: 604, balanceAfter: 604,
expiryDate: now.add(const Duration(days: 365)), expiryDate: now.add(const Duration(days: 365)),
timestamp: DateTime(2023, 9, 27, 17, 23, 18), timestamp: DateTime(2025, 9, 27, 17, 23, 18),
), ),
LoyaltyPointEntryModel( LoyaltyPointEntryModel(
entryId: 'entry_003', entryId: 'entry_003',
@@ -123,7 +123,7 @@ class PointsHistoryLocalDataSource {
complaintStatus: ComplaintStatus.none, complaintStatus: ComplaintStatus.none,
balanceAfter: 604, balanceAfter: 604,
expiryDate: null, expiryDate: null,
timestamp: DateTime(2023, 9, 20, 17, 23, 18), timestamp: DateTime(2025, 9, 20, 17, 23, 18),
), ),
LoyaltyPointEntryModel( LoyaltyPointEntryModel(
entryId: 'entry_004', entryId: 'entry_004',
@@ -138,7 +138,7 @@ class PointsHistoryLocalDataSource {
complaintStatus: ComplaintStatus.none, complaintStatus: ComplaintStatus.none,
balanceAfter: 604, balanceAfter: 604,
expiryDate: null, expiryDate: null,
timestamp: DateTime(2023, 9, 19, 17, 23, 18), timestamp: DateTime(2025, 9, 19, 17, 23, 18),
), ),
LoyaltyPointEntryModel( LoyaltyPointEntryModel(
entryId: 'entry_005', entryId: 'entry_005',
@@ -153,7 +153,7 @@ class PointsHistoryLocalDataSource {
complaintStatus: ComplaintStatus.none, complaintStatus: ComplaintStatus.none,
balanceAfter: 604, balanceAfter: 604,
expiryDate: now.add(const Duration(days: 365)), expiryDate: now.add(const Duration(days: 365)),
timestamp: DateTime(2023, 9, 10, 17, 23, 18), timestamp: DateTime(2025, 9, 10, 17, 23, 18),
), ),
LoyaltyPointEntryModel( LoyaltyPointEntryModel(
entryId: 'entry_006', entryId: 'entry_006',
@@ -168,7 +168,7 @@ class PointsHistoryLocalDataSource {
complaintStatus: ComplaintStatus.none, complaintStatus: ComplaintStatus.none,
balanceAfter: 604, balanceAfter: 604,
expiryDate: null, expiryDate: null,
timestamp: DateTime(2023, 9, 5, 17, 23, 18), timestamp: DateTime(2025, 9, 5, 17, 23, 18),
), ),
]; ];

View File

@@ -30,7 +30,7 @@ class PointsRecordRemoteDataSourceImpl
userId: 'user123', userId: 'user123',
invoiceNumber: 'INV-VG-001', invoiceNumber: 'INV-VG-001',
storeName: 'Công ty TNHH Vingroup', storeName: 'Công ty TNHH Vingroup',
transactionDate: DateTime(2023, 11, 15), transactionDate: DateTime(2025, 11, 15),
invoiceAmount: 2500000, invoiceAmount: 2500000,
notes: 'Gạch granite cao cấp cho khu vực lobby và hành lang', notes: 'Gạch granite cao cấp cho khu vực lobby và hành lang',
attachments: const [ attachments: const [
@@ -39,8 +39,8 @@ class PointsRecordRemoteDataSourceImpl
], ],
status: PointsStatus.approved, status: PointsStatus.approved,
pointsEarned: 250, pointsEarned: 250,
submittedAt: DateTime(2023, 11, 15, 10, 0), submittedAt: DateTime(2025, 11, 15, 10, 0),
processedAt: DateTime(2023, 11, 20, 14, 30), processedAt: DateTime(2025, 11, 20, 14, 30),
processedBy: 'admin001', processedBy: 'admin001',
), ),
PointsRecord( PointsRecord(
@@ -48,21 +48,21 @@ class PointsRecordRemoteDataSourceImpl
userId: 'user123', userId: 'user123',
invoiceNumber: 'INV-BTX-002', invoiceNumber: 'INV-BTX-002',
storeName: 'Tập đoàn Bitexco', storeName: 'Tập đoàn Bitexco',
transactionDate: DateTime(2023, 11, 25), transactionDate: DateTime(2025, 11, 25),
invoiceAmount: 1250000, invoiceAmount: 1250000,
notes: 'Gạch porcelain 80x80 cho sảnh chính và khu mua sắm', notes: 'Gạch porcelain 80x80 cho sảnh chính và khu mua sắm',
attachments: const [ attachments: const [
'https://example.com/invoice3.jpg', 'https://example.com/invoice3.jpg',
], ],
status: PointsStatus.pending, status: PointsStatus.pending,
submittedAt: DateTime(2023, 11, 25, 9, 15), submittedAt: DateTime(2025, 11, 25, 9, 15),
), ),
PointsRecord( PointsRecord(
recordId: 'PRR003', recordId: 'PRR003',
userId: 'user123', userId: 'user123',
invoiceNumber: 'INV-ABC-003', invoiceNumber: 'INV-ABC-003',
storeName: 'Công ty TNHH ABC Manufacturing', storeName: 'Công ty TNHH ABC Manufacturing',
transactionDate: DateTime(2023, 11, 20), transactionDate: DateTime(2025, 11, 20),
invoiceAmount: 4200000, invoiceAmount: 4200000,
notes: 'Gạch chống trơn cho khu vực sản xuất và kho bãi', notes: 'Gạch chống trơn cho khu vực sản xuất và kho bãi',
attachments: const [ attachments: const [
@@ -71,8 +71,8 @@ class PointsRecordRemoteDataSourceImpl
], ],
status: PointsStatus.rejected, status: PointsStatus.rejected,
rejectReason: 'Hình ảnh minh chứng không hợp lệ', rejectReason: 'Hình ảnh minh chứng không hợp lệ',
submittedAt: DateTime(2023, 11, 20, 11, 0), submittedAt: DateTime(2025, 11, 20, 11, 0),
processedAt: DateTime(2023, 11, 28, 16, 45), processedAt: DateTime(2025, 11, 28, 16, 45),
processedBy: 'admin002', processedBy: 'admin002',
), ),
PointsRecord( PointsRecord(
@@ -80,7 +80,7 @@ class PointsRecordRemoteDataSourceImpl
userId: 'user123', userId: 'user123',
invoiceNumber: 'INV-ECO-004', invoiceNumber: 'INV-ECO-004',
storeName: 'Ecopark Group', storeName: 'Ecopark Group',
transactionDate: DateTime(2023, 10, 10), transactionDate: DateTime(2025, 10, 10),
invoiceAmount: 3700000, invoiceAmount: 3700000,
notes: 'Gạch ceramic vân gỗ cho khu vực phòng khách và sân vườn', notes: 'Gạch ceramic vân gỗ cho khu vực phòng khách và sân vườn',
attachments: const [ attachments: const [
@@ -88,8 +88,8 @@ class PointsRecordRemoteDataSourceImpl
], ],
status: PointsStatus.approved, status: PointsStatus.approved,
pointsEarned: 370, pointsEarned: 370,
submittedAt: DateTime(2023, 10, 10, 8, 30), submittedAt: DateTime(2025, 10, 10, 8, 30),
processedAt: DateTime(2023, 10, 15, 10, 20), processedAt: DateTime(2025, 10, 15, 10, 20),
processedBy: 'admin001', processedBy: 'admin001',
), ),
PointsRecord( PointsRecord(
@@ -97,7 +97,7 @@ class PointsRecordRemoteDataSourceImpl
userId: 'user123', userId: 'user123',
invoiceNumber: 'INV-DMD-005', invoiceNumber: 'INV-DMD-005',
storeName: 'Diamond Hospitality Group', storeName: 'Diamond Hospitality Group',
transactionDate: DateTime(2023, 12, 1), transactionDate: DateTime(2025, 12, 1),
invoiceAmount: 8600000, invoiceAmount: 8600000,
notes: 'Gạch marble tự nhiên cho lobby và phòng suite', notes: 'Gạch marble tự nhiên cho lobby và phòng suite',
attachments: const [ attachments: const [
@@ -106,7 +106,7 @@ class PointsRecordRemoteDataSourceImpl
'https://example.com/invoice9.jpg', 'https://example.com/invoice9.jpg',
], ],
status: PointsStatus.pending, status: PointsStatus.pending,
submittedAt: DateTime(2023, 12, 1, 13, 0), submittedAt: DateTime(2025, 12, 1, 13, 0),
), ),
]; ];
} }

View File

@@ -8,28 +8,29 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:worker/core/widgets/loading_indicator.dart';
import 'package:worker/core/constants/ui_constants.dart'; import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/core/database/models/enums.dart';
import 'package:worker/core/theme/colors.dart'; import 'package:worker/core/theme/colors.dart';
import 'package:worker/features/loyalty/data/datasources/points_history_local_datasource.dart'; import 'package:worker/core/widgets/loading_indicator.dart';
import 'package:worker/features/loyalty/data/models/loyalty_point_entry_model.dart'; import 'package:worker/features/loyalty/data/models/loyalty_point_entry_model.dart';
import 'package:worker/features/loyalty/presentation/providers/points_history_provider.dart'; import 'package:worker/features/loyalty/presentation/providers/points_history_provider.dart';
/// Points History Page /// Points History Page
/// ///
/// Features: /// Features:
/// - Filter section with date range /// - Filter section with date range picker
/// - List of transaction cards /// - List of transaction cards with new design
/// - Each card shows: description, date, amount, points change, new balance /// - Each card shows: code, date, description, reference, points change, balance after
/// - Complaint button for each transaction
class PointsHistoryPage extends ConsumerWidget { class PointsHistoryPage extends ConsumerWidget {
const PointsHistoryPage({super.key}); const PointsHistoryPage({super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
// Use unfiltered data for now (mock data)
final historyAsync = ref.watch(pointsHistoryProvider); final historyAsync = ref.watch(pointsHistoryProvider);
final filter = ref.watch(pointsHistoryFilterProvider);
//todo: implement filtering logic in provider later
//:note: final historyAsync = ref.watch(filteredPointsHistoryProvider);
return Scaffold( return Scaffold(
backgroundColor: colorScheme.surfaceContainerLowest, backgroundColor: colorScheme.surfaceContainerLowest,
@@ -53,26 +54,22 @@ class PointsHistoryPage extends ConsumerWidget {
}, },
child: historyAsync.when( child: historyAsync.when(
data: (entries) { data: (entries) {
if (entries.isEmpty) { return ListView(
return _buildEmptyState(colorScheme);
}
return SingleChildScrollView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Filter Section // Filter Section
_buildFilterSection(colorScheme), _buildFilterSection(context, ref, colorScheme, filter),
const SizedBox(height: 16), const SizedBox(height: 16),
// Transaction List // Empty state or Transaction List
if (entries.isEmpty)
_buildEmptyStateInline(colorScheme)
else
...entries.map( ...entries.map(
(entry) => _buildTransactionCard(context, ref, entry, colorScheme), (entry) => _buildTransactionCard(context, entry, colorScheme),
), ),
], ],
),
); );
}, },
loading: () => const CustomLoadingIndicator(), loading: () => const CustomLoadingIndicator(),
@@ -82,14 +79,21 @@ class PointsHistoryPage extends ConsumerWidget {
); );
} }
/// Build filter section /// Build filter section with date pickers
Widget _buildFilterSection(ColorScheme colorScheme) { Widget _buildFilterSection(
BuildContext context,
WidgetRef ref,
ColorScheme colorScheme,
PointsHistoryFilter filter,
) {
final dateFormatter = DateFormat('dd/MM/yyyy');
return Card( return Card(
elevation: 1, elevation: 1,
margin: EdgeInsets.zero, margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding( child: Padding(
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(16),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -104,156 +108,130 @@ class PointsHistoryPage extends ConsumerWidget {
color: colorScheme.onSurface, color: colorScheme.onSurface,
), ),
), ),
FaIcon(FontAwesomeIcons.sliders, color: colorScheme.primary, size: 18), FaIcon(FontAwesomeIcons.filter, color: colorScheme.primary, size: 18),
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 16),
Text(
'Thời gian hiệu lực: 01/01/2023 - 31/12/2023',
style: TextStyle(fontSize: 12, color: colorScheme.onSurfaceVariant),
),
],
),
),
);
}
/// Build transaction card // Date range row
Widget _buildTransactionCard(
BuildContext context,
WidgetRef ref,
LoyaltyPointEntryModel entry,
ColorScheme colorScheme,
) {
final dateFormatter = DateFormat('dd/MM/yyyy HH:mm:ss');
final currencyFormatter = NumberFormat.currency(
locale: 'vi_VN',
symbol: 'VND',
decimalDigits: 0,
);
// Get transaction amount if it's a purchase
final datasource = ref.read(pointsHistoryLocalDataSourceProvider);
final transactionAmount = datasource.getTransactionAmount(
entry.description,
);
return Card(
elevation: 1,
margin: const EdgeInsets.only(bottom: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Top row: Description and Complaint button
Row( Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Start date
Expanded( Expanded(
child: Column( child: _buildDateField(
crossAxisAlignment: CrossAxisAlignment.start, context: context,
children: [ colorScheme: colorScheme,
// Description label: 'Từ ngày',
Text( value: filter.startDate,
entry.description, dateFormatter: dateFormatter,
style: TextStyle( onTap: () async {
fontSize: 15, final date = await showDatePicker(
fontWeight: FontWeight.w500, context: context,
color: colorScheme.primary, initialDate: filter.startDate ?? DateTime.now(),
), firstDate: DateTime(2020),
), lastDate: filter.endDate ?? DateTime.now(),
const SizedBox(height: 4),
// Timestamp
Text(
'Thời gian: ${dateFormatter.format(entry.timestamp)}',
style: TextStyle(
fontSize: 12,
color: colorScheme.onSurfaceVariant,
),
),
// Transaction amount (if purchase)
if (transactionAmount != null) ...[
const SizedBox(height: 2),
Text(
'Giao dịch: ${currencyFormatter.format(transactionAmount)}',
style: TextStyle(
fontSize: 12,
color: colorScheme.onSurfaceVariant,
),
),
],
],
),
),
const SizedBox(width: 12),
// Complaint button
OutlinedButton(
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Chức năng khiếu nại đang phát triển'),
),
); );
if (date != null) {
ref.read(pointsHistoryFilterProvider.notifier).setStartDate(date);
}
}, },
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
side: BorderSide(color: colorScheme.onSurfaceVariant),
foregroundColor: colorScheme.onSurface,
textStyle: const TextStyle(fontSize: 12),
minimumSize: const Size(0, 32),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6),
), ),
), ),
child: const Text('Khiếu nại'), const SizedBox(width: 12),
// End date
Expanded(
child: _buildDateField(
context: context,
colorScheme: colorScheme,
label: 'Đến ngày',
value: filter.endDate,
dateFormatter: dateFormatter,
onTap: () async {
final date = await showDatePicker(
context: context,
initialDate: filter.endDate ?? DateTime.now(),
firstDate: filter.startDate ?? DateTime(2020),
lastDate: DateTime.now(),
);
if (date != null) {
ref.read(pointsHistoryFilterProvider.notifier).setEndDate(date);
}
},
),
), ),
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
// Bottom row: Points change and new balance // Quick filter chips
Row( SingleChildScrollView(
mainAxisAlignment: MainAxisAlignment.end, scrollDirection: Axis.horizontal,
child: Row(
children: [ children: [
Column( _buildQuickFilterChip(
crossAxisAlignment: CrossAxisAlignment.end, context: context,
children: [ ref: ref,
// Points change colorScheme: colorScheme,
Text( label: 'Hôm nay',
entry.points > 0 ? '+${entry.points}' : '${entry.points}', onTap: () {
style: TextStyle( final now = DateTime.now();
fontSize: 16, final today = DateTime(now.year, now.month, now.day);
fontWeight: FontWeight.w500, ref.read(pointsHistoryFilterProvider.notifier).setDateRange(today, today);
color: entry.points > 0 },
? AppColors.success
: entry.points < 0
? AppColors.danger
: colorScheme.onSurface,
), ),
const SizedBox(width: 8),
_buildQuickFilterChip(
context: context,
ref: ref,
colorScheme: colorScheme,
label: '7 ngày',
onTap: () {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final weekAgo = today.subtract(const Duration(days: 7));
ref.read(pointsHistoryFilterProvider.notifier).setDateRange(weekAgo, today);
},
), ),
const SizedBox(height: 2), const SizedBox(width: 8),
_buildQuickFilterChip(
// New balance context: context,
Text( ref: ref,
'Điểm mới: ${entry.balanceAfter}', colorScheme: colorScheme,
style: TextStyle( label: '30 ngày',
fontSize: 12, onTap: () {
color: colorScheme.primary, final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final monthAgo = today.subtract(const Duration(days: 30));
ref.read(pointsHistoryFilterProvider.notifier).setDateRange(monthAgo, today);
},
), ),
const SizedBox(width: 8),
_buildQuickFilterChip(
context: context,
ref: ref,
colorScheme: colorScheme,
label: 'Năm nay',
onTap: () {
final now = DateTime.now();
ref.read(pointsHistoryFilterProvider.notifier).setDateRange(
DateTime(now.year, 1, 1),
DateTime(now.year, 12, 31),
);
},
),
const SizedBox(width: 8),
_buildQuickFilterChip(
context: context,
ref: ref,
colorScheme: colorScheme,
label: 'Tất cả',
onTap: () {
ref.read(pointsHistoryFilterProvider.notifier).clearFilter();
},
), ),
], ],
), ),
],
), ),
], ],
), ),
@@ -261,30 +239,257 @@ class PointsHistoryPage extends ConsumerWidget {
); );
} }
/// Build empty state Widget _buildDateField({
Widget _buildEmptyState(ColorScheme colorScheme) { required BuildContext context,
return Center( required ColorScheme colorScheme,
required String label,
required DateTime? value,
required DateFormat dateFormatter,
required VoidCallback onTap,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontSize: 12,
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
value != null ? dateFormatter.format(value) : 'Chọn',
style: TextStyle(
fontSize: 14,
color: value != null ? colorScheme.onSurface : colorScheme.onSurfaceVariant,
),
),
FaIcon(
FontAwesomeIcons.calendar,
size: 14,
color: colorScheme.onSurfaceVariant,
),
],
),
),
),
],
);
}
Widget _buildQuickFilterChip({
required BuildContext context,
required WidgetRef ref,
required ColorScheme colorScheme,
required String label,
required VoidCallback onTap,
}) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(16),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: colorScheme.outlineVariant),
),
child: Text(
label,
style: TextStyle(
fontSize: 12,
color: colorScheme.onSurface,
),
),
),
);
}
/// Build transaction card with new design
Widget _buildTransactionCard(
BuildContext context,
LoyaltyPointEntryModel entry,
ColorScheme colorScheme,
) {
final dateFormatter = DateFormat('dd/MM/yyyy');
// Determine points color
Color pointsColor;
String pointsPrefix;
if (entry.points > 0) {
pointsColor = AppColors.success;
pointsPrefix = '+';
} else if (entry.points < 0) {
pointsColor = AppColors.danger;
pointsPrefix = '';
} else {
pointsColor = colorScheme.onSurfaceVariant;
pointsPrefix = '';
}
return Card(
elevation: 1,
margin: const EdgeInsets.only(bottom: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
clipBehavior: Clip.antiAlias,
child: Column(
children: [
// Header: Code and Date
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
border: Border(
bottom: BorderSide(
color: colorScheme.outlineVariant,
width: 1,
),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
entry.entryId,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: colorScheme.primary,
),
),
Text(
dateFormatter.format(entry.timestamp),
style: TextStyle(
fontSize: 12,
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
// Content: Description and Points
Padding(
padding: const EdgeInsets.all(16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Description
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
entry.description,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
const SizedBox(height: 4),
Text(
'Mã tham chiếu: #${entry.entryId}',
style: TextStyle(
fontSize: 13,
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
const SizedBox(width: 16),
// Points
Text(
'$pointsPrefix${entry.points} điểm',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: pointsColor,
),
),
],
),
),
// Footer: Balance After
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerLowest,
border: Border(
top: BorderSide(
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
width: 1,
),
),
),
child: Row(
children: [
Text(
'Số dư sau: ',
style: TextStyle(
fontSize: 13,
color: colorScheme.onSurfaceVariant,
),
),
Text(
'${entry.balanceAfter} điểm',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: colorScheme.primary,
),
),
],
),
),
],
),
);
}
/// Build inline empty state (inside scrollable list)
Widget _buildEmptyStateInline(ColorScheme colorScheme) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 60),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
FaIcon( FaIcon(
FontAwesomeIcons.clockRotateLeft, FontAwesomeIcons.clockRotateLeft,
size: 80, size: 64,
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.5), color: colorScheme.onSurfaceVariant.withValues(alpha: 0.5),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'Chưa có lịch sử điểm', 'Không có giao dịch',
style: TextStyle( style: TextStyle(
fontSize: 18, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'Kéo xuống để làm mới', 'Không tìm thấy giao dịch trong khoảng thời gian này',
style: TextStyle(fontSize: 14, color: colorScheme.onSurfaceVariant), style: TextStyle(fontSize: 14, color: colorScheme.onSurfaceVariant),
textAlign: TextAlign.center,
), ),
], ],
), ),
@@ -293,7 +498,6 @@ class PointsHistoryPage extends ConsumerWidget {
/// Build error state /// Build error state
Widget _buildErrorState(Object error, ColorScheme colorScheme) { Widget _buildErrorState(Object error, ColorScheme colorScheme) {
print(error.toString());
return Center( return Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,

File diff suppressed because it is too large Load Diff

View File

@@ -8,9 +8,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:worker/core/widgets/loading_indicator.dart';
import 'package:worker/core/constants/ui_constants.dart'; import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/core/router/app_router.dart';
import 'package:worker/core/theme/colors.dart'; import 'package:worker/core/theme/colors.dart';
import 'package:worker/core/widgets/loading_indicator.dart';
import 'package:worker/features/loyalty/domain/entities/points_record.dart'; import 'package:worker/features/loyalty/domain/entities/points_record.dart';
import 'package:worker/features/loyalty/presentation/providers/points_records_provider.dart'; import 'package:worker/features/loyalty/presentation/providers/points_records_provider.dart';
@@ -47,13 +48,12 @@ class PointsRecordsPage extends ConsumerWidget {
color: colorScheme.onSurface, color: colorScheme.onSurface,
size: 20, size: 20,
), ),
onPressed: () { onPressed: () async {
// TODO: Navigate to points record create page final result = await context.push<bool>(RouteNames.pointsRecordCreate);
ScaffoldMessenger.of(context).showSnackBar( if (result == true) {
const SnackBar( // Refresh list after successful creation
content: Text('Tính năng tạo ghi nhận điểm sẽ được cập nhật'), ref.invalidate(allPointsRecordsProvider);
), }
);
}, },
), ),
const SizedBox(width: AppSpacing.sm), const SizedBox(width: AppSpacing.sm),

View File

@@ -9,6 +9,59 @@ import 'package:worker/features/loyalty/data/models/loyalty_point_entry_model.da
part 'points_history_provider.g.dart'; part 'points_history_provider.g.dart';
/// Points History Filter State
class PointsHistoryFilter {
const PointsHistoryFilter({
this.startDate,
this.endDate,
});
final DateTime? startDate;
final DateTime? endDate;
PointsHistoryFilter copyWith({
DateTime? startDate,
DateTime? endDate,
bool clearStartDate = false,
bool clearEndDate = false,
}) {
return PointsHistoryFilter(
startDate: clearStartDate ? null : (startDate ?? this.startDate),
endDate: clearEndDate ? null : (endDate ?? this.endDate),
);
}
}
/// Points History Filter Provider
@riverpod
class PointsHistoryFilterNotifier extends _$PointsHistoryFilterNotifier {
@override
PointsHistoryFilter build() {
// Default: current year
final now = DateTime.now();
return PointsHistoryFilter(
startDate: DateTime(now.year, 1, 1),
endDate: DateTime(now.year, 12, 31),
);
}
void setDateRange(DateTime? start, DateTime? end) {
state = PointsHistoryFilter(startDate: start, endDate: end);
}
void setStartDate(DateTime? date) {
state = state.copyWith(startDate: date);
}
void setEndDate(DateTime? date) {
state = state.copyWith(endDate: date);
}
void clearFilter() {
state = const PointsHistoryFilter();
}
}
/// Points History Local Data Source Provider /// Points History Local Data Source Provider
@riverpod @riverpod
PointsHistoryLocalDataSource pointsHistoryLocalDataSource(Ref ref) { PointsHistoryLocalDataSource pointsHistoryLocalDataSource(Ref ref) {
@@ -44,3 +97,41 @@ class PointsHistory extends _$PointsHistory {
}); });
} }
} }
/// Filtered Points History Provider
@riverpod
Future<List<LoyaltyPointEntryModel>> filteredPointsHistory(Ref ref) async {
final entries = await ref.watch(pointsHistoryProvider.future);
final filter = ref.watch(pointsHistoryFilterProvider);
return entries.where((entry) {
// Filter by start date
if (filter.startDate != null) {
final startOfDay = DateTime(
filter.startDate!.year,
filter.startDate!.month,
filter.startDate!.day,
);
if (entry.timestamp.isBefore(startOfDay)) {
return false;
}
}
// Filter by end date
if (filter.endDate != null) {
final endOfDay = DateTime(
filter.endDate!.year,
filter.endDate!.month,
filter.endDate!.day,
23,
59,
59,
);
if (entry.timestamp.isAfter(endOfDay)) {
return false;
}
}
return true;
}).toList();
}

View File

@@ -8,6 +8,68 @@ part of 'points_history_provider.dart';
// GENERATED CODE - DO NOT MODIFY BY HAND // GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning // ignore_for_file: type=lint, type=warning
/// Points History Filter Provider
@ProviderFor(PointsHistoryFilterNotifier)
const pointsHistoryFilterProvider = PointsHistoryFilterNotifierProvider._();
/// Points History Filter Provider
final class PointsHistoryFilterNotifierProvider
extends
$NotifierProvider<PointsHistoryFilterNotifier, PointsHistoryFilter> {
/// Points History Filter Provider
const PointsHistoryFilterNotifierProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'pointsHistoryFilterProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$pointsHistoryFilterNotifierHash();
@$internal
@override
PointsHistoryFilterNotifier create() => PointsHistoryFilterNotifier();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(PointsHistoryFilter value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<PointsHistoryFilter>(value),
);
}
}
String _$pointsHistoryFilterNotifierHash() =>
r'ef2587f4461c9488d9b15ed033e1d362042795f8';
/// Points History Filter Provider
abstract class _$PointsHistoryFilterNotifier
extends $Notifier<PointsHistoryFilter> {
PointsHistoryFilter build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<PointsHistoryFilter, PointsHistoryFilter>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<PointsHistoryFilter, PointsHistoryFilter>,
PointsHistoryFilter,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}
/// Points History Local Data Source Provider /// Points History Local Data Source Provider
@ProviderFor(pointsHistoryLocalDataSource) @ProviderFor(pointsHistoryLocalDataSource)
@@ -130,3 +192,50 @@ abstract class _$PointsHistory
element.handleValue(ref, created); element.handleValue(ref, created);
} }
} }
/// Filtered Points History Provider
@ProviderFor(filteredPointsHistory)
const filteredPointsHistoryProvider = FilteredPointsHistoryProvider._();
/// Filtered Points History Provider
final class FilteredPointsHistoryProvider
extends
$FunctionalProvider<
AsyncValue<List<LoyaltyPointEntryModel>>,
List<LoyaltyPointEntryModel>,
FutureOr<List<LoyaltyPointEntryModel>>
>
with
$FutureModifier<List<LoyaltyPointEntryModel>>,
$FutureProvider<List<LoyaltyPointEntryModel>> {
/// Filtered Points History Provider
const FilteredPointsHistoryProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'filteredPointsHistoryProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$filteredPointsHistoryHash();
@$internal
@override
$FutureProviderElement<List<LoyaltyPointEntryModel>> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<List<LoyaltyPointEntryModel>> create(Ref ref) {
return filteredPointsHistory(ref);
}
}
String _$filteredPointsHistoryHash() =>
r'989e2bf824eeb161b44b67d9ee81b713444a6e87';

View File

@@ -9,6 +9,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:shimmer/shimmer.dart'; import 'package:shimmer/shimmer.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:worker/core/constants/ui_constants.dart'; import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/core/theme/colors.dart'; import 'package:worker/core/theme/colors.dart';
import 'package:worker/features/products/domain/entities/product.dart'; import 'package:worker/features/products/domain/entities/product.dart';
@@ -273,17 +274,14 @@ class ProductCard extends ConsumerWidget {
width: double.infinity, width: double.infinity,
height: 36.0, height: 36.0,
child: OutlinedButton.icon( child: OutlinedButton.icon(
onPressed: () { onPressed: () async {
// TODO: Open 360 view in browser final url = Uri.parse(product.customLink360!);
// For now, show a message if (await canLaunchUrl(url)) {
ScaffoldMessenger.of(context).showSnackBar( await launchUrl(
const SnackBar( url,
content: Text( mode: LaunchMode.inAppWebView,
'Đang phát triển tính năng xem 360°',
),
duration: Duration(seconds: 2),
),
); );
}
}, },
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
foregroundColor: colorScheme.primary, foregroundColor: colorScheme.primary,

View File

@@ -7,6 +7,7 @@ import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:shimmer/shimmer.dart'; import 'package:shimmer/shimmer.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:worker/core/constants/ui_constants.dart'; import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/features/products/domain/entities/product.dart'; import 'package:worker/features/products/domain/entities/product.dart';
@@ -18,7 +19,6 @@ import 'package:worker/features/products/domain/entities/product.dart';
/// - Image indicators (dots) /// - Image indicators (dots)
/// - Thumbnail gallery row (horizontal scroll) /// - Thumbnail gallery row (horizontal scroll)
class ImageGallerySection extends StatefulWidget { class ImageGallerySection extends StatefulWidget {
const ImageGallerySection({super.key, required this.product}); const ImageGallerySection({super.key, required this.product});
final Product product; final Product product;
@@ -40,22 +40,15 @@ class _ImageGallerySectionState extends State<ImageGallerySection> {
setState(() { setState(() {
_currentImageIndex = index; _currentImageIndex = index;
}); });
_pageController.animateToPage( _pageController.animateToPage(index, duration: AppDuration.medium, curve: Curves.easeInOut);
index,
duration: AppDuration.medium,
curve: Curves.easeInOut,
);
} }
void _open360View() { void _open360View() async {
if (widget.product.customLink360 != null) { if (widget.product.customLink360 != null) {
// TODO: Open in browser final url = Uri.parse(widget.product.customLink360!);
ScaffoldMessenger.of(context).showSnackBar( if (await canLaunchUrl(url)) {
const SnackBar( await launchUrl(url, mode: LaunchMode.inAppWebView);
content: Text('Đang phát triển tính năng xem 360°'), }
duration: Duration(seconds: 2),
),
);
} }
} }
@@ -108,11 +101,7 @@ class _ImageGallerySectionState extends State<ImageGallerySection> {
), ),
errorWidget: (context, url, error) => Container( errorWidget: (context, url, error) => Container(
color: colorScheme.surfaceContainerHighest, color: colorScheme.surfaceContainerHighest,
child: Icon( child: Icon(FontAwesomeIcons.image, size: 64, color: colorScheme.onSurfaceVariant),
FontAwesomeIcons.image,
size: 64,
color: colorScheme.onSurfaceVariant,
),
), ),
), ),
); );
@@ -131,10 +120,7 @@ class _ImageGallerySectionState extends State<ImageGallerySection> {
onTap: _open360View, onTap: _open360View,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
child: Container( child: Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
horizontal: 12,
vertical: 8,
),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@@ -144,11 +130,7 @@ class _ImageGallerySectionState extends State<ImageGallerySection> {
builder: (context, double value, child) { builder: (context, double value, child) {
return Transform.rotate( return Transform.rotate(
angle: value * 2 * 3.14159, angle: value * 2 * 3.14159,
child: Icon( child: Icon(FontAwesomeIcons.arrowsRotate, size: 10, color: colorScheme.surface),
FontAwesomeIcons.arrowsRotate,
size: 10,
color: colorScheme.surface,
),
); );
}, },
onEnd: () { onEnd: () {
@@ -159,11 +141,7 @@ class _ImageGallerySectionState extends State<ImageGallerySection> {
const SizedBox(width: 6), const SizedBox(width: 6),
Text( Text(
'360°', '360°',
style: TextStyle( style: TextStyle(color: colorScheme.surface, fontSize: 12, fontWeight: FontWeight.w600),
color: colorScheme.surface,
fontSize: 12,
fontWeight: FontWeight.w600,
),
), ),
], ],
), ),
@@ -245,9 +223,7 @@ class _ImageGallerySectionState extends State<ImageGallerySection> {
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
border: Border.all( border: Border.all(
color: isActive color: isActive ? colorScheme.primary : Colors.transparent,
? colorScheme.primary
: Colors.transparent,
width: 2, width: 2,
), ),
), ),
@@ -256,15 +232,10 @@ class _ImageGallerySectionState extends State<ImageGallerySection> {
child: CachedNetworkImage( child: CachedNetworkImage(
imageUrl: imageUrl, imageUrl: imageUrl,
fit: BoxFit.cover, fit: BoxFit.cover,
placeholder: (context, url) => placeholder: (context, url) => Container(color: colorScheme.surfaceContainerHighest),
Container(color: colorScheme.surfaceContainerHighest),
errorWidget: (context, url, error) => Container( errorWidget: (context, url, error) => Container(
color: colorScheme.surfaceContainerHighest, color: colorScheme.surfaceContainerHighest,
child: Icon( child: Icon(FontAwesomeIcons.image, size: 20, color: colorScheme.onSurfaceVariant),
FontAwesomeIcons.image,
size: 20,
color: colorScheme.onSurfaceVariant,
),
), ),
), ),
), ),
@@ -285,12 +256,7 @@ class _ImageGallerySectionState extends State<ImageGallerySection> {
/// Image Lightbox for full-screen image viewing /// Image Lightbox for full-screen image viewing
class _ImageLightbox extends StatefulWidget { class _ImageLightbox extends StatefulWidget {
const _ImageLightbox({required this.images, required this.imageCaptions, required this.initialIndex});
const _ImageLightbox({
required this.images,
required this.imageCaptions,
required this.initialIndex,
});
final List<String> images; final List<String> images;
final Map<String, String> imageCaptions; final Map<String, String> imageCaptions;
final int initialIndex; final int initialIndex;
@@ -318,19 +284,13 @@ class _ImageLightboxState extends State<_ImageLightbox> {
void _nextImage() { void _nextImage() {
if (_currentIndex < widget.images.length - 1) { if (_currentIndex < widget.images.length - 1) {
_pageController.nextPage( _pageController.nextPage(duration: AppDuration.medium, curve: Curves.easeInOut);
duration: AppDuration.medium,
curve: Curves.easeInOut,
);
} }
} }
void _previousImage() { void _previousImage() {
if (_currentIndex > 0) { if (_currentIndex > 0) {
_pageController.previousPage( _pageController.previousPage(duration: AppDuration.medium, curve: Curves.easeInOut);
duration: AppDuration.medium,
curve: Curves.easeInOut,
);
} }
} }
@@ -372,11 +332,8 @@ class _ImageLightboxState extends State<_ImageLightbox> {
child: CachedNetworkImage( child: CachedNetworkImage(
imageUrl: widget.images[index], imageUrl: widget.images[index],
fit: BoxFit.contain, fit: BoxFit.contain,
errorWidget: (context, url, error) => Icon( errorWidget: (context, url, error) =>
FontAwesomeIcons.circleExclamation, Icon(FontAwesomeIcons.circleExclamation, color: colorScheme.surface, size: 64),
color: colorScheme.surface,
size: 64,
),
), ),
), ),
); );
@@ -393,15 +350,9 @@ class _ImageLightboxState extends State<_ImageLightbox> {
bottom: 0, bottom: 0,
child: Center( child: Center(
child: IconButton( child: IconButton(
icon: Icon( icon: Icon(FontAwesomeIcons.chevronLeft, color: colorScheme.surface, size: 32),
FontAwesomeIcons.chevronLeft,
color: colorScheme.surface,
size: 32,
),
onPressed: _previousImage, onPressed: _previousImage,
style: IconButton.styleFrom( style: IconButton.styleFrom(backgroundColor: Colors.white.withAlpha(51)),
backgroundColor: Colors.white.withAlpha(51),
),
), ),
), ),
), ),
@@ -414,15 +365,9 @@ class _ImageLightboxState extends State<_ImageLightbox> {
bottom: 0, bottom: 0,
child: Center( child: Center(
child: IconButton( child: IconButton(
icon: Icon( icon: Icon(FontAwesomeIcons.chevronRight, color: colorScheme.surface, size: 32),
FontAwesomeIcons.chevronRight,
color: colorScheme.surface,
size: 32,
),
onPressed: _nextImage, onPressed: _nextImage,
style: IconButton.styleFrom( style: IconButton.styleFrom(backgroundColor: Colors.white.withAlpha(51)),
backgroundColor: Colors.white.withAlpha(51),
),
), ),
), ),
), ),

View File

@@ -53,10 +53,14 @@ class _SubmissionCreatePageState extends ConsumerState<SubmissionCreatePage> {
bool _isSubmitting = false; bool _isSubmitting = false;
bool _isLoadingDetail = false; bool _isLoadingDetail = false;
String? _deletingFileId; // Track which file is being deleted String? _deletingFileId; // Track which file is being deleted
bool _isAllowModify = true; // From API detail response
/// Whether we're editing an existing submission /// Whether we're editing an existing submission
bool get isEditing => widget.submission != null; bool get isEditing => widget.submission != null;
/// Whether the form is read-only (editing but not allowed to modify)
bool get isReadOnly => isEditing && !_isAllowModify;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -101,6 +105,9 @@ class _SubmissionCreatePageState extends ConsumerState<SubmissionCreatePage> {
// Set existing files from API // Set existing files from API
_existingFiles = detail.filesList; _existingFiles = detail.filesList;
// Set modify permission from API
_isAllowModify = detail.isAllowModify;
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -146,7 +153,11 @@ class _SubmissionCreatePageState extends ConsumerState<SubmissionCreatePage> {
onPressed: () => Navigator.of(context).pop(), onPressed: () => Navigator.of(context).pop(),
), ),
title: Text( title: Text(
isEditing ? 'Chỉnh sửa Dự án' : 'Đăng ký Công trình', isReadOnly
? 'Chi tiết Dự án'
: isEditing
? 'Chỉnh sửa Dự án'
: 'Đăng ký Công trình',
style: TextStyle(color: colorScheme.onSurface), style: TextStyle(color: colorScheme.onSurface),
), ),
actions: [ actions: [
@@ -197,10 +208,12 @@ class _SubmissionCreatePageState extends ConsumerState<SubmissionCreatePage> {
// File Upload // File Upload
_buildFileUploadCard(colorScheme), _buildFileUploadCard(colorScheme),
const SizedBox(height: 24),
// Submit Button // Submit Button - only show when not read-only
if (!isReadOnly) ...[
const SizedBox(height: 24),
_buildSubmitButton(colorScheme), _buildSubmitButton(colorScheme),
],
const SizedBox(height: 40), const SizedBox(height: 40),
], ],
), ),
@@ -376,7 +389,8 @@ class _SubmissionCreatePageState extends ConsumerState<SubmissionCreatePage> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// Upload Area // Upload Area - only show when not read-only
if (!isReadOnly)
InkWell( InkWell(
onTap: _pickFiles, onTap: _pickFiles,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
@@ -488,7 +502,9 @@ class _SubmissionCreatePageState extends ConsumerState<SubmissionCreatePage> {
int maxLines = 1, int maxLines = 1,
TextInputType? keyboardType, TextInputType? keyboardType,
String? helperText, String? helperText,
bool? readOnly,
}) { }) {
final isFieldReadOnly = readOnly ?? isReadOnly;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -517,11 +533,12 @@ class _SubmissionCreatePageState extends ConsumerState<SubmissionCreatePage> {
controller: controller, controller: controller,
maxLines: maxLines, maxLines: maxLines,
keyboardType: keyboardType, keyboardType: keyboardType,
readOnly: isFieldReadOnly,
decoration: InputDecoration( decoration: InputDecoration(
hintText: hint, hintText: hint,
hintStyle: TextStyle(color: colorScheme.onSurfaceVariant), hintStyle: TextStyle(color: colorScheme.onSurfaceVariant),
filled: true, filled: true,
fillColor: colorScheme.surface, fillColor: isFieldReadOnly ? colorScheme.surfaceContainerHighest : colorScheme.surface,
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: colorScheme.surfaceContainerHighest), borderSide: BorderSide(color: colorScheme.surfaceContainerHighest),
@@ -594,10 +611,10 @@ class _SubmissionCreatePageState extends ConsumerState<SubmissionCreatePage> {
const SizedBox(height: 8), const SizedBox(height: 8),
progressListAsync.when( progressListAsync.when(
data: (progressList) => DropdownButtonFormField<ProjectProgress>( data: (progressList) => DropdownButtonFormField<ProjectProgress>(
initialValue: _selectedProgress, value: _selectedProgress,
decoration: InputDecoration( decoration: InputDecoration(
filled: true, filled: true,
fillColor: colorScheme.surface, fillColor: isReadOnly ? colorScheme.surfaceContainerHighest : colorScheme.surface,
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: colorScheme.surfaceContainerHighest), borderSide: BorderSide(color: colorScheme.surfaceContainerHighest),
@@ -618,7 +635,9 @@ class _SubmissionCreatePageState extends ConsumerState<SubmissionCreatePage> {
child: Text(progress.status), child: Text(progress.status),
)) ))
.toList(), .toList(),
onChanged: (value) { onChanged: isReadOnly
? null
: (value) {
setState(() { setState(() {
_selectedProgress = value; _selectedProgress = value;
}); });
@@ -692,11 +711,11 @@ class _SubmissionCreatePageState extends ConsumerState<SubmissionCreatePage> {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
InkWell( InkWell(
onTap: _pickExpectedDate, onTap: isReadOnly ? null : _pickExpectedDate,
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.surface, color: isReadOnly ? colorScheme.surfaceContainerHighest : colorScheme.surface,
border: Border.all(color: colorScheme.surfaceContainerHighest), border: Border.all(color: colorScheme.surfaceContainerHighest),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
@@ -847,8 +866,8 @@ class _SubmissionCreatePageState extends ConsumerState<SubmissionCreatePage> {
], ],
), ),
), ),
// Only show remove button when not uploading // Only show remove button when not uploading and not read-only
if (!_isSubmitting) if (!_isSubmitting && !isReadOnly)
IconButton( IconButton(
icon: const FaIcon( icon: const FaIcon(
FontAwesomeIcons.xmark, FontAwesomeIcons.xmark,
@@ -961,8 +980,8 @@ class _SubmissionCreatePageState extends ConsumerState<SubmissionCreatePage> {
], ],
), ),
), ),
// Delete button or checkmark // Delete button or checkmark - only show delete when not read-only
if (!_isSubmitting && !isDeleting) if (!_isSubmitting && !isDeleting && !isReadOnly)
IconButton( IconButton(
icon: const FaIcon( icon: const FaIcon(
FontAwesomeIcons.trash, FontAwesomeIcons.trash,
@@ -1147,13 +1166,13 @@ class _SubmissionCreatePageState extends ConsumerState<SubmissionCreatePage> {
height: 24, height: 24,
child: CustomLoadingIndicator(color: Colors.white, size: 20), child: CustomLoadingIndicator(color: Colors.white, size: 20),
) )
: const Row( : Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
FaIcon(FontAwesomeIcons.paperPlane, size: 16), const FaIcon(FontAwesomeIcons.paperPlane, size: 16),
SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
'Gửi đăng ký', isEditing ? 'Cập nhật' : 'Gửi đăng ký',
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@@ -1166,10 +1185,19 @@ class _SubmissionCreatePageState extends ConsumerState<SubmissionCreatePage> {
} }
Future<void> _pickExpectedDate() async { Future<void> _pickExpectedDate() async {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
// For editing mode with past date, allow selecting from that date
// Otherwise, start from today
final firstDate = (_expectedStartDate != null && _expectedStartDate!.isBefore(today))
? _expectedStartDate!
: today;
final date = await showDatePicker( final date = await showDatePicker(
context: context, context: context,
initialDate: _expectedStartDate ?? DateTime.now(), initialDate: _expectedStartDate ?? today,
firstDate: DateTime.now(), firstDate: firstDate,
lastDate: DateTime.now().add(const Duration(days: 365 * 3)), lastDate: DateTime.now().add(const Duration(days: 365 * 3)),
); );

View File

@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 1.0.1+23 version: 1.0.1+26
environment: environment:
sdk: ^3.10.0 sdk: ^3.10.0