update database
This commit is contained in:
580
CLAUDE.md
580
CLAUDE.md
@@ -1314,321 +1314,132 @@ class Auth extends _$Auth {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Cart State
|
|
||||||
```dart
|
|
||||||
@riverpod
|
|
||||||
class Cart extends _$Cart {
|
|
||||||
@override
|
|
||||||
List<CartItem> build() {
|
|
||||||
return ref.watch(cartRepositoryProvider).getCartItems();
|
|
||||||
}
|
|
||||||
|
|
||||||
void addItem(Product product, int quantity) {
|
### Domain Entities & Data Models
|
||||||
state = [
|
|
||||||
...state,
|
The app follows clean architecture with domain entities and Hive data models based on the database schema in `database.md`.
|
||||||
CartItem(
|
|
||||||
id: product.id,
|
#### **Domain Entities (28 files)**
|
||||||
productName: product.name,
|
Pure business logic entities with no external dependencies. All entities use freezed for immutability.
|
||||||
price: product.price,
|
|
||||||
quantity: quantity,
|
**Auth Feature** (`lib/features/auth/domain/entities/`)
|
||||||
imageUrl: product.images.first,
|
- `user.dart` - User with role, status, loyalty tier, company info
|
||||||
),
|
- `user_session.dart` - User session with device tracking
|
||||||
];
|
|
||||||
_persistCart();
|
**Products Feature** (`lib/features/products/domain/entities/`)
|
||||||
}
|
- `product.dart` - Product with images, specs, 360 view, ERPNext integration
|
||||||
|
- `stock_level.dart` - Inventory tracking (available/reserved/ordered)
|
||||||
void updateQuantity(String itemId, int quantity) {
|
- `category.dart` - Product categories
|
||||||
state = state.map((item) {
|
|
||||||
if (item.id == itemId) {
|
**Cart Feature** (`lib/features/cart/domain/entities/`)
|
||||||
return item.copyWith(quantity: quantity);
|
- `cart.dart` - Shopping cart with sync status
|
||||||
}
|
- `cart_item.dart` - Cart line items
|
||||||
return item;
|
|
||||||
}).toList();
|
**Orders Feature** (`lib/features/orders/domain/entities/`)
|
||||||
_persistCart();
|
- `order.dart` - Orders with status, addresses, ERPNext integration
|
||||||
}
|
- `order_item.dart` - Order line items with discounts
|
||||||
|
- `invoice.dart` - Invoices with type, status, payment tracking
|
||||||
void removeItem(String itemId) {
|
- `payment_line.dart` - Payment transactions with method and status
|
||||||
state = state.where((item) => item.id != itemId).toList();
|
|
||||||
_persistCart();
|
**Loyalty Feature** (`lib/features/loyalty/domain/entities/`)
|
||||||
}
|
- `loyalty_point_entry.dart` - Points transactions with type, source, complaints
|
||||||
|
- `gift_catalog.dart` - Redeemable gifts with category, points cost
|
||||||
void clear() {
|
- `redeemed_gift.dart` - User's redeemed gifts with voucher codes
|
||||||
state = [];
|
- `points_record.dart` - Invoice submissions for manual points
|
||||||
_persistCart();
|
|
||||||
}
|
**Projects Feature** (`lib/features/projects/domain/entities/`)
|
||||||
|
- `project_submission.dart` - Completed project submissions with photos
|
||||||
void _persistCart() {
|
- `design_request.dart` - Design consultation requests
|
||||||
ref.read(cartRepositoryProvider).saveCart(state);
|
|
||||||
}
|
**Quotes Feature** (`lib/features/quotes/domain/entities/`)
|
||||||
}
|
- `quote.dart` - Quotations with status, validity, conversion tracking
|
||||||
|
- `quote_item.dart` - Quote line items with price negotiation
|
||||||
@riverpod
|
|
||||||
double cartTotal(CartTotalRef ref) {
|
**Chat Feature** (`lib/features/chat/domain/entities/`)
|
||||||
final items = ref.watch(cartProvider);
|
- `chat_room.dart` - Chat rooms with type, participants
|
||||||
return items.fold(0, (sum, item) => sum + (item.price * item.quantity));
|
- `message.dart` - Messages with content type, attachments, read status
|
||||||
}
|
|
||||||
```
|
**Notifications Feature** (`lib/features/notifications/domain/entities/`)
|
||||||
|
- `notification.dart` - User notifications with type-based data
|
||||||
#### Loyalty Points State
|
|
||||||
```dart
|
**Showrooms Feature** (`lib/features/showrooms/domain/entities/`)
|
||||||
@riverpod
|
- `showroom.dart` - Virtual showrooms with 360 views, gallery
|
||||||
class LoyaltyPoints extends _$LoyaltyPoints {
|
- `showroom_product.dart` - Products used in showrooms
|
||||||
@override
|
|
||||||
Future<LoyaltyPointsData> build() async {
|
**Account Feature** (`lib/features/account/domain/entities/`)
|
||||||
return await ref.read(loyaltyRepositoryProvider).getLoyaltyPoints();
|
- `payment_reminder.dart` - Payment reminders with scheduling
|
||||||
}
|
- `audit_log.dart` - System audit trail
|
||||||
|
|
||||||
Future<void> refresh() async {
|
**Home Feature** (`lib/features/home/domain/entities/`)
|
||||||
state = const AsyncValue.loading();
|
- `member_card.dart` - Membership card display
|
||||||
state = await AsyncValue.guard(() async {
|
- `promotion.dart` - Promotional campaigns
|
||||||
return await ref.read(loyaltyRepositoryProvider).getLoyaltyPoints();
|
|
||||||
});
|
#### **Hive Data Models (25 files)**
|
||||||
}
|
Hive CE models for offline-first local storage. All models extend `HiveObject` with proper type adapters.
|
||||||
|
|
||||||
Future<void> redeemReward(Reward reward) async {
|
**Type ID Allocation:**
|
||||||
final result = await ref.read(loyaltyRepositoryProvider).redeemReward(reward.id);
|
- Models: 0-24 (user_model=0, user_session_model=1, product_model=2, etc.)
|
||||||
if (result.success) {
|
- Enums: 30-50 (UserRole=30, UserStatus=31, LoyaltyTier=32, etc.)
|
||||||
await refresh();
|
|
||||||
// Show success dialog with gift code
|
**Model Features:**
|
||||||
}
|
- `@HiveType` with unique typeId
|
||||||
}
|
- `@HiveField` for all properties
|
||||||
}
|
- `fromJson()` / `toJson()` for API serialization
|
||||||
```
|
- `toEntity()` / `fromEntity()` for domain conversion
|
||||||
|
- Helper methods for JSONB fields
|
||||||
---
|
- Full documentation
|
||||||
|
|
||||||
### Database Schema (Hive)
|
**All models are located in:** `lib/features/*/data/models/`
|
||||||
|
- Auth: `user_model.dart`, `user_session_model.dart`
|
||||||
#### User Model
|
- Products: `product_model.dart`, `stock_level_model.dart`
|
||||||
```dart
|
- Cart: `cart_model.dart`, `cart_item_model.dart`
|
||||||
@HiveType(typeId: 0)
|
- Orders: `order_model.dart`, `order_item_model.dart`, `invoice_model.dart`, `payment_line_model.dart`
|
||||||
class UserModel extends HiveObject {
|
- Loyalty: `loyalty_point_entry_model.dart`, `gift_catalog_model.dart`, `redeemed_gift_model.dart`, `points_record_model.dart`
|
||||||
@HiveField(0)
|
- Projects: `project_submission_model.dart`, `design_request_model.dart`
|
||||||
final String id;
|
- Quotes: `quote_model.dart`, `quote_item_model.dart`
|
||||||
|
- Chat: `chat_room_model.dart`, `message_model.dart`
|
||||||
@HiveField(1)
|
- Notifications: `notification_model.dart`
|
||||||
final String name;
|
- Showrooms: `showroom_model.dart`, `showroom_product_model.dart`
|
||||||
|
- Account: `payment_reminder_model.dart`, `audit_log_model.dart`
|
||||||
@HiveField(2)
|
|
||||||
final String phone;
|
#### **Enums (21 types)**
|
||||||
|
All enums are defined in `lib/core/database/models/enums.dart` with Hive type adapters:
|
||||||
@HiveField(3)
|
|
||||||
final String email;
|
**User & Auth:**
|
||||||
|
- `UserRole` - admin, customer, contractor, architect, distributor, broker
|
||||||
@HiveField(4)
|
- `UserStatus` - active, inactive, pending, suspended, banned
|
||||||
final String? avatar;
|
- `LoyaltyTier` - diamond, platinum, gold
|
||||||
|
|
||||||
@HiveField(5)
|
**Orders & Payments:**
|
||||||
final MemberTier memberTier; // diamond, platinum, gold
|
- `OrderStatus` - draft, pending, confirmed, processing, shipped, delivered, completed, cancelled, returned, refunded
|
||||||
|
- `InvoiceType` - sales, proforma, credit_note, debit_note
|
||||||
@HiveField(6)
|
- `InvoiceStatus` - draft, sent, viewed, overdue, paid, partially_paid, cancelled
|
||||||
final int points;
|
- `PaymentMethod` - cash, bank_transfer, credit_card, debit_card, e_wallet, cod
|
||||||
|
- `PaymentStatus` - pending, processing, completed, failed, refunded
|
||||||
@HiveField(7)
|
|
||||||
final String referralCode;
|
**Loyalty & Gifts:**
|
||||||
|
- `EntryType` - earn, spend, adjust, expire, reversal
|
||||||
@HiveField(8)
|
- `EntrySource` - purchase, referral, promotion, bonus, manual, project_submission, design_request, points_record
|
||||||
final String? company;
|
- `ComplaintStatus` - pending, reviewing, approved, rejected, resolved
|
||||||
|
- `GiftCategory` - voucher, product, service, experience, discount
|
||||||
@HiveField(9)
|
- `GiftStatus` - active, used, expired, cancelled
|
||||||
final UserType userType; // contractor, architect, distributor, broker
|
- `PointsStatus` - pending, approved, rejected
|
||||||
|
|
||||||
@HiveField(10)
|
**Projects & Quotes:**
|
||||||
final DateTime createdAt;
|
- `ProjectType` - residential, commercial, industrial, hospitality, retail, office, other
|
||||||
}
|
- `SubmissionStatus` - pending, reviewing, approved, rejected
|
||||||
```
|
- `DesignStatus` - submitted, assigned, in_progress, completed, delivered, revision_requested, approved, cancelled
|
||||||
|
- `QuoteStatus` - draft, sent, viewed, negotiating, accepted, rejected, expired, converted
|
||||||
#### Product Model
|
|
||||||
```dart
|
**Chat:**
|
||||||
@HiveType(typeId: 1)
|
- `RoomType` - support, sales, quote, order
|
||||||
class ProductModel extends HiveObject {
|
- `ContentType` - text, image, file, product, location
|
||||||
@HiveField(0)
|
- `ReminderType` - payment_due, overdue, final_notice, custom
|
||||||
final String id;
|
|
||||||
|
**Detailed Documentation:**
|
||||||
@HiveField(1)
|
- See `DOMAIN_ENTITIES_SUMMARY.md` for entity details
|
||||||
final String name;
|
- See `HIVE_MODELS_REFERENCE.md` for model templates and usage
|
||||||
|
- See `lib/core/constants/storage_constants.dart` for Type ID reference
|
||||||
@HiveField(2)
|
|
||||||
final String sku;
|
|
||||||
|
|
||||||
@HiveField(3)
|
|
||||||
final String description;
|
|
||||||
|
|
||||||
@HiveField(4)
|
|
||||||
final double price;
|
|
||||||
|
|
||||||
@HiveField(5)
|
|
||||||
final double? salePrice;
|
|
||||||
|
|
||||||
@HiveField(6)
|
|
||||||
final List<String> images;
|
|
||||||
|
|
||||||
@HiveField(7)
|
|
||||||
final String categoryId;
|
|
||||||
|
|
||||||
@HiveField(8)
|
|
||||||
final int stock;
|
|
||||||
|
|
||||||
@HiveField(9)
|
|
||||||
final bool isAvailable;
|
|
||||||
|
|
||||||
@HiveField(10)
|
|
||||||
final DateTime createdAt;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Cart Item Model
|
|
||||||
```dart
|
|
||||||
@HiveType(typeId: 2)
|
|
||||||
class CartItemModel extends HiveObject {
|
|
||||||
@HiveField(0)
|
|
||||||
final String id;
|
|
||||||
|
|
||||||
@HiveField(1)
|
|
||||||
final String productId;
|
|
||||||
|
|
||||||
@HiveField(2)
|
|
||||||
final String productName;
|
|
||||||
|
|
||||||
@HiveField(3)
|
|
||||||
final double price;
|
|
||||||
|
|
||||||
@HiveField(4)
|
|
||||||
final int quantity;
|
|
||||||
|
|
||||||
@HiveField(5)
|
|
||||||
final String imageUrl;
|
|
||||||
|
|
||||||
@HiveField(6)
|
|
||||||
final DateTime addedAt;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Order Model
|
|
||||||
```dart
|
|
||||||
@HiveType(typeId: 3)
|
|
||||||
class OrderModel extends HiveObject {
|
|
||||||
@HiveField(0)
|
|
||||||
final String id;
|
|
||||||
|
|
||||||
@HiveField(1)
|
|
||||||
final String orderNumber;
|
|
||||||
|
|
||||||
@HiveField(2)
|
|
||||||
final String customerId;
|
|
||||||
|
|
||||||
@HiveField(3)
|
|
||||||
final String customerName;
|
|
||||||
|
|
||||||
@HiveField(4)
|
|
||||||
final List<OrderItemModel> items;
|
|
||||||
|
|
||||||
@HiveField(5)
|
|
||||||
final double subtotal;
|
|
||||||
|
|
||||||
@HiveField(6)
|
|
||||||
final double discount;
|
|
||||||
|
|
||||||
@HiveField(7)
|
|
||||||
final double shipping;
|
|
||||||
|
|
||||||
@HiveField(8)
|
|
||||||
final double total;
|
|
||||||
|
|
||||||
@HiveField(9)
|
|
||||||
final OrderStatus status;
|
|
||||||
|
|
||||||
@HiveField(10)
|
|
||||||
final String paymentMethod;
|
|
||||||
|
|
||||||
@HiveField(11)
|
|
||||||
final AddressModel deliveryAddress;
|
|
||||||
|
|
||||||
@HiveField(12)
|
|
||||||
final DateTime createdAt;
|
|
||||||
|
|
||||||
@HiveField(13)
|
|
||||||
final DateTime? completedAt;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Project Model
|
|
||||||
```dart
|
|
||||||
@HiveType(typeId: 4)
|
|
||||||
class ProjectModel extends HiveObject {
|
|
||||||
@HiveField(0)
|
|
||||||
final String id;
|
|
||||||
|
|
||||||
@HiveField(1)
|
|
||||||
final String name;
|
|
||||||
|
|
||||||
@HiveField(2)
|
|
||||||
final String code;
|
|
||||||
|
|
||||||
@HiveField(3)
|
|
||||||
final String client;
|
|
||||||
|
|
||||||
@HiveField(4)
|
|
||||||
final String clientPhone;
|
|
||||||
|
|
||||||
@HiveField(5)
|
|
||||||
final String location;
|
|
||||||
|
|
||||||
@HiveField(6)
|
|
||||||
final DateTime startDate;
|
|
||||||
|
|
||||||
@HiveField(7)
|
|
||||||
final DateTime endDate;
|
|
||||||
|
|
||||||
@HiveField(8)
|
|
||||||
final int progress; // 0-100
|
|
||||||
|
|
||||||
@HiveField(9)
|
|
||||||
final ProjectStatus status; // planning, inProgress, completed
|
|
||||||
|
|
||||||
@HiveField(10)
|
|
||||||
final double budget;
|
|
||||||
|
|
||||||
@HiveField(11)
|
|
||||||
final String description;
|
|
||||||
|
|
||||||
@HiveField(12)
|
|
||||||
final ProjectType type; // residential, commercial, industrial
|
|
||||||
|
|
||||||
@HiveField(13)
|
|
||||||
final DateTime createdAt;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Loyalty Transaction Model
|
|
||||||
```dart
|
|
||||||
@HiveType(typeId: 5)
|
|
||||||
class LoyaltyTransactionModel extends HiveObject {
|
|
||||||
@HiveField(0)
|
|
||||||
final String id;
|
|
||||||
|
|
||||||
@HiveField(1)
|
|
||||||
final TransactionType type; // earn, redeem
|
|
||||||
|
|
||||||
@HiveField(2)
|
|
||||||
final int amount;
|
|
||||||
|
|
||||||
@HiveField(3)
|
|
||||||
final String description;
|
|
||||||
|
|
||||||
@HiveField(4)
|
|
||||||
final DateTime timestamp;
|
|
||||||
|
|
||||||
@HiveField(5)
|
|
||||||
final int newBalance;
|
|
||||||
|
|
||||||
@HiveField(6)
|
|
||||||
final String? orderId;
|
|
||||||
|
|
||||||
@HiveField(7)
|
|
||||||
final String? rewardId;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1788,150 +1599,9 @@ class OfflineQueue {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
|
|
||||||
### Network Errors
|
|
||||||
```dart
|
|
||||||
class ApiClient {
|
|
||||||
Future<T> request<T>(String endpoint, {Map<String, dynamic>? data}) async {
|
|
||||||
try {
|
|
||||||
final response = await dio.post(endpoint, data: data);
|
|
||||||
return _parseResponse<T>(response);
|
|
||||||
} on DioException catch (e) {
|
|
||||||
if (e.type == DioExceptionType.connectionTimeout) {
|
|
||||||
throw NetworkException('Connection timeout. Please check your internet.');
|
|
||||||
} else if (e.type == DioExceptionType.receiveTimeout) {
|
|
||||||
throw NetworkException('Server took too long to respond.');
|
|
||||||
} else if (e.response?.statusCode == 401) {
|
|
||||||
throw UnauthorizedException('Session expired. Please login again.');
|
|
||||||
} else if (e.response?.statusCode == 404) {
|
|
||||||
throw NotFoundException('Resource not found.');
|
|
||||||
} else if (e.response?.statusCode == 500) {
|
|
||||||
throw ServerException('Server error. Please try again later.');
|
|
||||||
} else {
|
|
||||||
throw NetworkException('Network error: ${e.message}');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
throw UnknownException('Unexpected error: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Form Validation
|
|
||||||
```dart
|
|
||||||
class Validators {
|
|
||||||
static String? required(String? value) {
|
|
||||||
if (value == null || value.trim().isEmpty) {
|
|
||||||
return 'Trường này là bắt buộc';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
static String? vietnamesePhone(String? value) {
|
|
||||||
if (value == null || value.isEmpty) {
|
|
||||||
return 'Vui lòng nhập số điện thoại';
|
|
||||||
}
|
|
||||||
|
|
||||||
final phoneRegex = RegExp(r'^(0|\+84)[3|5|7|8|9][0-9]{8}$');
|
|
||||||
if (!phoneRegex.hasMatch(value)) {
|
|
||||||
return 'Số điện thoại không hợp lệ';
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
static String? email(String? value) {
|
|
||||||
if (value == null || value.isEmpty) {
|
|
||||||
return 'Vui lòng nhập email';
|
|
||||||
}
|
|
||||||
|
|
||||||
final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');
|
|
||||||
if (!emailRegex.hasMatch(value)) {
|
|
||||||
return 'Email không hợp lệ';
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
static String? password(String? value) {
|
|
||||||
if (value == null || value.isEmpty) {
|
|
||||||
return 'Vui lòng nhập mật khẩu';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value.length < 8) {
|
|
||||||
return 'Mật khẩu phải có ít nhất 8 ký tự';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!RegExp(r'[A-Z]').hasMatch(value)) {
|
|
||||||
return 'Mật khẩu phải có ít nhất 1 chữ hoa';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!RegExp(r'[a-z]').hasMatch(value)) {
|
|
||||||
return 'Mật khẩu phải có ít nhất 1 chữ thường';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!RegExp(r'[0-9]').hasMatch(value)) {
|
|
||||||
return 'Mật khẩu phải có ít nhất 1 số';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!RegExp(r'[!@#\$%^&*(),.?":{}|<>]').hasMatch(value)) {
|
|
||||||
return 'Mật khẩu phải có ít nhất 1 ký tự đặc biệt';
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing Strategy
|
|
||||||
|
|
||||||
### Unit Tests
|
|
||||||
```dart
|
|
||||||
void main() {
|
|
||||||
group('CartNotifier', () {
|
|
||||||
late CartNotifier cartNotifier;
|
|
||||||
late MockCartRepository mockRepository;
|
|
||||||
|
|
||||||
setUp(() {
|
|
||||||
mockRepository = MockCartRepository();
|
|
||||||
cartNotifier = CartNotifier(mockRepository);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('addItem adds product to cart', () {
|
|
||||||
final product = Product(id: '1', name: 'Test', price: 100);
|
|
||||||
|
|
||||||
cartNotifier.addItem(product, 2);
|
|
||||||
|
|
||||||
expect(cartNotifier.state.length, 1);
|
|
||||||
expect(cartNotifier.state.first.quantity, 2);
|
|
||||||
verify(mockRepository.saveCart(any)).called(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('cartTotal calculates correct total', () {
|
|
||||||
final items = [
|
|
||||||
CartItem(id: '1', price: 100, quantity: 2),
|
|
||||||
CartItem(id: '2', price: 50, quantity: 3),
|
|
||||||
];
|
|
||||||
|
|
||||||
final total = calculateCartTotal(items);
|
|
||||||
|
|
||||||
expect(total, 350); // (100 * 2) + (50 * 3)
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Localization (Vietnamese Primary)
|
## Localization (Vietnamese Primary)
|
||||||
|
|
||||||
### Setup
|
### Setup
|
||||||
|
|||||||
235
DOMAIN_ENTITIES_SUMMARY.md
Normal file
235
DOMAIN_ENTITIES_SUMMARY.md
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
# Domain Entities Summary
|
||||||
|
|
||||||
|
This document provides an overview of all domain entities created based on the database schema.
|
||||||
|
|
||||||
|
## Created Domain Entities by Feature
|
||||||
|
|
||||||
|
### 1. Auth Feature (/lib/features/auth/domain/entities/)
|
||||||
|
- **user.dart** - User account with authentication and loyalty information
|
||||||
|
- Enums: UserRole, UserStatus, LoyaltyTier
|
||||||
|
- Supporting class: CompanyInfo
|
||||||
|
|
||||||
|
- **user_session.dart** - Active user session with device information
|
||||||
|
|
||||||
|
### 2. Products Feature (/lib/features/products/domain/entities/)
|
||||||
|
- **product.dart** - Product catalog item (UPDATED to match database schema)
|
||||||
|
- Product with images, specifications, 360 view, ERPNext integration
|
||||||
|
|
||||||
|
- **stock_level.dart** - Inventory stock level for products
|
||||||
|
- Available, reserved, and ordered quantities
|
||||||
|
|
||||||
|
- **category.dart** - Product category (existing)
|
||||||
|
|
||||||
|
### 3. Cart Feature (/lib/features/cart/domain/entities/)
|
||||||
|
- **cart.dart** - Shopping cart
|
||||||
|
- Cart-level totals and sync status
|
||||||
|
|
||||||
|
- **cart_item.dart** - Individual cart item
|
||||||
|
- Product reference, quantity, pricing
|
||||||
|
|
||||||
|
### 4. Orders Feature (/lib/features/orders/domain/entities/)
|
||||||
|
- **order.dart** - Customer order
|
||||||
|
- Enums: OrderStatus
|
||||||
|
- Supporting class: Address
|
||||||
|
|
||||||
|
- **order_item.dart** - Order line item
|
||||||
|
- Product, quantity, pricing, discounts
|
||||||
|
|
||||||
|
- **invoice.dart** - Invoice for orders
|
||||||
|
- Enums: InvoiceType, InvoiceStatus
|
||||||
|
- Payment tracking
|
||||||
|
|
||||||
|
- **payment_line.dart** - Payment transaction
|
||||||
|
- Enums: PaymentMethod, PaymentStatus
|
||||||
|
- Bank details, receipts
|
||||||
|
|
||||||
|
### 5. Loyalty Feature (/lib/features/loyalty/domain/entities/)
|
||||||
|
- **loyalty_point_entry.dart** - Points transaction
|
||||||
|
- Enums: EntryType, EntrySource, ComplaintStatus
|
||||||
|
- Points earned/spent tracking
|
||||||
|
|
||||||
|
- **gift_catalog.dart** - Redeemable gift catalog
|
||||||
|
- Enums: GiftCategory
|
||||||
|
- Availability and validity tracking
|
||||||
|
|
||||||
|
- **redeemed_gift.dart** - User-redeemed gift
|
||||||
|
- Enums: GiftStatus
|
||||||
|
- Voucher codes, QR codes, expiry
|
||||||
|
|
||||||
|
- **points_record.dart** - User-submitted invoice for points
|
||||||
|
- Enums: PointsStatus
|
||||||
|
- Invoice submission and approval
|
||||||
|
|
||||||
|
### 6. Projects Feature (/lib/features/projects/domain/entities/)
|
||||||
|
- **project_submission.dart** - Completed project submission
|
||||||
|
- Enums: ProjectType, SubmissionStatus
|
||||||
|
- Before/after photos, points earning
|
||||||
|
|
||||||
|
- **design_request.dart** - Design consultation request
|
||||||
|
- Enums: DesignStatus
|
||||||
|
- Project requirements, designer assignment
|
||||||
|
|
||||||
|
### 7. Quotes Feature (/lib/features/quotes/domain/entities/)
|
||||||
|
- **quote.dart** - Price quotation
|
||||||
|
- Enums: QuoteStatus
|
||||||
|
- Supporting class: DeliveryAddress
|
||||||
|
|
||||||
|
- **quote_item.dart** - Quote line item
|
||||||
|
- Price negotiation, discounts
|
||||||
|
|
||||||
|
### 8. Chat Feature (/lib/features/chat/domain/entities/)
|
||||||
|
- **chat_room.dart** - Chat conversation room
|
||||||
|
- Enums: RoomType
|
||||||
|
- Participants, context (order/quote)
|
||||||
|
|
||||||
|
- **message.dart** - Chat message
|
||||||
|
- Enums: ContentType
|
||||||
|
- Attachments, read status, product references
|
||||||
|
|
||||||
|
### 9. Notifications Feature (/lib/features/notifications/domain/entities/)
|
||||||
|
- **notification.dart** - User notification
|
||||||
|
- Type-based notifications (order, loyalty, promotion, system)
|
||||||
|
- Read status, push delivery
|
||||||
|
|
||||||
|
### 10. Showrooms Feature (/lib/features/showrooms/domain/entities/)
|
||||||
|
- **showroom.dart** - Virtual showroom display
|
||||||
|
- 360 views, gallery, metadata
|
||||||
|
|
||||||
|
- **showroom_product.dart** - Product used in showroom
|
||||||
|
- Quantity tracking
|
||||||
|
|
||||||
|
### 11. Account Feature (/lib/features/account/domain/entities/)
|
||||||
|
- **payment_reminder.dart** - Invoice payment reminder
|
||||||
|
- Enums: ReminderType
|
||||||
|
- Scheduling and delivery tracking
|
||||||
|
|
||||||
|
- **audit_log.dart** - System audit trail
|
||||||
|
- Action tracking, change history
|
||||||
|
|
||||||
|
### 12. Home Feature (/lib/features/home/domain/entities/)
|
||||||
|
- **member_card.dart** - Membership card (existing)
|
||||||
|
- Enums: MemberTier, MemberType
|
||||||
|
|
||||||
|
- **promotion.dart** - Promotion (existing)
|
||||||
|
|
||||||
|
## Entity Design Patterns
|
||||||
|
|
||||||
|
All entities follow these consistent patterns:
|
||||||
|
|
||||||
|
### Immutability
|
||||||
|
- All fields are `final`
|
||||||
|
- Constructor uses `const` when possible
|
||||||
|
- `copyWith()` method for creating modified copies
|
||||||
|
|
||||||
|
### Value Equality
|
||||||
|
- Proper `==` operator override
|
||||||
|
- `hashCode` override using `Object.hash()`
|
||||||
|
- `toString()` method for debugging
|
||||||
|
|
||||||
|
### Business Logic
|
||||||
|
- Computed properties (getters) for derived values
|
||||||
|
- Boolean checks (e.g., `isActive`, `isExpired`)
|
||||||
|
- Helper methods for common operations
|
||||||
|
|
||||||
|
### Type Safety
|
||||||
|
- Enums for status/type fields with `displayName` getters
|
||||||
|
- Nullable types (`?`) where appropriate
|
||||||
|
- List/Map types for collections and JSONB fields
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- Comprehensive documentation comments
|
||||||
|
- Feature-level context
|
||||||
|
- Field descriptions
|
||||||
|
|
||||||
|
## Database Field Mapping
|
||||||
|
|
||||||
|
All entities map 1:1 with database schema:
|
||||||
|
- `varchar` → `String` or `String?`
|
||||||
|
- `numeric` → `double`
|
||||||
|
- `integer` → `int`
|
||||||
|
- `boolean` → `bool`
|
||||||
|
- `timestamp` → `DateTime`
|
||||||
|
- `date` → `DateTime`
|
||||||
|
- `jsonb` → `Map<String, dynamic>` or typed classes
|
||||||
|
- `inet` → `String?`
|
||||||
|
- `text` → `String?`
|
||||||
|
- Arrays → `List<T>`
|
||||||
|
|
||||||
|
## Enums Created
|
||||||
|
|
||||||
|
### Auth
|
||||||
|
- UserRole (customer, sales, admin, accountant, designer)
|
||||||
|
- UserStatus (pending, active, suspended, rejected)
|
||||||
|
- LoyaltyTier (none, gold, platinum, diamond)
|
||||||
|
|
||||||
|
### Orders
|
||||||
|
- OrderStatus (draft, confirmed, processing, ready, shipped, delivered, completed, cancelled, returned)
|
||||||
|
- InvoiceType (standard, proforma, creditNote, debitNote)
|
||||||
|
- InvoiceStatus (draft, submitted, partiallyPaid, paid, overdue, cancelled)
|
||||||
|
- PaymentMethod (cash, bankTransfer, creditCard, ewallet, check, other)
|
||||||
|
- PaymentStatus (pending, processing, completed, failed, refunded, cancelled)
|
||||||
|
|
||||||
|
### Loyalty
|
||||||
|
- EntryType (earn, redeem, adjustment, expiry)
|
||||||
|
- EntrySource (order, referral, redemption, project, pointsRecord, manual, birthday, welcome, other)
|
||||||
|
- ComplaintStatus (none, submitted, reviewing, approved, rejected)
|
||||||
|
- GiftCategory (voucher, product, service, discount, other)
|
||||||
|
- GiftStatus (active, used, expired, cancelled)
|
||||||
|
- PointsStatus (pending, approved, rejected)
|
||||||
|
|
||||||
|
### Projects
|
||||||
|
- ProjectType (residential, commercial, industrial, infrastructure, other)
|
||||||
|
- SubmissionStatus (pending, reviewing, approved, rejected)
|
||||||
|
- DesignStatus (pending, assigned, inProgress, completed, cancelled)
|
||||||
|
|
||||||
|
### Quotes
|
||||||
|
- QuoteStatus (draft, sent, accepted, rejected, expired, converted)
|
||||||
|
|
||||||
|
### Chat
|
||||||
|
- RoomType (direct, group, support, order, quote)
|
||||||
|
- ContentType (text, image, file, product, system)
|
||||||
|
|
||||||
|
### Account
|
||||||
|
- ReminderType (initial, dueDate, firstOverdue, secondOverdue, finalWarning)
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
### ERPNext Integration
|
||||||
|
Many entities include ERPNext reference fields:
|
||||||
|
- `erpnextCustomerId` (User)
|
||||||
|
- `erpnextItemCode` (Product)
|
||||||
|
- `erpnextSalesOrder` (Order)
|
||||||
|
- `erpnextInvoice` (Invoice)
|
||||||
|
- `erpnextPaymentEntry` (PaymentLine)
|
||||||
|
- `erpnextQuotation` (Quote)
|
||||||
|
- `erpnextEntryId` (LoyaltyPointEntry)
|
||||||
|
|
||||||
|
### Address Handling
|
||||||
|
Reusable address classes for different contexts:
|
||||||
|
- `Address` (Order entity)
|
||||||
|
- `DeliveryAddress` (Quote entity)
|
||||||
|
- Both with JSON serialization support
|
||||||
|
|
||||||
|
### Attachment Management
|
||||||
|
List-based attachment fields for multiple files:
|
||||||
|
- User attachments (ID cards, licenses)
|
||||||
|
- Project photos (before/after)
|
||||||
|
- Points record invoices
|
||||||
|
- Design request references
|
||||||
|
- Chat message files
|
||||||
|
|
||||||
|
### Audit Trail Support
|
||||||
|
- Creation timestamps (`createdAt`)
|
||||||
|
- Update timestamps (`updatedAt`)
|
||||||
|
- User tracking (`createdBy`, `processedBy`, `reviewedBy`)
|
||||||
|
- Change tracking (AuditLog entity)
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
These domain entities are ready for:
|
||||||
|
1. Data layer model creation (extending entities with JSON serialization)
|
||||||
|
2. Repository interface definitions
|
||||||
|
3. Use case implementation
|
||||||
|
4. Provider/state management integration
|
||||||
|
|
||||||
|
All entities follow clean architecture principles with no external dependencies.
|
||||||
400
HIVE_MODELS_COMPLETED.md
Normal file
400
HIVE_MODELS_COMPLETED.md
Normal file
@@ -0,0 +1,400 @@
|
|||||||
|
# Hive CE Data Models - Completion Summary
|
||||||
|
|
||||||
|
## ✅ All Models Created Successfully!
|
||||||
|
|
||||||
|
A total of **25 Hive CE data models** have been created for the Worker mobile app, covering all features from the database schema.
|
||||||
|
|
||||||
|
## 📊 Created Models by Feature
|
||||||
|
|
||||||
|
### 1. Authentication (2 models)
|
||||||
|
- ✅ `user_model.dart` - Type ID: 0
|
||||||
|
- Maps to `users` table
|
||||||
|
- Includes: user info, loyalty tier, points, company info, referral
|
||||||
|
- ✅ `user_session_model.dart` - Type ID: 1
|
||||||
|
- Maps to `user_sessions` table
|
||||||
|
- Includes: session management, device info, tokens
|
||||||
|
|
||||||
|
### 2. Products (2 models)
|
||||||
|
- ✅ `product_model.dart` - Type ID: 2
|
||||||
|
- Maps to `products` table
|
||||||
|
- Includes: product details, images, specifications, pricing
|
||||||
|
- ✅ `stock_level_model.dart` - Type ID: 3
|
||||||
|
- Maps to `stock_levels` table
|
||||||
|
- Includes: inventory tracking, warehouse info
|
||||||
|
|
||||||
|
### 3. Cart (2 models)
|
||||||
|
- ✅ `cart_model.dart` - Type ID: 4
|
||||||
|
- Maps to `carts` table
|
||||||
|
- Includes: cart totals, sync status
|
||||||
|
- ✅ `cart_item_model.dart` - Type ID: 5
|
||||||
|
- Maps to `cart_items` table
|
||||||
|
- Includes: product items, quantities, prices
|
||||||
|
|
||||||
|
### 4. Orders (4 models)
|
||||||
|
- ✅ `order_model.dart` - Type ID: 6
|
||||||
|
- Maps to `orders` table
|
||||||
|
- Includes: order details, status, addresses, delivery
|
||||||
|
- ✅ `order_item_model.dart` - Type ID: 7
|
||||||
|
- Maps to `order_items` table
|
||||||
|
- Includes: line items, discounts
|
||||||
|
- ✅ `invoice_model.dart` - Type ID: 8
|
||||||
|
- Maps to `invoices` table
|
||||||
|
- Includes: invoice details, payment status, amounts
|
||||||
|
- ✅ `payment_line_model.dart` - Type ID: 9
|
||||||
|
- Maps to `payment_lines` table
|
||||||
|
- Includes: payment records, methods, receipts
|
||||||
|
|
||||||
|
### 5. Loyalty (4 models)
|
||||||
|
- ✅ `loyalty_point_entry_model.dart` - Type ID: 10
|
||||||
|
- Maps to `loyalty_point_entries` table
|
||||||
|
- Includes: points transactions, balance, complaints
|
||||||
|
- ✅ `gift_catalog_model.dart` - Type ID: 11
|
||||||
|
- Maps to `gift_catalog` table
|
||||||
|
- Includes: available rewards, points cost
|
||||||
|
- ✅ `redeemed_gift_model.dart` - Type ID: 12
|
||||||
|
- Maps to `redeemed_gifts` table
|
||||||
|
- Includes: user gifts, vouchers, QR codes
|
||||||
|
- ✅ `points_record_model.dart` - Type ID: 13
|
||||||
|
- Maps to `points_records` table
|
||||||
|
- Includes: invoice submissions for points
|
||||||
|
|
||||||
|
### 6. Projects (2 models)
|
||||||
|
- ✅ `project_submission_model.dart` - Type ID: 14
|
||||||
|
- Maps to `project_submissions` table
|
||||||
|
- Includes: project photos, review status
|
||||||
|
- ✅ `design_request_model.dart` - Type ID: 15
|
||||||
|
- Maps to `design_requests` table
|
||||||
|
- Includes: design requirements, assignments
|
||||||
|
|
||||||
|
### 7. Quotes (2 models)
|
||||||
|
- ✅ `quote_model.dart` - Type ID: 16
|
||||||
|
- Maps to `quotes` table
|
||||||
|
- Includes: quotation details, validity, conversion
|
||||||
|
- ✅ `quote_item_model.dart` - Type ID: 17
|
||||||
|
- Maps to `quote_items` table
|
||||||
|
- Includes: quoted products, negotiated prices
|
||||||
|
|
||||||
|
### 8. Chat (2 models)
|
||||||
|
- ✅ `chat_room_model.dart` - Type ID: 18
|
||||||
|
- Maps to `chat_rooms` table
|
||||||
|
- Includes: room info, participants, related entities
|
||||||
|
- ✅ `message_model.dart` - Type ID: 19
|
||||||
|
- Maps to `chat_messages` table
|
||||||
|
- Includes: message content, attachments, read status
|
||||||
|
|
||||||
|
### 9. Notifications (1 model)
|
||||||
|
- ✅ `notification_model.dart` - Type ID: 20
|
||||||
|
- Maps to `notifications` table
|
||||||
|
- Includes: notification type, data, read status
|
||||||
|
|
||||||
|
### 10. Showrooms (2 models)
|
||||||
|
- ✅ `showroom_model.dart` - Type ID: 21
|
||||||
|
- Maps to `showrooms` table
|
||||||
|
- Includes: showroom details, images, 360 view
|
||||||
|
- ✅ `showroom_product_model.dart` - Type ID: 22
|
||||||
|
- Maps to `showroom_products` table
|
||||||
|
- Includes: products used in showrooms
|
||||||
|
|
||||||
|
### 11. Account (2 models)
|
||||||
|
- ✅ `payment_reminder_model.dart` - Type ID: 23
|
||||||
|
- Maps to `payment_reminders` table
|
||||||
|
- Includes: reminder scheduling, status
|
||||||
|
- ✅ `audit_log_model.dart` - Type ID: 24
|
||||||
|
- Maps to `audit_logs` table
|
||||||
|
- Includes: user actions, changes tracking
|
||||||
|
|
||||||
|
## 🎯 Enum Types Created (21 enums)
|
||||||
|
|
||||||
|
All enum types are defined in `/Users/ssg/project/worker/lib/core/database/models/enums.dart`:
|
||||||
|
|
||||||
|
- UserRole (Type ID: 30)
|
||||||
|
- UserStatus (Type ID: 31)
|
||||||
|
- LoyaltyTier (Type ID: 32)
|
||||||
|
- OrderStatus (Type ID: 33)
|
||||||
|
- InvoiceType (Type ID: 34)
|
||||||
|
- InvoiceStatus (Type ID: 35)
|
||||||
|
- PaymentMethod (Type ID: 36)
|
||||||
|
- PaymentStatus (Type ID: 37)
|
||||||
|
- EntryType (Type ID: 38)
|
||||||
|
- EntrySource (Type ID: 39)
|
||||||
|
- ComplaintStatus (Type ID: 40)
|
||||||
|
- GiftCategory (Type ID: 41)
|
||||||
|
- GiftStatus (Type ID: 42)
|
||||||
|
- PointsStatus (Type ID: 43)
|
||||||
|
- ProjectType (Type ID: 44)
|
||||||
|
- SubmissionStatus (Type ID: 45)
|
||||||
|
- DesignStatus (Type ID: 46)
|
||||||
|
- QuoteStatus (Type ID: 47)
|
||||||
|
- RoomType (Type ID: 48)
|
||||||
|
- ContentType (Type ID: 49)
|
||||||
|
- ReminderType (Type ID: 50)
|
||||||
|
|
||||||
|
## 📦 Model Features
|
||||||
|
|
||||||
|
Each model includes:
|
||||||
|
- ✅ `@HiveType` annotation with unique Type ID
|
||||||
|
- ✅ `@HiveField` annotations for all fields
|
||||||
|
- ✅ `fromJson()` factory constructor for API deserialization
|
||||||
|
- ✅ `toJson()` method for API serialization
|
||||||
|
- ✅ Helper methods for JSONB fields (get as Map/List)
|
||||||
|
- ✅ Computed properties and validation
|
||||||
|
- ✅ `copyWith()` method for immutability
|
||||||
|
- ✅ `toString()` override
|
||||||
|
- ✅ Equality operators (`==` and `hashCode`)
|
||||||
|
- ✅ Comprehensive documentation
|
||||||
|
|
||||||
|
## 🚀 Next Steps
|
||||||
|
|
||||||
|
### 1. Generate Type Adapters
|
||||||
|
|
||||||
|
Run the Hive code generator to create `.g.dart` files:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/ssg/project/worker
|
||||||
|
dart run build_runner build --delete-conflicting-outputs
|
||||||
|
```
|
||||||
|
|
||||||
|
This will generate adapter files for all models and enums.
|
||||||
|
|
||||||
|
### 2. Register Adapters
|
||||||
|
|
||||||
|
Create or update Hive initialization file (e.g., `lib/core/database/hive_service.dart`):
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:hive_ce/hive.dart';
|
||||||
|
import 'package:hive_flutter/hive_flutter.dart';
|
||||||
|
|
||||||
|
// Import all generated adapters
|
||||||
|
import 'package:worker/core/database/models/enums.dart';
|
||||||
|
import 'package:worker/features/auth/data/models/user_model.dart';
|
||||||
|
import 'package:worker/features/auth/data/models/user_session_model.dart';
|
||||||
|
// ... import all other models
|
||||||
|
|
||||||
|
class HiveService {
|
||||||
|
static Future<void> init() async {
|
||||||
|
await Hive.initFlutter();
|
||||||
|
|
||||||
|
// Register all enum adapters
|
||||||
|
Hive.registerAdapter(UserRoleAdapter());
|
||||||
|
Hive.registerAdapter(UserStatusAdapter());
|
||||||
|
Hive.registerAdapter(LoyaltyTierAdapter());
|
||||||
|
Hive.registerAdapter(OrderStatusAdapter());
|
||||||
|
Hive.registerAdapter(InvoiceTypeAdapter());
|
||||||
|
Hive.registerAdapter(InvoiceStatusAdapter());
|
||||||
|
Hive.registerAdapter(PaymentMethodAdapter());
|
||||||
|
Hive.registerAdapter(PaymentStatusAdapter());
|
||||||
|
Hive.registerAdapter(EntryTypeAdapter());
|
||||||
|
Hive.registerAdapter(EntrySourceAdapter());
|
||||||
|
Hive.registerAdapter(ComplaintStatusAdapter());
|
||||||
|
Hive.registerAdapter(GiftCategoryAdapter());
|
||||||
|
Hive.registerAdapter(GiftStatusAdapter());
|
||||||
|
Hive.registerAdapter(PointsStatusAdapter());
|
||||||
|
Hive.registerAdapter(ProjectTypeAdapter());
|
||||||
|
Hive.registerAdapter(SubmissionStatusAdapter());
|
||||||
|
Hive.registerAdapter(DesignStatusAdapter());
|
||||||
|
Hive.registerAdapter(QuoteStatusAdapter());
|
||||||
|
Hive.registerAdapter(RoomTypeAdapter());
|
||||||
|
Hive.registerAdapter(ContentTypeAdapter());
|
||||||
|
Hive.registerAdapter(ReminderTypeAdapter());
|
||||||
|
|
||||||
|
// Register all model adapters
|
||||||
|
Hive.registerAdapter(UserModelAdapter());
|
||||||
|
Hive.registerAdapter(UserSessionModelAdapter());
|
||||||
|
Hive.registerAdapter(ProductModelAdapter());
|
||||||
|
Hive.registerAdapter(StockLevelModelAdapter());
|
||||||
|
Hive.registerAdapter(CartModelAdapter());
|
||||||
|
Hive.registerAdapter(CartItemModelAdapter());
|
||||||
|
Hive.registerAdapter(OrderModelAdapter());
|
||||||
|
Hive.registerAdapter(OrderItemModelAdapter());
|
||||||
|
Hive.registerAdapter(InvoiceModelAdapter());
|
||||||
|
Hive.registerAdapter(PaymentLineModelAdapter());
|
||||||
|
Hive.registerAdapter(LoyaltyPointEntryModelAdapter());
|
||||||
|
Hive.registerAdapter(GiftCatalogModelAdapter());
|
||||||
|
Hive.registerAdapter(RedeemedGiftModelAdapter());
|
||||||
|
Hive.registerAdapter(PointsRecordModelAdapter());
|
||||||
|
Hive.registerAdapter(ProjectSubmissionModelAdapter());
|
||||||
|
Hive.registerAdapter(DesignRequestModelAdapter());
|
||||||
|
Hive.registerAdapter(QuoteModelAdapter());
|
||||||
|
Hive.registerAdapter(QuoteItemModelAdapter());
|
||||||
|
Hive.registerAdapter(ChatRoomModelAdapter());
|
||||||
|
Hive.registerAdapter(MessageModelAdapter());
|
||||||
|
Hive.registerAdapter(NotificationModelAdapter());
|
||||||
|
Hive.registerAdapter(ShowroomModelAdapter());
|
||||||
|
Hive.registerAdapter(ShowroomProductModelAdapter());
|
||||||
|
Hive.registerAdapter(PaymentReminderModelAdapter());
|
||||||
|
Hive.registerAdapter(AuditLogModelAdapter());
|
||||||
|
|
||||||
|
// Open boxes
|
||||||
|
await Hive.openBox(HiveBoxNames.userBox);
|
||||||
|
await Hive.openBox(HiveBoxNames.productBox);
|
||||||
|
await Hive.openBox(HiveBoxNames.cartBox);
|
||||||
|
await Hive.openBox(HiveBoxNames.orderBox);
|
||||||
|
await Hive.openBox(HiveBoxNames.loyaltyBox);
|
||||||
|
await Hive.openBox(HiveBoxNames.projectBox);
|
||||||
|
await Hive.openBox(HiveBoxNames.notificationBox);
|
||||||
|
// ... open all other boxes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Create Datasources
|
||||||
|
|
||||||
|
Implement local datasources using these models:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// Example: lib/features/auth/data/datasources/auth_local_datasource.dart
|
||||||
|
class AuthLocalDataSource {
|
||||||
|
final Box userBox;
|
||||||
|
|
||||||
|
Future<void> cacheUser(UserModel user) async {
|
||||||
|
await userBox.put(HiveKeys.currentUser, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
UserModel? getCachedUser() {
|
||||||
|
return userBox.get(HiveKeys.currentUser) as UserModel?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Update Repository Implementations
|
||||||
|
|
||||||
|
Use models in repository implementations for caching:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class ProductRepositoryImpl implements ProductRepository {
|
||||||
|
final ProductRemoteDataSource remoteDataSource;
|
||||||
|
final ProductLocalDataSource localDataSource;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<Product>> getProducts() async {
|
||||||
|
try {
|
||||||
|
// Try to fetch from API
|
||||||
|
final products = await remoteDataSource.getProducts();
|
||||||
|
|
||||||
|
// Cache locally
|
||||||
|
await localDataSource.cacheProducts(products);
|
||||||
|
|
||||||
|
return products;
|
||||||
|
} catch (e) {
|
||||||
|
// Return cached data on error
|
||||||
|
return localDataSource.getCachedProducts();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📁 File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
lib/features/
|
||||||
|
├── auth/data/models/
|
||||||
|
│ ├── user_model.dart ✅
|
||||||
|
│ ├── user_model.g.dart (generated)
|
||||||
|
│ ├── user_session_model.dart ✅
|
||||||
|
│ └── user_session_model.g.dart (generated)
|
||||||
|
├── products/data/models/
|
||||||
|
│ ├── product_model.dart ✅
|
||||||
|
│ ├── product_model.g.dart (generated)
|
||||||
|
│ ├── stock_level_model.dart ✅
|
||||||
|
│ └── stock_level_model.g.dart (generated)
|
||||||
|
├── cart/data/models/
|
||||||
|
│ ├── cart_model.dart ✅
|
||||||
|
│ ├── cart_model.g.dart (generated)
|
||||||
|
│ ├── cart_item_model.dart ✅
|
||||||
|
│ └── cart_item_model.g.dart (generated)
|
||||||
|
├── orders/data/models/
|
||||||
|
│ ├── order_model.dart ✅
|
||||||
|
│ ├── order_model.g.dart (generated)
|
||||||
|
│ ├── order_item_model.dart ✅
|
||||||
|
│ ├── order_item_model.g.dart (generated)
|
||||||
|
│ ├── invoice_model.dart ✅
|
||||||
|
│ ├── invoice_model.g.dart (generated)
|
||||||
|
│ ├── payment_line_model.dart ✅
|
||||||
|
│ └── payment_line_model.g.dart (generated)
|
||||||
|
├── loyalty/data/models/
|
||||||
|
│ ├── loyalty_point_entry_model.dart ✅
|
||||||
|
│ ├── loyalty_point_entry_model.g.dart (generated)
|
||||||
|
│ ├── gift_catalog_model.dart ✅
|
||||||
|
│ ├── gift_catalog_model.g.dart (generated)
|
||||||
|
│ ├── redeemed_gift_model.dart ✅
|
||||||
|
│ ├── redeemed_gift_model.g.dart (generated)
|
||||||
|
│ ├── points_record_model.dart ✅
|
||||||
|
│ └── points_record_model.g.dart (generated)
|
||||||
|
├── projects/data/models/
|
||||||
|
│ ├── project_submission_model.dart ✅
|
||||||
|
│ ├── project_submission_model.g.dart (generated)
|
||||||
|
│ ├── design_request_model.dart ✅
|
||||||
|
│ └── design_request_model.g.dart (generated)
|
||||||
|
├── quotes/data/models/
|
||||||
|
│ ├── quote_model.dart ✅
|
||||||
|
│ ├── quote_model.g.dart (generated)
|
||||||
|
│ ├── quote_item_model.dart ✅
|
||||||
|
│ └── quote_item_model.g.dart (generated)
|
||||||
|
├── chat/data/models/
|
||||||
|
│ ├── chat_room_model.dart ✅
|
||||||
|
│ ├── chat_room_model.g.dart (generated)
|
||||||
|
│ ├── message_model.dart ✅
|
||||||
|
│ └── message_model.g.dart (generated)
|
||||||
|
├── notifications/data/models/
|
||||||
|
│ ├── notification_model.dart ✅
|
||||||
|
│ └── notification_model.g.dart (generated)
|
||||||
|
├── showrooms/data/models/
|
||||||
|
│ ├── showroom_model.dart ✅
|
||||||
|
│ ├── showroom_model.g.dart (generated)
|
||||||
|
│ ├── showroom_product_model.dart ✅
|
||||||
|
│ └── showroom_product_model.g.dart (generated)
|
||||||
|
└── account/data/models/
|
||||||
|
├── payment_reminder_model.dart ✅
|
||||||
|
├── payment_reminder_model.g.dart (generated)
|
||||||
|
├── audit_log_model.dart ✅
|
||||||
|
└── audit_log_model.g.dart (generated)
|
||||||
|
```
|
||||||
|
|
||||||
|
## ⚠️ Important Notes
|
||||||
|
|
||||||
|
### Type ID Management
|
||||||
|
- All Type IDs are unique across the app (0-24 for models, 30-50 for enums)
|
||||||
|
- Never change a Type ID once assigned - it will break existing cached data
|
||||||
|
- Type IDs are centrally managed in `/Users/ssg/project/worker/lib/core/constants/storage_constants.dart`
|
||||||
|
|
||||||
|
### JSONB Field Handling
|
||||||
|
- All JSONB fields from database are stored as JSON-encoded strings in Hive
|
||||||
|
- Helper methods provide easy access to parsed data (e.g., `companyInfoMap`, `participantsList`)
|
||||||
|
- Always use try-catch when decoding JSON fields
|
||||||
|
|
||||||
|
### DateTime Support
|
||||||
|
- Hive CE natively supports DateTime
|
||||||
|
- No need for custom serialization
|
||||||
|
- Use `DateTime.parse()` for JSON and `toIso8601String()` for API
|
||||||
|
|
||||||
|
### Best Practices
|
||||||
|
- Always extend `HiveObject` for automatic key management
|
||||||
|
- Use sequential field numbering (0, 1, 2, ...)
|
||||||
|
- Include comprehensive documentation
|
||||||
|
- Implement helper methods for computed properties
|
||||||
|
- Handle null values appropriately
|
||||||
|
|
||||||
|
## 🎉 Summary
|
||||||
|
|
||||||
|
- ✅ **25 data models** created
|
||||||
|
- ✅ **21 enum types** defined
|
||||||
|
- ✅ **All database tables** mapped
|
||||||
|
- ✅ Complete **JSON serialization/deserialization**
|
||||||
|
- ✅ Comprehensive **helper methods**
|
||||||
|
- ✅ Full **documentation**
|
||||||
|
- ✅ **Type-safe** implementations
|
||||||
|
- ✅ **Clean architecture** compliant
|
||||||
|
|
||||||
|
The Worker app now has a complete, production-ready Hive CE local database implementation for offline-first functionality and API response caching!
|
||||||
|
|
||||||
|
## 📚 Reference Documents
|
||||||
|
|
||||||
|
- `HIVE_MODELS_REFERENCE.md` - Detailed reference and templates
|
||||||
|
- `/Users/ssg/project/worker/database.md` - Original database schema
|
||||||
|
- `/Users/ssg/project/worker/lib/core/constants/storage_constants.dart` - Type IDs and constants
|
||||||
|
- `/Users/ssg/project/worker/lib/core/database/models/enums.dart` - All enum definitions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Generated**: 2025-10-24
|
||||||
|
**Status**: ✅ Complete and ready for code generation
|
||||||
423
HIVE_MODELS_REFERENCE.md
Normal file
423
HIVE_MODELS_REFERENCE.md
Normal file
@@ -0,0 +1,423 @@
|
|||||||
|
# Hive CE Data Models Reference
|
||||||
|
|
||||||
|
This document provides a complete reference for all Hive CE data models in the Worker app, mapped to the database schema.
|
||||||
|
|
||||||
|
## Summary of Created Models
|
||||||
|
|
||||||
|
### ✅ Completed Models
|
||||||
|
|
||||||
|
1. **UserModel** (`lib/features/auth/data/models/user_model.dart`) - Type ID: 0
|
||||||
|
2. **UserSessionModel** (`lib/features/auth/data/models/user_session_model.dart`) - Type ID: 1
|
||||||
|
3. **ProductModel** (`lib/features/products/data/models/product_model.dart`) - Type ID: 2
|
||||||
|
4. **StockLevelModel** (`lib/features/products/data/models/stock_level_model.dart`) - Type ID: 3
|
||||||
|
|
||||||
|
### 📋 Models To Be Created
|
||||||
|
|
||||||
|
The following model files need to be created following the same pattern:
|
||||||
|
|
||||||
|
#### Cart Models
|
||||||
|
- `lib/features/cart/data/models/cart_model.dart` - Type ID: 4
|
||||||
|
- `lib/features/cart/data/models/cart_item_model.dart` - Type ID: 5
|
||||||
|
|
||||||
|
#### Order Models
|
||||||
|
- `lib/features/orders/data/models/order_model.dart` - Type ID: 6
|
||||||
|
- `lib/features/orders/data/models/order_item_model.dart` - Type ID: 7
|
||||||
|
- `lib/features/orders/data/models/invoice_model.dart` - Type ID: 8
|
||||||
|
- `lib/features/orders/data/models/payment_line_model.dart` - Type ID: 9
|
||||||
|
|
||||||
|
#### Loyalty Models
|
||||||
|
- `lib/features/loyalty/data/models/loyalty_point_entry_model.dart` - Type ID: 10
|
||||||
|
- `lib/features/loyalty/data/models/gift_catalog_model.dart` - Type ID: 11
|
||||||
|
- `lib/features/loyalty/data/models/redeemed_gift_model.dart` - Type ID: 12
|
||||||
|
- `lib/features/loyalty/data/models/points_record_model.dart` - Type ID: 13
|
||||||
|
|
||||||
|
#### Project Models
|
||||||
|
- `lib/features/projects/data/models/project_submission_model.dart` - Type ID: 14
|
||||||
|
- `lib/features/projects/data/models/design_request_model.dart` - Type ID: 15
|
||||||
|
|
||||||
|
#### Quote Models
|
||||||
|
- `lib/features/quotes/data/models/quote_model.dart` - Type ID: 16
|
||||||
|
- `lib/features/quotes/data/models/quote_item_model.dart` - Type ID: 17
|
||||||
|
|
||||||
|
#### Chat Models
|
||||||
|
- `lib/features/chat/data/models/chat_room_model.dart` - Type ID: 18
|
||||||
|
- `lib/features/chat/data/models/message_model.dart` - Type ID: 19
|
||||||
|
|
||||||
|
#### Other Models
|
||||||
|
- `lib/features/notifications/data/models/notification_model.dart` - Type ID: 20
|
||||||
|
- `lib/features/showrooms/data/models/showroom_model.dart` - Type ID: 21
|
||||||
|
- `lib/features/showrooms/data/models/showroom_product_model.dart` - Type ID: 22
|
||||||
|
- `lib/features/account/data/models/payment_reminder_model.dart` - Type ID: 23
|
||||||
|
- `lib/features/account/data/models/audit_log_model.dart` - Type ID: 24
|
||||||
|
|
||||||
|
## Model Template
|
||||||
|
|
||||||
|
Each Hive model should follow this structure:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:hive_ce/hive.dart';
|
||||||
|
import 'package:worker/core/constants/storage_constants.dart';
|
||||||
|
import 'package:worker/core/database/models/enums.dart'; // If using enums
|
||||||
|
|
||||||
|
part 'model_name.g.dart';
|
||||||
|
|
||||||
|
/// Model Name
|
||||||
|
///
|
||||||
|
/// Hive CE model for caching [entity] data locally.
|
||||||
|
/// Maps to the '[table_name]' table in the database.
|
||||||
|
///
|
||||||
|
/// Type ID: [X]
|
||||||
|
@HiveType(typeId: HiveTypeIds.modelName)
|
||||||
|
class ModelName extends HiveObject {
|
||||||
|
ModelName({
|
||||||
|
required this.id,
|
||||||
|
required this.field1,
|
||||||
|
this.field2,
|
||||||
|
// ... other fields
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Primary key
|
||||||
|
@HiveField(0)
|
||||||
|
final String id;
|
||||||
|
|
||||||
|
/// Field description
|
||||||
|
@HiveField(1)
|
||||||
|
final String field1;
|
||||||
|
|
||||||
|
/// Optional field description
|
||||||
|
@HiveField(2)
|
||||||
|
final String? field2;
|
||||||
|
|
||||||
|
// Add @HiveField for each database column with sequential numbering
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// JSON SERIALIZATION
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
factory ModelName.fromJson(Map<String, dynamic> json) {
|
||||||
|
return ModelName(
|
||||||
|
id: json['id'] as String,
|
||||||
|
field1: json['field_1'] as String,
|
||||||
|
field2: json['field_2'] as String?,
|
||||||
|
// Map all fields from JSON (snake_case keys)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'field_1': field1,
|
||||||
|
'field_2': field2,
|
||||||
|
// Map all fields to JSON (snake_case keys)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// HELPER METHODS
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
// Add helper methods for:
|
||||||
|
// - Parsing JSONB fields (use jsonEncode/jsonDecode)
|
||||||
|
// - Computed properties
|
||||||
|
// - Validation checks
|
||||||
|
// - Formatting methods
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// COPY WITH
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
ModelName copyWith({
|
||||||
|
String? id,
|
||||||
|
String? field1,
|
||||||
|
String? field2,
|
||||||
|
}) {
|
||||||
|
return ModelName(
|
||||||
|
id: id ?? this.id,
|
||||||
|
field1: field1 ?? this.field1,
|
||||||
|
field2: field2 ?? this.field2,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'ModelName(id: $id, field1: $field1)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (identical(this, other)) return true;
|
||||||
|
return other is ModelName && other.id == id;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => id.hashCode;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Important Notes
|
||||||
|
|
||||||
|
### JSONB Field Handling
|
||||||
|
|
||||||
|
For JSONB fields in the database, store as String in Hive using `jsonEncode`:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// In model:
|
||||||
|
@HiveField(X)
|
||||||
|
final String? jsonbField;
|
||||||
|
|
||||||
|
// In fromJson:
|
||||||
|
jsonbField: json['jsonb_field'] != null
|
||||||
|
? jsonEncode(json['jsonb_field'])
|
||||||
|
: null,
|
||||||
|
|
||||||
|
// In toJson:
|
||||||
|
'jsonb_field': jsonbField != null ? jsonDecode(jsonbField!) : null,
|
||||||
|
|
||||||
|
// Helper method:
|
||||||
|
Map<String, dynamic>? get jsonbFieldMap {
|
||||||
|
if (jsonbField == null) return null;
|
||||||
|
try {
|
||||||
|
return jsonDecode(jsonbField!) as Map<String, dynamic>;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Enum Handling
|
||||||
|
|
||||||
|
Use the enums defined in `lib/core/database/models/enums.dart`:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// In model:
|
||||||
|
@HiveField(X)
|
||||||
|
final OrderStatus status;
|
||||||
|
|
||||||
|
// In fromJson:
|
||||||
|
status: OrderStatus.values.firstWhere(
|
||||||
|
(e) => e.name == (json['status'] as String),
|
||||||
|
orElse: () => OrderStatus.pending,
|
||||||
|
),
|
||||||
|
|
||||||
|
// In toJson:
|
||||||
|
'status': status.name,
|
||||||
|
```
|
||||||
|
|
||||||
|
### DateTime Handling
|
||||||
|
|
||||||
|
Hive CE supports DateTime natively:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// In model:
|
||||||
|
@HiveField(X)
|
||||||
|
final DateTime createdAt;
|
||||||
|
|
||||||
|
@HiveField(Y)
|
||||||
|
final DateTime? updatedAt;
|
||||||
|
|
||||||
|
// In fromJson:
|
||||||
|
createdAt: DateTime.parse(json['created_at'] as String),
|
||||||
|
updatedAt: json['updated_at'] != null
|
||||||
|
? DateTime.parse(json['updated_at'] as String)
|
||||||
|
: null,
|
||||||
|
|
||||||
|
// In toJson:
|
||||||
|
'created_at': createdAt.toIso8601String(),
|
||||||
|
'updated_at': updatedAt?.toIso8601String(),
|
||||||
|
```
|
||||||
|
|
||||||
|
### Numeric Fields
|
||||||
|
|
||||||
|
Handle numeric precision:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// For numeric(12,2) fields:
|
||||||
|
@HiveField(X)
|
||||||
|
final double amount;
|
||||||
|
|
||||||
|
// In fromJson:
|
||||||
|
amount: (json['amount'] as num).toDouble(),
|
||||||
|
```
|
||||||
|
|
||||||
|
### Array Fields (Lists)
|
||||||
|
|
||||||
|
For JSONB arrays, store as JSON string:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// In model:
|
||||||
|
@HiveField(X)
|
||||||
|
final String? participants; // JSONB array
|
||||||
|
|
||||||
|
// Helper:
|
||||||
|
List<String>? get participantsList {
|
||||||
|
if (participants == null) return null;
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(participants!) as List;
|
||||||
|
return decoded.map((e) => e.toString()).toList();
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database Table to Model Mapping
|
||||||
|
|
||||||
|
| Table Name | Model Name | Type ID | Feature | Status |
|
||||||
|
|------------|------------|---------|---------|--------|
|
||||||
|
| users | UserModel | 0 | auth | ✅ Created |
|
||||||
|
| user_sessions | UserSessionModel | 1 | auth | ✅ Created |
|
||||||
|
| products | ProductModel | 2 | products | ✅ Created |
|
||||||
|
| stock_levels | StockLevelModel | 3 | products | ✅ Created |
|
||||||
|
| carts | CartModel | 4 | cart | 📋 To Do |
|
||||||
|
| cart_items | CartItemModel | 5 | cart | 📋 To Do |
|
||||||
|
| orders | OrderModel | 6 | orders | 📋 To Do |
|
||||||
|
| order_items | OrderItemModel | 7 | orders | 📋 To Do |
|
||||||
|
| invoices | InvoiceModel | 8 | orders | 📋 To Do |
|
||||||
|
| payment_lines | PaymentLineModel | 9 | orders | 📋 To Do |
|
||||||
|
| loyalty_point_entries | LoyaltyPointEntryModel | 10 | loyalty | 📋 To Do |
|
||||||
|
| gift_catalog | GiftCatalogModel | 11 | loyalty | 📋 To Do |
|
||||||
|
| redeemed_gifts | RedeemedGiftModel | 12 | loyalty | 📋 To Do |
|
||||||
|
| points_records | PointsRecordModel | 13 | loyalty | 📋 To Do |
|
||||||
|
| project_submissions | ProjectSubmissionModel | 14 | projects | 📋 To Do |
|
||||||
|
| design_requests | DesignRequestModel | 15 | projects | 📋 To Do |
|
||||||
|
| quotes | QuoteModel | 16 | quotes | 📋 To Do |
|
||||||
|
| quote_items | QuoteItemModel | 17 | quotes | 📋 To Do |
|
||||||
|
| chat_rooms | ChatRoomModel | 18 | chat | 📋 To Do |
|
||||||
|
| chat_messages | MessageModel | 19 | chat | 📋 To Do |
|
||||||
|
| notifications | NotificationModel | 20 | notifications | 📋 To Do |
|
||||||
|
| showrooms | ShowroomModel | 21 | showrooms | 📋 To Do |
|
||||||
|
| showroom_products | ShowroomProductModel | 22 | showrooms | 📋 To Do |
|
||||||
|
| payment_reminders | PaymentReminderModel | 23 | account | 📋 To Do |
|
||||||
|
| audit_logs | AuditLogModel | 24 | account | 📋 To Do |
|
||||||
|
|
||||||
|
## Enum Type IDs (30-59)
|
||||||
|
|
||||||
|
All enum types are defined in `lib/core/database/models/enums.dart`:
|
||||||
|
|
||||||
|
| Enum Name | Type ID | Values |
|
||||||
|
|-----------|---------|--------|
|
||||||
|
| UserRole | 30 | customer, distributor, admin, staff |
|
||||||
|
| UserStatus | 31 | active, inactive, suspended, pending |
|
||||||
|
| LoyaltyTier | 32 | bronze, silver, gold, platinum, diamond, titan |
|
||||||
|
| OrderStatus | 33 | draft, pending, confirmed, processing, shipped, delivered, completed, cancelled, refunded |
|
||||||
|
| InvoiceType | 34 | sales, proforma, creditNote, debitNote |
|
||||||
|
| InvoiceStatus | 35 | draft, issued, partiallyPaid, paid, overdue, cancelled, refunded |
|
||||||
|
| PaymentMethod | 36 | cash, bankTransfer, creditCard, debitCard, eWallet, cheque, creditTerm |
|
||||||
|
| PaymentStatus | 37 | pending, processing, completed, failed, refunded, cancelled |
|
||||||
|
| EntryType | 38 | earn, redeem, adjustment, expiry, refund |
|
||||||
|
| EntrySource | 39 | purchase, referral, promotion, bonus, giftRedemption, projectSubmission, pointsRecord, manualAdjustment |
|
||||||
|
| ComplaintStatus | 40 | none, pending, investigating, resolved, rejected |
|
||||||
|
| GiftCategory | 41 | voucher, product, service, discount, experience |
|
||||||
|
| GiftStatus | 42 | active, used, expired, cancelled |
|
||||||
|
| PointsStatus | 43 | pending, approved, rejected |
|
||||||
|
| ProjectType | 44 | residential, commercial, industrial, infrastructure, renovation, interior, exterior |
|
||||||
|
| SubmissionStatus | 45 | pending, reviewing, approved, rejected, needsRevision |
|
||||||
|
| DesignStatus | 46 | pending, assigned, inProgress, reviewing, completed, cancelled, onHold |
|
||||||
|
| QuoteStatus | 47 | draft, sent, viewed, accepted, rejected, expired, converted, cancelled |
|
||||||
|
| RoomType | 48 | support, sales, orderInquiry, quoteDiscussion, general |
|
||||||
|
| ContentType | 49 | text, image, file, video, audio, productReference, orderReference, quoteReference |
|
||||||
|
| ReminderType | 50 | beforeDue, dueDate, overdue, final |
|
||||||
|
|
||||||
|
## Code Generation
|
||||||
|
|
||||||
|
After creating all models, run the Hive code generator:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate adapter files for all models
|
||||||
|
dart run build_runner build --delete-conflicting-outputs
|
||||||
|
|
||||||
|
# OR watch for changes
|
||||||
|
dart run build_runner watch --delete-conflicting-outputs
|
||||||
|
```
|
||||||
|
|
||||||
|
This will generate `.g.dart` files for all models with the `@HiveType` annotation.
|
||||||
|
|
||||||
|
## Hive Box Registration
|
||||||
|
|
||||||
|
Register all type adapters in the main initialization:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// In lib/core/database/hive_service.dart or similar
|
||||||
|
|
||||||
|
Future<void> initializeHive() async {
|
||||||
|
await Hive.initFlutter();
|
||||||
|
|
||||||
|
// Register enum adapters
|
||||||
|
Hive.registerAdapter(UserRoleAdapter());
|
||||||
|
Hive.registerAdapter(UserStatusAdapter());
|
||||||
|
Hive.registerAdapter(LoyaltyTierAdapter());
|
||||||
|
Hive.registerAdapter(OrderStatusAdapter());
|
||||||
|
// ... register all enum adapters
|
||||||
|
|
||||||
|
// Register model adapters
|
||||||
|
Hive.registerAdapter(UserModelAdapter());
|
||||||
|
Hive.registerAdapter(UserSessionModelAdapter());
|
||||||
|
Hive.registerAdapter(ProductModelAdapter());
|
||||||
|
Hive.registerAdapter(StockLevelModelAdapter());
|
||||||
|
// ... register all model adapters
|
||||||
|
|
||||||
|
// Open boxes
|
||||||
|
await Hive.openBox(HiveBoxNames.userBox);
|
||||||
|
await Hive.openBox(HiveBoxNames.productBox);
|
||||||
|
await Hive.openBox(HiveBoxNames.cartBox);
|
||||||
|
// ... open all boxes
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. Create all remaining model files following the template above
|
||||||
|
2. Ensure all typeIds are unique and match HiveTypeIds constants
|
||||||
|
3. Run code generation: `dart run build_runner build --delete-conflicting-outputs`
|
||||||
|
4. Register all adapters in Hive initialization
|
||||||
|
5. Test model serialization/deserialization
|
||||||
|
6. Create datasource implementations to use these models
|
||||||
|
|
||||||
|
## File Paths Reference
|
||||||
|
|
||||||
|
```
|
||||||
|
lib/features/
|
||||||
|
├── auth/data/models/
|
||||||
|
│ ├── user_model.dart ✅
|
||||||
|
│ └── user_session_model.dart ✅
|
||||||
|
├── products/data/models/
|
||||||
|
│ ├── product_model.dart ✅
|
||||||
|
│ └── stock_level_model.dart ✅
|
||||||
|
├── cart/data/models/
|
||||||
|
│ ├── cart_model.dart 📋
|
||||||
|
│ └── cart_item_model.dart 📋
|
||||||
|
├── orders/data/models/
|
||||||
|
│ ├── order_model.dart 📋
|
||||||
|
│ ├── order_item_model.dart 📋
|
||||||
|
│ ├── invoice_model.dart 📋
|
||||||
|
│ └── payment_line_model.dart 📋
|
||||||
|
├── loyalty/data/models/
|
||||||
|
│ ├── loyalty_point_entry_model.dart 📋
|
||||||
|
│ ├── gift_catalog_model.dart 📋
|
||||||
|
│ ├── redeemed_gift_model.dart 📋
|
||||||
|
│ └── points_record_model.dart 📋
|
||||||
|
├── projects/data/models/
|
||||||
|
│ ├── project_submission_model.dart 📋
|
||||||
|
│ └── design_request_model.dart 📋
|
||||||
|
├── quotes/data/models/
|
||||||
|
│ ├── quote_model.dart 📋
|
||||||
|
│ └── quote_item_model.dart 📋
|
||||||
|
├── chat/data/models/
|
||||||
|
│ ├── chat_room_model.dart 📋
|
||||||
|
│ └── message_model.dart 📋
|
||||||
|
├── notifications/data/models/
|
||||||
|
│ └── notification_model.dart 📋
|
||||||
|
├── showrooms/data/models/
|
||||||
|
│ ├── showroom_model.dart 📋
|
||||||
|
│ └── showroom_product_model.dart 📋
|
||||||
|
└── account/data/models/
|
||||||
|
├── payment_reminder_model.dart 📋
|
||||||
|
└── audit_log_model.dart 📋
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Legend:**
|
||||||
|
- ✅ Created and complete
|
||||||
|
- 📋 To be created following the template
|
||||||
159
HIVE_TYPEID_SUMMARY.md
Normal file
159
HIVE_TYPEID_SUMMARY.md
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
# Hive TypeId Assignment Summary
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
This document provides a complete mapping of all Hive TypeIds used in the Worker Flutter app.
|
||||||
|
All typeIds are now centralized in `lib/core/constants/storage_constants.dart` to prevent conflicts.
|
||||||
|
|
||||||
|
## TypeId Assignments
|
||||||
|
|
||||||
|
### Core Models (0-9)
|
||||||
|
|
||||||
|
| ID | Constant Name | Model | File Path |
|
||||||
|
|----|---------------|-------|-----------|
|
||||||
|
| 0 | userModel | UserModel | lib/features/auth/data/models/user_model.dart |
|
||||||
|
| 1 | userSessionModel | UserSessionModel | lib/features/auth/data/models/user_session_model.dart |
|
||||||
|
| 2 | productModel | ProductModel | lib/features/products/data/models/product_model.dart |
|
||||||
|
| 3 | stockLevelModel | StockLevelModel | lib/features/products/data/models/stock_level_model.dart |
|
||||||
|
| 4 | cartModel | CartModel | lib/features/cart/data/models/cart_model.dart |
|
||||||
|
| 5 | cartItemModel | CartItemModel | lib/features/cart/data/models/cart_item_model.dart |
|
||||||
|
| 6 | orderModel | OrderModel | lib/features/orders/data/models/order_model.dart |
|
||||||
|
| 7 | orderItemModel | OrderItemModel | lib/features/orders/data/models/order_item_model.dart |
|
||||||
|
| 8 | invoiceModel | InvoiceModel | lib/features/orders/data/models/invoice_model.dart |
|
||||||
|
| 9 | paymentLineModel | PaymentLineModel | lib/features/orders/data/models/payment_line_model.dart |
|
||||||
|
|
||||||
|
### Loyalty Models (10-13)
|
||||||
|
|
||||||
|
| ID | Constant Name | Model | File Path |
|
||||||
|
|----|---------------|-------|-----------|
|
||||||
|
| 10 | loyaltyPointEntryModel | LoyaltyPointEntryModel | lib/features/loyalty/data/models/loyalty_point_entry_model.dart |
|
||||||
|
| 11 | giftCatalogModel | GiftCatalogModel | lib/features/loyalty/data/models/gift_catalog_model.dart |
|
||||||
|
| 12 | redeemedGiftModel | RedeemedGiftModel | lib/features/loyalty/data/models/redeemed_gift_model.dart |
|
||||||
|
| 13 | pointsRecordModel | PointsRecordModel | lib/features/loyalty/data/models/points_record_model.dart |
|
||||||
|
|
||||||
|
### Project & Quote Models (14-17)
|
||||||
|
|
||||||
|
| ID | Constant Name | Model | File Path |
|
||||||
|
|----|---------------|-------|-----------|
|
||||||
|
| 14 | projectSubmissionModel | ProjectSubmissionModel | lib/features/projects/data/models/project_submission_model.dart |
|
||||||
|
| 15 | designRequestModel | DesignRequestModel | lib/features/projects/data/models/design_request_model.dart |
|
||||||
|
| 16 | quoteModel | QuoteModel | lib/features/quotes/data/models/quote_model.dart |
|
||||||
|
| 17 | quoteItemModel | QuoteItemModel | lib/features/quotes/data/models/quote_item_model.dart |
|
||||||
|
|
||||||
|
### Chat Models (18-19)
|
||||||
|
|
||||||
|
| ID | Constant Name | Model | File Path |
|
||||||
|
|----|---------------|-------|-----------|
|
||||||
|
| 18 | chatRoomModel | ChatRoomModel | lib/features/chat/data/models/chat_room_model.dart |
|
||||||
|
| 19 | messageModel | MessageModel | lib/features/chat/data/models/message_model.dart |
|
||||||
|
|
||||||
|
### Extended Models (20-29)
|
||||||
|
|
||||||
|
| ID | Constant Name | Model | File Path |
|
||||||
|
|----|---------------|-------|-----------|
|
||||||
|
| 20 | notificationModel | NotificationModel | lib/features/notifications/data/models/notification_model.dart |
|
||||||
|
| 21 | showroomModel | ShowroomModel | lib/features/showrooms/data/models/showroom_model.dart |
|
||||||
|
| 22 | showroomProductModel | ShowroomProductModel | lib/features/showrooms/data/models/showroom_product_model.dart |
|
||||||
|
| 23 | paymentReminderModel | PaymentReminderModel | lib/features/account/data/models/payment_reminder_model.dart |
|
||||||
|
| 24 | auditLogModel | AuditLogModel | lib/features/account/data/models/audit_log_model.dart |
|
||||||
|
| 25 | memberCardModel | MemberCardModel | lib/features/home/data/models/member_card_model.dart |
|
||||||
|
| 26 | promotionModel | PromotionModel | lib/features/home/data/models/promotion_model.dart |
|
||||||
|
| 27 | categoryModel | CategoryModel | lib/features/products/data/models/category_model.dart |
|
||||||
|
| 28 | *AVAILABLE* | - | - |
|
||||||
|
| 29 | *AVAILABLE* | - | - |
|
||||||
|
|
||||||
|
### Enum Types (30-59)
|
||||||
|
|
||||||
|
| ID | Constant Name | Enum | File Path |
|
||||||
|
|----|---------------|------|-----------|
|
||||||
|
| 30 | userRole | UserRole | lib/core/database/models/enums.dart |
|
||||||
|
| 31 | userStatus | UserStatus | lib/core/database/models/enums.dart |
|
||||||
|
| 32 | loyaltyTier | LoyaltyTier | lib/core/database/models/enums.dart |
|
||||||
|
| 33 | orderStatus | OrderStatus | lib/core/database/models/enums.dart |
|
||||||
|
| 34 | invoiceType | InvoiceType | lib/core/database/models/enums.dart |
|
||||||
|
| 35 | invoiceStatus | InvoiceStatus | lib/core/database/models/enums.dart |
|
||||||
|
| 36 | paymentMethod | PaymentMethod | lib/core/database/models/enums.dart |
|
||||||
|
| 37 | paymentStatus | PaymentStatus | lib/core/database/models/enums.dart |
|
||||||
|
| 38 | entryType | EntryType | lib/core/database/models/enums.dart |
|
||||||
|
| 39 | entrySource | EntrySource | lib/core/database/models/enums.dart |
|
||||||
|
| 40 | complaintStatus | ComplaintStatus | lib/core/database/models/enums.dart |
|
||||||
|
| 41 | giftCategory | GiftCategory | lib/core/database/models/enums.dart |
|
||||||
|
| 42 | giftStatus | GiftStatus | lib/core/database/models/enums.dart |
|
||||||
|
| 43 | pointsStatus | PointsStatus | lib/core/database/models/enums.dart |
|
||||||
|
| 44 | projectType | ProjectType | lib/core/database/models/enums.dart |
|
||||||
|
| 45 | submissionStatus | SubmissionStatus | lib/core/database/models/enums.dart |
|
||||||
|
| 46 | designStatus | DesignStatus | lib/core/database/models/enums.dart |
|
||||||
|
| 47 | quoteStatus | QuoteStatus | lib/core/database/models/enums.dart |
|
||||||
|
| 48 | roomType | RoomType | lib/core/database/models/enums.dart |
|
||||||
|
| 49 | contentType | ContentType | lib/core/database/models/enums.dart |
|
||||||
|
| 50 | reminderType | ReminderType | lib/core/database/models/enums.dart |
|
||||||
|
| 51 | notificationType | NotificationType | lib/core/database/models/enums.dart |
|
||||||
|
| 52-59 | *AVAILABLE* | - | - |
|
||||||
|
|
||||||
|
### Cache & Sync Models (60-69)
|
||||||
|
|
||||||
|
| ID | Constant Name | Model | File Path |
|
||||||
|
|----|---------------|-------|-----------|
|
||||||
|
| 60 | cachedData | CachedData | lib/core/database/models/cached_data.dart |
|
||||||
|
| 61 | syncState | SyncState | *Not yet implemented* |
|
||||||
|
| 62 | offlineRequest | OfflineRequest | *Not yet implemented* |
|
||||||
|
| 63-69 | *AVAILABLE* | - | - |
|
||||||
|
|
||||||
|
## Conflicts Resolved
|
||||||
|
|
||||||
|
The following typeIds had conflicts and were reassigned:
|
||||||
|
|
||||||
|
### Previous Conflicts (Now Fixed)
|
||||||
|
|
||||||
|
1. **TypeId 10** - Was hardcoded in MemberCardModel
|
||||||
|
- **CONFLICT**: loyaltyPointEntryModel already used 10
|
||||||
|
- **RESOLUTION**: MemberCardModel moved to typeId 25
|
||||||
|
|
||||||
|
2. **TypeId 11** - Was hardcoded in PromotionModel
|
||||||
|
- **CONFLICT**: giftCatalogModel already used 11
|
||||||
|
- **RESOLUTION**: PromotionModel moved to typeId 26
|
||||||
|
|
||||||
|
3. **TypeId 12** - Was hardcoded in CategoryModel
|
||||||
|
- **CONFLICT**: redeemedGiftModel already used 12
|
||||||
|
- **RESOLUTION**: CategoryModel moved to typeId 27
|
||||||
|
|
||||||
|
## Important Notes
|
||||||
|
|
||||||
|
1. **Never Change TypeIds**: Once a typeId is assigned and used in production, it should NEVER be changed as it will break existing data.
|
||||||
|
|
||||||
|
2. **Always Use Constants**: All models must use `HiveTypeIds` constants from `storage_constants.dart`. Never use hardcoded numbers.
|
||||||
|
|
||||||
|
3. **TypeId Range**: User-defined types must use typeIds in the range 0-223.
|
||||||
|
|
||||||
|
4. **Check Before Adding**: Always check this document and `storage_constants.dart` before adding a new typeId to avoid conflicts.
|
||||||
|
|
||||||
|
5. **Aliases**: Some typeIds have aliases for backward compatibility:
|
||||||
|
- `memberTier` → `loyaltyTier` (32)
|
||||||
|
- `userType` → `userRole` (30)
|
||||||
|
- `projectStatus` → `submissionStatus` (45)
|
||||||
|
- `transactionType` → `entryType` (38)
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
After updating typeIds, you must:
|
||||||
|
|
||||||
|
1. ✅ Update storage_constants.dart (COMPLETED)
|
||||||
|
2. ✅ Update model files to use constants (COMPLETED)
|
||||||
|
3. ⏳ Regenerate Hive adapters: `flutter pub run build_runner build --delete-conflicting-outputs`
|
||||||
|
4. ⏳ Test the app to ensure no runtime errors
|
||||||
|
5. ⏳ Clear existing Hive boxes if necessary (only for development)
|
||||||
|
|
||||||
|
## Verification Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Search for any remaining hardcoded typeIds
|
||||||
|
grep -r "@HiveType(typeId: [0-9]" lib/
|
||||||
|
|
||||||
|
# Verify all models use constants
|
||||||
|
grep -r "@HiveType(typeId: HiveTypeIds" lib/
|
||||||
|
|
||||||
|
# Check for duplicates in storage_constants.dart
|
||||||
|
grep "static const int" lib/core/constants/storage_constants.dart | awk '{print $5}' | sort | uniq -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Last Updated
|
||||||
|
2025-10-24 - Fixed typeId conflicts for MemberCardModel, PromotionModel, and CategoryModel
|
||||||
76
MODELS_FILE_LIST.txt
Normal file
76
MODELS_FILE_LIST.txt
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
HIVE CE DATA MODELS - ABSOLUTE FILE PATHS
|
||||||
|
==========================================
|
||||||
|
|
||||||
|
ENUMS:
|
||||||
|
------
|
||||||
|
/Users/ssg/project/worker/lib/core/database/models/enums.dart
|
||||||
|
|
||||||
|
CONSTANTS:
|
||||||
|
----------
|
||||||
|
/Users/ssg/project/worker/lib/core/constants/storage_constants.dart
|
||||||
|
|
||||||
|
AUTH MODELS (2):
|
||||||
|
----------------
|
||||||
|
/Users/ssg/project/worker/lib/features/auth/data/models/user_model.dart
|
||||||
|
/Users/ssg/project/worker/lib/features/auth/data/models/user_session_model.dart
|
||||||
|
|
||||||
|
PRODUCT MODELS (2):
|
||||||
|
-------------------
|
||||||
|
/Users/ssg/project/worker/lib/features/products/data/models/product_model.dart
|
||||||
|
/Users/ssg/project/worker/lib/features/products/data/models/stock_level_model.dart
|
||||||
|
|
||||||
|
CART MODELS (2):
|
||||||
|
----------------
|
||||||
|
/Users/ssg/project/worker/lib/features/cart/data/models/cart_model.dart
|
||||||
|
/Users/ssg/project/worker/lib/features/cart/data/models/cart_item_model.dart
|
||||||
|
|
||||||
|
ORDER MODELS (4):
|
||||||
|
-----------------
|
||||||
|
/Users/ssg/project/worker/lib/features/orders/data/models/order_model.dart
|
||||||
|
/Users/ssg/project/worker/lib/features/orders/data/models/order_item_model.dart
|
||||||
|
/Users/ssg/project/worker/lib/features/orders/data/models/invoice_model.dart
|
||||||
|
/Users/ssg/project/worker/lib/features/orders/data/models/payment_line_model.dart
|
||||||
|
|
||||||
|
LOYALTY MODELS (4):
|
||||||
|
-------------------
|
||||||
|
/Users/ssg/project/worker/lib/features/loyalty/data/models/loyalty_point_entry_model.dart
|
||||||
|
/Users/ssg/project/worker/lib/features/loyalty/data/models/gift_catalog_model.dart
|
||||||
|
/Users/ssg/project/worker/lib/features/loyalty/data/models/redeemed_gift_model.dart
|
||||||
|
/Users/ssg/project/worker/lib/features/loyalty/data/models/points_record_model.dart
|
||||||
|
|
||||||
|
PROJECT MODELS (2):
|
||||||
|
-------------------
|
||||||
|
/Users/ssg/project/worker/lib/features/projects/data/models/project_submission_model.dart
|
||||||
|
/Users/ssg/project/worker/lib/features/projects/data/models/design_request_model.dart
|
||||||
|
|
||||||
|
QUOTE MODELS (2):
|
||||||
|
-----------------
|
||||||
|
/Users/ssg/project/worker/lib/features/quotes/data/models/quote_model.dart
|
||||||
|
/Users/ssg/project/worker/lib/features/quotes/data/models/quote_item_model.dart
|
||||||
|
|
||||||
|
CHAT MODELS (2):
|
||||||
|
----------------
|
||||||
|
/Users/ssg/project/worker/lib/features/chat/data/models/chat_room_model.dart
|
||||||
|
/Users/ssg/project/worker/lib/features/chat/data/models/message_model.dart
|
||||||
|
|
||||||
|
NOTIFICATION MODELS (1):
|
||||||
|
------------------------
|
||||||
|
/Users/ssg/project/worker/lib/features/notifications/data/models/notification_model.dart
|
||||||
|
|
||||||
|
SHOWROOM MODELS (2):
|
||||||
|
--------------------
|
||||||
|
/Users/ssg/project/worker/lib/features/showrooms/data/models/showroom_model.dart
|
||||||
|
/Users/ssg/project/worker/lib/features/showrooms/data/models/showroom_product_model.dart
|
||||||
|
|
||||||
|
ACCOUNT MODELS (2):
|
||||||
|
-------------------
|
||||||
|
/Users/ssg/project/worker/lib/features/account/data/models/payment_reminder_model.dart
|
||||||
|
/Users/ssg/project/worker/lib/features/account/data/models/audit_log_model.dart
|
||||||
|
|
||||||
|
DOCUMENTATION:
|
||||||
|
--------------
|
||||||
|
/Users/ssg/project/worker/HIVE_MODELS_COMPLETED.md
|
||||||
|
/Users/ssg/project/worker/HIVE_MODELS_REFERENCE.md
|
||||||
|
/Users/ssg/project/worker/database.md
|
||||||
|
|
||||||
|
TOTAL: 25 models + 21 enums
|
||||||
168
TYPEID_VERIFICATION.md
Normal file
168
TYPEID_VERIFICATION.md
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
# TypeId Verification Report
|
||||||
|
|
||||||
|
## ✅ All TypeId Conflicts Resolved
|
||||||
|
|
||||||
|
### Changes Made
|
||||||
|
|
||||||
|
1. **storage_constants.dart** - Added new typeIds:
|
||||||
|
- `memberCardModel = 25`
|
||||||
|
- `promotionModel = 26`
|
||||||
|
- `categoryModel = 27`
|
||||||
|
|
||||||
|
2. **member_card_model.dart** - Changed from hardcoded `typeId: 10` to `typeId: HiveTypeIds.memberCardModel` (25)
|
||||||
|
|
||||||
|
3. **promotion_model.dart** - Changed from hardcoded `typeId: 11` to `typeId: HiveTypeIds.promotionModel` (26)
|
||||||
|
|
||||||
|
4. **category_model.dart** - Changed from hardcoded `typeId: 12` to `typeId: HiveTypeIds.categoryModel` (27)
|
||||||
|
|
||||||
|
### TypeId Range Summary
|
||||||
|
|
||||||
|
```
|
||||||
|
Core Models: 0-9 (10 slots, all assigned)
|
||||||
|
Loyalty Models: 10-13 (4 slots, all assigned)
|
||||||
|
Project/Quote: 14-17 (4 slots, all assigned)
|
||||||
|
Chat: 18-19 (2 slots, all assigned)
|
||||||
|
Extended Models: 20-27 (8 used, 2 available: 28-29)
|
||||||
|
Enums: 30-51 (22 assigned, 8 available: 52-59)
|
||||||
|
Cache/Sync: 60-62 (3 assigned, 7 available: 63-69)
|
||||||
|
Reserved Future: 70-99 (30 slots available)
|
||||||
|
Special: 100 (2 values - max cache size config)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Unique TypeIds Assigned (0-69)
|
||||||
|
|
||||||
|
**Models: 0-27**
|
||||||
|
```
|
||||||
|
0 = userModel
|
||||||
|
1 = userSessionModel
|
||||||
|
2 = productModel
|
||||||
|
3 = stockLevelModel
|
||||||
|
4 = cartModel
|
||||||
|
5 = cartItemModel
|
||||||
|
6 = orderModel
|
||||||
|
7 = orderItemModel
|
||||||
|
8 = invoiceModel
|
||||||
|
9 = paymentLineModel
|
||||||
|
10 = loyaltyPointEntryModel
|
||||||
|
11 = giftCatalogModel
|
||||||
|
12 = redeemedGiftModel
|
||||||
|
13 = pointsRecordModel
|
||||||
|
14 = projectSubmissionModel
|
||||||
|
15 = designRequestModel
|
||||||
|
16 = quoteModel
|
||||||
|
17 = quoteItemModel
|
||||||
|
18 = chatRoomModel
|
||||||
|
19 = messageModel
|
||||||
|
20 = notificationModel
|
||||||
|
21 = showroomModel
|
||||||
|
22 = showroomProductModel
|
||||||
|
23 = paymentReminderModel
|
||||||
|
24 = auditLogModel
|
||||||
|
25 = memberCardModel ✨ (NEW - was conflicting at 10)
|
||||||
|
26 = promotionModel ✨ (NEW - was conflicting at 11)
|
||||||
|
27 = categoryModel ✨ (NEW - was conflicting at 12)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Enums: 30-51**
|
||||||
|
```
|
||||||
|
30 = userRole
|
||||||
|
31 = userStatus
|
||||||
|
32 = loyaltyTier
|
||||||
|
33 = orderStatus
|
||||||
|
34 = invoiceType
|
||||||
|
35 = invoiceStatus
|
||||||
|
36 = paymentMethod
|
||||||
|
37 = paymentStatus
|
||||||
|
38 = entryType
|
||||||
|
39 = entrySource
|
||||||
|
40 = complaintStatus
|
||||||
|
41 = giftCategory
|
||||||
|
42 = giftStatus
|
||||||
|
43 = pointsStatus
|
||||||
|
44 = projectType
|
||||||
|
45 = submissionStatus
|
||||||
|
46 = designStatus
|
||||||
|
47 = quoteStatus
|
||||||
|
48 = roomType
|
||||||
|
49 = contentType
|
||||||
|
50 = reminderType
|
||||||
|
51 = notificationType
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cache/Sync: 60-62**
|
||||||
|
```
|
||||||
|
60 = cachedData
|
||||||
|
61 = syncState
|
||||||
|
62 = offlineRequest
|
||||||
|
```
|
||||||
|
|
||||||
|
### Aliases (Reference existing typeIds)
|
||||||
|
```
|
||||||
|
memberTier = loyaltyTier (32)
|
||||||
|
userType = userRole (30)
|
||||||
|
projectStatus = submissionStatus (45)
|
||||||
|
transactionType = entryType (38)
|
||||||
|
```
|
||||||
|
|
||||||
|
## ✅ No Duplicates Found
|
||||||
|
|
||||||
|
Verified using command:
|
||||||
|
```bash
|
||||||
|
grep "static const int" lib/core/constants/storage_constants.dart | awk '{print $5}' | sort | uniq -d
|
||||||
|
```
|
||||||
|
Result: **No duplicate values**
|
||||||
|
|
||||||
|
## ✅ No Hardcoded TypeIds Found
|
||||||
|
|
||||||
|
Verified using command:
|
||||||
|
```bash
|
||||||
|
grep -r "@HiveType(typeId: [0-9]" lib/
|
||||||
|
```
|
||||||
|
Result: **No hardcoded typeIds in lib/ directory**
|
||||||
|
|
||||||
|
## ✅ All Models Use Constants
|
||||||
|
|
||||||
|
Verified that all 47+ models now use `HiveTypeIds.constantName` format:
|
||||||
|
- ✅ All auth models
|
||||||
|
- ✅ All product models
|
||||||
|
- ✅ All cart models
|
||||||
|
- ✅ All order models
|
||||||
|
- ✅ All loyalty models
|
||||||
|
- ✅ All project models
|
||||||
|
- ✅ All quote models
|
||||||
|
- ✅ All chat models
|
||||||
|
- ✅ All showroom models
|
||||||
|
- ✅ All notification models
|
||||||
|
- ✅ All enum types
|
||||||
|
- ✅ All cache models
|
||||||
|
|
||||||
|
## Next Actions Required
|
||||||
|
|
||||||
|
### 1. Regenerate Hive Adapters
|
||||||
|
```bash
|
||||||
|
flutter pub run build_runner build --delete-conflicting-outputs
|
||||||
|
```
|
||||||
|
|
||||||
|
This will regenerate:
|
||||||
|
- `member_card_model.g.dart` with new typeId 25
|
||||||
|
- `promotion_model.g.dart` with new typeId 26
|
||||||
|
- `category_model.g.dart` with new typeId 27
|
||||||
|
|
||||||
|
### 2. Clear Development Data (Optional)
|
||||||
|
If testing locally, you may want to clear existing Hive boxes:
|
||||||
|
```dart
|
||||||
|
await Hive.deleteBoxFromDisk('home_box');
|
||||||
|
await Hive.deleteBoxFromDisk('products_box');
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Test the App
|
||||||
|
Run the app and verify:
|
||||||
|
- ✅ No HiveError about duplicate typeIds
|
||||||
|
- ✅ Member cards load correctly
|
||||||
|
- ✅ Promotions load correctly
|
||||||
|
- ✅ Product categories load correctly
|
||||||
|
- ✅ All Hive operations work as expected
|
||||||
|
|
||||||
|
## Status: ✅ READY FOR ADAPTER GENERATION
|
||||||
|
|
||||||
|
All typeId conflicts have been resolved. The codebase is now ready for running the build_runner to regenerate adapters.
|
||||||
415
database.md
Normal file
415
database.md
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
classDiagram
|
||||||
|
direction BT
|
||||||
|
class audit_logs {
|
||||||
|
varchar(50) user_id
|
||||||
|
varchar(100) action
|
||||||
|
varchar(50) entity_type
|
||||||
|
varchar(50) entity_id
|
||||||
|
jsonb old_value
|
||||||
|
jsonb new_value
|
||||||
|
inet ip_address
|
||||||
|
text user_agent
|
||||||
|
timestamp with time zone timestamp
|
||||||
|
bigint log_id
|
||||||
|
}
|
||||||
|
class cart_items {
|
||||||
|
varchar(50) cart_id
|
||||||
|
varchar(50) product_id
|
||||||
|
numeric(12,2) quantity
|
||||||
|
numeric(12,2) unit_price
|
||||||
|
numeric(12,2) subtotal
|
||||||
|
timestamp with time zone added_at
|
||||||
|
varchar(50) cart_item_id
|
||||||
|
}
|
||||||
|
class carts {
|
||||||
|
varchar(50) user_id
|
||||||
|
numeric(12,2) total_amount
|
||||||
|
boolean is_synced
|
||||||
|
timestamp with time zone last_modified
|
||||||
|
timestamp with time zone created_at
|
||||||
|
varchar(50) cart_id
|
||||||
|
}
|
||||||
|
class chat_messages {
|
||||||
|
varchar(50) chat_room_id
|
||||||
|
varchar(50) sender_id
|
||||||
|
content_type content_type
|
||||||
|
text content
|
||||||
|
varchar(500) attachment_url
|
||||||
|
varchar(50) product_reference
|
||||||
|
boolean is_read
|
||||||
|
boolean is_edited
|
||||||
|
boolean is_deleted
|
||||||
|
jsonb read_by
|
||||||
|
timestamp with time zone timestamp
|
||||||
|
timestamp with time zone edited_at
|
||||||
|
varchar(50) message_id
|
||||||
|
}
|
||||||
|
class chat_rooms {
|
||||||
|
room_type room_type
|
||||||
|
varchar(50) related_quote_id
|
||||||
|
varchar(50) related_order_id
|
||||||
|
jsonb participants
|
||||||
|
varchar(100) room_name
|
||||||
|
boolean is_active
|
||||||
|
timestamp with time zone last_activity
|
||||||
|
timestamp with time zone created_at
|
||||||
|
varchar(50) created_by
|
||||||
|
varchar(50) chat_room_id
|
||||||
|
}
|
||||||
|
class design_requests {
|
||||||
|
varchar(50) user_id
|
||||||
|
varchar(200) project_name
|
||||||
|
project_type project_type
|
||||||
|
numeric(10,2) area
|
||||||
|
varchar(100) style
|
||||||
|
numeric(12,2) budget
|
||||||
|
text current_situation
|
||||||
|
text requirements
|
||||||
|
text notes
|
||||||
|
jsonb attachments
|
||||||
|
design_status status
|
||||||
|
varchar(100) assigned_designer
|
||||||
|
varchar(500) final_design_link
|
||||||
|
text feedback
|
||||||
|
integer rating
|
||||||
|
date estimated_completion
|
||||||
|
timestamp with time zone created_at
|
||||||
|
timestamp with time zone completed_at
|
||||||
|
timestamp with time zone updated_at
|
||||||
|
varchar(50) request_id
|
||||||
|
}
|
||||||
|
class gift_catalog {
|
||||||
|
varchar(200) name
|
||||||
|
text description
|
||||||
|
varchar(500) image_url
|
||||||
|
gift_category category
|
||||||
|
integer points_cost
|
||||||
|
numeric(12,2) cash_value
|
||||||
|
integer quantity_available
|
||||||
|
integer quantity_redeemed
|
||||||
|
text terms_conditions
|
||||||
|
boolean is_active
|
||||||
|
date valid_from
|
||||||
|
date valid_until
|
||||||
|
timestamp with time zone created_at
|
||||||
|
timestamp with time zone updated_at
|
||||||
|
varchar(50) catalog_id
|
||||||
|
}
|
||||||
|
class invoices {
|
||||||
|
varchar(50) invoice_number
|
||||||
|
varchar(50) user_id
|
||||||
|
varchar(50) order_id
|
||||||
|
invoice_type invoice_type
|
||||||
|
date issue_date
|
||||||
|
date due_date
|
||||||
|
varchar(3) currency
|
||||||
|
numeric(12,2) subtotal_amount
|
||||||
|
numeric(12,2) tax_amount
|
||||||
|
numeric(12,2) discount_amount
|
||||||
|
numeric(12,2) shipping_amount
|
||||||
|
numeric(12,2) total_amount
|
||||||
|
numeric(12,2) amount_paid
|
||||||
|
numeric(12,2) amount_remaining
|
||||||
|
invoice_status status
|
||||||
|
text payment_terms
|
||||||
|
text notes
|
||||||
|
varchar(50) erpnext_invoice
|
||||||
|
timestamp with time zone created_at
|
||||||
|
timestamp with time zone updated_at
|
||||||
|
timestamp with time zone last_reminder_sent
|
||||||
|
varchar(50) invoice_id
|
||||||
|
}
|
||||||
|
class loyalty_point_entries {
|
||||||
|
varchar(50) user_id
|
||||||
|
integer points
|
||||||
|
entry_type entry_type
|
||||||
|
entry_source source
|
||||||
|
text description
|
||||||
|
varchar(50) reference_id
|
||||||
|
varchar(50) reference_type
|
||||||
|
jsonb complaint
|
||||||
|
complaint_status complaint_status
|
||||||
|
integer balance_after
|
||||||
|
date expiry_date
|
||||||
|
timestamp with time zone timestamp
|
||||||
|
varchar(50) erpnext_entry_id
|
||||||
|
varchar(50) entry_id
|
||||||
|
}
|
||||||
|
class notifications {
|
||||||
|
varchar(50) user_id
|
||||||
|
varchar(50) type
|
||||||
|
varchar(200) title
|
||||||
|
text message
|
||||||
|
jsonb data
|
||||||
|
boolean is_read
|
||||||
|
boolean is_pushed
|
||||||
|
timestamp with time zone created_at
|
||||||
|
timestamp with time zone read_at
|
||||||
|
varchar(50) notification_id
|
||||||
|
}
|
||||||
|
class order_items {
|
||||||
|
varchar(50) order_id
|
||||||
|
varchar(50) product_id
|
||||||
|
numeric(12,2) quantity
|
||||||
|
numeric(12,2) unit_price
|
||||||
|
numeric(5,2) discount_percent
|
||||||
|
numeric(12,2) subtotal
|
||||||
|
text notes
|
||||||
|
varchar(50) order_item_id
|
||||||
|
}
|
||||||
|
class orders {
|
||||||
|
varchar(50) order_number
|
||||||
|
varchar(50) user_id
|
||||||
|
order_status status
|
||||||
|
numeric(12,2) total_amount
|
||||||
|
numeric(12,2) discount_amount
|
||||||
|
numeric(12,2) tax_amount
|
||||||
|
numeric(12,2) shipping_fee
|
||||||
|
numeric(12,2) final_amount
|
||||||
|
jsonb shipping_address
|
||||||
|
jsonb billing_address
|
||||||
|
date expected_delivery_date
|
||||||
|
date actual_delivery_date
|
||||||
|
text notes
|
||||||
|
text cancellation_reason
|
||||||
|
varchar(50) erpnext_sales_order
|
||||||
|
timestamp with time zone created_at
|
||||||
|
timestamp with time zone updated_at
|
||||||
|
varchar(50) order_id
|
||||||
|
}
|
||||||
|
class payment_lines {
|
||||||
|
varchar(50) invoice_id
|
||||||
|
varchar(50) payment_number
|
||||||
|
date payment_date
|
||||||
|
numeric(12,2) amount
|
||||||
|
payment_method payment_method
|
||||||
|
varchar(100) bank_name
|
||||||
|
varchar(50) bank_account
|
||||||
|
varchar(100) reference_number
|
||||||
|
text notes
|
||||||
|
payment_status status
|
||||||
|
varchar(500) receipt_url
|
||||||
|
varchar(50) erpnext_payment_entry
|
||||||
|
timestamp with time zone created_at
|
||||||
|
timestamp with time zone processed_at
|
||||||
|
varchar(50) payment_line_id
|
||||||
|
}
|
||||||
|
class payment_reminders {
|
||||||
|
varchar(50) invoice_id
|
||||||
|
varchar(50) user_id
|
||||||
|
reminder_type reminder_type
|
||||||
|
varchar(200) subject
|
||||||
|
text message
|
||||||
|
boolean is_read
|
||||||
|
boolean is_sent
|
||||||
|
timestamp with time zone scheduled_at
|
||||||
|
timestamp with time zone sent_at
|
||||||
|
timestamp with time zone read_at
|
||||||
|
varchar(50) reminder_id
|
||||||
|
}
|
||||||
|
class points_records {
|
||||||
|
varchar(50) user_id
|
||||||
|
varchar(100) invoice_number
|
||||||
|
varchar(200) store_name
|
||||||
|
date transaction_date
|
||||||
|
numeric(12,2) invoice_amount
|
||||||
|
text notes
|
||||||
|
jsonb attachments
|
||||||
|
points_status status
|
||||||
|
text reject_reason
|
||||||
|
integer points_earned
|
||||||
|
timestamp with time zone submitted_at
|
||||||
|
timestamp with time zone processed_at
|
||||||
|
varchar(50) processed_by
|
||||||
|
varchar(50) record_id
|
||||||
|
}
|
||||||
|
class products {
|
||||||
|
varchar(200) name
|
||||||
|
text description
|
||||||
|
numeric(12,2) base_price
|
||||||
|
jsonb images
|
||||||
|
jsonb image_captions
|
||||||
|
varchar(500) link_360
|
||||||
|
jsonb specifications
|
||||||
|
varchar(100) category
|
||||||
|
varchar(50) brand
|
||||||
|
varchar(20) unit
|
||||||
|
boolean is_active
|
||||||
|
boolean is_featured
|
||||||
|
varchar(50) erpnext_item_code
|
||||||
|
timestamp with time zone created_at
|
||||||
|
timestamp with time zone updated_at
|
||||||
|
varchar(50) product_id
|
||||||
|
}
|
||||||
|
class project_submissions {
|
||||||
|
varchar(50) user_id
|
||||||
|
varchar(200) project_name
|
||||||
|
text project_address
|
||||||
|
numeric(12,2) project_value
|
||||||
|
project_type project_type
|
||||||
|
jsonb before_photos
|
||||||
|
jsonb after_photos
|
||||||
|
jsonb invoices
|
||||||
|
submission_status status
|
||||||
|
text review_notes
|
||||||
|
text rejection_reason
|
||||||
|
integer points_earned
|
||||||
|
timestamp with time zone submitted_at
|
||||||
|
timestamp with time zone reviewed_at
|
||||||
|
varchar(50) reviewed_by
|
||||||
|
varchar(50) submission_id
|
||||||
|
}
|
||||||
|
class quote_items {
|
||||||
|
varchar(50) quote_id
|
||||||
|
varchar(50) product_id
|
||||||
|
numeric(12,2) quantity
|
||||||
|
numeric(12,2) original_price
|
||||||
|
numeric(12,2) negotiated_price
|
||||||
|
numeric(5,2) discount_percent
|
||||||
|
numeric(12,2) subtotal
|
||||||
|
text notes
|
||||||
|
varchar(50) quote_item_id
|
||||||
|
}
|
||||||
|
class quotes {
|
||||||
|
varchar(50) quote_number
|
||||||
|
varchar(50) user_id
|
||||||
|
quote_status status
|
||||||
|
numeric(12,2) total_amount
|
||||||
|
numeric(12,2) discount_amount
|
||||||
|
numeric(12,2) final_amount
|
||||||
|
varchar(200) project_name
|
||||||
|
jsonb delivery_address
|
||||||
|
text payment_terms
|
||||||
|
text notes
|
||||||
|
date valid_until
|
||||||
|
varchar(50) converted_order_id
|
||||||
|
varchar(50) erpnext_quotation
|
||||||
|
timestamp with time zone created_at
|
||||||
|
timestamp with time zone updated_at
|
||||||
|
varchar(50) quote_id
|
||||||
|
}
|
||||||
|
class redeemed_gifts {
|
||||||
|
varchar(50) user_id
|
||||||
|
varchar(50) catalog_id
|
||||||
|
varchar(200) name
|
||||||
|
text description
|
||||||
|
varchar(50) voucher_code
|
||||||
|
varchar(500) qr_code_image
|
||||||
|
gift_category gift_type
|
||||||
|
integer points_cost
|
||||||
|
numeric(12,2) cash_value
|
||||||
|
date expiry_date
|
||||||
|
gift_status status
|
||||||
|
timestamp with time zone redeemed_at
|
||||||
|
timestamp with time zone used_at
|
||||||
|
varchar(200) used_location
|
||||||
|
varchar(100) used_reference
|
||||||
|
varchar(50) gift_id
|
||||||
|
}
|
||||||
|
class showroom_products {
|
||||||
|
numeric(10,2) quantity_used
|
||||||
|
varchar(50) showroom_id
|
||||||
|
varchar(50) product_id
|
||||||
|
}
|
||||||
|
class showrooms {
|
||||||
|
varchar(200) title
|
||||||
|
text description
|
||||||
|
varchar(500) cover_image
|
||||||
|
varchar(500) link_360
|
||||||
|
numeric(10,2) area
|
||||||
|
varchar(100) style
|
||||||
|
varchar(200) location
|
||||||
|
jsonb gallery_images
|
||||||
|
integer view_count
|
||||||
|
boolean is_featured
|
||||||
|
boolean is_active
|
||||||
|
timestamp with time zone published_at
|
||||||
|
varchar(50) created_by
|
||||||
|
varchar(50) showroom_id
|
||||||
|
}
|
||||||
|
class stock_levels {
|
||||||
|
numeric(12,2) available_qty
|
||||||
|
numeric(12,2) reserved_qty
|
||||||
|
numeric(12,2) ordered_qty
|
||||||
|
varchar(50) warehouse_code
|
||||||
|
timestamp with time zone last_updated
|
||||||
|
varchar(50) product_id
|
||||||
|
}
|
||||||
|
class system_settings {
|
||||||
|
jsonb setting_value
|
||||||
|
text description
|
||||||
|
boolean is_public
|
||||||
|
timestamp with time zone updated_at
|
||||||
|
varchar(50) updated_by
|
||||||
|
varchar(100) setting_key
|
||||||
|
}
|
||||||
|
class user_sessions {
|
||||||
|
varchar(50) user_id
|
||||||
|
varchar(100) device_id
|
||||||
|
varchar(50) device_type
|
||||||
|
varchar(100) device_name
|
||||||
|
inet ip_address
|
||||||
|
text user_agent
|
||||||
|
varchar(500) refresh_token
|
||||||
|
timestamp with time zone expires_at
|
||||||
|
timestamp with time zone created_at
|
||||||
|
timestamp with time zone last_activity
|
||||||
|
varchar(100) session_id
|
||||||
|
}
|
||||||
|
class users {
|
||||||
|
varchar(20) phone_number
|
||||||
|
varchar(255) password_hash
|
||||||
|
varchar(100) full_name
|
||||||
|
varchar(100) email
|
||||||
|
user_role role
|
||||||
|
user_status status
|
||||||
|
loyalty_tier loyalty_tier
|
||||||
|
integer total_points
|
||||||
|
jsonb company_info
|
||||||
|
varchar(20) cccd
|
||||||
|
jsonb attachments
|
||||||
|
text address
|
||||||
|
varchar(500) avatar_url
|
||||||
|
varchar(20) referral_code
|
||||||
|
varchar(50) referred_by
|
||||||
|
varchar(50) erpnext_customer_id
|
||||||
|
timestamp with time zone created_at
|
||||||
|
timestamp with time zone updated_at
|
||||||
|
timestamp with time zone last_login_at
|
||||||
|
varchar(50) user_id
|
||||||
|
}
|
||||||
|
|
||||||
|
cart_items --> carts : cart_id
|
||||||
|
cart_items --> products : product_id
|
||||||
|
carts --> users : user_id
|
||||||
|
chat_messages --> chat_rooms : chat_room_id
|
||||||
|
chat_messages --> products : product_reference:product_id
|
||||||
|
chat_messages --> users : sender_id:user_id
|
||||||
|
chat_rooms --> orders : related_order_id:order_id
|
||||||
|
chat_rooms --> quotes : related_quote_id:quote_id
|
||||||
|
chat_rooms --> users : created_by:user_id
|
||||||
|
design_requests --> users : user_id
|
||||||
|
invoices --> orders : order_id
|
||||||
|
invoices --> users : user_id
|
||||||
|
loyalty_point_entries --> users : user_id
|
||||||
|
notifications --> users : user_id
|
||||||
|
order_items --> orders : order_id
|
||||||
|
order_items --> products : product_id
|
||||||
|
orders --> users : user_id
|
||||||
|
payment_lines --> invoices : invoice_id
|
||||||
|
payment_reminders --> invoices : invoice_id
|
||||||
|
payment_reminders --> users : user_id
|
||||||
|
points_records --> users : user_id
|
||||||
|
project_submissions --> users : user_id
|
||||||
|
quote_items --> products : product_id
|
||||||
|
quote_items --> quotes : quote_id
|
||||||
|
quotes --> orders : converted_order_id:order_id
|
||||||
|
quotes --> users : user_id
|
||||||
|
redeemed_gifts --> gift_catalog : catalog_id
|
||||||
|
redeemed_gifts --> users : user_id
|
||||||
|
showroom_products --> products : product_id
|
||||||
|
showroom_products --> showrooms : showroom_id
|
||||||
|
showrooms --> users : created_by:user_id
|
||||||
|
stock_levels --> products : product_id
|
||||||
|
user_sessions --> users : user_id
|
||||||
|
users --> users : referred_by:user_id
|
||||||
@@ -628,7 +628,7 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tab-item.active {
|
.tab-item.active {
|
||||||
background: var(--primary-blue);
|
/*background: var(--primary-blue);*/
|
||||||
color: var(--white);
|
color: var(--white);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -918,6 +918,7 @@ p {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
height: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-complaint:hover {
|
.btn-complaint:hover {
|
||||||
@@ -1158,6 +1159,15 @@ p {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*.order-card {
|
||||||
|
background: var(--white);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: var(--shadow-light);
|
||||||
|
display: flex;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}*/
|
||||||
|
|
||||||
.order-card {
|
.order-card {
|
||||||
background: var(--white);
|
background: var(--white);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
@@ -1165,6 +1175,13 @@ p {
|
|||||||
display: flex;
|
display: flex;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: var(--shadow-medium);
|
||||||
}
|
}
|
||||||
|
|
||||||
.order-status-indicator {
|
.order-status-indicator {
|
||||||
@@ -2106,3 +2123,130 @@ p {
|
|||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@keyframes rotate {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================
|
||||||
|
PRODUCT ACTIONS STYLES
|
||||||
|
=========================== */
|
||||||
|
|
||||||
|
.product-actions {
|
||||||
|
/*display: flex;*/
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
width: 100%
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-360 {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border: 1px solid var(--primary-blue);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--primary-blue);
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-360:hover {
|
||||||
|
background: var(--primary-blue);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-add-cart {
|
||||||
|
flex: 2;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================
|
||||||
|
SHARE MODAL STYLES
|
||||||
|
=========================== */
|
||||||
|
|
||||||
|
|
||||||
|
/*Thêm*/
|
||||||
|
|
||||||
|
/* New Quote Status Badges */
|
||||||
|
.status-badge.negotiating {
|
||||||
|
background: #ff9800; /* Orange */
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.finalized {
|
||||||
|
background: var(--success-color); /* Green */
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.converted {
|
||||||
|
background: #9c27b0; /* Purple */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Quote Request Card Styles */
|
||||||
|
.quote-request-card {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-request-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: var(--shadow-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-request-card.negotiating .quote-request-status-indicator {
|
||||||
|
background: #ff9800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-request-card.finalized .quote-request-status-indicator {
|
||||||
|
background: var(--success-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-request-card.converted .quote-request-status-indicator {
|
||||||
|
background: #9c27b0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-request-card.sent .quote-request-status-indicator {
|
||||||
|
background: var(--primary-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-request-card.cancelled .quote-request-status-indicator {
|
||||||
|
background: var(--text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Quick Info Grid */
|
||||||
|
.quick-info {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item {
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--background-gray);
|
||||||
|
border-radius: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
color: var(--primary-blue);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-light);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-dark);
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Thanh toán - EuroTile Worker</title>
|
<title>Thanh toán - EuroTile Worker</title>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<!--<script src="https://cdn.tailwindcss.com"></script>-->
|
||||||
<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>
|
||||||
|
|||||||
760
html/design-request-create.html
Normal file
760
html/design-request-create.html
Normal file
@@ -0,0 +1,760 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="vi">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Tạo Yêu cầu Thiết kế - EuroTile Worker</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<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">
|
||||||
|
<style>
|
||||||
|
.form-container {
|
||||||
|
max-width: 480px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
background: #f8fafc;
|
||||||
|
min-height: calc(100vh - 120px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
display: block;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #374151;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.required {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input,
|
||||||
|
.form-textarea,
|
||||||
|
.form-select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 2px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:focus,
|
||||||
|
.form-textarea:focus,
|
||||||
|
.form-select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #2563eb;
|
||||||
|
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-textarea {
|
||||||
|
height: 100px;
|
||||||
|
resize: vertical;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-upload-area {
|
||||||
|
border: 2px dashed #d1d5db;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-upload-area:hover {
|
||||||
|
border-color: #2563eb;
|
||||||
|
background: #eff6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-upload-area.drag-over {
|
||||||
|
border-color: #2563eb;
|
||||||
|
background: #eff6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-icon {
|
||||||
|
font-size: 32px;
|
||||||
|
color: #9ca3af;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-text {
|
||||||
|
color: #6b7280;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-preview {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px;
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
background: #2563eb;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
margin-right: 12px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-size {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-remove {
|
||||||
|
background: #ef4444;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 14px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
border: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: white;
|
||||||
|
color: #374151;
|
||||||
|
border: 2px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
border-color: #2563eb;
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-steps {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #e5e7eb;
|
||||||
|
color: #6b7280;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 8px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step.active {
|
||||||
|
background: #2563eb;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step.completed {
|
||||||
|
background: #10b981;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
right: -24px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 16px;
|
||||||
|
height: 2px;
|
||||||
|
background: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step:last-child::after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step.completed::after {
|
||||||
|
background: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
color: #ef4444;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 4px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: #10b981;
|
||||||
|
color: white;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||||
|
z-index: 1000;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.show {
|
||||||
|
display: flex;
|
||||||
|
animation: slideDown 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideDown {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-50%) translateY(-20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(-50%) translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page-wrapper">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="header">
|
||||||
|
<a href="nha-mau.html" class="back-button">
|
||||||
|
<i class="fas fa-arrow-left"></i>
|
||||||
|
</a>
|
||||||
|
<h1 class="header-title">Tạo yêu cầu thiết kế mới</h1>
|
||||||
|
<div style="width: 32px;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-container">
|
||||||
|
<!-- Progress Steps -->
|
||||||
|
<div class="progress-steps">
|
||||||
|
<div class="step active">1</div>
|
||||||
|
<div class="step">2</div>
|
||||||
|
<div class="step">3</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Form -->
|
||||||
|
<form id="design-request-form" onsubmit="submitForm(event)">
|
||||||
|
<!-- Basic Information -->
|
||||||
|
<div class="form-card">
|
||||||
|
<h3 style="font-weight: 700; font-size: 18px; color: #1f2937; margin-bottom: 20px; display: flex; align-items: center; gap: 8px;">
|
||||||
|
<i class="fas fa-info-circle" style="color: #2563eb;"></i>
|
||||||
|
Thông tin cơ bản
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">
|
||||||
|
Tên dự án/Khách hàng <span class="required">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-input"
|
||||||
|
id="project-name"
|
||||||
|
placeholder="VD: Thiết kế nhà anh Minh - Quận 7"
|
||||||
|
required>
|
||||||
|
<div class="error-message" id="project-name-error">Vui lòng nhập tên dự án</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">
|
||||||
|
Diện tích (m²) <span class="required">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
class="form-input"
|
||||||
|
id="project-area"
|
||||||
|
placeholder="VD: 120"
|
||||||
|
min="1"
|
||||||
|
required>
|
||||||
|
<div class="error-message" id="project-area-error">Vui lòng nhập diện tích hợp lệ</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">
|
||||||
|
Phong cách mong muốn <span class="required">*</span>
|
||||||
|
</label>
|
||||||
|
<select class="form-select" id="project-style" required>
|
||||||
|
<option value="">-- Chọn phong cách --</option>
|
||||||
|
<option value="hien-dai">Hiện đại</option>
|
||||||
|
<option value="toi-gian">Tối giản</option>
|
||||||
|
<option value="co-dien">Cổ điển</option>
|
||||||
|
<option value="scandinavian">Scandinavian</option>
|
||||||
|
<option value="industrial">Industrial</option>
|
||||||
|
<option value="tropical">Tropical</option>
|
||||||
|
<option value="luxury">Luxury</option>
|
||||||
|
<option value="khac">Khác (ghi rõ trong ghi chú)</option>
|
||||||
|
</select>
|
||||||
|
<div class="error-message" id="project-style-error">Vui lòng chọn phong cách</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">
|
||||||
|
Ngân sách dự kiến
|
||||||
|
</label>
|
||||||
|
<select class="form-select" id="project-budget">
|
||||||
|
<option value="">-- Chọn ngân sách --</option>
|
||||||
|
<option value="duoi-100tr">Dưới 100 triệu</option>
|
||||||
|
<option value="100-300tr">100 - 300 triệu</option>
|
||||||
|
<option value="300-500tr">300 - 500 triệu</option>
|
||||||
|
<option value="500tr-1ty">500 triệu - 1 tỷ</option>
|
||||||
|
<option value="tren-1ty">Trên 1 tỷ</option>
|
||||||
|
<option value="trao-doi">Trao đổi trực tiếp</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Detailed Requirements -->
|
||||||
|
<div class="form-card">
|
||||||
|
<h3 style="font-weight: 700; font-size: 18px; color: #1f2937; margin-bottom: 20px; display: flex; align-items: center; gap: 8px;">
|
||||||
|
<i class="fas fa-edit" style="color: #2563eb;"></i>
|
||||||
|
Yêu cầu chi tiết
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">
|
||||||
|
Ghi chú chi tiết <span class="required">*</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
class="form-textarea"
|
||||||
|
id="project-notes"
|
||||||
|
placeholder="Mô tả chi tiết về yêu cầu thiết kế, số phòng, công năng sử dụng, sở thích cá nhân..."
|
||||||
|
required></textarea>
|
||||||
|
<div class="error-message" id="project-notes-error">Vui lòng mô tả yêu cầu chi tiết</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">
|
||||||
|
Thông tin liên hệ
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-input"
|
||||||
|
id="contact-info"
|
||||||
|
placeholder="Số điện thoại, email hoặc địa chỉ (tùy chọn)">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- File Upload -->
|
||||||
|
<div class="form-card">
|
||||||
|
<h3 style="font-weight: 700; font-size: 18px; color: #1f2937; margin-bottom: 20px; display: flex; align-items: center; gap: 8px;">
|
||||||
|
<i class="fas fa-cloud-upload-alt" style="color: #2563eb;"></i>
|
||||||
|
Đính kèm tài liệu
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">
|
||||||
|
Đính kèm mặt bằng/Ảnh hiện trạng
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="file-upload-area" onclick="document.getElementById('file-input').click()">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
id="file-input"
|
||||||
|
multiple
|
||||||
|
accept="image/*,.pdf,.dwg,.jpg,.jpeg,.png"
|
||||||
|
style="display: none;"
|
||||||
|
onchange="handleFileSelect(event)">
|
||||||
|
|
||||||
|
<div class="upload-icon">
|
||||||
|
<i class="fas fa-cloud-upload-alt"></i>
|
||||||
|
</div>
|
||||||
|
<div class="upload-text">
|
||||||
|
Nhấn để chọn file hoặc kéo thả vào đây
|
||||||
|
</div>
|
||||||
|
<div class="upload-hint">
|
||||||
|
Hỗ trợ: JPG, PNG, PDF, DWG (Tối đa 10MB mỗi file)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="file-preview" id="file-preview">
|
||||||
|
<!-- File previews will be inserted here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Form Actions -->
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="saveDraft()">
|
||||||
|
<i class="fas fa-save"></i>
|
||||||
|
Lưu nháp
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="fas fa-paper-plane"></i>
|
||||||
|
Gửi yêu cầu
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom Navigation -->
|
||||||
|
<!--<div class="bottom-nav">
|
||||||
|
<a href="index.html" class="nav-item">
|
||||||
|
<i class="fas fa-home"></i>
|
||||||
|
<span>Trang chủ</span>
|
||||||
|
</a>
|
||||||
|
<a href="loyalty.html" class="nav-item">
|
||||||
|
<i class="fas fa-star"></i>
|
||||||
|
<span>Hội viên</span>
|
||||||
|
</a>
|
||||||
|
<a href="promotions.html" class="nav-item">
|
||||||
|
<i class="fas fa-tags"></i>
|
||||||
|
<span>Khuyến mãi</span>
|
||||||
|
</a>
|
||||||
|
<a href="notifications.html" class="nav-item">
|
||||||
|
<i class="fas fa-bell"></i>
|
||||||
|
<span>Thông báo</span>
|
||||||
|
</a>
|
||||||
|
<a href="account.html" class="nav-item active">
|
||||||
|
<i class="fas fa-user"></i>
|
||||||
|
<span>Cài đặt</span>
|
||||||
|
</a>
|
||||||
|
</div>-->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toast Notification -->
|
||||||
|
<div class="toast" id="toast">
|
||||||
|
<i class="fas fa-check-circle"></i>
|
||||||
|
<span id="toast-message">Thành công!</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let selectedFiles = [];
|
||||||
|
let currentStep = 1;
|
||||||
|
|
||||||
|
// File upload handling
|
||||||
|
function handleFileSelect(event) {
|
||||||
|
const files = Array.from(event.target.files);
|
||||||
|
|
||||||
|
files.forEach(file => {
|
||||||
|
if (validateFile(file)) {
|
||||||
|
selectedFiles.push(file);
|
||||||
|
addFilePreview(file);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear input to allow re-selecting same file
|
||||||
|
event.target.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateFile(file) {
|
||||||
|
const maxSize = 10 * 1024 * 1024; // 10MB
|
||||||
|
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'application/pdf'];
|
||||||
|
|
||||||
|
if (file.size > maxSize) {
|
||||||
|
showToast('File quá lớn. Vui lòng chọn file dưới 10MB', 'error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allowedTypes.includes(file.type) && !file.name.toLowerCase().endsWith('.dwg')) {
|
||||||
|
showToast('Định dạng file không được hỗ trợ', 'error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addFilePreview(file) {
|
||||||
|
const preview = document.getElementById('file-preview');
|
||||||
|
const fileItem = document.createElement('div');
|
||||||
|
fileItem.className = 'file-item';
|
||||||
|
|
||||||
|
const fileIcon = getFileIcon(file.type, file.name);
|
||||||
|
const fileSize = formatFileSize(file.size);
|
||||||
|
|
||||||
|
fileItem.innerHTML = `
|
||||||
|
<div class="file-icon">
|
||||||
|
<i class="${fileIcon}"></i>
|
||||||
|
</div>
|
||||||
|
<div class="file-info">
|
||||||
|
<div class="file-name">${file.name}</div>
|
||||||
|
<div class="file-size">${fileSize}</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="file-remove" onclick="removeFile(${selectedFiles.length - 1}, this)">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
preview.appendChild(fileItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFileIcon(type, name) {
|
||||||
|
if (type.startsWith('image/')) return 'fas fa-image';
|
||||||
|
if (type === 'application/pdf') return 'fas fa-file-pdf';
|
||||||
|
if (name.toLowerCase().endsWith('.dwg')) return 'fas fa-drafting-compass';
|
||||||
|
return 'fas fa-file';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFileSize(bytes) {
|
||||||
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFile(index, button) {
|
||||||
|
selectedFiles.splice(index, 1);
|
||||||
|
button.parentElement.remove();
|
||||||
|
|
||||||
|
// Update indices for remaining files
|
||||||
|
updateFileIndices();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFileIndices() {
|
||||||
|
const fileItems = document.querySelectorAll('.file-remove');
|
||||||
|
fileItems.forEach((button, index) => {
|
||||||
|
button.onclick = () => removeFile(index, button);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drag and drop handling
|
||||||
|
const uploadArea = document.querySelector('.file-upload-area');
|
||||||
|
|
||||||
|
uploadArea.addEventListener('dragover', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
uploadArea.classList.add('drag-over');
|
||||||
|
});
|
||||||
|
|
||||||
|
uploadArea.addEventListener('dragleave', () => {
|
||||||
|
uploadArea.classList.remove('drag-over');
|
||||||
|
});
|
||||||
|
|
||||||
|
uploadArea.addEventListener('drop', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
uploadArea.classList.remove('drag-over');
|
||||||
|
|
||||||
|
const files = Array.from(e.dataTransfer.files);
|
||||||
|
files.forEach(file => {
|
||||||
|
if (validateFile(file)) {
|
||||||
|
selectedFiles.push(file);
|
||||||
|
addFilePreview(file);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Form validation and submission
|
||||||
|
function validateForm() {
|
||||||
|
let isValid = true;
|
||||||
|
|
||||||
|
// Clear previous errors
|
||||||
|
document.querySelectorAll('.error-message').forEach(el => {
|
||||||
|
el.style.display = 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
const requiredFields = [
|
||||||
|
{ id: 'project-name', message: 'Vui lòng nhập tên dự án' },
|
||||||
|
{ id: 'project-area', message: 'Vui lòng nhập diện tích hợp lệ' },
|
||||||
|
{ id: 'project-style', message: 'Vui lòng chọn phong cách' },
|
||||||
|
{ id: 'project-notes', message: 'Vui lòng mô tả yêu cầu chi tiết' }
|
||||||
|
];
|
||||||
|
|
||||||
|
requiredFields.forEach(field => {
|
||||||
|
const input = document.getElementById(field.id);
|
||||||
|
const error = document.getElementById(field.id + '-error');
|
||||||
|
|
||||||
|
if (!input.value.trim()) {
|
||||||
|
error.textContent = field.message;
|
||||||
|
error.style.display = 'block';
|
||||||
|
input.style.borderColor = '#ef4444';
|
||||||
|
isValid = false;
|
||||||
|
} else {
|
||||||
|
input.style.borderColor = '#e5e7eb';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Validate area is a positive number
|
||||||
|
const area = document.getElementById('project-area');
|
||||||
|
if (area.value && (isNaN(area.value) || parseFloat(area.value) <= 0)) {
|
||||||
|
const error = document.getElementById('project-area-error');
|
||||||
|
error.textContent = 'Diện tích phải là số dương';
|
||||||
|
error.style.display = 'block';
|
||||||
|
area.style.borderColor = '#ef4444';
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isValid;
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitForm(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (!validateForm()) {
|
||||||
|
showToast('Vui lòng kiểm tra lại thông tin', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate form submission
|
||||||
|
const submitButton = event.target.querySelector('button[type="submit"]');
|
||||||
|
const originalText = submitButton.innerHTML;
|
||||||
|
|
||||||
|
submitButton.disabled = true;
|
||||||
|
submitButton.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Đang gửi...';
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
// Generate random request ID
|
||||||
|
const requestId = 'YC' + Math.random().toString().substr(2, 6).toUpperCase();
|
||||||
|
|
||||||
|
// Store request data (in real app, send to backend)
|
||||||
|
const requestData = {
|
||||||
|
id: requestId,
|
||||||
|
name: document.getElementById('project-name').value,
|
||||||
|
area: document.getElementById('project-area').value,
|
||||||
|
style: document.getElementById('project-style').value,
|
||||||
|
budget: document.getElementById('project-budget').value,
|
||||||
|
notes: document.getElementById('project-notes').value,
|
||||||
|
contact: document.getElementById('contact-info').value,
|
||||||
|
files: selectedFiles.map(f => f.name),
|
||||||
|
status: 'pending',
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
localStorage.setItem('newDesignRequest', JSON.stringify(requestData));
|
||||||
|
|
||||||
|
showToast('Yêu cầu thiết kế đã được gửi thành công!');
|
||||||
|
|
||||||
|
// Redirect after delay
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = `design-request-detail.html?id=${requestId}`;
|
||||||
|
}, 1500);
|
||||||
|
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveDraft() {
|
||||||
|
const draftData = {
|
||||||
|
name: document.getElementById('project-name').value,
|
||||||
|
area: document.getElementById('project-area').value,
|
||||||
|
style: document.getElementById('project-style').value,
|
||||||
|
budget: document.getElementById('project-budget').value,
|
||||||
|
notes: document.getElementById('project-notes').value,
|
||||||
|
contact: document.getElementById('contact-info').value,
|
||||||
|
files: selectedFiles.map(f => f.name)
|
||||||
|
};
|
||||||
|
|
||||||
|
localStorage.setItem('designRequestDraft', JSON.stringify(draftData));
|
||||||
|
showToast('Đã lưu nháp thành công!');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(message, type = 'success') {
|
||||||
|
const toast = document.getElementById('toast');
|
||||||
|
const toastMessage = document.getElementById('toast-message');
|
||||||
|
|
||||||
|
toastMessage.textContent = message;
|
||||||
|
|
||||||
|
if (type === 'error') {
|
||||||
|
toast.style.background = '#ef4444';
|
||||||
|
} else {
|
||||||
|
toast.style.background = '#10b981';
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.classList.add('show');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.classList.remove('show');
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load draft on page load
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const draft = localStorage.getItem('designRequestDraft');
|
||||||
|
if (draft) {
|
||||||
|
try {
|
||||||
|
const draftData = JSON.parse(draft);
|
||||||
|
|
||||||
|
if (confirm('Có bản nháp đã lưu. Bạn có muốn khôi phục không?')) {
|
||||||
|
Object.keys(draftData).forEach(key => {
|
||||||
|
if (key !== 'files') {
|
||||||
|
const input = document.getElementById('project-' + key) || document.getElementById('contact-info');
|
||||||
|
if (input && draftData[key]) {
|
||||||
|
input.value = draftData[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear draft after loading
|
||||||
|
localStorage.removeItem('designRequestDraft');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error loading draft:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
738
html/design-request-detail.html
Normal file
738
html/design-request-detail.html
Normal file
@@ -0,0 +1,738 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="vi">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Chi tiết Yêu cầu Thiết kế - EuroTile Worker</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<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">
|
||||||
|
<style>
|
||||||
|
.detail-container {
|
||||||
|
max-width: 480px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
background: #f8fafc;
|
||||||
|
min-height: calc(100vh - 120px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-id {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f2937;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-date {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pending {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #d97706;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-designing {
|
||||||
|
background: #e0e7ff;
|
||||||
|
color: #3730a3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-completed {
|
||||||
|
background: #d1fae5;
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item {
|
||||||
|
text-align: center;
|
||||||
|
padding: 16px 12px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6b7280;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-section {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f2937;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-content {
|
||||||
|
color: #4b5563;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item {
|
||||||
|
display: flex;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 20px;
|
||||||
|
top: 40px;
|
||||||
|
bottom: -20px;
|
||||||
|
width: 2px;
|
||||||
|
background: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item:last-child::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 16px;
|
||||||
|
margin-right: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-icon.pending {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #d97706;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-icon.designing {
|
||||||
|
background: #e0e7ff;
|
||||||
|
color: #3730a3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-icon.completed {
|
||||||
|
background: #d1fae5;
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-content {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-description {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-date {
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.completion-highlight {
|
||||||
|
background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%);
|
||||||
|
border: 2px solid #10b981;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.completion-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #065f46;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-design-btn {
|
||||||
|
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0 auto;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-design-btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-list {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px;
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-icon {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
background: #2563eb;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
margin-right: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
border: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: white;
|
||||||
|
color: #374151;
|
||||||
|
border: 2px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
border-color: #2563eb;
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.info-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
text-align: left;
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page-wrapper">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="header">
|
||||||
|
<a href="nha-mau.html" class="back-button">
|
||||||
|
<i class="fas fa-arrow-left"></i>
|
||||||
|
</a>
|
||||||
|
<h1 class="header-title">Chi tiết Yêu cầu</h1>
|
||||||
|
<button class="icon-button" onclick="shareRequest()">
|
||||||
|
<i class="fas fa-share-alt"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-container">
|
||||||
|
<!-- Request Header -->
|
||||||
|
<div class="detail-card">
|
||||||
|
<div class="request-header">
|
||||||
|
<h2 class="request-id" id="request-id">#YC001</h2>
|
||||||
|
<div class="request-date" id="request-date">Ngày gửi: 20/10/2024</div>
|
||||||
|
<span class="status-badge" id="status-badge">Hoàn thành</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Project Info Grid -->
|
||||||
|
<div class="info-grid">
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="info-label">Diện tích</div>
|
||||||
|
<div class="info-value" id="project-area">120m²</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="info-label">Phong cách</div>
|
||||||
|
<div class="info-value" id="project-style">Hiện đại</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-grid">
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="info-label">Ngân sách</div>
|
||||||
|
<div class="info-value" id="project-budget">300-500 triệu</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="info-label">Trạng thái</div>
|
||||||
|
<div class="info-value" id="project-status">Đã hoàn thành</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Completion Highlight (only show if completed) -->
|
||||||
|
<div class="completion-highlight" id="completion-highlight">
|
||||||
|
<h3 class="completion-title">🎉 Thiết kế đã hoàn thành!</h3>
|
||||||
|
<p style="color: #065f46; margin-bottom: 16px;">
|
||||||
|
Thiết kế 3D của bạn đã sẵn sàng để xem
|
||||||
|
</p>
|
||||||
|
<button class="view-design-btn" onclick="viewDesign3D()">
|
||||||
|
<i class="fas fa-cube"></i>
|
||||||
|
Xem Link Thiết kế 3D
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Project Details -->
|
||||||
|
<div class="detail-card">
|
||||||
|
<div class="detail-section">
|
||||||
|
<h3 class="section-title">
|
||||||
|
<i class="fas fa-info-circle" style="color: #2563eb;"></i>
|
||||||
|
Thông tin dự án
|
||||||
|
</h3>
|
||||||
|
<div class="section-content">
|
||||||
|
<p><strong>Tên dự án:</strong> <span id="project-name">Thiết kế nhà phố 3 tầng - Anh Minh (Quận 7)</span></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-section">
|
||||||
|
<h3 class="section-title">
|
||||||
|
<i class="fas fa-edit" style="color: #2563eb;"></i>
|
||||||
|
Mô tả yêu cầu
|
||||||
|
</h3>
|
||||||
|
<div class="section-content" id="project-description">
|
||||||
|
Thiết kế nhà phố 3 tầng phong cách hiện đại với 4 phòng ngủ, 3 phòng tắm, phòng khách rộng rãi và khu bếp mở.
|
||||||
|
Ưu tiên sử dụng gạch men màu sáng để tạo cảm giác thoáng đãng. Tầng 1: garage, phòng khách, bếp.
|
||||||
|
Tầng 2: 2 phòng ngủ, 2 phòng tắm. Tầng 3: phòng ngủ master, phòng làm việc, sân thượng.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-section">
|
||||||
|
<h3 class="section-title">
|
||||||
|
<i class="fas fa-phone" style="color: #2563eb;"></i>
|
||||||
|
Thông tin liên hệ
|
||||||
|
</h3>
|
||||||
|
<div class="section-content" id="contact-info">
|
||||||
|
SĐT: 0901234567 | Email: minh.nguyen@email.com
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-section">
|
||||||
|
<h3 class="section-title">
|
||||||
|
<i class="fas fa-paperclip" style="color: #2563eb;"></i>
|
||||||
|
Tài liệu đính kèm
|
||||||
|
</h3>
|
||||||
|
<div class="files-list" id="files-list">
|
||||||
|
<div class="file-item">
|
||||||
|
<div class="file-icon">
|
||||||
|
<i class="fas fa-image"></i>
|
||||||
|
</div>
|
||||||
|
<div class="file-info">
|
||||||
|
<div class="file-name">mat-bang-hien-tai.jpg</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="file-item">
|
||||||
|
<div class="file-icon">
|
||||||
|
<i class="fas fa-drafting-compass"></i>
|
||||||
|
</div>
|
||||||
|
<div class="file-info">
|
||||||
|
<div class="file-name">ban-ve-kien-truc.dwg</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status Timeline -->
|
||||||
|
<div class="detail-card">
|
||||||
|
<h3 class="section-title">
|
||||||
|
<i class="fas fa-history" style="color: #2563eb;"></i>
|
||||||
|
Lịch sử trạng thái
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="timeline">
|
||||||
|
<div class="timeline-item">
|
||||||
|
<div class="timeline-icon completed">
|
||||||
|
<i class="fas fa-check"></i>
|
||||||
|
</div>
|
||||||
|
<div class="timeline-content">
|
||||||
|
<div class="timeline-title">Thiết kế hoàn thành</div>
|
||||||
|
<div class="timeline-description">File thiết kế 3D đã được gửi đến khách hàng</div>
|
||||||
|
<div class="timeline-date">25/10/2024 - 14:30</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="timeline-item">
|
||||||
|
<div class="timeline-icon designing">
|
||||||
|
<i class="fas fa-drafting-compass"></i>
|
||||||
|
</div>
|
||||||
|
<div class="timeline-content">
|
||||||
|
<div class="timeline-title">Bắt đầu thiết kế</div>
|
||||||
|
<div class="timeline-description">KTS Nguyễn Văn An đã nhận và bắt đầu thiết kế</div>
|
||||||
|
<div class="timeline-date">22/10/2024 - 09:00</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="timeline-item">
|
||||||
|
<div class="timeline-icon pending">
|
||||||
|
<i class="fas fa-clock"></i>
|
||||||
|
</div>
|
||||||
|
<div class="timeline-content">
|
||||||
|
<div class="timeline-title">Tiếp nhận yêu cầu</div>
|
||||||
|
<div class="timeline-description">Yêu cầu thiết kế đã được tiếp nhận và xem xét</div>
|
||||||
|
<div class="timeline-date">20/10/2024 - 16:45</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="timeline-item">
|
||||||
|
<div class="timeline-icon pending">
|
||||||
|
<i class="fas fa-paper-plane"></i>
|
||||||
|
</div>
|
||||||
|
<div class="timeline-content">
|
||||||
|
<div class="timeline-title">Gửi yêu cầu</div>
|
||||||
|
<div class="timeline-description">Yêu cầu thiết kế đã được gửi thành công</div>
|
||||||
|
<div class="timeline-date">20/10/2024 - 16:30</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="action-buttons">
|
||||||
|
<button class="btn btn-secondary" onclick="editRequest()">
|
||||||
|
<i class="fas fa-edit"></i>
|
||||||
|
Chỉnh sửa
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary" onclick="contactSupport()">
|
||||||
|
<i class="fas fa-comments"></i>
|
||||||
|
Liên hệ
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom Navigation -->
|
||||||
|
<!--<div class="bottom-nav">
|
||||||
|
<a href="index.html" class="nav-item">
|
||||||
|
<i class="fas fa-home"></i>
|
||||||
|
<span>Trang chủ</span>
|
||||||
|
</a>
|
||||||
|
<a href="loyalty.html" class="nav-item">
|
||||||
|
<i class="fas fa-star"></i>
|
||||||
|
<span>Hội viên</span>
|
||||||
|
</a>
|
||||||
|
<a href="promotions.html" class="nav-item">
|
||||||
|
<i class="fas fa-tags"></i>
|
||||||
|
<span>Khuyến mãi</span>
|
||||||
|
</a>
|
||||||
|
<a href="notifications.html" class="nav-item">
|
||||||
|
<i class="fas fa-bell"></i>
|
||||||
|
<span>Thông báo</span>
|
||||||
|
</a>
|
||||||
|
<a href="account.html" class="nav-item active">
|
||||||
|
<i class="fas fa-user"></i>
|
||||||
|
<span>Cài đặt</span>
|
||||||
|
</a>
|
||||||
|
</div>-->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Request data mapping
|
||||||
|
const requestDatabase = {
|
||||||
|
'YC001': {
|
||||||
|
id: 'YC001',
|
||||||
|
name: 'Thiết kế nhà phố 3 tầng - Anh Minh (Quận 7)',
|
||||||
|
area: '120m²',
|
||||||
|
style: 'Hiện đại',
|
||||||
|
budget: '300-500 triệu',
|
||||||
|
status: 'completed',
|
||||||
|
statusText: 'Đã hoàn thành',
|
||||||
|
description: 'Thiết kế nhà phố 3 tầng phong cách hiện đại với 4 phòng ngủ, 3 phòng tắm, phòng khách rộng rãi và khu bếp mở. Ưu tiên sử dụng gạch men màu sáng để tạo cảm giác thoáng đãng. Tầng 1: garage, phòng khách, bếp. Tầng 2: 2 phòng ngủ, 2 phòng tắm. Tầng 3: phòng ngủ master, phòng làm việc, sân thượng.',
|
||||||
|
contact: 'SĐT: 0901234567 | Email: minh.nguyen@email.com',
|
||||||
|
createdDate: '20/10/2024',
|
||||||
|
files: ['mat-bang-hien-tai.jpg', 'ban-ve-kien-truc.dwg'],
|
||||||
|
designLink: 'https://example.com/3d-design/YC001'
|
||||||
|
},
|
||||||
|
'YC002': {
|
||||||
|
id: 'YC002',
|
||||||
|
name: 'Cải tạo căn hộ chung cư - Chị Lan (Quận 2)',
|
||||||
|
area: '85m²',
|
||||||
|
style: 'Scandinavian',
|
||||||
|
budget: '100-300 triệu',
|
||||||
|
status: 'designing',
|
||||||
|
statusText: 'Đang thiết kế',
|
||||||
|
description: 'Cải tạo căn hộ chung cư 3PN theo phong cách Scandinavian. Tối ưu không gian lưu trữ, sử dụng gạch men màu sáng và gỗ tự nhiên.',
|
||||||
|
contact: 'SĐT: 0987654321',
|
||||||
|
createdDate: '25/10/2024',
|
||||||
|
files: ['hinh-anh-hien-trang.jpg'],
|
||||||
|
designLink: null
|
||||||
|
},
|
||||||
|
'YC003': {
|
||||||
|
id: 'YC003',
|
||||||
|
name: 'Thiết kế biệt thự 2 tầng - Anh Đức (Bình Dương)',
|
||||||
|
area: '200m²',
|
||||||
|
style: 'Luxury',
|
||||||
|
budget: 'Trên 1 tỷ',
|
||||||
|
status: 'pending',
|
||||||
|
statusText: 'Chờ tiếp nhận',
|
||||||
|
description: 'Thiết kế biệt thự 2 tầng phong cách luxury với hồ bơi và sân vườn. 5 phòng ngủ, 4 phòng tắm, phòng giải trí và garage 2 xe.',
|
||||||
|
contact: 'SĐT: 0923456789 | Email: duc.le@gmail.com',
|
||||||
|
createdDate: '28/10/2024',
|
||||||
|
files: ['mat-bang-dat.pdf', 'y-tuong-thiet-ke.jpg'],
|
||||||
|
designLink: null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize page
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const requestId = urlParams.get('id') || 'YC001';
|
||||||
|
|
||||||
|
loadRequestDetail(requestId);
|
||||||
|
});
|
||||||
|
|
||||||
|
function loadRequestDetail(requestId) {
|
||||||
|
const request = requestDatabase[requestId];
|
||||||
|
if (!request) {
|
||||||
|
// Try to load from localStorage (for newly created requests)
|
||||||
|
const newRequest = localStorage.getItem('newDesignRequest');
|
||||||
|
if (newRequest) {
|
||||||
|
const requestData = JSON.parse(newRequest);
|
||||||
|
if (requestData.id === requestId) {
|
||||||
|
loadRequestData(requestData);
|
||||||
|
localStorage.removeItem('newDesignRequest');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to default
|
||||||
|
loadRequestData(requestDatabase['YC001']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadRequestData(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadRequestData(request) {
|
||||||
|
// Update basic info
|
||||||
|
document.getElementById('request-id').textContent = '#' + request.id;
|
||||||
|
document.getElementById('request-date').textContent = 'Ngày gửi: ' + request.createdDate;
|
||||||
|
document.getElementById('project-name').textContent = request.name;
|
||||||
|
document.getElementById('project-area').textContent = request.area;
|
||||||
|
document.getElementById('project-style').textContent = request.style;
|
||||||
|
document.getElementById('project-budget').textContent = request.budget;
|
||||||
|
document.getElementById('project-status').textContent = request.statusText;
|
||||||
|
document.getElementById('project-description').textContent = request.description;
|
||||||
|
document.getElementById('contact-info').textContent = request.contact;
|
||||||
|
|
||||||
|
// Update status badge
|
||||||
|
const statusBadge = document.getElementById('status-badge');
|
||||||
|
statusBadge.textContent = request.statusText;
|
||||||
|
statusBadge.className = `status-badge status-${request.status}`;
|
||||||
|
|
||||||
|
// Show completion highlight if completed
|
||||||
|
const completionHighlight = document.getElementById('completion-highlight');
|
||||||
|
if (request.status === 'completed') {
|
||||||
|
completionHighlight.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
completionHighlight.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update files list
|
||||||
|
updateFilesList(request.files);
|
||||||
|
|
||||||
|
// Update page title
|
||||||
|
document.title = `${request.id} - Chi tiết Yêu cầu Thiết kế`;
|
||||||
|
|
||||||
|
// Store design link for later use
|
||||||
|
window.currentDesignLink = request.designLink;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFilesList(files) {
|
||||||
|
const filesList = document.getElementById('files-list');
|
||||||
|
|
||||||
|
if (!files || files.length === 0) {
|
||||||
|
filesList.innerHTML = '<p style="color: #6b7280; font-style: italic;">Không có tài liệu đính kèm</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
filesList.innerHTML = files.map(fileName => {
|
||||||
|
const fileIcon = getFileIcon(fileName);
|
||||||
|
return `
|
||||||
|
<div class="file-item">
|
||||||
|
<div class="file-icon">
|
||||||
|
<i class="${fileIcon}"></i>
|
||||||
|
</div>
|
||||||
|
<div class="file-info">
|
||||||
|
<div class="file-name">${fileName}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFileIcon(fileName) {
|
||||||
|
const extension = fileName.toLowerCase().split('.').pop();
|
||||||
|
|
||||||
|
if (['jpg', 'jpeg', 'png', 'gif'].includes(extension)) return 'fas fa-image';
|
||||||
|
if (extension === 'pdf') return 'fas fa-file-pdf';
|
||||||
|
if (extension === 'dwg') return 'fas fa-drafting-compass';
|
||||||
|
if (['doc', 'docx'].includes(extension)) return 'fas fa-file-word';
|
||||||
|
return 'fas fa-file';
|
||||||
|
}
|
||||||
|
|
||||||
|
function viewDesign3D() {
|
||||||
|
if (window.currentDesignLink) {
|
||||||
|
// In real app, open the 3D design viewer
|
||||||
|
window.open(window.currentDesignLink, '_blank');
|
||||||
|
} else {
|
||||||
|
// For demo, show alert
|
||||||
|
alert('Mở trình xem thiết kế 3D...\n\nTrong ứng dụng thực tế, đây sẽ mở link thiết kế 3D của bạn.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function editRequest() {
|
||||||
|
const requestId = document.getElementById('request-id').textContent.replace('#', '');
|
||||||
|
// In real app, navigate to edit form with pre-filled data
|
||||||
|
alert(`Chỉnh sửa yêu cầu ${requestId}\n\nChức năng này sẽ mở form chỉnh sửa với dữ liệu đã điền sẵn.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function contactSupport() {
|
||||||
|
// In real app, open chat or contact form
|
||||||
|
if (confirm('Liên hệ hỗ trợ về yêu cầu thiết kế này?')) {
|
||||||
|
// Navigate to chat or contact form
|
||||||
|
window.location.href = 'chat-list.html';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function shareRequest() {
|
||||||
|
const requestId = document.getElementById('request-id').textContent;
|
||||||
|
const requestName = document.getElementById('project-name').textContent;
|
||||||
|
|
||||||
|
if (navigator.share) {
|
||||||
|
navigator.share({
|
||||||
|
title: `Yêu cầu thiết kế ${requestId}`,
|
||||||
|
text: requestName,
|
||||||
|
url: window.location.href
|
||||||
|
}).catch(console.error);
|
||||||
|
} else {
|
||||||
|
// Fallback: copy URL to clipboard
|
||||||
|
navigator.clipboard.writeText(window.location.href).then(() => {
|
||||||
|
showToast('Đã sao chép link chia sẻ!');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(message) {
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = 'fixed top-20 left-1/2 transform -translate-x-1/2 bg-green-500 text-white px-4 py-2 rounded-lg z-50 transition-all duration-300';
|
||||||
|
toast.textContent = message;
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.style.opacity = '0';
|
||||||
|
setTimeout(() => {
|
||||||
|
if (document.body.contains(toast)) {
|
||||||
|
document.body.removeChild(toast);
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
116
html/favorites.html
Normal file
116
html/favorites.html
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="vi">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Yêu thích - EuroTile Worker</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<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">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page-wrapper">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="header">
|
||||||
|
<a href="index.html" class="back-button">
|
||||||
|
<i class="fas fa-arrow-left"></i>
|
||||||
|
</a>
|
||||||
|
<h1 class="header-title">Yêu thích</h1>
|
||||||
|
<a href="cart.html" class="back-button" style="position: relative;">
|
||||||
|
<i class="fas fa-shopping-cart"></i>
|
||||||
|
<span class="badge">3</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<!-- Search Bar -->
|
||||||
|
<!--<div class="search-bar">
|
||||||
|
<i class="fas fa-search search-icon"></i>
|
||||||
|
<input type="text" class="search-input" placeholder="Tìm kiếm sản phẩm...">
|
||||||
|
</div>-->
|
||||||
|
|
||||||
|
<!-- Filter Pills -->
|
||||||
|
<div class="filter-container">
|
||||||
|
<button class="filter-pill active">Tất cả</button>
|
||||||
|
<button class="filter-pill">Gạch lát nền</button>
|
||||||
|
<button class="filter-pill">Gạch ốp tường</button>
|
||||||
|
<button class="filter-pill">Gạch trang trí</button>
|
||||||
|
<button class="filter-pill">Gạch ngoài trời</button>
|
||||||
|
<button class="filter-pill">Phụ kiện</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Product Grid -->
|
||||||
|
<div class="product-grid">
|
||||||
|
<!-- Product 1 -->
|
||||||
|
<div class="product-card">
|
||||||
|
<img src="https://www.eurotile.vn/pictures/catalog/product/0-gachkholon/cat-tuong/CAT-S01G-1.jpg" alt="Gạch men" class="product-image" onclick="window.location.href='product-detail.html'">
|
||||||
|
<div class="product-info">
|
||||||
|
<div class="product-name" onclick="window.location.href='product-detail.html'">Gạch Cát Tường 1200x1200</div>
|
||||||
|
<div class="product-price">450.000đ/m²</div>
|
||||||
|
<div class="product-actions">
|
||||||
|
<button class="btn btn-primary btn-sm btn-add-cart">
|
||||||
|
<i class="fas fa-cart-plus"></i> Thêm vào giỏ
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary btn-sm btn-360" onclick="window.location.href='https://design.eurotile.vn/pub/tool/panorama/show?obsPlanId=3FO3H1VE59R5&locale=en_US&_gl=1*1udzqeo*_gcl_au*MTI3NjIxMzY1NS4xNzU5NzE2Mjg5'">
|
||||||
|
<i class="fas fa-cube"></i> Phối cảnh 360°
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!--<button class="btn btn-primary btn-sm btn-block">
|
||||||
|
<i class="fas fa-cart-plus"></i> Thêm vào giỏ
|
||||||
|
</button>-->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Product 5 -->
|
||||||
|
<div class="product-card">
|
||||||
|
<img src="https://images.unsplash.com/photo-1615874694520-474822394e73?w=300&h=300&fit=crop" alt="Gạch ceramic" class="product-image">
|
||||||
|
<div class="product-info">
|
||||||
|
<div class="product-name">Gạch ceramic chống trượt</div>
|
||||||
|
<div class="product-price">380.000đ/m²</div>
|
||||||
|
<button class="btn btn-primary btn-sm btn-block">
|
||||||
|
<i class="fas fa-cart-plus"></i> Thêm vào giỏ
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary btn-sm btn-360" onclick="window.location.href='https://design.eurotile.vn/pub/tool/panorama/show?obsPlanId=3FO3H1VE59R5&locale=en_US&_gl=1*1udzqeo*_gcl_au*MTI3NjIxMzY1NS4xNzU5NzE2Mjg5'">
|
||||||
|
<i class="fas fa-cube"></i> Phối cảnh 360°
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Product 3 -->
|
||||||
|
<div class="product-card">
|
||||||
|
<img src="https://images.unsplash.com/photo-1615971677499-5467cbab01c0?w=300&h=300&fit=crop" alt="Gạch mosaic" class="product-image">
|
||||||
|
<div class="product-info">
|
||||||
|
<div class="product-name">Gạch mosaic trang trí</div>
|
||||||
|
<div class="product-price">320.000đ/m²</div>
|
||||||
|
<button class="btn btn-primary btn-sm btn-block">
|
||||||
|
<i class="fas fa-cart-plus"></i> Thêm vào giỏ
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary btn-sm btn-360" onclick="window.location.href='https://design.eurotile.vn/pub/tool/panorama/show?obsPlanId=3FO3H1VE59R5&locale=en_US&_gl=1*1udzqeo*_gcl_au*MTI3NjIxMzY1NS4xNzU5NzE2Mjg5'">
|
||||||
|
<i class="fas fa-cube"></i> Phối cảnh 360°
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Product 4 -->
|
||||||
|
<div class="product-card">
|
||||||
|
<img src="https://images.unsplash.com/photo-1600566753190-17f0baa2a6c3?w=300&h=300&fit=crop" alt="Gạch 3D" class="product-image">
|
||||||
|
<div class="product-info">
|
||||||
|
<div class="product-name">Gạch 3D họa tiết</div>
|
||||||
|
<div class="product-price">750.000đ/m²</div>
|
||||||
|
<button class="btn btn-primary btn-sm btn-block">
|
||||||
|
<i class="fas fa-cart-plus"></i> Thêm vào giỏ
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary btn-sm btn-360" onclick="window.location.href='https://design.eurotile.vn/pub/tool/panorama/show?obsPlanId=3FO3H1VE59R5&locale=en_US&_gl=1*1udzqeo*_gcl_au*MTI3NjIxMzY1NS4xNzU5NzE2Mjg5'">
|
||||||
|
<i class="fas fa-cube"></i> Phối cảnh 360°
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,741 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="vi">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Trang chủ - EuroTile Worker</title>
|
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
|
||||||
<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">
|
|
||||||
<style>
|
|
||||||
/* Policy Modal Styles */
|
|
||||||
.policy-modal {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.8);
|
|
||||||
z-index: 9999;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 1rem;
|
|
||||||
opacity: 0;
|
|
||||||
visibility: hidden;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.policy-modal.show {
|
|
||||||
opacity: 1;
|
|
||||||
visibility: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
.policy-modal-content {
|
|
||||||
background: white;
|
|
||||||
border-radius: 1.5rem;
|
|
||||||
max-width: 480px;
|
|
||||||
width: 100%;
|
|
||||||
max-height: 90vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
transform: scale(0.9) translateY(50px);
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
.policy-modal.show .policy-modal-content {
|
|
||||||
transform: scale(1) translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.policy-header {
|
|
||||||
background: linear-gradient(135deg, #2563eb, #1d4ed8);
|
|
||||||
color: white;
|
|
||||||
padding: 2rem 1.5rem 1.5rem;
|
|
||||||
text-align: center;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.policy-header::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
height: 20px;
|
|
||||||
background: white;
|
|
||||||
border-radius: 20px 20px 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.policy-icon {
|
|
||||||
width: 80px;
|
|
||||||
height: 80px;
|
|
||||||
background: rgba(255, 255, 255, 0.2);
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
margin: 0 auto 1rem;
|
|
||||||
animation: bounce 2s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.policy-icon i {
|
|
||||||
font-size: 2rem;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes bounce {
|
|
||||||
0%, 20%, 50%, 80%, 100% {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
40% {
|
|
||||||
transform: translateY(-10px);
|
|
||||||
}
|
|
||||||
60% {
|
|
||||||
transform: translateY(-5px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.policy-title {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 700;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.policy-subtitle {
|
|
||||||
font-size: 1rem;
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.policy-body {
|
|
||||||
flex: 1;
|
|
||||||
padding: 1.5rem;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.welcome-text {
|
|
||||||
font-size: 1rem;
|
|
||||||
color: #1e293b;
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.benefits-section {
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.benefits-title {
|
|
||||||
font-size: 1.125rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #1e293b;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.benefits-list {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.benefit-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 1rem;
|
|
||||||
padding: 0.75rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
background: #f8fafc;
|
|
||||||
border-radius: 0.75rem;
|
|
||||||
border-left: 4px solid #2563eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.benefit-icon {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
background: linear-gradient(135deg, #2563eb, #1d4ed8);
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: white;
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.benefit-content {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.benefit-title {
|
|
||||||
font-weight: 600;
|
|
||||||
color: #1e293b;
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.benefit-desc {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: #64748b;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.policy-footer {
|
|
||||||
padding: 1.5rem;
|
|
||||||
border-top: 1px solid #e2e8f0;
|
|
||||||
background: #f8fafc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.policy-actions {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.policy-accept-btn {
|
|
||||||
background: linear-gradient(135deg, #2563eb, #1d4ed8);
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: 0.75rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.policy-accept-btn:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 4px 15px rgba(37, 99, 235, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.policy-note {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: #64748b;
|
|
||||||
text-align: center;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.policy-modal {
|
|
||||||
padding: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.policy-header {
|
|
||||||
padding: 1.5rem 1rem 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.policy-title {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.policy-body {
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.policy-footer {
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* News Section Styles */
|
|
||||||
.news-slider-container {
|
|
||||||
overflow-x: auto;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.news-slider-wrapper {
|
|
||||||
display: flex;
|
|
||||||
gap: 16px;
|
|
||||||
padding-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.news-card {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 280px;
|
|
||||||
background: white;
|
|
||||||
border-radius: 12px;
|
|
||||||
overflow: hidden;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.news-card:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.news-image {
|
|
||||||
width: 100%;
|
|
||||||
height: 140px;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
.news-content {
|
|
||||||
padding: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.news-title {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #1e293b;
|
|
||||||
line-height: 1.3;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.news-desc {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #64748b;
|
|
||||||
line-height: 1.4;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.news-meta {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
font-size: 11px;
|
|
||||||
color: #94a3b8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.news-meta span {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="page-wrapper">
|
|
||||||
<div class="container">
|
|
||||||
<!-- Member Card Diamond -->
|
|
||||||
<div class="member-card member-card-diamond">
|
|
||||||
<div class="d-flex justify-between align-center">
|
|
||||||
<div>
|
|
||||||
<h3 style="color: white; font-size: 24px; margin-bottom: 4px;">EUROTILE</h3>
|
|
||||||
<p style="color: rgba(255,255,255,0.9); font-size: 11px;">ARCHITECT MEMBERSHIP</p>
|
|
||||||
</div>
|
|
||||||
<div style="text-align: right;">
|
|
||||||
<p style="color: rgba(255,255,255,0.8); font-size: 11px;">Valid through</p>
|
|
||||||
<p style="color: white; font-size: 14px; font-weight: 500;">31/12/2021</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex justify-between align-center" style="margin-top: auto;">
|
|
||||||
<div>
|
|
||||||
<p style="color: white; font-size: 18px; font-weight: 600; margin-bottom: 4px;">La Nguyen Quynh</p>
|
|
||||||
<p style="color: rgba(255,255,255,0.9); font-size: 12px;">CLASS: <span style="font-weight: 600;">DIAMOND</span></p>
|
|
||||||
<p style="color: rgba(255,255,255,0.9); font-size: 12px;">Points: <span style="font-weight: 600;">9750</span></p>
|
|
||||||
</div>
|
|
||||||
<div style="background: white; padding: 8px; border-radius: 8px;">
|
|
||||||
<img src="https://api.qrserver.com/v1/create-qr-code/?size=60x60&data=0983441099" alt="QR Code" style="width: 60px; height: 60px;">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Promotions Section -->
|
|
||||||
<div class="mb-3">
|
|
||||||
<h2>Chương trình ưu đãi</h2>
|
|
||||||
<div class="slider-container">
|
|
||||||
<div class="slider-wrapper">
|
|
||||||
<div class="slider-item">
|
|
||||||
<img src="https://images.unsplash.com/photo-1615971677499-5467cbab01c0?w=280&h=140&fit=crop" alt="Khuyến mãi 1">
|
|
||||||
<div style="padding: 12px; background: white;">
|
|
||||||
<h3 style="font-size: 14px;">Mua công nhắc - Khuyến mãi cảng lớn</h3>
|
|
||||||
<p class="text-small text-muted">Giảm đến 30% cho đơn hàng từ 10 triệu</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="slider-item">
|
|
||||||
<img src="https://images.unsplash.com/photo-1600607687644-aac4c3eac7f4?w=280&h=140&fit=crop" alt="Khuyến mãi 2">
|
|
||||||
<div style="padding: 12px; background: white;">
|
|
||||||
<h3 style="font-size: 14px;">Keo chà ron tặng kèm</h3>
|
|
||||||
<p class="text-small text-muted">Mua gạch Eurotile tặng keo chà ron cao cấp</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="slider-item">
|
|
||||||
<img src="https://images.unsplash.com/photo-1565538420870-da08ff96a207?w=280&h=140&fit=crop" alt="Khuyến mãi 3">
|
|
||||||
<div style="padding: 12px; background: white;">
|
|
||||||
<h3 style="font-size: 14px;">Ưu đãi đặc biệt thành viên VIP</h3>
|
|
||||||
<p class="text-small text-muted">Chiết khấu thêm 5% cho thành viên Diamond</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- News Section -->
|
|
||||||
<div class="mb-3">
|
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
|
||||||
<h2>Tin tức & Chuyên môn</h2>
|
|
||||||
<a href="news-list.html" style="color: #2563eb; font-size: 14px; text-decoration: none; font-weight: 500;">
|
|
||||||
Xem tất cả <i class="fas fa-arrow-right" style="margin-left: 4px;"></i>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div class="news-slider-container">
|
|
||||||
<div class="news-slider-wrapper">
|
|
||||||
<div class="news-card">
|
|
||||||
<img src="https://images.unsplash.com/photo-1503387762-592deb58ef4e?w=280&h=140&fit=crop" alt="Tin tức 1" class="news-image">
|
|
||||||
<div class="news-content">
|
|
||||||
<h3 class="news-title">5 xu hướng gạch men phòng tắm được ưa chuộng năm 2024</h3>
|
|
||||||
<p class="news-desc">Khám phá những mẫu gạch men hiện đại, sang trọng cho không gian phòng tắm.</p>
|
|
||||||
<div class="news-meta">
|
|
||||||
<span><i class="fas fa-calendar"></i> 15/11/2024</span>
|
|
||||||
<span><i class="fas fa-eye"></i> 2.3K lượt xem</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="news-card">
|
|
||||||
<img src="https://images.unsplash.com/photo-1586023492125-27b2c045efd7?w=280&h=140&fit=crop" alt="Tin tức 2" class="news-image">
|
|
||||||
<div class="news-content">
|
|
||||||
<h3 class="news-title">Hướng dẫn thi công gạch granite 60x60 chuyên nghiệp</h3>
|
|
||||||
<p class="news-desc">Quy trình thi công chi tiết từ A-Z cho thầy thợ xây dựng.</p>
|
|
||||||
<div class="news-meta">
|
|
||||||
<span><i class="fas fa-calendar"></i> 12/11/2024</span>
|
|
||||||
<span><i class="fas fa-eye"></i> 1.8K lượt xem</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="news-card">
|
|
||||||
<img src="https://images.unsplash.com/photo-1560448204-e02f11c3d0e2?w=280&h=140&fit=crop" alt="Tin tức 3" class="news-image">
|
|
||||||
<div class="news-content">
|
|
||||||
<h3 class="news-title">Bảng giá gạch men cao cấp mới nhất tháng 11/2024</h3>
|
|
||||||
<p class="news-desc">Cập nhật bảng giá chi tiết các dòng sản phẩm gạch men nhập khẩu.</p>
|
|
||||||
<div class="news-meta">
|
|
||||||
<span><i class="fas fa-calendar"></i> 10/11/2024</span>
|
|
||||||
<span><i class="fas fa-eye"></i> 3.1K lượt xem</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Products & Cart Section -->
|
|
||||||
<div class="card">
|
|
||||||
<h3 class="card-title">Sản phẩm & Giỏ hàng</h3>
|
|
||||||
<div class="feature-grid">
|
|
||||||
<a href="products.html" class="feature-item">
|
|
||||||
<div class="feature-icon">
|
|
||||||
<i class="fas fa-th-large"></i>
|
|
||||||
</div>
|
|
||||||
<div class="feature-title">Sản phẩm</div>
|
|
||||||
</a>
|
|
||||||
<a href="cart.html" class="feature-item">
|
|
||||||
<div class="feature-icon">
|
|
||||||
<i class="fas fa-shopping-cart"></i>
|
|
||||||
</div>
|
|
||||||
<div class="feature-title">Giỏ hàng</div>
|
|
||||||
<span class="badge">3</span>
|
|
||||||
</a>
|
|
||||||
<a href="#" class="feature-item">
|
|
||||||
<div class="feature-icon">
|
|
||||||
<i class="fas fa-heart"></i>
|
|
||||||
</div>
|
|
||||||
<div class="feature-title">Yêu thích</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Loyalty Section -->
|
|
||||||
<div class="card">
|
|
||||||
<h3 class="card-title">Khách hàng thân thiết</h3>
|
|
||||||
<div class="feature-grid">
|
|
||||||
<a href="loyalty-rewards.html" class="feature-item">
|
|
||||||
<div class="feature-icon">
|
|
||||||
<i class="fas fa-gift"></i>
|
|
||||||
</div>
|
|
||||||
<div class="feature-title">Đổi quà</div>
|
|
||||||
</a>
|
|
||||||
<a href="points-history.html" class="feature-item">
|
|
||||||
<div class="feature-icon">
|
|
||||||
<i class="fas fa-history"></i>
|
|
||||||
</div>
|
|
||||||
<div class="feature-title">Lịch sử điểm</div>
|
|
||||||
</a>
|
|
||||||
<a href="#" class="feature-item">
|
|
||||||
<div class="feature-icon">
|
|
||||||
<i class="fas fa-user-plus"></i>
|
|
||||||
</div>
|
|
||||||
<div class="feature-title">Giới thiệu bạn</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Orders & Payment Section -->
|
|
||||||
<div class="card">
|
|
||||||
<h3 class="card-title">Yêu cầu báo giá & báo giá</h3>
|
|
||||||
<div class="grid grid-2">
|
|
||||||
<a href="#" class="feature-item">
|
|
||||||
<div class="feature-icon">
|
|
||||||
<i class="fas fa-file-alt"></i>
|
|
||||||
</div>
|
|
||||||
<div class="feature-title">Yêu cầu báo giá</div>
|
|
||||||
</a>
|
|
||||||
<a href="#" class="feature-item">
|
|
||||||
<div class="feature-icon">
|
|
||||||
<i class="fas fa-file-invoice"></i>
|
|
||||||
</div>
|
|
||||||
<div class="feature-title">Báo giá</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Orders & Payment Section -->
|
|
||||||
<div class="card">
|
|
||||||
<h3 class="card-title">Đơn hàng & thanh toán</h3>
|
|
||||||
<div class="grid grid-2">
|
|
||||||
<a href="#" class="feature-item">
|
|
||||||
<div class="feature-icon">
|
|
||||||
<i class="fas fa-box"></i>
|
|
||||||
</div>
|
|
||||||
<div class="feature-title">Đơn hàng</div>
|
|
||||||
</a>
|
|
||||||
<a href="#" class="feature-item">
|
|
||||||
<div class="feature-icon">
|
|
||||||
<i class="fas fa-credit-card"></i>
|
|
||||||
</div>
|
|
||||||
<div class="feature-title">Thanh toán</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Collaboration & Reports Section -->
|
|
||||||
<div class="card">
|
|
||||||
<h3 class="card-title">Công trình, hợp đồng & báo cáo</h3>
|
|
||||||
<div class="feature-grid">
|
|
||||||
<a href="#" class="feature-item">
|
|
||||||
<div class="feature-icon">
|
|
||||||
<i class="fas fa-building"></i>
|
|
||||||
</div>
|
|
||||||
<div class="feature-title">Công trình</div>
|
|
||||||
</a>
|
|
||||||
<a href="#" class="feature-item">
|
|
||||||
<div class="feature-icon">
|
|
||||||
<i class="fas fa-handshake"></i>
|
|
||||||
</div>
|
|
||||||
<div class="feature-title">Hợp đồng</div>
|
|
||||||
</a>
|
|
||||||
<a href="#" class="feature-item">
|
|
||||||
<div class="feature-icon">
|
|
||||||
<i class="fas fa-chart-line"></i>
|
|
||||||
</div>
|
|
||||||
<div class="feature-title">Tổng quan</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Floating Action Button -->
|
|
||||||
<button class="fab">
|
|
||||||
<i class="fas fa-comments"></i>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Bottom Navigation -->
|
|
||||||
<div class="bottom-nav">
|
|
||||||
<a href="index.html" class="nav-item active">
|
|
||||||
<i class="fas fa-home nav-icon"></i>
|
|
||||||
<span class="nav-label">Trang chủ</span>
|
|
||||||
</a>
|
|
||||||
<a href="loyalty.html" class="nav-item">
|
|
||||||
<i class="fas fa-crown nav-icon"></i>
|
|
||||||
<span class="nav-label">Hội viên</span>
|
|
||||||
</a>
|
|
||||||
<a href="promotions.html" class="nav-item">
|
|
||||||
<i class="fas fa-tags nav-icon"></i>
|
|
||||||
<span class="nav-label">Khuyến mãi</span>
|
|
||||||
</a>
|
|
||||||
<a href="notifications.html" class="nav-item">
|
|
||||||
<i class="fas fa-bell nav-icon"></i>
|
|
||||||
<span class="nav-label">Thông báo</span>
|
|
||||||
<span class="badge">5</span>
|
|
||||||
</a>
|
|
||||||
<a href="account.html" class="nav-item">
|
|
||||||
<i class="fas fa-user nav-icon"></i>
|
|
||||||
<span class="nav-label">Cài đặt</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Policy Modal -->
|
|
||||||
<div id="policyModal" class="policy-modal">
|
|
||||||
<div class="policy-modal-content">
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="policy-header">
|
|
||||||
<div class="policy-icon">
|
|
||||||
<i class="fas fa-hands-helping"></i>
|
|
||||||
</div>
|
|
||||||
<h2 class="policy-title">Chào mừng bạn đến với Worker App!</h2>
|
|
||||||
<p class="policy-subtitle">Nền tảng hỗ trợ chuyên nghiệp dành cho thầu thợ & kiến trúc sư</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Body -->
|
|
||||||
<div class="policy-body">
|
|
||||||
<p class="welcome-text">
|
|
||||||
Cảm ơn bạn đã tham gia cộng đồng Worker App! Chúng tôi cam kết mang đến những quyền lợi tốt nhất
|
|
||||||
và hỗ trợ bạn thành công trong mọi dự án.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Benefits Section -->
|
|
||||||
<div class="benefits-section">
|
|
||||||
<h3 class="benefits-title">
|
|
||||||
<i class="fas fa-star"></i>
|
|
||||||
Quyền lợi đặc biệt của bạn
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<ul class="benefits-list">
|
|
||||||
<li class="benefit-item">
|
|
||||||
<div class="benefit-icon">
|
|
||||||
<i class="fas fa-percentage"></i>
|
|
||||||
</div>
|
|
||||||
<div class="benefit-content">
|
|
||||||
<div class="benefit-title">Chiết khấu độc quyền</div>
|
|
||||||
<div class="benefit-desc">Giảm giá đến 20% cho tất cả sản phẩm, tăng theo hạng thành viên</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li class="benefit-item">
|
|
||||||
<div class="benefit-icon">
|
|
||||||
<i class="fas fa-coins"></i>
|
|
||||||
</div>
|
|
||||||
<div class="benefit-content">
|
|
||||||
<div class="benefit-title">Tích điểm thưởng</div>
|
|
||||||
<div class="benefit-desc">Nhận 1 điểm cho mỗi 100k chi tiêu, đổi điểm lấy quà hấp dẫn</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li class="benefit-item">
|
|
||||||
<div class="benefit-icon">
|
|
||||||
<i class="fas fa-shipping-fast"></i>
|
|
||||||
</div>
|
|
||||||
<div class="benefit-content">
|
|
||||||
<div class="benefit-title">Giao hàng ưu tiên</div>
|
|
||||||
<div class="benefit-desc">Miễn phí vận chuyển cho đơn từ 5 triệu, giao hàng nhanh trong 24h</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li class="benefit-item">
|
|
||||||
<div class="benefit-icon">
|
|
||||||
<i class="fas fa-user-tie"></i>
|
|
||||||
</div>
|
|
||||||
<div class="benefit-content">
|
|
||||||
<div class="benefit-title">Tư vấn chuyên nghiệp</div>
|
|
||||||
<div class="benefit-desc">Hỗ trợ thiết kế 3D miễn phí, tư vấn kỹ thuật 24/7</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li class="benefit-item">
|
|
||||||
<div class="benefit-icon">
|
|
||||||
<i class="fas fa-calendar-star"></i>
|
|
||||||
</div>
|
|
||||||
<div class="benefit-content">
|
|
||||||
<div class="benefit-title">Sự kiện độc quyền</div>
|
|
||||||
<div class="benefit-desc">Tham gia hội thảo, workshop và các sự kiện ngành chỉ dành cho thành viên</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li class="benefit-item">
|
|
||||||
<div class="benefit-icon">
|
|
||||||
<i class="fas fa-gift"></i>
|
|
||||||
</div>
|
|
||||||
<div class="benefit-content">
|
|
||||||
<div class="benefit-title">Quà tặng sinh nhật</div>
|
|
||||||
<div class="benefit-desc">Voucher và điểm thưởng đặc biệt vào dịp sinh nhật</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Important Note -->
|
|
||||||
<div style="background: #fef3c7; border: 1px solid #f59e0b; border-radius: 0.75rem; padding: 1rem; margin-top: 1rem;">
|
|
||||||
<div style="display: flex; align-items: flex-start; gap: 0.75rem;">
|
|
||||||
<i class="fas fa-exclamation-triangle" style="color: #f59e0b; margin-top: 0.25rem;"></i>
|
|
||||||
<div>
|
|
||||||
<div style="font-weight: 600; color: #92400e; margin-bottom: 0.5rem;">Điều khoản sử dụng</div>
|
|
||||||
<div style="font-size: 0.875rem; color: #92400e; line-height: 1.4;">
|
|
||||||
Bằng cách sử dụng ứng dụng, bạn đồng ý với các điều khoản dịch vụ và chính sách bảo mật của chúng tôi.
|
|
||||||
Các quyền lợi có thể thay đổi theo thời gian.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Footer -->
|
|
||||||
<div class="policy-footer">
|
|
||||||
<div class="policy-actions">
|
|
||||||
<button class="policy-accept-btn" onclick="closePolicyModal()">
|
|
||||||
<i class="fas fa-check"></i>
|
|
||||||
Tôi đã đọc và đồng ý
|
|
||||||
</button>
|
|
||||||
<p class="policy-note">
|
|
||||||
Bằng cách nhấn "Đồng ý", bạn xác nhận đã đọc và hiểu các điều khoản trên.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// Show policy modal on page load (for demo purposes)
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
// Check if user has seen the policy before
|
|
||||||
const hasSeenPolicy = localStorage.getItem('hasSeenPolicy');
|
|
||||||
|
|
||||||
if (!hasSeenPolicy) {
|
|
||||||
setTimeout(() => {
|
|
||||||
showPolicyModal();
|
|
||||||
}, 1000); // Show after 1 second for better UX
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function showPolicyModal() {
|
|
||||||
const modal = document.getElementById('policyModal');
|
|
||||||
modal.classList.add('show');
|
|
||||||
document.body.style.overflow = 'hidden'; // Prevent background scrolling
|
|
||||||
}
|
|
||||||
|
|
||||||
function closePolicyModal() {
|
|
||||||
const modal = document.getElementById('policyModal');
|
|
||||||
modal.classList.remove('show');
|
|
||||||
document.body.style.overflow = ''; // Restore scrolling
|
|
||||||
|
|
||||||
// Remember that user has seen the policy
|
|
||||||
localStorage.setItem('hasSeenPolicy', 'true');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close modal when clicking outside
|
|
||||||
document.getElementById('policyModal').addEventListener('click', function(e) {
|
|
||||||
if (e.target === this) {
|
|
||||||
closePolicyModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Temporary function to show modal for testing (remove in production)
|
|
||||||
function testShowModal() {
|
|
||||||
localStorage.removeItem('hasSeenPolicy');
|
|
||||||
showPolicyModal();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add a way to show modal again for testing
|
|
||||||
// Double-click on the member card to show modal again
|
|
||||||
document.querySelector('.member-card').addEventListener('dblclick', function() {
|
|
||||||
showPolicyModal();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -95,6 +95,12 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<h3 class="card-title">Khách hàng thân thiết</h3>
|
<h3 class="card-title">Khách hàng thân thiết</h3>
|
||||||
<div class="feature-grid">
|
<div class="feature-grid">
|
||||||
|
<a href="points-record.html" class="feature-item">
|
||||||
|
<div class="feature-icon">
|
||||||
|
<i class="fas fa-plus-circle"></i>
|
||||||
|
</div>
|
||||||
|
<div class="feature-title">Ghi nhận điểm</div>
|
||||||
|
</a>
|
||||||
<a href="loyalty-rewards.html" class="feature-item">
|
<a href="loyalty-rewards.html" class="feature-item">
|
||||||
<div class="feature-icon">
|
<div class="feature-icon">
|
||||||
<i class="fas fa-gift"></i>
|
<i class="fas fa-gift"></i>
|
||||||
@@ -107,17 +113,17 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="feature-title">Lịch sử điểm</div>
|
<div class="feature-title">Lịch sử điểm</div>
|
||||||
</a>
|
</a>
|
||||||
<a href="referral.html" class="feature-item">
|
<!--<a href="referral.html" class="feature-item">
|
||||||
<div class="feature-icon">
|
<div class="feature-icon">
|
||||||
<i class="fas fa-user-plus"></i>
|
<i class="fas fa-user-plus"></i>
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-title">Giới thiệu bạn</div>
|
<div class="feature-title">Giới thiệu bạn</div>
|
||||||
</a>
|
</a>-->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Orders & Payment Section -->
|
<!-- Orders & Payment Section -->
|
||||||
<div class="card">
|
<!--<div class="card">
|
||||||
<h3 class="card-title">Yêu cầu báo giá & báo giá</h3>
|
<h3 class="card-title">Yêu cầu báo giá & báo giá</h3>
|
||||||
<div class="grid grid-2">
|
<div class="grid grid-2">
|
||||||
<a href="quotes-list.html" class="feature-item">
|
<a href="quotes-list.html" class="feature-item">
|
||||||
@@ -133,12 +139,18 @@
|
|||||||
<div class="feature-title">Báo giá</div>
|
<div class="feature-title">Báo giá</div>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>-->
|
||||||
|
|
||||||
<!-- Orders & Payment Section -->
|
<!-- Orders & Payment Section -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3 class="card-title">Đơn hàng & thanh toán</h3>
|
<h3 class="card-title">Đơn hàng & thanh toán</h3>
|
||||||
<div class="grid grid-2">
|
<div class="feature-grid">
|
||||||
|
<a href="quotes-list.html" class="feature-item">
|
||||||
|
<div class="feature-icon">
|
||||||
|
<i class="fas fa-file-alt"></i>
|
||||||
|
</div>
|
||||||
|
<div class="feature-title">Yêu cầu báo giá</div>
|
||||||
|
</a>
|
||||||
<a href="orders.html" class="feature-item">
|
<a href="orders.html" class="feature-item">
|
||||||
<div class="feature-icon">
|
<div class="feature-icon">
|
||||||
<i class="fas fa-box"></i>
|
<i class="fas fa-box"></i>
|
||||||
@@ -147,7 +159,7 @@
|
|||||||
</a>
|
</a>
|
||||||
<a href="payments.html" class="feature-item">
|
<a href="payments.html" class="feature-item">
|
||||||
<div class="feature-icon">
|
<div class="feature-icon">
|
||||||
<i class="fas fa-credit-card"></i>
|
<i class="fas fa-file-invoice-dollar"></i>
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-title">Thanh toán</div>
|
<div class="feature-title">Thanh toán</div>
|
||||||
</a>
|
</a>
|
||||||
@@ -182,7 +194,7 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<h3 class="card-title">Nhà mẫu, dự án & tin tức</h3>
|
<h3 class="card-title">Nhà mẫu, dự án & tin tức</h3>
|
||||||
<div class="feature-grid">
|
<div class="feature-grid">
|
||||||
<a href="nha-mau-list.html" class="feature-item">
|
<a href="nha-mau.html" class="feature-item">
|
||||||
<div class="feature-icon">
|
<div class="feature-icon">
|
||||||
<!--<i class="fas fa-building"></i>-->
|
<!--<i class="fas fa-building"></i>-->
|
||||||
<i class="fa-solid fa-house-chimney"></i>
|
<i class="fa-solid fa-house-chimney"></i>
|
||||||
@@ -198,7 +210,7 @@
|
|||||||
</a>
|
</a>
|
||||||
<a href="news-list.html" class="feature-item">
|
<a href="news-list.html" class="feature-item">
|
||||||
<div class="feature-icon">
|
<div class="feature-icon">
|
||||||
<i class="fas fa-chart-line"></i>
|
<i class="fa-solid fa-newspaper"></i>
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-title">Tin tức</div>
|
<div class="feature-title">Tin tức</div>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
757
html/my-gift-detail.html
Normal file
757
html/my-gift-detail.html
Normal file
@@ -0,0 +1,757 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="vi">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Chi tiết Quà tặng - EuroTile Worker</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<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">
|
||||||
|
<style>
|
||||||
|
.gift-detail-container {
|
||||||
|
min-height: calc(100vh - 120px);
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gift-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 30px 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
box-shadow: 0 10px 30px rgba(0,0,0,0.1);
|
||||||
|
text-align: center;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gift-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 4px;
|
||||||
|
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gift-icon {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0 auto 20px;
|
||||||
|
color: white;
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gift-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #2d3748;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gift-description {
|
||||||
|
color: #718096;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voucher-code-section {
|
||||||
|
background: #f7fafc;
|
||||||
|
border: 2px dashed #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 20px 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voucher-code {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #2d3748;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-button {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-button:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-code-section {
|
||||||
|
background: white;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-code-container {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border: 2px solid #e9ecef;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 15px 0;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-code-placeholder {
|
||||||
|
width: 200px;
|
||||||
|
height: 200px;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 2px dashed #dee2e6;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-direction: column;
|
||||||
|
color: #6c757d;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-code-placeholder i {
|
||||||
|
font-size: 3rem;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
color: #adb5bd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gift-info-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 15px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item {
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #6c757d;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #2d3748;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-valid {
|
||||||
|
background: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-used {
|
||||||
|
background: #d1ecf1;
|
||||||
|
color: #0c5460;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-expired {
|
||||||
|
background: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-use {
|
||||||
|
flex: 1;
|
||||||
|
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-use:hover:not(:disabled) {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 5px 15px rgba(40, 167, 69, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-use:disabled {
|
||||||
|
background: #6c757d;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-share {
|
||||||
|
background: #ffffff;
|
||||||
|
color: #667eea;
|
||||||
|
border: 2px solid #667eea;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 15px 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-share:hover {
|
||||||
|
background: #667eea;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terms-section {
|
||||||
|
background: white;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-top: 20px;
|
||||||
|
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terms-title {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #2d3748;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terms-list {
|
||||||
|
color: #4a5568;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terms-list li {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding-left: 10px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terms-list li::before {
|
||||||
|
content: '•';
|
||||||
|
color: #667eea;
|
||||||
|
font-weight: bold;
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-history {
|
||||||
|
background: white;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-top: 20px;
|
||||||
|
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-title {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #2d3748;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-empty {
|
||||||
|
text-align: center;
|
||||||
|
color: #9ca3af;
|
||||||
|
padding: 20px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: #28a745;
|
||||||
|
color: white;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||||
|
z-index: 1000;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.show {
|
||||||
|
display: flex;
|
||||||
|
animation: slideDown 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideDown {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-50%) translateY(-20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(-50%) translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.gift-detail-container {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gift-card {
|
||||||
|
padding: 20px 15px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voucher-code {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-code-placeholder {
|
||||||
|
width: 150px;
|
||||||
|
height: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gift-info-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page-wrapper">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="header">
|
||||||
|
<a href="my-gifts.html" class="back-button">
|
||||||
|
<i class="fas fa-arrow-left"></i>
|
||||||
|
</a>
|
||||||
|
<h1 class="header-title">Chi tiết Quà tặng</h1>
|
||||||
|
<button class="icon-button" onclick="shareGift()">
|
||||||
|
<i class="fas fa-share-alt"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="gift-detail-container">
|
||||||
|
<!-- Gift Card -->
|
||||||
|
<div class="gift-card">
|
||||||
|
<div class="gift-icon">
|
||||||
|
<i class="fas fa-gift" id="giftIcon"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="status-badge" id="statusBadge">
|
||||||
|
Còn hạn sử dụng
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="gift-title" id="giftTitle">
|
||||||
|
Voucher giảm 100.000đ
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p class="gift-description" id="giftDescription">
|
||||||
|
Áp dụng cho đơn hàng từ 2.000.000đ
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Voucher Code Section -->
|
||||||
|
<div class="voucher-code-section">
|
||||||
|
<div class="voucher-code" id="voucherCode">
|
||||||
|
SAVE100K
|
||||||
|
</div>
|
||||||
|
<button class="copy-button" onclick="copyVoucherCode()">
|
||||||
|
<i class="fas fa-copy"></i>
|
||||||
|
Sao chép mã
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Gift Info Grid -->
|
||||||
|
<div class="gift-info-grid">
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="info-label">Hạn sử dụng</div>
|
||||||
|
<div class="info-value" id="expiryDate">31/12/2023</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="info-label">Giá trị</div>
|
||||||
|
<div class="info-value" id="giftValue">100.000đ</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="action-buttons">
|
||||||
|
<button class="btn-use" id="useButton" onclick="useGift()">
|
||||||
|
<i class="fas fa-check"></i>
|
||||||
|
Sử dụng ngay
|
||||||
|
</button>
|
||||||
|
<button class="btn-share" onclick="shareGift()">
|
||||||
|
<i class="fas fa-share-alt"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- QR Code Section -->
|
||||||
|
<div class="qr-code-section">
|
||||||
|
<h3 style="font-weight: 700; color: #2d3748; margin-bottom: 15px; text-align: center;">
|
||||||
|
<i class="fas fa-qrcode"></i>
|
||||||
|
Mã QR Code
|
||||||
|
</h3>
|
||||||
|
<p style="color: #6c757d; font-size: 0.9rem; margin-bottom: 15px;">
|
||||||
|
Đưa mã QR này cho nhân viên để quét tại cửa hàng
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="qr-code-container">
|
||||||
|
<div class="qr-code-placeholder" id="qrCodePlaceholder">
|
||||||
|
<i class="fas fa-qrcode"></i>
|
||||||
|
<div>Mã QR Code</div>
|
||||||
|
<div style="font-size: 0.8rem;" id="qrCodeValue">SAVE100K</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="color: #6c757d; font-size: 0.8rem; margin-top: 10px;">
|
||||||
|
<i class="fas fa-info-circle"></i>
|
||||||
|
Mã QR sẽ tự động cập nhật khi bạn sử dụng voucher
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Terms and Conditions -->
|
||||||
|
<div class="terms-section">
|
||||||
|
<h3 class="terms-title">
|
||||||
|
<i class="fas fa-list-check"></i>
|
||||||
|
Điều kiện sử dụng
|
||||||
|
</h3>
|
||||||
|
<ul class="terms-list" id="termsList">
|
||||||
|
<li>Áp dụng cho đơn hàng từ 2.000.000đ</li>
|
||||||
|
<li>Không áp dụng cùng với khuyến mãi khác</li>
|
||||||
|
<li>Chỉ sử dụng 1 lần duy nhất</li>
|
||||||
|
<li>Không hoàn trả hoặc đổi thành tiền mặt</li>
|
||||||
|
<li>Có thể sử dụng cho cả mua hàng online và offline</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Usage History -->
|
||||||
|
<div class="usage-history">
|
||||||
|
<h3 class="history-title">
|
||||||
|
<i class="fas fa-history"></i>
|
||||||
|
Lịch sử sử dụng
|
||||||
|
</h3>
|
||||||
|
<div class="history-empty" id="usageHistory">
|
||||||
|
Chưa có lịch sử sử dụng
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toast Notification -->
|
||||||
|
<div class="toast" id="toast">
|
||||||
|
<i class="fas fa-check-circle"></i>
|
||||||
|
<span id="toastMessage">Đã sao chép mã voucher!</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Gift data mapping
|
||||||
|
const giftDatabase = {
|
||||||
|
'SAVE100K': {
|
||||||
|
name: 'Voucher giảm 100.000đ',
|
||||||
|
description: 'Áp dụng cho đơn hàng từ 2.000.000đ',
|
||||||
|
icon: 'fas fa-percentage',
|
||||||
|
value: '100.000đ',
|
||||||
|
expiry: '31/12/2023',
|
||||||
|
minOrder: '2.000.000đ',
|
||||||
|
terms: [
|
||||||
|
'Áp dụng cho đơn hàng từ 2.000.000đ',
|
||||||
|
'Không áp dụng cùng với khuyến mãi khác',
|
||||||
|
'Chỉ sử dụng 1 lần duy nhất',
|
||||||
|
'Không hoàn trả hoặc đổi thành tiền mặt',
|
||||||
|
'Có thể sử dụng cho cả mua hàng online và offline'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'FREECERAMIC': {
|
||||||
|
name: 'Gạch ceramic miễn phí',
|
||||||
|
description: '1m² gạch ceramic 30x30 cao cấp',
|
||||||
|
icon: 'fas fa-gift',
|
||||||
|
value: '1m²',
|
||||||
|
expiry: '15/01/2024',
|
||||||
|
minOrder: 'Không có',
|
||||||
|
terms: [
|
||||||
|
'Áp dụng cho gạch ceramic 30x30 cao cấp',
|
||||||
|
'Tối đa 1m² mỗi voucher',
|
||||||
|
'Chỉ sử dụng 1 lần duy nhất',
|
||||||
|
'Phải đến cửa hàng để nhận hàng',
|
||||||
|
'Không áp dụng cùng với khuyến mãi khác'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'FREEDESIGN': {
|
||||||
|
name: 'Tư vấn thiết kế miễn phí',
|
||||||
|
description: 'Dịch vụ tư vấn thiết kế chuyên nghiệp',
|
||||||
|
icon: 'fas fa-star',
|
||||||
|
value: 'Miễn phí',
|
||||||
|
expiry: '28/02/2024',
|
||||||
|
minOrder: 'Không có',
|
||||||
|
terms: [
|
||||||
|
'Dịch vụ tư vấn thiết kế chuyên nghiệp',
|
||||||
|
'Thời gian tư vấn: 60 phút',
|
||||||
|
'Đặt lịch hẹn trước 24h',
|
||||||
|
'Áp dụng cho dự án từ 20m² trở lên',
|
||||||
|
'Bao gồm bản vẽ 2D cơ bản'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize page with gift data
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const savedGift = localStorage.getItem('selectedGift');
|
||||||
|
if (savedGift) {
|
||||||
|
const giftData = JSON.parse(savedGift);
|
||||||
|
loadGiftDetails(giftData.code, giftData.status);
|
||||||
|
} else {
|
||||||
|
// Fallback to default gift
|
||||||
|
loadGiftDetails('SAVE100K', 'valid');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function loadGiftDetails(giftCode, status) {
|
||||||
|
const gift = giftDatabase[giftCode];
|
||||||
|
if (!gift) return;
|
||||||
|
|
||||||
|
// Update gift information
|
||||||
|
document.getElementById('giftTitle').textContent = gift.name;
|
||||||
|
document.getElementById('giftDescription').textContent = gift.description;
|
||||||
|
document.getElementById('voucherCode').textContent = giftCode;
|
||||||
|
document.getElementById('qrCodeValue').textContent = giftCode;
|
||||||
|
document.getElementById('giftValue').textContent = gift.value;
|
||||||
|
document.getElementById('expiryDate').textContent = gift.expiry;
|
||||||
|
|
||||||
|
// Update icon
|
||||||
|
const iconElement = document.getElementById('giftIcon');
|
||||||
|
iconElement.className = gift.icon;
|
||||||
|
|
||||||
|
// Update status
|
||||||
|
const statusBadge = document.getElementById('statusBadge');
|
||||||
|
const useButton = document.getElementById('useButton');
|
||||||
|
|
||||||
|
switch(status) {
|
||||||
|
case 'valid':
|
||||||
|
statusBadge.textContent = 'Còn hạn sử dụng';
|
||||||
|
statusBadge.className = 'status-badge status-valid';
|
||||||
|
useButton.disabled = false;
|
||||||
|
useButton.innerHTML = '<i class="fas fa-check"></i> Sử dụng ngay';
|
||||||
|
break;
|
||||||
|
case 'used':
|
||||||
|
statusBadge.textContent = 'Đã sử dụng';
|
||||||
|
statusBadge.className = 'status-badge status-used';
|
||||||
|
useButton.disabled = true;
|
||||||
|
useButton.innerHTML = '<i class="fas fa-check-circle"></i> Đã sử dụng';
|
||||||
|
updateUsageHistory();
|
||||||
|
break;
|
||||||
|
case 'expired':
|
||||||
|
statusBadge.textContent = 'Đã hết hạn';
|
||||||
|
statusBadge.className = 'status-badge status-expired';
|
||||||
|
useButton.disabled = true;
|
||||||
|
useButton.innerHTML = '<i class="fas fa-times-circle"></i> Hết hạn';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update terms
|
||||||
|
const termsList = document.getElementById('termsList');
|
||||||
|
termsList.innerHTML = '';
|
||||||
|
gift.terms.forEach(term => {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.textContent = term;
|
||||||
|
termsList.appendChild(li);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate QR code placeholder (in a real app, this would be an actual QR code)
|
||||||
|
generateQRCodePlaceholder(giftCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateQRCodePlaceholder(code) {
|
||||||
|
const qrPlaceholder = document.getElementById('qrCodePlaceholder');
|
||||||
|
// In a real application, you would integrate with a QR code library
|
||||||
|
// For now, we'll create a styled placeholder
|
||||||
|
qrPlaceholder.innerHTML = `
|
||||||
|
<div style="
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(45deg, #f8f9fa 25%, transparent 25%, transparent 75%, #f8f9fa 75%, #f8f9fa),
|
||||||
|
linear-gradient(45deg, #f8f9fa 25%, transparent 25%, transparent 75%, #f8f9fa 75%, #f8f9fa);
|
||||||
|
background-size: 10px 10px;
|
||||||
|
background-position: 0 0, 5px 5px;
|
||||||
|
border: 2px solid #333;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-direction: column;
|
||||||
|
color: #333;
|
||||||
|
font-weight: bold;
|
||||||
|
">
|
||||||
|
<i class="fas fa-qrcode" style="font-size: 2rem; margin-bottom: 10px;"></i>
|
||||||
|
<div style="font-size: 0.8rem;">${code}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyVoucherCode() {
|
||||||
|
const voucherCode = document.getElementById('voucherCode').textContent;
|
||||||
|
|
||||||
|
// Copy to clipboard
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
navigator.clipboard.writeText(voucherCode).then(() => {
|
||||||
|
showToast('Đã sao chép mã voucher!');
|
||||||
|
}).catch(() => {
|
||||||
|
fallbackCopyTextToClipboard(voucherCode);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
fallbackCopyTextToClipboard(voucherCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackCopyTextToClipboard(text) {
|
||||||
|
const textArea = document.createElement("textarea");
|
||||||
|
textArea.value = text;
|
||||||
|
textArea.style.top = "0";
|
||||||
|
textArea.style.left = "0";
|
||||||
|
textArea.style.position = "fixed";
|
||||||
|
document.body.appendChild(textArea);
|
||||||
|
textArea.focus();
|
||||||
|
textArea.select();
|
||||||
|
|
||||||
|
try {
|
||||||
|
document.execCommand('copy');
|
||||||
|
showToast('Đã sao chép mã voucher!');
|
||||||
|
} catch (err) {
|
||||||
|
showToast('Không thể sao chép. Vui lòng sao chép thủ công.', 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.removeChild(textArea);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useGift() {
|
||||||
|
const useButton = document.getElementById('useButton');
|
||||||
|
if (useButton.disabled) return;
|
||||||
|
|
||||||
|
// Confirm usage
|
||||||
|
if (confirm('Bạn có chắc chắn muốn sử dụng voucher này? Hành động này không thể hoàn tác.')) {
|
||||||
|
// Update gift status to used
|
||||||
|
const giftCode = document.getElementById('voucherCode').textContent;
|
||||||
|
|
||||||
|
// Update localStorage
|
||||||
|
const giftData = {
|
||||||
|
code: giftCode,
|
||||||
|
name: document.getElementById('giftTitle').textContent,
|
||||||
|
status: 'used'
|
||||||
|
};
|
||||||
|
localStorage.setItem('selectedGift', JSON.stringify(giftData));
|
||||||
|
|
||||||
|
// Reload page with used status
|
||||||
|
loadGiftDetails(giftCode, 'used');
|
||||||
|
|
||||||
|
showToast('Voucher đã được sử dụng thành công!');
|
||||||
|
|
||||||
|
// Redirect to store or cart page after 2 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = 'cart.html';
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function shareGift() {
|
||||||
|
const giftName = document.getElementById('giftTitle').textContent;
|
||||||
|
const giftCode = document.getElementById('voucherCode').textContent;
|
||||||
|
const shareText = `Xem voucher ${giftName} - Mã: ${giftCode}`;
|
||||||
|
|
||||||
|
if (navigator.share) {
|
||||||
|
navigator.share({
|
||||||
|
title: giftName,
|
||||||
|
text: shareText,
|
||||||
|
url: window.location.href,
|
||||||
|
}).catch((error) => {
|
||||||
|
console.log('Error sharing:', error);
|
||||||
|
fallbackShare(shareText);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
fallbackShare(shareText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackShare(text) {
|
||||||
|
if (navigator.clipboard) {
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
showToast('Đã sao chép thông tin để chia sẻ!');
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
showToast('Sao chép thủ công: ' + text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateUsageHistory() {
|
||||||
|
const historyElement = document.getElementById('usageHistory');
|
||||||
|
const currentDate = new Date().toLocaleDateString('vi-VN');
|
||||||
|
historyElement.innerHTML = `
|
||||||
|
<div style="text-align: left; color: #4a5568;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; padding: 10px 0; border-bottom: 1px solid #e2e8f0;">
|
||||||
|
<div>
|
||||||
|
<div style="font-weight: 600;">Đã sử dụng voucher</div>
|
||||||
|
<div style="font-size: 0.8rem; color: #718096;">Đơn hàng #DH${Date.now().toString().slice(-6)}</div>
|
||||||
|
</div>
|
||||||
|
<div style="text-align: right;">
|
||||||
|
<div style="color: #28a745; font-weight: 600;">${currentDate}</div>
|
||||||
|
<div style="font-size: 0.8rem; color: #718096;">Thành công</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(message, type = 'success') {
|
||||||
|
const toast = document.getElementById('toast');
|
||||||
|
const toastMessage = document.getElementById('toastMessage');
|
||||||
|
|
||||||
|
toastMessage.textContent = message;
|
||||||
|
|
||||||
|
if (type === 'error') {
|
||||||
|
toast.style.background = '#dc3545';
|
||||||
|
} else {
|
||||||
|
toast.style.background = '#28a745';
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.classList.add('show');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.classList.remove('show');
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="gift-actions">
|
<div class="gift-actions">
|
||||||
<button class="btn btn-primary btn-sm">Sử dụng</button>
|
<button class="btn btn-primary btn-sm">Sử dụng</button>
|
||||||
<button class="btn btn-secondary btn-sm">Chi tiết</button>
|
<button class="btn btn-secondary btn-sm" onclick="viewGiftDetail('SAVE100K', 'Voucher giảm 100.000đ', 'valid')">Chi tiết</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="gift-actions">
|
<div class="gift-actions">
|
||||||
<button class="btn btn-primary btn-sm">Sử dụng</button>
|
<button class="btn btn-primary btn-sm">Sử dụng</button>
|
||||||
<button class="btn btn-secondary btn-sm">Chi tiết</button>
|
<button class="btn btn-secondary btn-sm" onclick="viewGiftDetail('FREECERAMIC', 'Gạch ceramic miễn phí', 'valid')">Chi tiết</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -169,12 +169,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="gift-actions">
|
<div class="gift-actions">
|
||||||
<button class="btn btn-primary btn-sm">Sử dụng</button>
|
<button class="btn btn-primary btn-sm">Sử dụng</button>
|
||||||
<button class="btn btn-secondary btn-sm">Chi tiết</button>
|
<button class="btn btn-secondary btn-sm" onclick="viewGiftDetail('FREEDESIGN', 'Tư vấn thiết kế miễn phí', 'valid')">Chi tiết</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function filterGifts(status) {
|
function filterGifts(status) {
|
||||||
@@ -195,6 +196,19 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function viewGiftDetail(giftCode, giftName, status) {
|
||||||
|
// Store gift data in localStorage for the detail page
|
||||||
|
const giftData = {
|
||||||
|
code: giftCode,
|
||||||
|
name: giftName,
|
||||||
|
status: status
|
||||||
|
};
|
||||||
|
localStorage.setItem('selectedGift', JSON.stringify(giftData));
|
||||||
|
|
||||||
|
// Navigate to gift detail page
|
||||||
|
window.location.href = 'my-gift-detail.html';
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
794
html/nha-mau-360-detail.html
Normal file
794
html/nha-mau-360-detail.html
Normal file
@@ -0,0 +1,794 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="vi">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Chi tiết Nhà mẫu - EuroTile Worker</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<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">
|
||||||
|
<style>
|
||||||
|
/* 360 View Container */
|
||||||
|
.view-360-container {
|
||||||
|
/*margin: 16px;*/
|
||||||
|
background: var(--white);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: var(--shadow-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 360 Preview Card */
|
||||||
|
.view-360-preview {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 400px;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-360-preview:hover {
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-360-preview::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: url('https://images.unsplash.com/photo-1600585154340-be6161a56a0c?w=800&h=600&fit=crop') center/cover;
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-360-content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-360-icon {
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
margin: 0 auto 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 3px solid var(--white);
|
||||||
|
position: relative;
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
70% {
|
||||||
|
box-shadow: 0 0 0 20px rgba(255, 255, 255, 0);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(255, 255, 255, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-360-icon svg {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
fill: var(--white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-360-text {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
text-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-360-subtitle {
|
||||||
|
font-size: 14px;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-360-button {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 12px 32px;
|
||||||
|
background: var(--white);
|
||||||
|
color: var(--primary-blue);
|
||||||
|
border: none;
|
||||||
|
border-radius: 24px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-360-button:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 20px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
.webview-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 300px;
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webview-iframe {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webview-placeholder {
|
||||||
|
text-align: center;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webview-placeholder i {
|
||||||
|
font-size: 3rem;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
color: #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-info {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f2937;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-specs {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-item {
|
||||||
|
text-align: center;
|
||||||
|
padding: 16px 12px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6b7280;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-value {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-section {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f2937;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-scroll {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-item {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-item:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0,0,0,0.9);
|
||||||
|
z-index: 1000;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-content {
|
||||||
|
position: relative;
|
||||||
|
max-width: 90%;
|
||||||
|
max-height: 90%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-caption {
|
||||||
|
position: absolute;
|
||||||
|
bottom: -50px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
color: white;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-close {
|
||||||
|
position: absolute;
|
||||||
|
top: -50px;
|
||||||
|
right: 0;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
font-size: 32px;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-nav {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: rgba(255,255,255,0.2);
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 20px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-nav:hover {
|
||||||
|
background: rgba(255,255,255,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-prev {
|
||||||
|
left: -70px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-next {
|
||||||
|
right: -70px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-counter {
|
||||||
|
position: absolute;
|
||||||
|
top: -50px;
|
||||||
|
left: 0;
|
||||||
|
color: white;
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 200px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 4px solid #f3f4f6;
|
||||||
|
border-top: 4px solid #2563eb;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.project-specs {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
text-align: left;
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-value {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-item {
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-nav {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-prev {
|
||||||
|
left: -50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-next {
|
||||||
|
right: -50px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page-wrapper">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="header">
|
||||||
|
<a href="nha-mau.html" class="back-button">
|
||||||
|
<i class="fas fa-arrow-left"></i>
|
||||||
|
</a>
|
||||||
|
<h1 class="header-title">Chi tiết Nhà mẫu</h1>
|
||||||
|
<button class="icon-button" onclick="shareModel()">
|
||||||
|
<i class="fas fa-share-alt"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container" >
|
||||||
|
<!-- 360° View Container -->
|
||||||
|
<div class="view-360-container">
|
||||||
|
<!-- 360° Preview/Launch Card -->
|
||||||
|
<div class="view-360-preview" onclick="window.location.href='https://vr.house3d.com/web/panorama-player/H00179549'">
|
||||||
|
<div class="view-360-content">
|
||||||
|
<div class="view-360-icon">
|
||||||
|
<!-- Custom 360° SVG Icon -->
|
||||||
|
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g stroke="currentColor" stroke-width="3" fill="none">
|
||||||
|
<!-- Circular arrow -->
|
||||||
|
<path d="M 80 50 A 30 30 0 1 1 50 20" />
|
||||||
|
<polygon points="48,15 48,25 58,20" fill="currentColor"/>
|
||||||
|
|
||||||
|
<!-- 360° text -->
|
||||||
|
<text x="50" y="50" text-anchor="middle" dominant-baseline="middle"
|
||||||
|
font-family="Roboto, sans-serif" font-size="20" fill="currentColor">
|
||||||
|
360°
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="view-360-text">Xem nhà mẫu 360°</div>
|
||||||
|
<div class="view-360-subtitle">Trải nghiệm không gian thực tế ảo</div>
|
||||||
|
<button class="view-360-button">
|
||||||
|
<i class="fas fa-play"></i>
|
||||||
|
Bắt đầu tham quan
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 360° WebView Container -->
|
||||||
|
<!--<div class="webview-container" id="webview-container">-->
|
||||||
|
<!-- Loading State -->
|
||||||
|
<!--<div class="loading-state" id="loading-state">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<span>Đang tải mô hình 360°...</span>
|
||||||
|
</div>-->
|
||||||
|
|
||||||
|
<!-- Placeholder for 360° iframe -->
|
||||||
|
<!--<iframe
|
||||||
|
id="webview-iframe"
|
||||||
|
class="webview-iframe"
|
||||||
|
src="about:blank"
|
||||||
|
title="Mô hình 360° Nhà mẫu"
|
||||||
|
style="display: none;">
|
||||||
|
</iframe>-->
|
||||||
|
|
||||||
|
<!-- Fallback placeholder -->
|
||||||
|
<!--<div class="webview-placeholder" id="webview-placeholder" style="display: none;">
|
||||||
|
<i class="fas fa-cube"></i>
|
||||||
|
<h3>Mô hình 360°</h3>
|
||||||
|
<p>Đang tải trình xem 3D...</p>
|
||||||
|
</div>
|
||||||
|
</div>-->
|
||||||
|
|
||||||
|
<!-- Project Information -->
|
||||||
|
<div class="project-info">
|
||||||
|
<h2 class="project-title" id="project-title">Căn hộ Studio</h2>
|
||||||
|
|
||||||
|
<div class="project-specs">
|
||||||
|
<div class="spec-item">
|
||||||
|
<div class="spec-label">Diện tích</div>
|
||||||
|
<div class="spec-value" id="project-area">35m²</div>
|
||||||
|
</div>
|
||||||
|
<div class="spec-item">
|
||||||
|
<div class="spec-label">Địa điểm</div>
|
||||||
|
<div class="spec-value" id="project-location">Quận 7</div>
|
||||||
|
</div>
|
||||||
|
<div class="spec-item">
|
||||||
|
<div class="spec-label">Phong cách</div>
|
||||||
|
<div class="spec-value" id="project-style">Hiện đại</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="color: #4b5563; line-height: 1.6;" id="project-description">
|
||||||
|
Thiết kế hiện đại cho căn hộ studio 35m², tối ưu không gian sống với gạch men cao cấp và màu sắc hài hòa.
|
||||||
|
Sử dụng gạch granite nhập khẩu cho khu vực phòng khách và gạch ceramic chống thấm cho khu vực ẩm ướt.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Image Gallery -->
|
||||||
|
<div class="gallery-section">
|
||||||
|
<h3 class="gallery-title">
|
||||||
|
<i class="fas fa-images"></i>
|
||||||
|
Thư viện Hình ảnh
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="gallery-scroll" id="gallery-scroll">
|
||||||
|
<div class="gallery-item" onclick="openLightbox(0)">
|
||||||
|
<img src="https://images.unsplash.com/photo-1600596542815-ffad4c1539a9?w=200&h=200&fit=crop"
|
||||||
|
alt="Phối cảnh tổng thể"
|
||||||
|
class="gallery-image">
|
||||||
|
</div>
|
||||||
|
<div class="gallery-item" onclick="openLightbox(1)">
|
||||||
|
<img src="https://center.eurotile.vn/data/eurotileData/design/202009/23/4/main_img.jpg"
|
||||||
|
alt="Khu vực phòng khách"
|
||||||
|
class="gallery-image">
|
||||||
|
</div>
|
||||||
|
<div class="gallery-item" onclick="openLightbox(2)">
|
||||||
|
<img src="https://center.eurotile.vn/data/eurotileData/design/202009/23/4/project_img_1.jpg?v=1"
|
||||||
|
alt="Phòng ngủ chính"
|
||||||
|
class="gallery-image">
|
||||||
|
</div>
|
||||||
|
<div class="gallery-item" onclick="openLightbox(3)">
|
||||||
|
<img src="https://center.eurotile.vn/data/eurotileData/design/202009/23/4/project_img_0.jpg?v=1"
|
||||||
|
alt="Khu vực bếp"
|
||||||
|
class="gallery-image">
|
||||||
|
</div>
|
||||||
|
<div class="gallery-item" onclick="openLightbox(4)">
|
||||||
|
<img src="https://images.unsplash.com/photo-1620626011761-996317b8d101?w=200&h=200&fit=crop"
|
||||||
|
alt="Phòng tắm hiện đại"
|
||||||
|
class="Phòng">
|
||||||
|
</div>
|
||||||
|
<div class="gallery-item" onclick="openLightbox(5)">
|
||||||
|
<img src="https://center.eurotile.vn/data/eurotileData/design/202009/23/4/project_img_3.jpg?v=1"
|
||||||
|
alt="Khu vực bàn ăn"
|
||||||
|
class="gallery-image">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom Navigation -->
|
||||||
|
<!--<div class="bottom-nav">
|
||||||
|
<a href="index.html" class="nav-item">
|
||||||
|
<i class="fas fa-home"></i>
|
||||||
|
<span>Trang chủ</span>
|
||||||
|
</a>
|
||||||
|
<a href="loyalty.html" class="nav-item">
|
||||||
|
<i class="fas fa-star"></i>
|
||||||
|
<span>Hội viên</span>
|
||||||
|
</a>
|
||||||
|
<a href="promotions.html" class="nav-item">
|
||||||
|
<i class="fas fa-tags"></i>
|
||||||
|
<span>Khuyến mãi</span>
|
||||||
|
</a>
|
||||||
|
<a href="notifications.html" class="nav-item">
|
||||||
|
<i class="fas fa-bell"></i>
|
||||||
|
<span>Thông báo</span>
|
||||||
|
</a>
|
||||||
|
<a href="account.html" class="nav-item active">
|
||||||
|
<i class="fas fa-user"></i>
|
||||||
|
<span>Cài đặt</span>
|
||||||
|
</a>
|
||||||
|
</div>-->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Lightbox Modal -->
|
||||||
|
<div class="lightbox" id="lightbox">
|
||||||
|
<div class="lightbox-content">
|
||||||
|
<button class="lightbox-close" onclick="closeLightbox()">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="lightbox-counter" id="lightbox-counter">1 / 6</div>
|
||||||
|
|
||||||
|
<img id="lightbox-image" class="lightbox-image" src="" alt="">
|
||||||
|
|
||||||
|
<div class="lightbox-caption" id="lightbox-caption">
|
||||||
|
Phối cảnh tổng thể căn hộ studio với thiết kế hiện đại
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="lightbox-nav lightbox-prev" onclick="prevImage()">
|
||||||
|
<i class="fas fa-chevron-left"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="lightbox-nav lightbox-next" onclick="nextImage()">
|
||||||
|
<i class="fas fa-chevron-right"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Gallery data
|
||||||
|
const galleryData = [
|
||||||
|
{
|
||||||
|
src: "https://images.unsplash.com/photo-1600596542815-ffad4c1539a9?w=800&h=600&fit=crop",
|
||||||
|
caption: "Phối cảnh tổng thể căn hộ studio với thiết kế hiện đại"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "https://center.eurotile.vn/data/eurotileData/design/202009/23/4/main_img.jpg",
|
||||||
|
caption: "Khu vực phòng khách với gạch granite cao cấp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "https://center.eurotile.vn/data/eurotileData/design/202009/23/4/project_img_1.jpg?v=1",
|
||||||
|
caption: "Phòng ngủ chính với gạch ceramic màu trung tính"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "https://center.eurotile.vn/data/eurotileData/design/202009/23/4/project_img_0.jpg?v=1",
|
||||||
|
caption: "Khu vực bếp với gạch mosaic điểm nhấn"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "https://images.unsplash.com/photo-1620626011761-996317b8d101?w=200&h=200&fit=crop",
|
||||||
|
caption: "Phòng tắm hiện đại với gạch chống thấm cao cấp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "https://center.eurotile.vn/data/eurotileData/design/202009/23/4/project_img_3.jpg?v=1",
|
||||||
|
caption: "Khu vực bàn ăn ấm cúng"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
let currentImageIndex = 0;
|
||||||
|
|
||||||
|
// Project data mapping
|
||||||
|
const projectData = {
|
||||||
|
'studio-apartment': {
|
||||||
|
title: 'Căn hộ Studio',
|
||||||
|
area: '35m²',
|
||||||
|
location: 'Quận 7',
|
||||||
|
style: 'Hiện đại',
|
||||||
|
description: 'Thiết kế hiện đại cho căn hộ studio 35m², tối ưu không gian sống với gạch men cao cấp và màu sắc hài hòa. Sử dụng gạch granite nhập khẩu cho khu vực phòng khách và gạch ceramic chống thấm cho khu vực ẩm ướt.',
|
||||||
|
iframe360: 'https://example.com/360-viewer/studio-apartment' // placeholder URL
|
||||||
|
},
|
||||||
|
'modern-villa': {
|
||||||
|
title: 'Biệt thự Hiện đại',
|
||||||
|
area: '250m²',
|
||||||
|
location: 'Quận 2',
|
||||||
|
style: 'Hiện đại',
|
||||||
|
description: 'Biệt thự 3 tầng với phong cách kiến trúc hiện đại, sử dụng gạch granite và ceramic premium tạo điểm nhấn cho từng không gian sống.',
|
||||||
|
iframe360: 'https://example.com/360-viewer/modern-villa'
|
||||||
|
},
|
||||||
|
'minimalist-house': {
|
||||||
|
title: 'Nhà phố Tối giản',
|
||||||
|
area: '120m²',
|
||||||
|
location: 'Quận 10',
|
||||||
|
style: 'Tối giản',
|
||||||
|
description: 'Nhà phố 4x15m với thiết kế tối giản, tận dụng ánh sáng tự nhiên và gạch men màu trung tính để tạo không gian sống thoáng đãng.',
|
||||||
|
iframe360: 'https://example.com/360-viewer/minimalist-house'
|
||||||
|
},
|
||||||
|
'luxury-condo': {
|
||||||
|
title: 'Chung cư Cao cấp',
|
||||||
|
area: '85m²',
|
||||||
|
location: 'Quận 1',
|
||||||
|
style: 'Sang trọng',
|
||||||
|
description: 'Căn hộ 3PN với nội thất sang trọng, sử dụng gạch marble và ceramic cao cấp nhập khẩu Italy để tạo điểm nhấn đẳng cấp.',
|
||||||
|
iframe360: 'https://example.com/360-viewer/luxury-condo'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize page with project data
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const projectId = urlParams.get('id') || 'studio-apartment';
|
||||||
|
|
||||||
|
loadProjectData(projectId);
|
||||||
|
simulateIframeLoading();
|
||||||
|
});
|
||||||
|
|
||||||
|
function loadProjectData(projectId) {
|
||||||
|
const project = projectData[projectId];
|
||||||
|
if (!project) return;
|
||||||
|
|
||||||
|
document.getElementById('project-title').textContent = project.title;
|
||||||
|
document.getElementById('project-area').textContent = project.area;
|
||||||
|
document.getElementById('project-location').textContent = project.location;
|
||||||
|
document.getElementById('project-style').textContent = project.style;
|
||||||
|
document.getElementById('project-description').textContent = project.description;
|
||||||
|
|
||||||
|
// Update page title
|
||||||
|
document.title = `${project.title} - Chi tiết Nhà mẫu`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function simulateIframeLoading() {
|
||||||
|
const loadingState = document.getElementById('loading-state');
|
||||||
|
const iframe = document.getElementById('webview-iframe');
|
||||||
|
const placeholder = document.getElementById('webview-placeholder');
|
||||||
|
|
||||||
|
// Simulate loading time
|
||||||
|
setTimeout(() => {
|
||||||
|
loadingState.style.display = 'none';
|
||||||
|
|
||||||
|
// In a real app, you would load the actual 360° URL
|
||||||
|
// For demo, we'll show a placeholder
|
||||||
|
placeholder.style.display = 'flex';
|
||||||
|
|
||||||
|
// Uncomment below to load actual iframe in production:
|
||||||
|
// iframe.src = project.iframe360;
|
||||||
|
// iframe.style.display = 'block';
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openLightbox(index) {
|
||||||
|
currentImageIndex = index;
|
||||||
|
const lightbox = document.getElementById('lightbox');
|
||||||
|
const image = document.getElementById('lightbox-image');
|
||||||
|
const caption = document.getElementById('lightbox-caption');
|
||||||
|
const counter = document.getElementById('lightbox-counter');
|
||||||
|
|
||||||
|
image.src = galleryData[index].src;
|
||||||
|
caption.textContent = galleryData[index].caption;
|
||||||
|
counter.textContent = `${index + 1} / ${galleryData.length}`;
|
||||||
|
|
||||||
|
lightbox.classList.add('active');
|
||||||
|
document.body.style.overflow = 'hidden'; // Prevent background scroll
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeLightbox() {
|
||||||
|
const lightbox = document.getElementById('lightbox');
|
||||||
|
lightbox.classList.remove('active');
|
||||||
|
document.body.style.overflow = 'auto'; // Restore scroll
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextImage() {
|
||||||
|
currentImageIndex = (currentImageIndex + 1) % galleryData.length;
|
||||||
|
updateLightboxImage();
|
||||||
|
}
|
||||||
|
|
||||||
|
function prevImage() {
|
||||||
|
currentImageIndex = (currentImageIndex - 1 + galleryData.length) % galleryData.length;
|
||||||
|
updateLightboxImage();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLightboxImage() {
|
||||||
|
const image = document.getElementById('lightbox-image');
|
||||||
|
const caption = document.getElementById('lightbox-caption');
|
||||||
|
const counter = document.getElementById('lightbox-counter');
|
||||||
|
|
||||||
|
image.src = galleryData[currentImageIndex].src;
|
||||||
|
caption.textContent = galleryData[currentImageIndex].caption;
|
||||||
|
counter.textContent = `${currentImageIndex + 1} / ${galleryData.length}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shareModel() {
|
||||||
|
const projectTitle = document.getElementById('project-title').textContent;
|
||||||
|
|
||||||
|
if (navigator.share) {
|
||||||
|
navigator.share({
|
||||||
|
title: projectTitle,
|
||||||
|
text: `Xem mô hình 360° ${projectTitle}`,
|
||||||
|
url: window.location.href
|
||||||
|
}).catch(console.error);
|
||||||
|
} else {
|
||||||
|
// Fallback: copy URL to clipboard
|
||||||
|
navigator.clipboard.writeText(window.location.href).then(() => {
|
||||||
|
showToast('Đã sao chép link chia sẻ!');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(message) {
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = 'fixed top-20 left-1/2 transform -translate-x-1/2 bg-green-500 text-white px-4 py-2 rounded-lg z-50 transition-all duration-300';
|
||||||
|
toast.textContent = message;
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.style.opacity = '0';
|
||||||
|
setTimeout(() => {
|
||||||
|
document.body.removeChild(toast);
|
||||||
|
}, 300);
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close lightbox with Escape key
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Escape' && document.getElementById('lightbox').classList.contains('active')) {
|
||||||
|
closeLightbox();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close lightbox by clicking background
|
||||||
|
document.getElementById('lightbox').addEventListener('click', function(e) {
|
||||||
|
if (e.target === this) {
|
||||||
|
closeLightbox();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keyboard navigation in lightbox
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (document.getElementById('lightbox').classList.contains('active')) {
|
||||||
|
if (e.key === 'ArrowLeft') {
|
||||||
|
prevImage();
|
||||||
|
} else if (e.key === 'ArrowRight') {
|
||||||
|
nextImage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
167
html/nha-mau-create.html
Normal file
167
html/nha-mau-create.html
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="vi">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Tạo nhà mẫu mới - EuroTile Worker</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<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">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page-wrapper">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="header">
|
||||||
|
<a href="nha-mau-list.html" class="back-button">
|
||||||
|
<i class="fas fa-arrow-left"></i>
|
||||||
|
</a>
|
||||||
|
<h1 class="header-title">Tạo nhà mẫu mới</h1>
|
||||||
|
<div style="width: 32px;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<!-- Project Creation Form -->
|
||||||
|
<div class="form-container">
|
||||||
|
<div class="card">
|
||||||
|
<h3 class="card-title mb-3">Thông tin nhà mẫu</h3>
|
||||||
|
|
||||||
|
<form id="projectForm">
|
||||||
|
<!-- Project Name -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Tên nhà mẫu *</label>
|
||||||
|
<input type="text" class="form-input" placeholder="Nhập tên nhà mẫu" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Project Owner -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Tên chủ đầu tư *</label>
|
||||||
|
<input type="text" class="form-input" placeholder="Nhập tên chủ đầu tư" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contact Information -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Số điện thoại liên hệ *</label>
|
||||||
|
<input type="tel" class="form-input" placeholder="Nhập số điện thoại" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Address -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Địa chỉ nhà mẫu *</label>
|
||||||
|
<input type="text" class="form-input" placeholder="Nhập địa chỉ chi tiết" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Province/City -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Tỉnh/Thành phố *</label>
|
||||||
|
<select class="form-select" required>
|
||||||
|
<option value="">Chọn tỉnh/thành phố</option>
|
||||||
|
<option value="hcm">TP. Hồ Chí Minh</option>
|
||||||
|
<option value="hanoi">Hà Nội</option>
|
||||||
|
<option value="danang">Đà Nẵng</option>
|
||||||
|
<option value="binhduong">Bình Dương</option>
|
||||||
|
<option value="dongnai">Đồng Nai</option>
|
||||||
|
<option value="cantho">Cần Thơ</option>
|
||||||
|
<option value="other">Khác</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- District -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Quận/Huyện</label>
|
||||||
|
<input type="text" class="form-input" placeholder="Nhập quận/huyện">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Project Type -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Loại nhà mẫu *</label>
|
||||||
|
<select class="form-select" required>
|
||||||
|
<option value="">Chọn loại nhà mẫu</option>
|
||||||
|
<option value="villa">Villa</option>
|
||||||
|
<option value="apartment">Chung cư</option>
|
||||||
|
<option value="office">Văn phòng</option>
|
||||||
|
<option value="commercial">Thương mại</option>
|
||||||
|
<option value="resort">Resort/Khách sạn</option>
|
||||||
|
<option value="house">Nhà phố</option>
|
||||||
|
<option value="other">Khác</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Area -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Diện tích (m²)</label>
|
||||||
|
<input type="number" class="form-input" placeholder="Nhập diện tích">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Start Date -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Ngày bắt đầu *</label>
|
||||||
|
<input type="date" class="form-input" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Expected End Date -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Ngày dự kiến hoàn thành</label>
|
||||||
|
<input type="date" class="form-input">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Budget -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Ngân sách dự kiến (VND)</label>
|
||||||
|
<input type="number" class="form-input" placeholder="Nhập ngân sách">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Mô tả chi tiết</label>
|
||||||
|
<textarea class="form-textarea" rows="4" placeholder="Nhập mô tả chi tiết về nhà mẫu"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notes -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Ghi chú</label>
|
||||||
|
<textarea class="form-textarea" rows="3" placeholder="Ghi chú thêm (nếu có)"></textarea>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="history.back()">
|
||||||
|
Hủy bỏ
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn btn-primary" onclick="saveProject()">
|
||||||
|
<i class="fas fa-save"></i>
|
||||||
|
Lưu nhà mẫu
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function saveProject() {
|
||||||
|
const form = document.getElementById('projectForm');
|
||||||
|
|
||||||
|
if (form.checkValidity()) {
|
||||||
|
// Show success message
|
||||||
|
alert('Nhà mẫu đã được tạo thành công!');
|
||||||
|
|
||||||
|
// Redirect to projects list
|
||||||
|
window.location.href = 'nha-mau-list.html';
|
||||||
|
} else {
|
||||||
|
// Show validation errors
|
||||||
|
form.reportValidity();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set today's date as default start date
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
const startDateInput = document.querySelector('input[type="date"]');
|
||||||
|
if (startDateInput) {
|
||||||
|
startDateInput.value = today;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -415,13 +415,14 @@
|
|||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<header class="header">
|
<header class="header">
|
||||||
<div class="header-content">
|
<div class="header-content">
|
||||||
<button class="back-button" onclick="goBack()">
|
<button class="back-button" onclick="window.location.href='index.html'">
|
||||||
|
|
||||||
<i class="fas fa-arrow-left"></i>
|
<i class="fas fa-arrow-left"></i>
|
||||||
</button>
|
</button>
|
||||||
<h1 class="header-title">Quản lý Nhà mẫu</h1>
|
<h1 class="header-title">Quản lý Nhà mẫu</h1>
|
||||||
<button class="add-button" onclick="createNewNhaMau()">
|
<button class="back-button" onclick="window.location.href='nha-mau-create.html'">
|
||||||
<i class="fas fa-plus"></i>
|
<i class="fas fa-plus"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -578,9 +579,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Floating Create Button -->
|
<!-- Floating Create Button -->
|
||||||
<button class="floating-create-btn" onclick="createNewNhaMau()">
|
<!--<button class="floating-create-btn" onclick="createNewNhaMau()">
|
||||||
<i class="fas fa-plus"></i>
|
<i class="fas fa-plus"></i>
|
||||||
</button>
|
</button>-->
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function goBack() {
|
function goBack() {
|
||||||
|
|||||||
481
html/nha-mau.html
Normal file
481
html/nha-mau.html
Normal file
@@ -0,0 +1,481 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="vi">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Nhà mẫu - EuroTile Worker</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<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">
|
||||||
|
<style>
|
||||||
|
.tab-navigation {
|
||||||
|
background: white;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
display: flex;
|
||||||
|
position: sticky;
|
||||||
|
top: 60px;
|
||||||
|
z-index: 40;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item {
|
||||||
|
flex: 1;
|
||||||
|
padding: 16px 12px;
|
||||||
|
text-align: center;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #64748b;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
border-bottom: 3px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item.active {
|
||||||
|
color: #004980;
|
||||||
|
border-bottom-color: #004980;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
display: none;
|
||||||
|
padding: 20px;
|
||||||
|
min-height: calc(100vh - 140px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 16px rgba(0,0,0,0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 200px;
|
||||||
|
object-fit: cover;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
background: rgba(37, 99, 235, 0.9);
|
||||||
|
color: white;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-content {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f2937;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-date {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-description {
|
||||||
|
color: #4b5563;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-card:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-code {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f2937;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-date {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pending {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #d97706;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-designing {
|
||||||
|
background: #e0e7ff;
|
||||||
|
color: #3730a3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-completed {
|
||||||
|
background: #d1fae5;
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 90px;
|
||||||
|
right: 20px;
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
font-size: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 4px 16px rgba(37, 99, 235, 0.4);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: 0 8px 24px rgba(37, 99, 235, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state i {
|
||||||
|
font-size: 48px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
color: #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state h3 {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #374151;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.tab-content {
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-content {
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-card {
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page-wrapper">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="header">
|
||||||
|
<a href="index.html" class="back-button">
|
||||||
|
<i class="fas fa-arrow-left"></i>
|
||||||
|
</a>
|
||||||
|
<h1 class="header-title">Nhà mẫu</h1>
|
||||||
|
<div style="width: 32px;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab Navigation -->
|
||||||
|
<div class="tab-navigation">
|
||||||
|
<button class="tab-item active" onclick="switchTab('library')">
|
||||||
|
Thư viện mẫu
|
||||||
|
</button>
|
||||||
|
<button class="tab-item" onclick="switchTab('requests')">
|
||||||
|
Yêu cầu thiết kế
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab 1: Thư viện Mẫu -->
|
||||||
|
<div class="tab-content active" id="library-tab">
|
||||||
|
<div class="library-list">
|
||||||
|
<!-- Library Item 1 -->
|
||||||
|
<div class="library-card" >
|
||||||
|
<div style="position: relative;" onclick="viewLibraryDetail('studio-apartment')">
|
||||||
|
<img src="https://images.unsplash.com/photo-1600596542815-ffad4c1539a9?w=800&h=200&fit=crop"
|
||||||
|
alt="Căn hộ Studio"
|
||||||
|
class="library-image">
|
||||||
|
<div class="image-overlay" onclick="window.location.href='https://vr.house3d.com/web/panorama-player/H00179549'">Xem 360°</div>
|
||||||
|
</div>
|
||||||
|
<div class="library-content" onclick="viewLibraryDetail('studio-apartment')">
|
||||||
|
<h3 class="library-title">Căn hộ Studio</h3>
|
||||||
|
<div class="library-date">
|
||||||
|
<i class="fas fa-calendar-alt"></i>
|
||||||
|
<span>Ngày đăng: 15/11/2024</span>
|
||||||
|
</div>
|
||||||
|
<p class="library-description">
|
||||||
|
Thiết kế hiện đại cho căn hộ studio 35m², tối ưu không gian sống với gạch men cao cấp và màu sắc hài hòa.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Library Item 2 -->
|
||||||
|
<div class="library-card" onclick="viewLibraryDetail('modern-villa')">
|
||||||
|
<div style="position: relative;">
|
||||||
|
<img src="https://images.unsplash.com/photo-1570129477492-45c003edd2be?w=800&h=200&fit=crop"
|
||||||
|
alt="Biệt thự Hiện đại"
|
||||||
|
class="library-image">
|
||||||
|
<div class="image-overlay">Xem 360°</div>
|
||||||
|
</div>
|
||||||
|
<div class="library-content">
|
||||||
|
<h3 class="library-title">Biệt thự Hiện đại</h3>
|
||||||
|
<div class="library-date">
|
||||||
|
<i class="fas fa-calendar-alt"></i>
|
||||||
|
<span>Ngày đăng: 12/11/2024</span>
|
||||||
|
</div>
|
||||||
|
<p class="library-description">
|
||||||
|
Biệt thự 3 tầng với phong cách kiến trúc hiện đại, sử dụng gạch granite và ceramic premium tạo điểm nhấn.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Library Item 3 -->
|
||||||
|
<div class="library-card" onclick="viewLibraryDetail('minimalist-house')">
|
||||||
|
<div style="position: relative;">
|
||||||
|
<img src="https://images.unsplash.com/photo-1562663474-6cbb3eaa4d14?w=800&h=200&fit=crop"
|
||||||
|
alt="Nhà phố Tối giản"
|
||||||
|
class="library-image">
|
||||||
|
<div class="image-overlay">Xem 360°</div>
|
||||||
|
</div>
|
||||||
|
<div class="library-content">
|
||||||
|
<h3 class="library-title">Nhà phố Tối giản</h3>
|
||||||
|
<div class="library-date">
|
||||||
|
<i class="fas fa-calendar-alt"></i>
|
||||||
|
<span>Ngày đăng: 08/11/2024</span>
|
||||||
|
</div>
|
||||||
|
<p class="library-description">
|
||||||
|
Nhà phố 4x15m với thiết kế tối giản, tận dụng ánh sáng tự nhiên và gạch men màu trung tính.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Library Item 4 -->
|
||||||
|
<div class="library-card" onclick="viewLibraryDetail('luxury-condo')">
|
||||||
|
<div style="position: relative;">
|
||||||
|
<img src="https://images.unsplash.com/photo-1600607687939-ce8a6c25118c?w=800&h=200&fit=crop"
|
||||||
|
alt="Chung cư Cao cấp"
|
||||||
|
class="library-image">
|
||||||
|
<div class="image-overlay">Xem 360°</div>
|
||||||
|
</div>
|
||||||
|
<div class="library-content">
|
||||||
|
<h3 class="library-title">Chung cư Cao cấp</h3>
|
||||||
|
<div class="library-date">
|
||||||
|
<i class="fas fa-calendar-alt"></i>
|
||||||
|
<span>Ngày đăng: 05/11/2024</span>
|
||||||
|
</div>
|
||||||
|
<p class="library-description">
|
||||||
|
Căn hộ 3PN với nội thất sang trọng, sử dụng gạch marble và ceramic cao cấp nhập khẩu Italy.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab 2: Yêu cầu Thiết kế -->
|
||||||
|
<div class="tab-content" id="requests-tab">
|
||||||
|
<div class="requests-list">
|
||||||
|
<!-- Request Item 1 -->
|
||||||
|
<div class="request-card" onclick="viewRequestDetail('YC001')">
|
||||||
|
<div class="request-header">
|
||||||
|
<div class="request-code">Mã yêu cầu: #YC001</div>
|
||||||
|
<span class="status-badge status-completed">Hoàn thành</span>
|
||||||
|
</div>
|
||||||
|
<div class="request-date">Ngày gửi: 20/10/2024</div>
|
||||||
|
<div style="color: #374151; font-size: 14px;">
|
||||||
|
Thiết kế nhà phố 3 tầng - Anh Minh (Quận 7)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Request Item 2 -->
|
||||||
|
<div class="request-card" onclick="viewRequestDetail('YC002')">
|
||||||
|
<div class="request-header">
|
||||||
|
<div class="request-code">Mã yêu cầu: #YC002</div>
|
||||||
|
<span class="status-badge status-designing">Đang thiết kế</span>
|
||||||
|
</div>
|
||||||
|
<div class="request-date">Ngày gửi: 25/10/2024</div>
|
||||||
|
<div style="color: #374151; font-size: 14px;">
|
||||||
|
Cải tạo căn hộ chung cư - Chị Lan (Quận 2)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Request Item 3 -->
|
||||||
|
<div class="request-card" onclick="viewRequestDetail('YC003')">
|
||||||
|
<div class="request-header">
|
||||||
|
<div class="request-code">Mã yêu cầu: #YC003</div>
|
||||||
|
<span class="status-badge status-pending">Chờ tiếp nhận</span>
|
||||||
|
</div>
|
||||||
|
<div class="request-date">Ngày gửi: 28/10/2024</div>
|
||||||
|
<div style="color: #374151; font-size: 14px;">
|
||||||
|
Thiết kế biệt thự 2 tầng - Anh Đức (Bình Dương)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Request Item 4 -->
|
||||||
|
<div class="request-card" onclick="viewRequestDetail('YC004')">
|
||||||
|
<div class="request-header">
|
||||||
|
<div class="request-code">Mã yêu cầu: #YC004</div>
|
||||||
|
<span class="status-badge status-pending">Chờ tiếp nhận</span>
|
||||||
|
</div>
|
||||||
|
<div class="request-date">Ngày gửi: 01/11/2024</div>
|
||||||
|
<div style="color: #374151; font-size: 14px;">
|
||||||
|
Thiết kế cửa hàng kinh doanh - Chị Mai (Quận 1)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty state when no requests (hidden by default) -->
|
||||||
|
<div class="empty-state" id="empty-requests" style="display: none;">
|
||||||
|
<i class="fas fa-drafting-compass"></i>
|
||||||
|
<h3>Chưa có yêu cầu thiết kế</h3>
|
||||||
|
<p>Tạo yêu cầu thiết kế đầu tiên để nhận tư vấn từ đội ngũ chuyên gia</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom Navigation -->
|
||||||
|
<!--<div class="bottom-nav">
|
||||||
|
<a href="index.html" class="nav-item">
|
||||||
|
<i class="fas fa-home"></i>
|
||||||
|
<span>Trang chủ</span>
|
||||||
|
</a>
|
||||||
|
<a href="loyalty.html" class="nav-item">
|
||||||
|
<i class="fas fa-star"></i>
|
||||||
|
<span>Hội viên</span>
|
||||||
|
</a>
|
||||||
|
<a href="promotions.html" class="nav-item">
|
||||||
|
<i class="fas fa-tags"></i>
|
||||||
|
<span>Khuyến mãi</span>
|
||||||
|
</a>
|
||||||
|
<a href="notifications.html" class="nav-item">
|
||||||
|
<i class="fas fa-bell"></i>
|
||||||
|
<span>Thông báo</span>
|
||||||
|
</a>
|
||||||
|
<a href="account.html" class="nav-item active">
|
||||||
|
<i class="fas fa-user"></i>
|
||||||
|
<span>Cài đặt</span>
|
||||||
|
</a>
|
||||||
|
</div>-->
|
||||||
|
|
||||||
|
<!-- Floating Action Button (only show on Requests tab) -->
|
||||||
|
<button class="fab" id="fab-button" style="display: none;" onclick="createNewRequest()">
|
||||||
|
<i class="fas fa-plus"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function switchTab(tab) {
|
||||||
|
// Update tab navigation
|
||||||
|
document.querySelectorAll('.tab-item').forEach(item => {
|
||||||
|
item.classList.remove('active');
|
||||||
|
});
|
||||||
|
event.target.classList.add('active');
|
||||||
|
|
||||||
|
// Update tab content
|
||||||
|
document.querySelectorAll('.tab-content').forEach(content => {
|
||||||
|
content.classList.remove('active');
|
||||||
|
});
|
||||||
|
document.getElementById(tab + '-tab').classList.add('active');
|
||||||
|
|
||||||
|
// Show/hide FAB based on tab
|
||||||
|
const fab = document.getElementById('fab-button');
|
||||||
|
if (tab === 'requests') {
|
||||||
|
fab.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
fab.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function viewLibraryDetail(modelId) {
|
||||||
|
// Navigate to 360° detail page
|
||||||
|
window.location.href = `nha-mau-360-detail.html?id=${modelId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function viewRequestDetail(requestId) {
|
||||||
|
// Navigate to request detail page
|
||||||
|
window.location.href = `design-request-detail.html?id=${requestId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createNewRequest() {
|
||||||
|
// Navigate to create new request page
|
||||||
|
window.location.href = 'design-request-create.html';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize page
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Set default state
|
||||||
|
switchTab('library');
|
||||||
|
|
||||||
|
// Add animation to cards
|
||||||
|
const cards = document.querySelectorAll('.library-card, .request-card');
|
||||||
|
cards.forEach((card, index) => {
|
||||||
|
card.style.opacity = '0';
|
||||||
|
card.style.transform = 'translateY(20px)';
|
||||||
|
card.style.transition = 'all 0.5s ease';
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
card.style.opacity = '1';
|
||||||
|
card.style.transform = 'translateY(0)';
|
||||||
|
}, index * 100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Order Item 2 - Completed -->
|
<!-- Order Item 2 - Completed -->
|
||||||
<div class="order-card completed" onclick="viewOrderDetail('DH001233')">
|
<div class="order-card completed">
|
||||||
<div class="order-status-indicator"></div>
|
<div class="order-status-indicator"></div>
|
||||||
<div class="order-content">
|
<div class="order-content">
|
||||||
<div class="d-flex justify-between align-start mb-2">
|
<div class="d-flex justify-between align-start mb-2">
|
||||||
@@ -81,7 +81,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Order Item 3 - Shipping -->
|
<!-- Order Item 3 - Shipping -->
|
||||||
<div class="order-card shipping" onclick="viewOrderDetail('DH001232')">
|
<div class="order-card shipping">
|
||||||
<div class="order-status-indicator"></div>
|
<div class="order-status-indicator"></div>
|
||||||
<div class="order-content">
|
<div class="order-content">
|
||||||
<div class="d-flex justify-between align-start mb-2">
|
<div class="d-flex justify-between align-start mb-2">
|
||||||
@@ -101,7 +101,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Order Item 4 - Pending -->
|
<!-- Order Item 4 - Pending -->
|
||||||
<div class="order-card pending" onclick="viewOrderDetail('DH001231')">
|
<div class="order-card pending">
|
||||||
<div class="order-status-indicator"></div>
|
<div class="order-status-indicator"></div>
|
||||||
<div class="order-content">
|
<div class="order-content">
|
||||||
<div class="d-flex justify-between align-start mb-2">
|
<div class="d-flex justify-between align-start mb-2">
|
||||||
@@ -121,7 +121,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Order Item 5 - Cancelled -->
|
<!-- Order Item 5 - Cancelled -->
|
||||||
<div class="order-card cancelled" onclick="viewOrderDetail('DH001230')">
|
<div class="order-card cancelled">
|
||||||
<div class="order-status-indicator"></div>
|
<div class="order-status-indicator"></div>
|
||||||
<div class="order-content">
|
<div class="order-content">
|
||||||
<div class="d-flex justify-between align-start mb-2">
|
<div class="d-flex justify-between align-start mb-2">
|
||||||
@@ -142,35 +142,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Bottom Navigation -->
|
|
||||||
<div class="bottom-nav">
|
|
||||||
<a href="index.html" class="nav-item active">
|
|
||||||
<i class="fas fa-home"></i>
|
|
||||||
<span>Trang chủ</span>
|
|
||||||
</a>
|
|
||||||
<a href="loyalty.html" class="nav-item">
|
|
||||||
<i class="fas fa-star"></i>
|
|
||||||
<span>Hội viên</span>
|
|
||||||
</a>
|
|
||||||
<a href="promotions.html" class="nav-item">
|
|
||||||
<i class="fas fa-tags"></i>
|
|
||||||
<span>Khuyến mãi</span>
|
|
||||||
</a>
|
|
||||||
<a href="notifications.html" class="nav-item">
|
|
||||||
<i class="fas fa-bell"></i>
|
|
||||||
<span>Thông báo</span>
|
|
||||||
</a>
|
|
||||||
<a href="account.html" class="nav-item">
|
|
||||||
<i class="fas fa-user"></i>
|
|
||||||
<span>Cài đặt</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
</div>
|
||||||
function viewOrderDetail(orderId) {
|
|
||||||
window.location.href = `order-detail.html?id=${orderId}`;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -29,13 +29,23 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Status Filters -->
|
<!-- Status Filters -->
|
||||||
<div class="tab-nav mb-3">
|
<!--<div class="tab-nav mb-3">
|
||||||
<button class="tab-item active">Tất cả</button>
|
<button class="tab-item active">Tất cả</button>
|
||||||
<button class="tab-item">Chờ xác nhận</button>
|
<button class="tab-item">Chờ xác nhận</button>
|
||||||
<button class="tab-item">Đang xử lý</button>
|
<button class="tab-item">Đang xử lý</button>
|
||||||
<button class="tab-item">Đang giao</button>
|
<button class="tab-item">Đang giao</button>
|
||||||
<button class="tab-item">Hoàn thành</button>
|
<button class="tab-item">Hoàn thành</button>
|
||||||
<button class="tab-item">Đã hủy</button>
|
<button class="tab-item">Đã hủy</button>
|
||||||
|
</div>-->
|
||||||
|
<!-- Filter Pills -->
|
||||||
|
<div class="filter-container">
|
||||||
|
<button class="filter-pill active">Tất cả</button>
|
||||||
|
<button class="filter-pill">Chờ xác nhận</button>
|
||||||
|
<button class="filter-pill">Gạch ốp tường</button>
|
||||||
|
<button class="filter-pill">Đang xử lý</button>
|
||||||
|
<button class="filter-pill">Đang giao</button>
|
||||||
|
<button class="filter-pill">Hoàn thành</button>
|
||||||
|
<button class="filter-pill">Đã hủy</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Orders List -->
|
<!-- Orders List -->
|
||||||
@@ -50,18 +60,18 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="order-details">
|
<div class="order-details">
|
||||||
<p class="order-date">Ngày đặt: 03/08/2023</p>
|
<p class="order-date">Ngày đặt: 03/08/2025</p>
|
||||||
<p class="order-customer">Khách hàng: Nguyễn Văn A</p>
|
<p class="order-customer">Ngày giao: 06/08/2025</p>
|
||||||
|
<p class="order-customer">Địa chỉ: Quận 7, HCM</p>
|
||||||
<p class="order-status-text">
|
<p class="order-status-text">
|
||||||
<span class="status-badge processing">Đang xử lý</span>
|
<span class="status-badge processing">Đang xử lý</span>
|
||||||
</p>
|
</p>
|
||||||
<p class="order-note">Gạch granite 60x60 - Số lượng: 50m²</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Order Item 2 - Completed -->
|
<!-- Order Item 2 - Completed -->
|
||||||
<div class="order-card completed">
|
<div class="order-card completed" onclick="viewOrderDetail('DH001233')">
|
||||||
<div class="order-status-indicator"></div>
|
<div class="order-status-indicator"></div>
|
||||||
<div class="order-content">
|
<div class="order-content">
|
||||||
<div class="d-flex justify-between align-start mb-2">
|
<div class="d-flex justify-between align-start mb-2">
|
||||||
@@ -70,18 +80,18 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="order-details">
|
<div class="order-details">
|
||||||
<p class="order-date">Ngày đặt: 02/08/2023</p>
|
<p class="order-date">Ngày đặt: 24/06/2025</p>
|
||||||
<p class="order-customer">Khách hàng: Trần Thị B</p>
|
<p class="order-customer">Ngày giao: 27/06/202</p>
|
||||||
|
<p class="order-customer">Địa chỉ: Thủ Dầu Một, Bình Dương</p>
|
||||||
<p class="order-status-text">
|
<p class="order-status-text">
|
||||||
<span class="status-badge completed">Hoàn thành</span>
|
<span class="status-badge completed">Hoàn thành</span>
|
||||||
</p>
|
</p>
|
||||||
<p class="order-note">Gạch ceramic 30x30 - Số lượng: 80m²</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Order Item 3 - Shipping -->
|
<!-- Order Item 3 - Shipping -->
|
||||||
<div class="order-card shipping">
|
<div class="order-card shipping" onclick="viewOrderDetail('DH001232')">
|
||||||
<div class="order-status-indicator"></div>
|
<div class="order-status-indicator"></div>
|
||||||
<div class="order-content">
|
<div class="order-content">
|
||||||
<div class="d-flex justify-between align-start mb-2">
|
<div class="d-flex justify-between align-start mb-2">
|
||||||
@@ -90,18 +100,18 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="order-details">
|
<div class="order-details">
|
||||||
<p class="order-date">Ngày đặt: 01/08/2023</p>
|
<p class="order-date">Ngày đặt: 01/03/2025</p>
|
||||||
<p class="order-customer">Khách hàng: Lê Văn C</p>
|
<p class="order-customer">Ngày giao: 05/03/2025</p>
|
||||||
|
<p class="order-customer">Địa chỉ: Cầu Giấy, Hà Nội</p>
|
||||||
<p class="order-status-text">
|
<p class="order-status-text">
|
||||||
<span class="status-badge shipping">Đang giao</span>
|
<span class="status-badge shipping">Đang giao</span>
|
||||||
</p>
|
</p>
|
||||||
<p class="order-note">Gạch porcelain 80x80 - Số lượng: 100m²</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Order Item 4 - Pending -->
|
<!-- Order Item 4 - Pending -->
|
||||||
<div class="order-card pending">
|
<div class="order-card pending" onclick="viewOrderDetail('DH001231')">
|
||||||
<div class="order-status-indicator"></div>
|
<div class="order-status-indicator"></div>
|
||||||
<div class="order-content">
|
<div class="order-content">
|
||||||
<div class="d-flex justify-between align-start mb-2">
|
<div class="d-flex justify-between align-start mb-2">
|
||||||
@@ -110,18 +120,18 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="order-details">
|
<div class="order-details">
|
||||||
<p class="order-date">Ngày đặt: 31/07/2023</p>
|
<p class="order-date">Ngày đặt: 08/11/2024</p>
|
||||||
<p class="order-customer">Khách hàng: Phạm Thị D</p>
|
<p class="order-customer">Ngày giao: 12/11/2024</p>
|
||||||
|
<p class="order-customer">Địa chỉ: Thủ Đức, HCM</p>
|
||||||
<p class="order-status-text">
|
<p class="order-status-text">
|
||||||
<span class="status-badge pending">Chờ xác nhận</span>
|
<span class="status-badge pending">Chờ xác nhận</span>
|
||||||
</p>
|
</p>
|
||||||
<p class="order-note">Gạch mosaic 25x25 - Số lượng: 40m²</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Order Item 5 - Cancelled -->
|
<!-- Order Item 5 - Cancelled -->
|
||||||
<div class="order-card cancelled">
|
<div class="order-card cancelled" onclick="viewOrderDetail('DH001230')">
|
||||||
<div class="order-status-indicator"></div>
|
<div class="order-status-indicator"></div>
|
||||||
<div class="order-content">
|
<div class="order-content">
|
||||||
<div class="d-flex justify-between align-start mb-2">
|
<div class="d-flex justify-between align-start mb-2">
|
||||||
@@ -130,19 +140,47 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="order-details">
|
<div class="order-details">
|
||||||
<p class="order-date">Ngày đặt: 30/07/2023</p>
|
<p class="order-date">Ngày đặt: 30/07/2024</p>
|
||||||
<p class="order-customer">Khách hàng: Hoàng Văn E</p>
|
<p class="order-customer">Ngày giao: 04/08/2024</p>
|
||||||
|
<p class="order-customer">Địa chỉ: Rạch Giá, Kiên Giang</p>
|
||||||
<p class="order-status-text">
|
<p class="order-status-text">
|
||||||
<span class="status-badge cancelled">Đã hủy</span>
|
<span class="status-badge cancelled">Đã hủy</span>
|
||||||
</p>
|
</p>
|
||||||
<p class="order-note">Gạch terrazzo 40x40 - Số lượng: 20m²</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom Navigation -->
|
||||||
|
<!-- <div class="bottom-nav">
|
||||||
|
<a href="index.html" class="nav-item active">
|
||||||
|
<i class="fas fa-home"></i>
|
||||||
|
<span>Trang chủ</span>
|
||||||
|
</a>
|
||||||
|
<a href="loyalty.html" class="nav-item">
|
||||||
|
<i class="fas fa-star"></i>
|
||||||
|
<span>Hội viên</span>
|
||||||
|
</a>
|
||||||
|
<a href="promotions.html" class="nav-item">
|
||||||
|
<i class="fas fa-tags"></i>
|
||||||
|
<span>Khuyến mãi</span>
|
||||||
|
</a>
|
||||||
|
<a href="notifications.html" class="nav-item">
|
||||||
|
<i class="fas fa-bell"></i>
|
||||||
|
<span>Thông báo</span>
|
||||||
|
</a>
|
||||||
|
<a href="account.html" class="nav-item">
|
||||||
|
<i class="fas fa-user"></i>
|
||||||
|
<span>Cài đặt</span>
|
||||||
|
</a>
|
||||||
|
</div>-->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function viewOrderDetail(orderId) {
|
||||||
|
window.location.href = `order-detail.html?id=${orderId}`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
733
html/payment-detail.html
Normal file
733
html/payment-detail.html
Normal file
@@ -0,0 +1,733 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="vi">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Chi tiết Hóa đơn - EuroTile Worker</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<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">
|
||||||
|
<style>
|
||||||
|
.detail-container {
|
||||||
|
max-width: 480px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
background: #f8fafc;
|
||||||
|
min-height: calc(100vh - 120px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-id {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f2937;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-date {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-overdue {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-unpaid {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #d97706;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-paid {
|
||||||
|
background: #d1fae5;
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-partial {
|
||||||
|
background: #e0e7ff;
|
||||||
|
color: #3730a3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-summary {
|
||||||
|
background: #f8fafc;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-row:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 2px solid #e5e7eb;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remaining-amount {
|
||||||
|
color: #dc2626;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f2937;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-image {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-right: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-sku {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6b7280;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-details {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-quantity {
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-price {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-history {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
background: #d1fae5;
|
||||||
|
color: #065f46;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-right: 16px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-content {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-details {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6b7280;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-date {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-amount {
|
||||||
|
text-align: right;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #065f46;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 14px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
border: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: white;
|
||||||
|
color: #374151;
|
||||||
|
border: 2px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
border-color: #2563eb;
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-download {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #374151;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-download:hover {
|
||||||
|
background: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-section {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-history {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-history i {
|
||||||
|
font-size: 32px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
color: #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.detail-container {
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-card {
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-details {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-row {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-row:last-child {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page-wrapper">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="header">
|
||||||
|
<a href="payments.html" class="back-button">
|
||||||
|
<i class="fas fa-arrow-left"></i>
|
||||||
|
</a>
|
||||||
|
<h1 class="header-title">Chi tiết Hóa đơn</h1>
|
||||||
|
<button class="icon-button" onclick="shareInvoice()">
|
||||||
|
<i class="fas fa-share-alt"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-container">
|
||||||
|
<!-- Invoice Header -->
|
||||||
|
<div class="detail-card">
|
||||||
|
<div class="invoice-header">
|
||||||
|
<h2 class="invoice-id" id="invoice-id">#INV001</h2>
|
||||||
|
<div class="invoice-date" id="invoice-date">Đơn hàng: #SO001 | Ngày đặt: 15/10/2024</div>
|
||||||
|
<span class="status-badge" id="status-badge">Quá hạn</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Payment Summary -->
|
||||||
|
<div class="payment-summary">
|
||||||
|
<div class="summary-row">
|
||||||
|
<span>Tổng tiền hóa đơn:</span>
|
||||||
|
<span id="total-amount">85.000.000đ</span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-row">
|
||||||
|
<span>Đã thanh toán:</span>
|
||||||
|
<span id="paid-amount">25.000.000đ</span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-row">
|
||||||
|
<span>Còn lại:</span>
|
||||||
|
<span class="remaining-amount" id="remaining-amount">60.000.000đ</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Customer Info -->
|
||||||
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 20px;">
|
||||||
|
<div>
|
||||||
|
<div style="font-size: 12px; color: #6b7280; margin-bottom: 4px;">Ngày đặt hàng</div>
|
||||||
|
<div style="font-weight: 600;" id="order-date">15/10/2024</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size: 12px; color: #6b7280; margin-bottom: 4px;">Hạn thanh toán</div>
|
||||||
|
<div style="font-weight: 600; color: #dc2626;" id="due-date">30/10/2024</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="padding: 16px; background: #f8fafc; border-radius: 8px;">
|
||||||
|
<div style="font-size: 14px; font-weight: 600; margin-bottom: 8px;">Thông tin khách hàng</div>
|
||||||
|
<div style="font-size: 14px; color: #4b5563;" id="customer-info">
|
||||||
|
Công ty TNHH Xây Dựng Minh An<br>
|
||||||
|
Địa chỉ: 123 Nguyễn Văn Linh, Quận 7, TP.HCM<br>
|
||||||
|
SĐT: 0901234567 | Email: contact@minhan.com
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Product Details -->
|
||||||
|
<div class="detail-card">
|
||||||
|
<h3 class="section-title">
|
||||||
|
<i class="fas fa-box" style="color: #2563eb;"></i>
|
||||||
|
Danh sách sản phẩm
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="products-list" id="products-list">
|
||||||
|
<div class="product-item">
|
||||||
|
<div class="product-image">
|
||||||
|
<i class="fas fa-image"></i>
|
||||||
|
</div>
|
||||||
|
<div class="product-info">
|
||||||
|
<div class="product-name">Gạch Granite Eurotile Premium 60x60</div>
|
||||||
|
<div class="product-sku">SKU: GT-PR-6060-001</div>
|
||||||
|
<div class="product-details">
|
||||||
|
<span class="product-quantity">Số lượng: 150 m²</span>
|
||||||
|
<span class="product-price">450.000đ/m²</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="product-item">
|
||||||
|
<div class="product-image">
|
||||||
|
<i class="fas fa-image"></i>
|
||||||
|
</div>
|
||||||
|
<div class="product-info">
|
||||||
|
<div class="product-name">Gạch Ceramic Cao Cấp 30x60</div>
|
||||||
|
<div class="product-sku">SKU: CE-CC-3060-002</div>
|
||||||
|
<div class="product-details">
|
||||||
|
<span class="product-quantity">Số lượng: 80 m²</span>
|
||||||
|
<span class="product-price">280.000đ/m²</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="product-item">
|
||||||
|
<div class="product-image">
|
||||||
|
<i class="fas fa-image"></i>
|
||||||
|
</div>
|
||||||
|
<div class="product-info">
|
||||||
|
<div class="product-name">Keo dán gạch chuyên dụng</div>
|
||||||
|
<div class="product-sku">SKU: KD-CD-001</div>
|
||||||
|
<div class="product-details">
|
||||||
|
<span class="product-quantity">Số lượng: 20 bao</span>
|
||||||
|
<span class="product-price">85.000đ/bao</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Payment History -->
|
||||||
|
<div class="detail-card">
|
||||||
|
<h3 class="section-title">
|
||||||
|
<i class="fas fa-history" style="color: #2563eb;"></i>
|
||||||
|
Lịch sử thanh toán
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="payment-history" id="payment-history">
|
||||||
|
<div class="history-item">
|
||||||
|
<div class="history-icon">
|
||||||
|
<i class="fas fa-check"></i>
|
||||||
|
</div>
|
||||||
|
<div class="history-content">
|
||||||
|
<div class="history-title">Thanh toán lần 1</div>
|
||||||
|
<div class="history-details">Chuyển khoản | Ref: TK20241020001</div>
|
||||||
|
<div class="history-date">20/10/2024 - 14:30</div>
|
||||||
|
</div>
|
||||||
|
<div class="history-amount">25.000.000đ</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Download Section -->
|
||||||
|
<div class="detail-card download-section">
|
||||||
|
<h3 class="section-title">
|
||||||
|
<i class="fas fa-download" style="color: #2563eb;"></i>
|
||||||
|
Tải chứng từ
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px;">
|
||||||
|
<button class="btn btn-download" onclick="downloadPDF('invoice')">
|
||||||
|
<i class="fas fa-file-pdf"></i>
|
||||||
|
Hóa đơn PDF
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-download" onclick="downloadPDF('receipt')">
|
||||||
|
<i class="fas fa-receipt"></i>
|
||||||
|
Phiếu thu PDF
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="action-buttons">
|
||||||
|
<button class="btn btn-secondary" onclick="contactSupport()">
|
||||||
|
<i class="fas fa-comments"></i>
|
||||||
|
Liên hệ hỗ trợ
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary" onclick="makePayment()" id="pay-button">
|
||||||
|
<i class="fas fa-credit-card"></i>
|
||||||
|
Thanh toán
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Invoice data mapping
|
||||||
|
const invoiceDatabase = {
|
||||||
|
'INV001': {
|
||||||
|
id: 'INV001',
|
||||||
|
orderId: 'SO001',
|
||||||
|
orderDate: '15/10/2024',
|
||||||
|
dueDate: '30/10/2024',
|
||||||
|
status: 'overdue',
|
||||||
|
statusText: 'Quá hạn',
|
||||||
|
totalAmount: 85000000,
|
||||||
|
paidAmount: 25000000,
|
||||||
|
remainingAmount: 60000000,
|
||||||
|
customer: {
|
||||||
|
name: 'Công ty TNHH Xây Dựng Minh An',
|
||||||
|
address: '123 Nguyễn Văn Linh, Quận 7, TP.HCM',
|
||||||
|
phone: '0901234567',
|
||||||
|
email: 'contact@minhan.com'
|
||||||
|
},
|
||||||
|
products: [
|
||||||
|
{
|
||||||
|
name: 'Gạch Granite Eurotile Premium 60x60',
|
||||||
|
sku: 'GT-PR-6060-001',
|
||||||
|
quantity: 150,
|
||||||
|
unit: 'm²',
|
||||||
|
price: 450000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Gạch Ceramic Cao Cấp 30x60',
|
||||||
|
sku: 'CE-CC-3060-002',
|
||||||
|
quantity: 80,
|
||||||
|
unit: 'm²',
|
||||||
|
price: 280000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Keo dán gạch chuyên dụng',
|
||||||
|
sku: 'KD-CD-001',
|
||||||
|
quantity: 20,
|
||||||
|
unit: 'bao',
|
||||||
|
price: 85000
|
||||||
|
}
|
||||||
|
],
|
||||||
|
paymentHistory: [
|
||||||
|
{
|
||||||
|
date: '20/10/2024',
|
||||||
|
time: '14:30',
|
||||||
|
amount: 25000000,
|
||||||
|
method: 'Chuyển khoản',
|
||||||
|
reference: 'TK20241020001',
|
||||||
|
title: 'Thanh toán lần 1'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'INV002': {
|
||||||
|
id: 'INV002',
|
||||||
|
orderId: 'SO002',
|
||||||
|
orderDate: '25/10/2024',
|
||||||
|
dueDate: '09/11/2024',
|
||||||
|
status: 'unpaid',
|
||||||
|
statusText: 'Chưa thanh toán',
|
||||||
|
totalAmount: 42500000,
|
||||||
|
paidAmount: 0,
|
||||||
|
remainingAmount: 42500000,
|
||||||
|
customer: {
|
||||||
|
name: 'Anh Nguyễn Văn Minh',
|
||||||
|
address: '456 Lê Văn Việt, Quận 9, TP.HCM',
|
||||||
|
phone: '0987654321',
|
||||||
|
email: 'minh.nguyen@email.com'
|
||||||
|
},
|
||||||
|
paymentHistory: []
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize page
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const invoiceId = urlParams.get('id') || 'INV001';
|
||||||
|
|
||||||
|
loadInvoiceDetail(invoiceId);
|
||||||
|
});
|
||||||
|
|
||||||
|
function loadInvoiceDetail(invoiceId) {
|
||||||
|
const invoice = invoiceDatabase[invoiceId];
|
||||||
|
if (!invoice) return;
|
||||||
|
|
||||||
|
// Update basic info
|
||||||
|
document.getElementById('invoice-id').textContent = '#' + invoice.id;
|
||||||
|
document.getElementById('invoice-date').textContent = `Đơn hàng: #${invoice.orderId} | Ngày đặt: ${invoice.orderDate}`;
|
||||||
|
document.getElementById('order-date').textContent = invoice.orderDate;
|
||||||
|
document.getElementById('due-date').textContent = invoice.dueDate;
|
||||||
|
|
||||||
|
// Update amounts
|
||||||
|
document.getElementById('total-amount').textContent = formatCurrency(invoice.totalAmount);
|
||||||
|
document.getElementById('paid-amount').textContent = formatCurrency(invoice.paidAmount);
|
||||||
|
document.getElementById('remaining-amount').textContent = formatCurrency(invoice.remainingAmount);
|
||||||
|
|
||||||
|
// Update status
|
||||||
|
const statusBadge = document.getElementById('status-badge');
|
||||||
|
statusBadge.textContent = invoice.statusText;
|
||||||
|
statusBadge.className = `status-badge status-${invoice.status}`;
|
||||||
|
|
||||||
|
// Update customer info
|
||||||
|
const customerInfo = document.getElementById('customer-info');
|
||||||
|
customerInfo.innerHTML = `
|
||||||
|
${invoice.customer.name}<br>
|
||||||
|
Địa chỉ: ${invoice.customer.address}<br>
|
||||||
|
SĐT: ${invoice.customer.phone} | Email: ${invoice.customer.email}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Update products
|
||||||
|
if (invoice.products) {
|
||||||
|
updateProductsList(invoice.products);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update payment history
|
||||||
|
updatePaymentHistory(invoice.paymentHistory);
|
||||||
|
|
||||||
|
// Update pay button
|
||||||
|
const payButton = document.getElementById('pay-button');
|
||||||
|
if (invoice.remainingAmount <= 0) {
|
||||||
|
payButton.innerHTML = '<i class="fas fa-check-circle"></i> Đã hoàn tất';
|
||||||
|
payButton.className = 'btn btn-success';
|
||||||
|
payButton.style.background = '#10b981';
|
||||||
|
payButton.disabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update page title
|
||||||
|
document.title = `${invoice.id} - Chi tiết Hóa đơn`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateProductsList(products) {
|
||||||
|
const productsList = document.getElementById('products-list');
|
||||||
|
|
||||||
|
if (!products || products.length === 0) {
|
||||||
|
productsList.innerHTML = '<p style="text-align: center; color: #6b7280;">Không có sản phẩm</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
productsList.innerHTML = products.map(product => `
|
||||||
|
<div class="product-item">
|
||||||
|
<div class="product-image">
|
||||||
|
<i class="fas fa-image"></i>
|
||||||
|
</div>
|
||||||
|
<div class="product-info">
|
||||||
|
<div class="product-name">${product.name}</div>
|
||||||
|
<div class="product-sku">SKU: ${product.sku}</div>
|
||||||
|
<div class="product-details">
|
||||||
|
<span class="product-quantity">Số lượng: ${product.quantity} ${product.unit}</span>
|
||||||
|
<span class="product-price">${formatCurrency(product.price)}/${product.unit}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePaymentHistory(history) {
|
||||||
|
const historyContainer = document.getElementById('payment-history');
|
||||||
|
|
||||||
|
if (!history || history.length === 0) {
|
||||||
|
historyContainer.innerHTML = `
|
||||||
|
<div class="empty-history">
|
||||||
|
<i class="fas fa-receipt"></i>
|
||||||
|
<h4>Chưa có lịch sử thanh toán</h4>
|
||||||
|
<p>Hóa đơn này chưa được thanh toán</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
historyContainer.innerHTML = history.map((payment, index) => `
|
||||||
|
<div class="history-item">
|
||||||
|
<div class="history-icon">
|
||||||
|
<i class="fas fa-check"></i>
|
||||||
|
</div>
|
||||||
|
<div class="history-content">
|
||||||
|
<div class="history-title">${payment.title}</div>
|
||||||
|
<div class="history-details">${payment.method} | Ref: ${payment.reference}</div>
|
||||||
|
<div class="history-date">${payment.date} - ${payment.time}</div>
|
||||||
|
</div>
|
||||||
|
<div class="history-amount">${formatCurrency(payment.amount)}</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function makePayment() {
|
||||||
|
// In real app, open payment modal or navigate to payment gateway
|
||||||
|
alert('Mở cổng thanh toán...\n\nTrong ứng dụng thực tế, đây sẽ chuyển đến trang thanh toán hoặc mở ví điện tử.');
|
||||||
|
}
|
||||||
|
|
||||||
|
function contactSupport() {
|
||||||
|
// Navigate to support chat
|
||||||
|
if (confirm('Liên hệ hỗ trợ về hóa đơn này?')) {
|
||||||
|
window.location.href = 'chat-list.html';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadPDF(type) {
|
||||||
|
const invoiceId = document.getElementById('invoice-id').textContent.replace('#', '');
|
||||||
|
|
||||||
|
// Simulate PDF download
|
||||||
|
const filename = type === 'invoice' ? `Hoa-don-${invoiceId}.pdf` : `Phieu-thu-${invoiceId}.pdf`;
|
||||||
|
|
||||||
|
// In real app, this would trigger actual PDF download
|
||||||
|
alert(`Đang tải ${filename}...\n\nTrong ứng dụng thực tế, file PDF sẽ được tải về thiết bị.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shareInvoice() {
|
||||||
|
const invoiceId = document.getElementById('invoice-id').textContent;
|
||||||
|
|
||||||
|
if (navigator.share) {
|
||||||
|
navigator.share({
|
||||||
|
title: `Hóa đơn ${invoiceId}`,
|
||||||
|
text: `Chi tiết hóa đơn ${invoiceId}`,
|
||||||
|
url: window.location.href
|
||||||
|
}).catch(console.error);
|
||||||
|
} else {
|
||||||
|
// Fallback: copy URL to clipboard
|
||||||
|
navigator.clipboard.writeText(window.location.href).then(() => {
|
||||||
|
showToast('Đã sao chép link chia sẻ!');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(message) {
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = 'fixed top-20 left-1/2 transform -translate-x-1/2 bg-green-500 text-white px-4 py-2 rounded-lg z-50 transition-all duration-300';
|
||||||
|
toast.textContent = message;
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.style.opacity = '0';
|
||||||
|
setTimeout(() => {
|
||||||
|
if (document.body.contains(toast)) {
|
||||||
|
document.body.removeChild(toast);
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCurrency(amount) {
|
||||||
|
return new Intl.NumberFormat('vi-VN', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'VND',
|
||||||
|
minimumFractionDigits: 0
|
||||||
|
}).format(amount);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -3,10 +3,267 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Danh sách thanh toán - EuroTile Worker</title>
|
<title>Thanh toán - EuroTile Worker</title>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
<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">
|
||||||
|
<style>
|
||||||
|
.payments-container {
|
||||||
|
max-width: 480px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: #f8fafc;
|
||||||
|
min-height: calc(100vh - 120px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-filters {
|
||||||
|
background: white;
|
||||||
|
display: flex;
|
||||||
|
position: sticky;
|
||||||
|
top: 60px;
|
||||||
|
z-index: 40;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
justify-content: space-evenly;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-tab {
|
||||||
|
/*flex: 1;*/
|
||||||
|
padding: 12px 8px;
|
||||||
|
text-align: center;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #64748b;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
border-bottom: 3px solid transparent;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-tab.active {
|
||||||
|
color: #2563eb;
|
||||||
|
border-bottom-color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payments-list {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 16px rgba(0,0,0,0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-codes {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-id {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f2937;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-id {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-total {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-amount {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-details {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 8px 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-label {
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-value {
|
||||||
|
color: #1f2937;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overdue {
|
||||||
|
color: #dc2626 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-summary {
|
||||||
|
background: #f8fafc;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-row:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid #e2e8f0;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remaining-amount {
|
||||||
|
color: #dc2626 !important;
|
||||||
|
font-weight: 700 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
border: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success {
|
||||||
|
background: #10b981;
|
||||||
|
color: white;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-unpaid {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #d97706;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-overdue {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-paid {
|
||||||
|
background: #d1fae5;
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-partial {
|
||||||
|
background: #e0e7ff;
|
||||||
|
color: #3730a3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state i {
|
||||||
|
font-size: 48px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
color: #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state h3 {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #374151;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.payments-list {
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-card {
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-details {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="page-wrapper">
|
<div class="page-wrapper">
|
||||||
@@ -15,126 +272,253 @@
|
|||||||
<a href="index.html" class="back-button">
|
<a href="index.html" class="back-button">
|
||||||
<i class="fas fa-arrow-left"></i>
|
<i class="fas fa-arrow-left"></i>
|
||||||
</a>
|
</a>
|
||||||
<h1 class="header-title">Danh sách thanh toán</h1>
|
<h1 class="header-title">Thanh toán</h1>
|
||||||
<button class="back-button">
|
<div style="width: 32px;"></div>
|
||||||
<i class="fas fa-plus"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="container">
|
<div class="payments-container">
|
||||||
<!-- Search Bar -->
|
<!-- Filter Pills -->
|
||||||
<div class="search-bar">
|
<!--<div class="filter-container">
|
||||||
<i class="fas fa-search search-icon"></i>
|
<button class="filter-pill active" onclick="filterInvoices('all')">>Tất cả</button>
|
||||||
<input type="text" class="search-input" placeholder="Mã phiếu thanh toán">
|
<button class="filter-pill" onclick="filterInvoices('unpaid')">Chưa thanh toán</button>
|
||||||
</div>
|
<button class="filter-pill" onclick="filterInvoices('overdue')">Quá hạn</button>
|
||||||
|
<button class="filter-pill" onclick="filterInvoices('paid')">Đã thanh toán</button>
|
||||||
<!-- Filter Section -->
|
</div>-->
|
||||||
<div class="card mb-3">
|
<!-- Tab Filters -->
|
||||||
<div class="d-flex justify-between align-center">
|
<div class="tab-filters">
|
||||||
<h3 class="card-title">Bộ lọc</h3>
|
<button class="filter-tab active" onclick="filterInvoices('all')">
|
||||||
<i class="fas fa-filter" style="color: var(--primary-blue);"></i>
|
Tất cả
|
||||||
</div>
|
</button>
|
||||||
|
<button class="filter-tab" onclick="filterInvoices('unpaid')">
|
||||||
|
Chưa thanh toán
|
||||||
|
</button>
|
||||||
|
<button class="filter-tab" onclick="filterInvoices('overdue')">
|
||||||
|
Quá hạn
|
||||||
|
</button>
|
||||||
|
<button class="filter-tab" onclick="filterInvoices('paid')">
|
||||||
|
Đã thanh toán
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Payments List -->
|
<!-- Payments List -->
|
||||||
<div class="payments-list">
|
<div class="payments-list" id="payments-list">
|
||||||
<!-- Payment Item 1 - Processing -->
|
<!-- Invoice Card 1 - Overdue -->
|
||||||
<div class="payment-card processing">
|
<div class="invoice-card" data-status="overdue" onclick="viewInvoiceDetail('INV001')">
|
||||||
<div class="payment-status-indicator"></div>
|
<div class="invoice-header">
|
||||||
<div class="payment-content">
|
<div class="invoice-codes">
|
||||||
<div class="d-flex justify-between align-start mb-2">
|
<span class="invoice-id">Mã hóa đơn: #INV001</span>
|
||||||
<h4 class="payment-id">#212221</h4>
|
<span class="order-id">Đơn hàng: #SO001</span>
|
||||||
<span class="payment-amount">12.900.000 VND</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<span class="status-badge status-overdue">Quá hạn</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="payment-details">
|
<div class="invoice-details">
|
||||||
<p class="payment-time">Thời gian: 03/08/2023</p>
|
<div class="detail-item">
|
||||||
<p class="payment-status-text">
|
<span class="detail-label">Ngày đặt:</span>
|
||||||
<span class="status-badge processing">Đang xử lý</span>
|
<span class="detail-value">15/10/2024</span>
|
||||||
</p>
|
|
||||||
<p class="payment-store">Cửa hàng: CH Thủ Đức</p>
|
|
||||||
<p class="payment-note">Ghi chú: 21347 TT Đơn hàng 54970</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="detail-label">Hạn TT:</span>
|
||||||
|
<span class="detail-value overdue">30/10/2024</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="payment-summary">
|
||||||
|
<div class="payment-row">
|
||||||
|
<span>Tổng tiền:</span>
|
||||||
|
<span>85.000.000đ</span>
|
||||||
|
</div>
|
||||||
|
<div class="payment-row">
|
||||||
|
<span>Đã thanh toán:</span>
|
||||||
|
<span>25.000.000đ</span>
|
||||||
|
</div>
|
||||||
|
<div class="payment-row">
|
||||||
|
<span>Còn lại:</span>
|
||||||
|
<span class="remaining-amount">60.000.000đ</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="invoice-actions">
|
||||||
|
<button class="btn btn-primary" onclick="event.stopPropagation(); payInvoice('INV001')">
|
||||||
|
<i class="fas fa-credit-card"></i>
|
||||||
|
Thanh toán
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Payment Item 2 - Completed -->
|
<!-- Invoice Card 2 - Unpaid -->
|
||||||
<div class="payment-card completed">
|
<div class="invoice-card" data-status="unpaid" onclick="viewInvoiceDetail('INV002')">
|
||||||
<div class="payment-status-indicator"></div>
|
<div class="invoice-header">
|
||||||
<div class="payment-content">
|
<div class="invoice-codes">
|
||||||
<div class="d-flex justify-between align-start mb-2">
|
<span class="invoice-id">Mã hóa đơn: #INV002</span>
|
||||||
<h4 class="payment-id">#212221</h4>
|
<span class="order-id">Đơn hàng: #SO002</span>
|
||||||
<span class="payment-amount">12.900.000 VND</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<span class="status-badge status-unpaid">Chưa thanh toán</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="payment-details">
|
<div class="invoice-details">
|
||||||
<p class="payment-time">Thời gian: 03/08/2023</p>
|
<div class="detail-item">
|
||||||
<p class="payment-status-text">
|
<span class="detail-label">Ngày đặt:</span>
|
||||||
<span class="status-badge completed">Hoàn thành</span>
|
<span class="detail-value">25/10/2024</span>
|
||||||
</p>
|
|
||||||
<p class="payment-store">Cửa hàng: CH Thủ Đức</p>
|
|
||||||
<p class="payment-note">Ghi chú: 21347 TT Đơn hàng 54970</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="detail-label">Hạn TT:</span>
|
||||||
|
<span class="detail-value">09/11/2024</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="payment-summary">
|
||||||
|
<div class="payment-row">
|
||||||
|
<span>Tổng tiền:</span>
|
||||||
|
<span>42.500.000đ</span>
|
||||||
|
</div>
|
||||||
|
<div class="payment-row">
|
||||||
|
<span>Đã thanh toán:</span>
|
||||||
|
<span>0đ</span>
|
||||||
|
</div>
|
||||||
|
<div class="payment-row">
|
||||||
|
<span>Còn lại:</span>
|
||||||
|
<span class="remaining-amount">42.500.000đ</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="invoice-actions">
|
||||||
|
<button class="btn btn-primary" onclick="event.stopPropagation(); payInvoice('INV002')">
|
||||||
|
<i class="fas fa-credit-card"></i>
|
||||||
|
Thanh toán
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Payment Item 3 - Processing -->
|
<!-- Invoice Card 3 - Partial Payment -->
|
||||||
<div class="payment-card processing">
|
<div class="invoice-card" data-status="unpaid" onclick="viewInvoiceDetail('INV003')">
|
||||||
<div class="payment-status-indicator"></div>
|
<div class="invoice-header">
|
||||||
<div class="payment-content">
|
<div class="invoice-codes">
|
||||||
<div class="d-flex justify-between align-start mb-2">
|
<span class="invoice-id">Mã hóa đơn: #INV003</span>
|
||||||
<h4 class="payment-id">#212220</h4>
|
<span class="order-id">Đơn hàng: #SO003</span>
|
||||||
<span class="payment-amount">8.500.000 VND</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<span class="status-badge status-partial">Thanh toán 1 phần</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="payment-details">
|
<div class="invoice-details">
|
||||||
<p class="payment-time">Thời gian: 02/08/2023</p>
|
<div class="detail-item">
|
||||||
<p class="payment-status-text">
|
<span class="detail-label">Ngày đặt:</span>
|
||||||
<span class="status-badge processing">Đang xử lý</span>
|
<span class="detail-value">20/10/2024</span>
|
||||||
</p>
|
|
||||||
<p class="payment-store">Cửa hàng: CH Bình Dương</p>
|
|
||||||
<p class="payment-note">Ghi chú: 21346 TT Đơn hàng 54969</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="detail-label">Hạn TT:</span>
|
||||||
|
<span class="detail-value">04/11/2024</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="payment-summary">
|
||||||
|
<div class="payment-row">
|
||||||
|
<span>Tổng tiền:</span>
|
||||||
|
<span>150.000.000đ</span>
|
||||||
|
</div>
|
||||||
|
<div class="payment-row">
|
||||||
|
<span>Đã thanh toán:</span>
|
||||||
|
<span>75.000.000đ</span>
|
||||||
|
</div>
|
||||||
|
<div class="payment-row">
|
||||||
|
<span>Còn lại:</span>
|
||||||
|
<span class="remaining-amount">75.000.000đ</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="invoice-actions">
|
||||||
|
<button class="btn btn-primary" onclick="event.stopPropagation(); payInvoice('INV003')">
|
||||||
|
<i class="fas fa-credit-card"></i>
|
||||||
|
Thanh toán
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Payment Item 4 - Completed -->
|
<!-- Invoice Card 4 - Paid -->
|
||||||
<div class="payment-card completed">
|
<div class="invoice-card" data-status="paid" onclick="viewInvoiceDetail('INV004')">
|
||||||
<div class="payment-status-indicator"></div>
|
<div class="invoice-header">
|
||||||
<div class="payment-content">
|
<div class="invoice-codes">
|
||||||
<div class="d-flex justify-between align-start mb-2">
|
<span class="invoice-id">Mã hóa đơn: #INV004</span>
|
||||||
<h4 class="payment-id">#212219</h4>
|
<span class="order-id">Đơn hàng: #SO004</span>
|
||||||
<span class="payment-amount">15.200.000 VND</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<span class="status-badge status-paid">Đã hoàn tất</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="payment-details">
|
<div class="invoice-details">
|
||||||
<p class="payment-time">Thời gian: 01/08/2023</p>
|
<div class="detail-item">
|
||||||
<p class="payment-status-text">
|
<span class="detail-label">Ngày đặt:</span>
|
||||||
<span class="status-badge completed">Hoàn thành</span>
|
<span class="detail-value">10/10/2024</span>
|
||||||
</p>
|
|
||||||
<p class="payment-store">Cửa hàng: CH Thủ Đức</p>
|
|
||||||
<p class="payment-note">Ghi chú: 21345 TT Đơn hàng 54968</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="detail-label">Hạn TT:</span>
|
||||||
|
<span class="detail-value">25/10/2024</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="payment-summary">
|
||||||
|
<div class="payment-row">
|
||||||
|
<span>Tổng tiền:</span>
|
||||||
|
<span>32.800.000đ</span>
|
||||||
|
</div>
|
||||||
|
<div class="payment-row">
|
||||||
|
<span>Đã thanh toán:</span>
|
||||||
|
<span>32.800.000đ</span>
|
||||||
|
</div>
|
||||||
|
<div class="payment-row">
|
||||||
|
<span>Còn lại:</span>
|
||||||
|
<span>0đ</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="invoice-actions">
|
||||||
|
<button class="btn btn-success">
|
||||||
|
<i class="fas fa-check-circle"></i>
|
||||||
|
Đã hoàn tất
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Payment Item 5 - Processing -->
|
<!-- Invoice Card 5 - Overdue -->
|
||||||
<div class="payment-card processing">
|
<div class="invoice-card" data-status="overdue" onclick="viewInvoiceDetail('INV005')">
|
||||||
<div class="payment-status-indicator"></div>
|
<div class="invoice-header">
|
||||||
<div class="payment-content">
|
<div class="invoice-codes">
|
||||||
<div class="d-flex justify-between align-start mb-2">
|
<span class="invoice-id">Mã hóa đơn: #INV005</span>
|
||||||
<h4 class="payment-id">#212218</h4>
|
<span class="order-id">Đơn hàng: #SO005</span>
|
||||||
<span class="payment-amount">6.750.000 VND</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<span class="status-badge status-overdue">Quá hạn</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="payment-details">
|
<div class="invoice-details">
|
||||||
<p class="payment-time">Thời gian: 31/07/2023</p>
|
<div class="detail-item">
|
||||||
<p class="payment-status-text">
|
<span class="detail-label">Ngày đặt:</span>
|
||||||
<span class="status-badge processing">Đang xử lý</span>
|
<span class="detail-value">05/10/2024</span>
|
||||||
</p>
|
|
||||||
<p class="payment-store">Cửa hàng: CH Gò Vấp</p>
|
|
||||||
<p class="payment-note">Ghi chú: 21344 TT Đơn hàng 54967</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="detail-label">Hạn TT:</span>
|
||||||
|
<span class="detail-value overdue">20/10/2024</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="payment-summary">
|
||||||
|
<div class="payment-row">
|
||||||
|
<span>Tổng tiền:</span>
|
||||||
|
<span>95.300.000đ</span>
|
||||||
|
</div>
|
||||||
|
<div class="payment-row">
|
||||||
|
<span>Đã thanh toán:</span>
|
||||||
|
<span>0đ</span>
|
||||||
|
</div>
|
||||||
|
<div class="payment-row">
|
||||||
|
<span>Còn lại:</span>
|
||||||
|
<span class="remaining-amount">95.300.000đ</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="invoice-actions">
|
||||||
|
<button class="btn btn-primary" onclick="event.stopPropagation(); payInvoice('INV005')">
|
||||||
|
<i class="fas fa-credit-card"></i>
|
||||||
|
Thanh toán
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -142,5 +526,108 @@
|
|||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function filterInvoices(status) {
|
||||||
|
// Update active tab
|
||||||
|
document.querySelectorAll('.filter-tab').forEach(tab => {
|
||||||
|
tab.classList.remove('active');
|
||||||
|
});
|
||||||
|
event.target.classList.add('active');
|
||||||
|
|
||||||
|
// Filter invoice cards
|
||||||
|
const cards = document.querySelectorAll('.invoice-card');
|
||||||
|
cards.forEach(card => {
|
||||||
|
const cardStatus = card.getAttribute('data-status');
|
||||||
|
|
||||||
|
if (status === 'all') {
|
||||||
|
card.style.display = 'block';
|
||||||
|
} else if (status === 'unpaid') {
|
||||||
|
// Show cards that are not fully paid (unpaid, overdue, partial)
|
||||||
|
card.style.display = cardStatus !== 'paid' ? 'block' : 'none';
|
||||||
|
} else {
|
||||||
|
card.style.display = cardStatus === status ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show empty state if no results
|
||||||
|
const visibleCards = Array.from(cards).filter(card => card.style.display !== 'none');
|
||||||
|
if (visibleCards.length === 0) {
|
||||||
|
showEmptyState(status);
|
||||||
|
} else {
|
||||||
|
hideEmptyState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showEmptyState(filterType) {
|
||||||
|
const existingEmptyState = document.querySelector('.empty-state');
|
||||||
|
if (existingEmptyState) {
|
||||||
|
existingEmptyState.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
const paymentsList = document.getElementById('payments-list');
|
||||||
|
const emptyState = document.createElement('div');
|
||||||
|
emptyState.className = 'empty-state';
|
||||||
|
|
||||||
|
let message = '';
|
||||||
|
switch(filterType) {
|
||||||
|
case 'unpaid':
|
||||||
|
message = 'Không có hóa đơn chưa thanh toán';
|
||||||
|
break;
|
||||||
|
case 'overdue':
|
||||||
|
message = 'Không có hóa đơn quá hạn';
|
||||||
|
break;
|
||||||
|
case 'paid':
|
||||||
|
message = 'Không có hóa đơn đã thanh toán';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
message = 'Không có hóa đơn nào';
|
||||||
|
}
|
||||||
|
|
||||||
|
emptyState.innerHTML = `
|
||||||
|
<i class="fas fa-file-invoice"></i>
|
||||||
|
<h3>${message}</h3>
|
||||||
|
<p>Hiện tại không có hóa đơn nào trong danh mục này</p>
|
||||||
|
`;
|
||||||
|
|
||||||
|
paymentsList.appendChild(emptyState);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideEmptyState() {
|
||||||
|
const existingEmptyState = document.querySelector('.empty-state');
|
||||||
|
if (existingEmptyState) {
|
||||||
|
existingEmptyState.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function viewInvoiceDetail(invoiceId) {
|
||||||
|
// Navigate to invoice detail page
|
||||||
|
window.location.href = `payment-detail.html?id=${invoiceId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function payInvoice(invoiceId) {
|
||||||
|
// In real app, open payment modal or navigate to payment page
|
||||||
|
alert(`Mở trang thanh toán cho hóa đơn ${invoiceId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize page
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Set default filter to 'all'
|
||||||
|
filterInvoices('all');
|
||||||
|
|
||||||
|
// Add animation to cards
|
||||||
|
const cards = document.querySelectorAll('.invoice-card');
|
||||||
|
cards.forEach((card, index) => {
|
||||||
|
card.style.opacity = '0';
|
||||||
|
card.style.transform = 'translateY(20px)';
|
||||||
|
card.style.transition = 'all 0.5s ease';
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
card.style.opacity = '1';
|
||||||
|
card.style.transform = 'translateY(0)';
|
||||||
|
}, index * 100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
632
html/point-complaint.html
Normal file
632
html/point-complaint.html
Normal file
@@ -0,0 +1,632 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="vi">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Khiếu nại Giao dịch điểm - EuroTile Worker</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<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">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page-wrapper">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="header">
|
||||||
|
<a href="points-history.html" class="back-button">
|
||||||
|
<i class="fas fa-arrow-left"></i>
|
||||||
|
</a>
|
||||||
|
<h1 class="header-title">Khiếu nại Giao dịch điểm</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="complaint-content">
|
||||||
|
<!-- Transaction Info Card -->
|
||||||
|
<div class="transaction-info-card">
|
||||||
|
<h3><i class="fas fa-receipt"></i> Thông tin giao dịch</h3>
|
||||||
|
<div class="transaction-details">
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Mã giao dịch:</span>
|
||||||
|
<span class="detail-value" id="transactionId">TXN123456</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Loại giao dịch:</span>
|
||||||
|
<span class="detail-value" id="transactionTitle">Mua hàng tại cửa hàng</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Ngày giao dịch:</span>
|
||||||
|
<span class="detail-value" id="transactionDate">22/09/2023 17:23:18</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Complaint Form -->
|
||||||
|
<div class="complaint-form-card">
|
||||||
|
<h3><i class="fas fa-edit"></i> Nội dung khiếu nại</h3>
|
||||||
|
|
||||||
|
<form id="complaintForm">
|
||||||
|
<!-- Complaint Type -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Lý do khiếu nại *</label>
|
||||||
|
<div class="complaint-types">
|
||||||
|
<label class="complaint-type-option">
|
||||||
|
<input type="radio" name="complaintType" value="incorrect-points" required>
|
||||||
|
<span class="radio-custom"></span>
|
||||||
|
<div class="option-content">
|
||||||
|
<div class="option-title">Số điểm không chính xác</div>
|
||||||
|
<div class="option-description">Số điểm được cộng/trừ không đúng</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="complaint-type-option">
|
||||||
|
<input type="radio" name="complaintType" value="wrong-transaction" required>
|
||||||
|
<span class="radio-custom"></span>
|
||||||
|
<div class="option-content">
|
||||||
|
<div class="option-title">Giao dịch không phải của tôi</div>
|
||||||
|
<div class="option-description">Tôi không thực hiện giao dịch này</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="complaint-type-option">
|
||||||
|
<input type="radio" name="complaintType" value="missing-points" required>
|
||||||
|
<span class="radio-custom"></span>
|
||||||
|
<div class="option-content">
|
||||||
|
<div class="option-title">Thiếu điểm thưởng</div>
|
||||||
|
<div class="option-description">Không nhận được điểm sau giao dịch</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="complaint-type-option">
|
||||||
|
<input type="radio" name="complaintType" value="other" required>
|
||||||
|
<span class="radio-custom"></span>
|
||||||
|
<div class="option-content">
|
||||||
|
<div class="option-title">Lý do khác</div>
|
||||||
|
<div class="option-description">Vấn đề khác liên quan đến giao dịch</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Detailed Description -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="complaintContent">Mô tả chi tiết vấn đề *</label>
|
||||||
|
<textarea
|
||||||
|
id="complaintContent"
|
||||||
|
class="form-textarea"
|
||||||
|
rows="5"
|
||||||
|
placeholder="Vui lòng mô tả chi tiết vấn đề bạn gặp phải với giao dịch này. Thông tin càng chi tiết sẽ giúp chúng tôi xử lý nhanh chóng hơn."
|
||||||
|
required></textarea>
|
||||||
|
<div class="form-help">Tối thiểu 20 ký tự</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Evidence Upload -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Ảnh minh chứng (tùy chọn)</label>
|
||||||
|
<div class="evidence-upload-section">
|
||||||
|
<div class="upload-area" onclick="document.getElementById('evidenceFiles').click()">
|
||||||
|
<div class="upload-placeholder">
|
||||||
|
<i class="fas fa-camera"></i>
|
||||||
|
<div class="upload-text">
|
||||||
|
<div class="upload-title">Tải ảnh minh chứng</div>
|
||||||
|
<div class="upload-subtitle">Hóa đơn, ảnh chụp màn hình...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input type="file" id="evidenceFiles" multiple accept="image/*" style="display: none;" onchange="handleFileUpload(this)">
|
||||||
|
</div>
|
||||||
|
<div class="uploaded-files" id="uploadedFiles"></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-help">Tối đa 5 ảnh, mỗi ảnh không quá 10MB</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contact Info -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="contactPhone">Số điện thoại liên hệ</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
id="contactPhone"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="0901234567"
|
||||||
|
value="0901234567">
|
||||||
|
<div class="form-help">Để nhận thông báo kết quả xử lý</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Policy Notice -->
|
||||||
|
<div class="policy-notice">
|
||||||
|
<div class="notice-content">
|
||||||
|
<i class="fas fa-info-circle"></i>
|
||||||
|
<div class="notice-text">
|
||||||
|
<div class="notice-title">Lưu ý quan trọng</div>
|
||||||
|
<ul class="notice-list">
|
||||||
|
<li>Khiếu nại sẽ được xử lý trong vòng 3-5 ngày làm việc</li>
|
||||||
|
<li>Vui lòng cung cấp thông tin chính xác và đầy đủ</li>
|
||||||
|
<li>Chúng tôi có thể liên hệ để xác minh thông tin</li>
|
||||||
|
<li>Kết quả sẽ được thông báo qua SMS và email</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Submit Button -->
|
||||||
|
<div class="submit-container">
|
||||||
|
<button class="submit-btn" onclick="submitComplaint()">
|
||||||
|
<i class="fas fa-paper-plane"></i>
|
||||||
|
Gửi Khiếu nại
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.complaint-content {
|
||||||
|
padding: 0 0 100px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Transaction Info Card */
|
||||||
|
.transaction-info-card,
|
||||||
|
.complaint-form-card {
|
||||||
|
background: var(--white);
|
||||||
|
margin: 16px;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: var(--shadow-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.transaction-info-card h3,
|
||||||
|
.complaint-form-card h3 {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-dark);
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transaction-details {
|
||||||
|
background: var(--background-gray);
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-label {
|
||||||
|
color: var(--text-light);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-value {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-dark);
|
||||||
|
text-align: right;
|
||||||
|
flex: 1;
|
||||||
|
margin-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Complaint Types */
|
||||||
|
.complaint-types {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.complaint-type-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.complaint-type-option:hover {
|
||||||
|
border-color: var(--primary-blue);
|
||||||
|
background: var(--background-gray);
|
||||||
|
}
|
||||||
|
|
||||||
|
.complaint-type-option input[type="radio"] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-custom {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
border-radius: 50%;
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.complaint-type-option input[type="radio"]:checked + .radio-custom {
|
||||||
|
border-color: var(--primary-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.complaint-type-option input[type="radio"]:checked + .radio-custom::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
background: var(--primary-blue);
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-content {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-dark);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-description {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-light);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Evidence Upload */
|
||||||
|
.evidence-upload-section {
|
||||||
|
border: 2px dashed var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-area {
|
||||||
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-area:hover {
|
||||||
|
background: var(--background-gray);
|
||||||
|
border-color: var(--primary-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-placeholder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-placeholder i {
|
||||||
|
font-size: 32px;
|
||||||
|
color: var(--primary-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-subtitle {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploaded-files {
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
background: var(--background-gray);
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploaded-file {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploaded-file:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-thumbnail {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 6px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-dark);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-size {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-remove {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: none;
|
||||||
|
background: var(--danger-color);
|
||||||
|
color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-remove:hover {
|
||||||
|
background: #c62828;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Policy Notice */
|
||||||
|
.policy-notice {
|
||||||
|
background: linear-gradient(135deg, #fff3e0, #ffeaa7);
|
||||||
|
border: 1px solid #ffb74d;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
margin: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-content {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-content i {
|
||||||
|
color: #f57c00;
|
||||||
|
font-size: 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #e65100;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-list li {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #bf360c;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
padding-left: 16px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-list li::before {
|
||||||
|
content: '•';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
color: #f57c00;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Submit Container */
|
||||||
|
.submit-container {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: var(--white);
|
||||||
|
padding: 16px;
|
||||||
|
box-shadow: 0 -4px 15px rgba(0, 0, 0, 0.1);
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--primary-blue);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 14px 20px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn:hover {
|
||||||
|
background: var(--light-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn:disabled {
|
||||||
|
background: var(--text-light);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile Responsiveness */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.transaction-info-card,
|
||||||
|
.complaint-form-card,
|
||||||
|
.policy-notice {
|
||||||
|
margin: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-value {
|
||||||
|
text-align: left;
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.complaint-type-option {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-placeholder i {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let uploadedFiles = [];
|
||||||
|
|
||||||
|
// Initialize page with transaction details from URL
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const transactionId = urlParams.get('id') || 'TXN123456';
|
||||||
|
const transactionTitle = urlParams.get('title') || 'Mua hàng tại cửa hàng';
|
||||||
|
const transactionDate = urlParams.get('date') || '22/09/2023 17:23:18';
|
||||||
|
|
||||||
|
document.getElementById('transactionId').textContent = transactionId;
|
||||||
|
document.getElementById('transactionTitle').textContent = transactionTitle;
|
||||||
|
document.getElementById('transactionDate').textContent = transactionDate;
|
||||||
|
|
||||||
|
// Update page title
|
||||||
|
document.title = `Khiếu nại ${transactionId} - EuroTile Worker`;
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleFileUpload(input) {
|
||||||
|
const files = Array.from(input.files);
|
||||||
|
|
||||||
|
files.forEach(file => {
|
||||||
|
if (uploadedFiles.length >= 5) {
|
||||||
|
alert('Tối đa 5 ảnh được phép tải lên');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > 10 * 1024 * 1024) {
|
||||||
|
alert(`File ${file.name} quá lớn. Vui lòng chọn file nhỏ hơn 10MB.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
alert(`File ${file.name} không phải là ảnh hợp lệ.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileData = {
|
||||||
|
id: Date.now() + Math.random(),
|
||||||
|
file: file,
|
||||||
|
name: file.name,
|
||||||
|
size: formatFileSize(file.size),
|
||||||
|
thumbnail: URL.createObjectURL(file)
|
||||||
|
};
|
||||||
|
|
||||||
|
uploadedFiles.push(fileData);
|
||||||
|
renderUploadedFiles();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear input
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderUploadedFiles() {
|
||||||
|
const container = document.getElementById('uploadedFiles');
|
||||||
|
|
||||||
|
if (uploadedFiles.length === 0) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filesHtml = uploadedFiles.map(file => `
|
||||||
|
<div class="uploaded-file">
|
||||||
|
<div class="file-info">
|
||||||
|
<img src="${file.thumbnail}" alt="${file.name}" class="file-thumbnail">
|
||||||
|
<div>
|
||||||
|
<div class="file-name">${file.name}</div>
|
||||||
|
<div class="file-size">${file.size}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="file-remove" onclick="removeFile(${file.id})">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
container.innerHTML = filesHtml;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFile(fileId) {
|
||||||
|
const fileIndex = uploadedFiles.findIndex(f => f.id === fileId);
|
||||||
|
if (fileIndex > -1) {
|
||||||
|
URL.revokeObjectURL(uploadedFiles[fileIndex].thumbnail);
|
||||||
|
uploadedFiles.splice(fileIndex, 1);
|
||||||
|
renderUploadedFiles();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFileSize(bytes) {
|
||||||
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitComplaint() {
|
||||||
|
// Validate form
|
||||||
|
const form = document.getElementById('complaintForm');
|
||||||
|
if (!form.checkValidity()) {
|
||||||
|
form.reportValidity();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const complaintContent = document.getElementById('complaintContent').value.trim();
|
||||||
|
if (complaintContent.length < 20) {
|
||||||
|
alert('Vui lòng mô tả chi tiết hơn (tối thiểu 20 ký tự)');
|
||||||
|
document.getElementById('complaintContent').focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get form data
|
||||||
|
const formData = new FormData(form);
|
||||||
|
const complaintType = formData.get('complaintType');
|
||||||
|
const contactPhone = document.getElementById('contactPhone').value;
|
||||||
|
|
||||||
|
// Show loading state
|
||||||
|
const submitBtn = document.querySelector('.submit-btn');
|
||||||
|
const originalContent = submitBtn.innerHTML;
|
||||||
|
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Đang gửi...';
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
|
||||||
|
// Simulate API call
|
||||||
|
setTimeout(() => {
|
||||||
|
alert('Khiếu nại đã được gửi thành công!\\n\\nMã khiếu nại: KN' + Date.now() + '\\nChúng tôi sẽ xử lý và phản hồi trong vòng 3-5 ngày làm việc.\\n\\nThông báo kết quả sẽ được gửi qua SMS: ' + contactPhone);
|
||||||
|
|
||||||
|
// Navigate back to points history
|
||||||
|
window.location.href = 'points-history.html';
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
Giao dịch: 100.000.000 VND
|
Giao dịch: 100.000.000 VND
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-complaint">
|
<button class="btn-complaint" onclick="openComplaint(this)">
|
||||||
Khiếu nại
|
Khiếu nại
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
Giao dịch: 200.000.000 VND
|
Giao dịch: 200.000.000 VND
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-complaint">
|
<button class="btn-complaint" onclick="openComplaint(this)">
|
||||||
Khiếu nại
|
Khiếu nại
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -98,7 +98,7 @@
|
|||||||
Thời gian: 20/09/2023 17:23:18
|
Thời gian: 20/09/2023 17:23:18
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-complaint">
|
<button class="btn-complaint" onclick="openComplaint(this)">
|
||||||
Khiếu nại
|
Khiếu nại
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -122,7 +122,7 @@
|
|||||||
Thời gian: 19/09/2023 17:23:18
|
Thời gian: 19/09/2023 17:23:18
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-complaint">
|
<button class="btn-complaint" onclick="openComplaint(this)">
|
||||||
Khiếu nại
|
Khiếu nại
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -146,7 +146,7 @@
|
|||||||
Thời gian: 10/09/2023 17:23:18
|
Thời gian: 10/09/2023 17:23:18
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-complaint">
|
<button class="btn-complaint" onclick="openComplaint(this)">
|
||||||
Khiếu nại
|
Khiếu nại
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -170,7 +170,7 @@
|
|||||||
Thời gian: 19/09/2023 17:23:18
|
Thời gian: 19/09/2023 17:23:18
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-complaint">
|
<button class="btn-complaint" onclick="openComplaint(this)">
|
||||||
Khiếu nại
|
Khiếu nại
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -185,5 +185,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function openComplaint(buttonElement) {
|
||||||
|
// Get transaction info from the card
|
||||||
|
const card = buttonElement.closest('.card');
|
||||||
|
const transactionTitle = card.querySelector('h4').textContent.trim();
|
||||||
|
const transactionDate = card.querySelector('.text-muted').textContent.replace('Thời gian: ', '').trim();
|
||||||
|
|
||||||
|
// Generate a transaction ID (in real app this would be from data attributes)
|
||||||
|
const transactionId = 'TXN' + Math.floor(Math.random() * 1000000);
|
||||||
|
|
||||||
|
// Navigate to complaint page with transaction details
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
id: transactionId,
|
||||||
|
title: transactionTitle,
|
||||||
|
date: transactionDate
|
||||||
|
});
|
||||||
|
|
||||||
|
window.location.href = `point-complaint.html?${params.toString()}`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -370,7 +370,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Store Location -->
|
<!-- Store Location -->
|
||||||
<div class="form-group">
|
<!--<div class="form-group">
|
||||||
<label class="form-label required">Cửa hàng mua</label>
|
<label class="form-label required">Cửa hàng mua</label>
|
||||||
<select class="form-input form-select" id="storeLocation" required onchange="validateForm()">
|
<select class="form-input form-select" id="storeLocation" required onchange="validateForm()">
|
||||||
<option value="">Chọn cửa hàng</option>
|
<option value="">Chọn cửa hàng</option>
|
||||||
@@ -383,7 +383,7 @@
|
|||||||
<option value="danang-haichau">Đà Nẵng - Hải Châu</option>
|
<option value="danang-haichau">Đà Nẵng - Hải Châu</option>
|
||||||
<option value="other">Cửa hàng khác</option>
|
<option value="other">Cửa hàng khác</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>-->
|
||||||
|
|
||||||
<!-- Other Store -->
|
<!-- Other Store -->
|
||||||
<div class="form-group" id="otherStoreGroup" style="display: none;">
|
<div class="form-group" id="otherStoreGroup" style="display: none;">
|
||||||
|
|||||||
1195
html/product-detail.html
Normal file
1195
html/product-detail.html
Normal file
File diff suppressed because it is too large
Load Diff
537
html/product-view-360.html
Normal file
537
html/product-view-360.html
Normal file
@@ -0,0 +1,537 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="vi">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Xem 360° - Gạch Eurotile MỘC LAM E03 - EuroTile Worker</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<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">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page-wrapper">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="header">
|
||||||
|
<a href="product-detail.html" class="back-button">
|
||||||
|
<i class="fas fa-arrow-left"></i>
|
||||||
|
</a>
|
||||||
|
<h1 class="header-title">Xem 360° - Gạch Eurotile MỘC LAM E03</h1>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button class="header-action-btn" onclick="shareProduct360()">
|
||||||
|
<i class="fas fa-share"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="view-360-content">
|
||||||
|
<!-- 360° Viewer Container -->
|
||||||
|
<div class="viewer-360-container">
|
||||||
|
<!-- Placeholder Image -->
|
||||||
|
<div class="viewer-360-main">
|
||||||
|
<!--<img id="product360Image"
|
||||||
|
src="https://placehold.co/600x600/F5F5F5/005B9A/png?text=Gạch+Eurotile+MỘC+LAM+E03+360°"
|
||||||
|
alt="Gạch Eurotile MỘC LAM E03 - Xem 360°">-->
|
||||||
|
|
||||||
|
<!-- Nhúng link VR360 -->
|
||||||
|
<iframe
|
||||||
|
src="https://design.eurotile.vn/pub/tool/panorama/show?obsPlanId=3FO3H1VE59R5&locale=en_US&_gl=1*1udzqeo*_gcl_au*MTI3NjIxMzY1NS4xNzU5NzE2Mjg5"
|
||||||
|
width="100%"
|
||||||
|
height="600"
|
||||||
|
style="border: none; border-radius: 12px;"
|
||||||
|
allowfullscreen>
|
||||||
|
</iframe>
|
||||||
|
|
||||||
|
<!-- 360° Indicator -->
|
||||||
|
<div class="rotation-indicator">
|
||||||
|
<i class="fas fa-sync-alt"></i>
|
||||||
|
<span>Vuốt để xoay 360°</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Touch/Mouse Overlay -->
|
||||||
|
<div class="viewer-overlay"
|
||||||
|
ontouchstart="startRotation(event)"
|
||||||
|
ontouchmove="rotateProduct(event)"
|
||||||
|
ontouchend="endRotation()"
|
||||||
|
onmousedown="startRotation(event)"
|
||||||
|
onmousemove="rotateProduct(event)"
|
||||||
|
onmouseup="endRotation()"
|
||||||
|
onmouseleave="endRotation()">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Controls -->
|
||||||
|
<div class="viewer-controls">
|
||||||
|
<button class="control-btn" onclick="autoRotate()" id="autoRotateBtn">
|
||||||
|
<i class="fas fa-play"></i>
|
||||||
|
<span>Tự động xoay</span>
|
||||||
|
</button>
|
||||||
|
<button class="control-btn" onclick="resetView()">
|
||||||
|
<i class="fas fa-redo"></i>
|
||||||
|
<span>Đặt lại</span>
|
||||||
|
</button>
|
||||||
|
<button class="control-btn" onclick="toggleFullscreen()">
|
||||||
|
<i class="fas fa-expand"></i>
|
||||||
|
<span>Toàn màn hình</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Product Info Summary -->
|
||||||
|
<div class="product-info-360">
|
||||||
|
<div class="product-name-360">Gạch Eurotile MỘC LAM E03</div>
|
||||||
|
<div class="product-price-360">
|
||||||
|
<span class="price-current">285.000 VND/m²</span>
|
||||||
|
<span class="price-original">320.000 VND/m²</span>
|
||||||
|
</div>
|
||||||
|
<div class="product-dimensions">Kích thước: 60cm x 60cm</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Instructions -->
|
||||||
|
<div class="instructions-card">
|
||||||
|
<h3><i class="fas fa-info-circle"></i> Hướng dẫn sử dụng</h3>
|
||||||
|
<ul>
|
||||||
|
<li><i class="fas fa-hand-pointer"></i> Vuốt trái/phải để xoay sản phẩm 360°</li>
|
||||||
|
<li><i class="fas fa-play"></i> Nhấn "Tự động xoay" để xem tự động</li>
|
||||||
|
<li><i class="fas fa-expand"></i> Chế độ toàn màn hình để xem chi tiết hơn</li>
|
||||||
|
<li><i class="fas fa-redo"></i> Đặt lại để quay về góc nhìn ban đầu</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Bar -->
|
||||||
|
<div class="action-bar-360">
|
||||||
|
<button class="btn-back" onclick="goBackToProduct()">
|
||||||
|
<i class="fas fa-arrow-left"></i>
|
||||||
|
Quay lại sản phẩm
|
||||||
|
</button>
|
||||||
|
<button class="btn-add-cart" onclick="addToCartFrom360()">
|
||||||
|
<i class="fas fa-shopping-cart"></i>
|
||||||
|
Thêm vào giỏ
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.view-360-content {
|
||||||
|
padding: 0 0 100px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-360-container {
|
||||||
|
background: var(--white);
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 0 0 16px 16px;
|
||||||
|
box-shadow: var(--shadow-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-360-main {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-360-main img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rotation-indicator {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 12px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
color: white;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
animation: fadeInOut 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rotation-indicator i {
|
||||||
|
animation: rotate 2s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInOut {
|
||||||
|
0%, 100% { opacity: 0.7; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rotate {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
cursor: grab;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-overlay:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-controls {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn {
|
||||||
|
background: var(--primary-blue);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
min-width: 100px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn:hover {
|
||||||
|
background: var(--light-blue);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn.active {
|
||||||
|
background: var(--success-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-info-360 {
|
||||||
|
background: var(--white);
|
||||||
|
margin: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: var(--shadow-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-name-360 {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-dark);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-price-360 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-current {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--danger-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-original {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-light);
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-dimensions {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructions-card {
|
||||||
|
background: var(--white);
|
||||||
|
margin: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: var(--shadow-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructions-card h3 {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-dark);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructions-card ul {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructions-card li {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-light);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructions-card li i {
|
||||||
|
color: var(--primary-blue);
|
||||||
|
margin-top: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-bar-360 {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: var(--white);
|
||||||
|
padding: 16px;
|
||||||
|
box-shadow: 0 -4px 15px rgba(0, 0, 0, 0.1);
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-back {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--border-color);
|
||||||
|
color: var(--text-dark);
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-back:hover {
|
||||||
|
background: #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-add-cart {
|
||||||
|
flex: 2;
|
||||||
|
background: var(--primary-blue);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-add-cart:hover {
|
||||||
|
background: var(--light-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile Responsiveness */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.viewer-360-container {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-controls {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructions-card {
|
||||||
|
margin: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-info-360 {
|
||||||
|
margin: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let isRotating = false;
|
||||||
|
let startX = 0;
|
||||||
|
let currentRotation = 0;
|
||||||
|
let autoRotateInterval = null;
|
||||||
|
let isAutoRotating = false;
|
||||||
|
|
||||||
|
// Mock 360° images array (in a real app, these would be actual 360° view frames)
|
||||||
|
const rotation360Images = [
|
||||||
|
"https://placehold.co/600x600/F5F5F5/005B9A/png?text=Góc+0°",
|
||||||
|
"https://placehold.co/600x600/F0F0F0/005B9A/png?text=Góc+45°",
|
||||||
|
"https://placehold.co/600x600/EEEEEE/005B9A/png?text=Góc+90°",
|
||||||
|
"https://placehold.co/600x600/EBEBEB/005B9A/png?text=Góc+135°",
|
||||||
|
"https://placehold.co/600x600/E8E8E8/005B9A/png?text=Góc+180°",
|
||||||
|
"https://placehold.co/600x600/E5E5E5/005B9A/png?text=Góc+225°",
|
||||||
|
"https://placehold.co/600x600/E2E2E2/005B9A/png?text=Góc+270°",
|
||||||
|
"https://placehold.co/600x600/DFDFDF/005B9A/png?text=Góc+315°"
|
||||||
|
];
|
||||||
|
|
||||||
|
function startRotation(event) {
|
||||||
|
isRotating = true;
|
||||||
|
startX = event.type.includes('touch') ? event.touches[0].clientX : event.clientX;
|
||||||
|
stopAutoRotate();
|
||||||
|
}
|
||||||
|
|
||||||
|
function rotateProduct(event) {
|
||||||
|
if (!isRotating) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
const currentX = event.type.includes('touch') ? event.touches[0].clientX : event.clientX;
|
||||||
|
const deltaX = currentX - startX;
|
||||||
|
|
||||||
|
// Calculate rotation based on movement
|
||||||
|
const rotationSensitivity = 2;
|
||||||
|
const newRotation = currentRotation + (deltaX / rotationSensitivity);
|
||||||
|
|
||||||
|
// Update image based on rotation
|
||||||
|
const imageIndex = Math.floor((newRotation % 360) / 45) % rotation360Images.length;
|
||||||
|
const normalizedIndex = imageIndex < 0 ? rotation360Images.length + imageIndex : imageIndex;
|
||||||
|
|
||||||
|
document.getElementById('product360Image').src = rotation360Images[normalizedIndex];
|
||||||
|
|
||||||
|
startX = currentX;
|
||||||
|
currentRotation = newRotation;
|
||||||
|
}
|
||||||
|
|
||||||
|
function endRotation() {
|
||||||
|
isRotating = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function autoRotate() {
|
||||||
|
const btn = document.getElementById('autoRotateBtn');
|
||||||
|
|
||||||
|
if (isAutoRotating) {
|
||||||
|
stopAutoRotate();
|
||||||
|
} else {
|
||||||
|
startAutoRotate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startAutoRotate() {
|
||||||
|
isAutoRotating = true;
|
||||||
|
const btn = document.getElementById('autoRotateBtn');
|
||||||
|
btn.classList.add('active');
|
||||||
|
btn.innerHTML = '<i class="fas fa-pause"></i><span>Dừng xoay</span>';
|
||||||
|
|
||||||
|
let imageIndex = 0;
|
||||||
|
autoRotateInterval = setInterval(() => {
|
||||||
|
document.getElementById('product360Image').src = rotation360Images[imageIndex];
|
||||||
|
imageIndex = (imageIndex + 1) % rotation360Images.length;
|
||||||
|
}, 500); // Change image every 500ms
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopAutoRotate() {
|
||||||
|
if (autoRotateInterval) {
|
||||||
|
clearInterval(autoRotateInterval);
|
||||||
|
autoRotateInterval = null;
|
||||||
|
}
|
||||||
|
isAutoRotating = false;
|
||||||
|
const btn = document.getElementById('autoRotateBtn');
|
||||||
|
btn.classList.remove('active');
|
||||||
|
btn.innerHTML = '<i class="fas fa-play"></i><span>Tự động xoay</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetView() {
|
||||||
|
currentRotation = 0;
|
||||||
|
document.getElementById('product360Image').src = rotation360Images[0];
|
||||||
|
stopAutoRotate();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFullscreen() {
|
||||||
|
const element = document.querySelector('.viewer-360-container');
|
||||||
|
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
element.requestFullscreen().catch(err => {
|
||||||
|
console.log('Fullscreen not supported:', err);
|
||||||
|
// Fallback for mobile
|
||||||
|
element.style.position = 'fixed';
|
||||||
|
element.style.top = '0';
|
||||||
|
element.style.left = '0';
|
||||||
|
element.style.right = '0';
|
||||||
|
element.style.bottom = '0';
|
||||||
|
element.style.zIndex = '9999';
|
||||||
|
element.style.background = '#000';
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
document.exitFullscreen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function shareProduct360() {
|
||||||
|
if (navigator.share) {
|
||||||
|
navigator.share({
|
||||||
|
title: 'Xem 360° - Gạch Eurotile MỘC LAM E03',
|
||||||
|
text: 'Xem sản phẩm gạch granite 360° này!',
|
||||||
|
url: window.location.href
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
navigator.clipboard.writeText(window.location.href);
|
||||||
|
alert('Đã sao chép link xem 360°!');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goBackToProduct() {
|
||||||
|
window.location.href = 'product-detail.html';
|
||||||
|
}
|
||||||
|
|
||||||
|
function addToCartFrom360() {
|
||||||
|
alert('Đã thêm 1 m² gạch Eurotile MỘC LAM E03 vào giỏ hàng!');
|
||||||
|
// In a real app, this would update the cart
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up intervals when page is unloaded
|
||||||
|
window.addEventListener('beforeunload', () => {
|
||||||
|
stopAutoRotate();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle fullscreen changes
|
||||||
|
document.addEventListener('fullscreenchange', () => {
|
||||||
|
const element = document.querySelector('.viewer-360-container');
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
// Reset styles when exiting fullscreen
|
||||||
|
element.style.position = '';
|
||||||
|
element.style.top = '';
|
||||||
|
element.style.left = '';
|
||||||
|
element.style.right = '';
|
||||||
|
element.style.bottom = '';
|
||||||
|
element.style.zIndex = '';
|
||||||
|
element.style.background = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
550
html/products-page-redesign.html
Normal file
550
html/products-page-redesign.html
Normal file
@@ -0,0 +1,550 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="vi">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Sản phẩm - EuroTile Worker</title>
|
||||||
|
<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">
|
||||||
|
<style>
|
||||||
|
/* Custom styles for product cards */
|
||||||
|
.product-card {
|
||||||
|
background: var(--white);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: var(--shadow-light);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: var(--shadow-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-image-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
padding-bottom: 100%; /* 1:1 Aspect Ratio */
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--background-gray);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-image {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card:hover .product-image {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: var(--danger-color);
|
||||||
|
color: var(--white);
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-info {
|
||||||
|
padding: 12px;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-dark);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
line-height: 1.4;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-price {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary-blue);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-price-old {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-light);
|
||||||
|
text-decoration: line-through;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Product Actions - Horizontal Layout */
|
||||||
|
.product-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-360 {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 8px 12px !important;
|
||||||
|
background: var(--background-gray) !important;
|
||||||
|
color: var(--text-dark) !important;
|
||||||
|
border: 1px solid var(--border-color) !important;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 12px !important;
|
||||||
|
font-weight: 500;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-360:hover {
|
||||||
|
background: var(--white) !important;
|
||||||
|
border-color: var(--primary-blue) !important;
|
||||||
|
color: var(--primary-blue) !important;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-360 i {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-add-cart {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 12px !important;
|
||||||
|
background: var(--primary-blue) !important;
|
||||||
|
color: var(--white) !important;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 12px !important;
|
||||||
|
font-weight: 500;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-add-cart:hover {
|
||||||
|
background: var(--light-blue) !important;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 91, 154, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-add-cart i {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Search Bar Enhancements */
|
||||||
|
.search-bar {
|
||||||
|
background: var(--white);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
box-shadow: var(--shadow-light);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
flex: 1;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-dark);
|
||||||
|
background: transparent;
|
||||||
|
padding-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-icon {
|
||||||
|
color: var(--primary-blue);
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Filter Pills Enhancement */
|
||||||
|
.filter-container {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 4px 0;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-pill {
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: var(--white);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-dark);
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-pill:hover {
|
||||||
|
background: var(--background-gray);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-pill.active {
|
||||||
|
background: var(--primary-blue);
|
||||||
|
color: var(--white);
|
||||||
|
border-color: var(--primary-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Product Grid */
|
||||||
|
.product-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Load More Button */
|
||||||
|
.load-more-container {
|
||||||
|
text-align: center;
|
||||||
|
padding: 16px 0;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-load-more {
|
||||||
|
padding: 12px 32px;
|
||||||
|
background: var(--white);
|
||||||
|
color: var(--primary-blue);
|
||||||
|
border: 2px solid var(--primary-blue);
|
||||||
|
border-radius: 24px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-load-more:hover {
|
||||||
|
background: var(--primary-blue);
|
||||||
|
color: var(--white);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: var(--shadow-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header Enhancement */
|
||||||
|
.header {
|
||||||
|
background: var(--white);
|
||||||
|
padding: 16px;
|
||||||
|
box-shadow: var(--shadow-light);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-button {
|
||||||
|
position: relative;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-dark);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -4px;
|
||||||
|
right: -4px;
|
||||||
|
background: var(--danger-color);
|
||||||
|
color: var(--white);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 10px;
|
||||||
|
min-width: 18px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments */
|
||||||
|
@media (max-width: 360px) {
|
||||||
|
.product-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-360 {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.product-grid {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page-wrapper">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="header">
|
||||||
|
<a href="index.html" class="back-button">
|
||||||
|
<i class="fas fa-arrow-left"></i>
|
||||||
|
</a>
|
||||||
|
<h1 class="header-title">Sản phẩm</h1>
|
||||||
|
<a href="cart.html" class="cart-button">
|
||||||
|
<i class="fas fa-shopping-cart"></i>
|
||||||
|
<span class="cart-badge">3</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<!-- Search Bar -->
|
||||||
|
<div class="search-bar">
|
||||||
|
<i class="fas fa-search search-icon"></i>
|
||||||
|
<input type="text" class="search-input" placeholder="Tìm kiếm sản phẩm...">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filter Pills -->
|
||||||
|
<div class="filter-container">
|
||||||
|
<button class="filter-pill active" onclick="filterProducts('all')">Tất cả</button>
|
||||||
|
<button class="filter-pill" onclick="filterProducts('floor')">Gạch lát nền</button>
|
||||||
|
<button class="filter-pill" onclick="filterProducts('wall')">Gạch ốp tường</button>
|
||||||
|
<button class="filter-pill" onclick="filterProducts('decorative')">Gạch trang trí</button>
|
||||||
|
<button class="filter-pill" onclick="filterProducts('outdoor')">Gạch ngoài trời</button>
|
||||||
|
<button class="filter-pill" onclick="filterProducts('accessories')">Phụ kiện</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Product Grid -->
|
||||||
|
<div class="product-grid">
|
||||||
|
<!-- Product 1 -->
|
||||||
|
<div class="product-card">
|
||||||
|
<div class="product-image-container" onclick="window.location.href='product-detail.html'">
|
||||||
|
<img src="https://images.unsplash.com/photo-1615971677499-5467cbab01c0?w=300&h=300&fit=crop" alt="Gạch men" class="product-image">
|
||||||
|
<span class="product-badge">-10%</span>
|
||||||
|
</div>
|
||||||
|
<div class="product-info">
|
||||||
|
<div class="product-name">Gạch men cao cấp 60x60</div>
|
||||||
|
<div class="product-price">
|
||||||
|
450.000đ/m²
|
||||||
|
<span class="product-price-old">500.000đ</span>
|
||||||
|
</div>
|
||||||
|
<div class="product-actions">
|
||||||
|
<button class="btn-360" onclick="event.stopPropagation(); view360('#')">
|
||||||
|
<i class="fas fa-cube"></i>
|
||||||
|
<span>360°</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn-add-cart" onclick="event.stopPropagation(); addToCart('Product 1')">
|
||||||
|
<i class="fas fa-cart-plus"></i>
|
||||||
|
<span>Thêm vào giỏ</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Product 2 -->
|
||||||
|
<div class="product-card">
|
||||||
|
<div class="product-image-container" onclick="window.location.href='product-detail.html'">
|
||||||
|
<img src="https://images.unsplash.com/photo-1565193566173-7a0ee3dbe261?w=300&h=300&fit=crop" alt="Gạch granite" class="product-image">
|
||||||
|
</div>
|
||||||
|
<div class="product-info">
|
||||||
|
<div class="product-name">Gạch granite nhập khẩu</div>
|
||||||
|
<div class="product-price">680.000đ/m²</div>
|
||||||
|
<div class="product-actions">
|
||||||
|
<button class="btn-360" onclick="event.stopPropagation(); view360('#')">
|
||||||
|
<i class="fas fa-cube"></i>
|
||||||
|
<span>360°</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn-add-cart" onclick="event.stopPropagation(); addToCart('Product 2')">
|
||||||
|
<i class="fas fa-cart-plus"></i>
|
||||||
|
<span>Thêm vào giỏ</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Product 3 -->
|
||||||
|
<div class="product-card">
|
||||||
|
<div class="product-image-container" onclick="window.location.href='product-detail.html'">
|
||||||
|
<img src="https://images.unsplash.com/photo-1600607687644-aac4c3eac7f4?w=300&h=300&fit=crop" alt="Gạch mosaic" class="product-image">
|
||||||
|
<span class="product-badge">Mới</span>
|
||||||
|
</div>
|
||||||
|
<div class="product-info">
|
||||||
|
<div class="product-name">Gạch mosaic trang trí</div>
|
||||||
|
<div class="product-price">320.000đ/m²</div>
|
||||||
|
<div class="product-actions">
|
||||||
|
<button class="btn-360" onclick="event.stopPropagation(); view360('#')">
|
||||||
|
<i class="fas fa-cube"></i>
|
||||||
|
<span>360°</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn-add-cart" onclick="event.stopPropagation(); addToCart('Product 3')">
|
||||||
|
<i class="fas fa-cart-plus"></i>
|
||||||
|
<span>Thêm vào giỏ</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Product 4 -->
|
||||||
|
<div class="product-card">
|
||||||
|
<div class="product-image-container" onclick="window.location.href='product-detail.html'">
|
||||||
|
<img src="https://images.unsplash.com/photo-1600566753190-17f0baa2a6c3?w=300&h=300&fit=crop" alt="Gạch 3D" class="product-image">
|
||||||
|
</div>
|
||||||
|
<div class="product-info">
|
||||||
|
<div class="product-name">Gạch 3D họa tiết độc đáo</div>
|
||||||
|
<div class="product-price">750.000đ/m²</div>
|
||||||
|
<div class="product-actions">
|
||||||
|
<button class="btn-360" onclick="event.stopPropagation(); view360('#')">
|
||||||
|
<i class="fas fa-cube"></i>
|
||||||
|
<span>360°</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn-add-cart" onclick="event.stopPropagation(); addToCart('Product 4')">
|
||||||
|
<i class="fas fa-cart-plus"></i>
|
||||||
|
<span>Thêm vào giỏ</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Product 5 -->
|
||||||
|
<div class="product-card">
|
||||||
|
<div class="product-image-container" onclick="window.location.href='product-detail.html'">
|
||||||
|
<img src="https://images.unsplash.com/photo-1615874694520-474822394e73?w=300&h=300&fit=crop" alt="Gạch ceramic" class="product-image">
|
||||||
|
<span class="product-badge">-15%</span>
|
||||||
|
</div>
|
||||||
|
<div class="product-info">
|
||||||
|
<div class="product-name">Gạch ceramic chống trượt</div>
|
||||||
|
<div class="product-price">
|
||||||
|
380.000đ/m²
|
||||||
|
<span class="product-price-old">447.000đ</span>
|
||||||
|
</div>
|
||||||
|
<div class="product-actions">
|
||||||
|
<button class="btn-360" onclick="event.stopPropagation(); view360('#')">
|
||||||
|
<i class="fas fa-cube"></i>
|
||||||
|
<span>360°</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn-add-cart" onclick="event.stopPropagation(); addToCart('Product 5')">
|
||||||
|
<i class="fas fa-cart-plus"></i>
|
||||||
|
<span>Thêm vào giỏ</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Product 6 -->
|
||||||
|
<div class="product-card">
|
||||||
|
<div class="product-image-container" onclick="window.location.href='product-detail.html'">
|
||||||
|
<img src="https://images.unsplash.com/photo-1564013799919-ab600027ffc6?w=300&h=300&fit=crop" alt="Gạch terrazzo" class="product-image">
|
||||||
|
</div>
|
||||||
|
<div class="product-info">
|
||||||
|
<div class="product-name">Gạch terrazzo đá mài cao cấp</div>
|
||||||
|
<div class="product-price">890.000đ/m²</div>
|
||||||
|
<div class="product-actions">
|
||||||
|
<button class="btn-360" onclick="event.stopPropagation(); view360('#')">
|
||||||
|
<i class="fas fa-cube"></i>
|
||||||
|
<span>360°</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn-add-cart" onclick="event.stopPropagation(); addToCart('Product 6')">
|
||||||
|
<i class="fas fa-cart-plus"></i>
|
||||||
|
<span>Thêm vào giỏ</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Load More -->
|
||||||
|
<div class="load-more-container">
|
||||||
|
<button class="btn-load-more" onclick="loadMore()">
|
||||||
|
<i class="fas fa-sync-alt"></i>
|
||||||
|
<span>Tải thêm sản phẩm</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// View 360 function
|
||||||
|
function view360(url) {
|
||||||
|
console.log('Opening 360 view:', url);
|
||||||
|
// In real app, this would open 360 view modal or page
|
||||||
|
alert('Xem sản phẩm 360°');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to cart function
|
||||||
|
function addToCart(productName) {
|
||||||
|
console.log('Adding to cart:', productName);
|
||||||
|
// Update cart badge
|
||||||
|
const badge = document.querySelector('.cart-badge');
|
||||||
|
const currentCount = parseInt(badge.textContent);
|
||||||
|
badge.textContent = currentCount + 1;
|
||||||
|
|
||||||
|
// Show feedback
|
||||||
|
alert('Đã thêm sản phẩm vào giỏ hàng!');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter products
|
||||||
|
function filterProducts(category) {
|
||||||
|
// Update active pill
|
||||||
|
document.querySelectorAll('.filter-pill').forEach(pill => {
|
||||||
|
pill.classList.remove('active');
|
||||||
|
});
|
||||||
|
event.target.classList.add('active');
|
||||||
|
|
||||||
|
// In real app, this would filter the product grid
|
||||||
|
console.log('Filtering by:', category);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load more products
|
||||||
|
function loadMore() {
|
||||||
|
console.log('Loading more products...');
|
||||||
|
// In real app, this would load additional products
|
||||||
|
alert('Đang tải thêm sản phẩm...');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search functionality
|
||||||
|
document.querySelector('.search-input').addEventListener('input', function(e) {
|
||||||
|
const searchTerm = e.target.value.toLowerCase();
|
||||||
|
console.log('Searching for:', searchTerm);
|
||||||
|
// In real app, this would filter products based on search
|
||||||
|
});
|
||||||
|
|
||||||
|
// Prevent card click when clicking buttons
|
||||||
|
document.querySelectorAll('.product-actions button').forEach(button => {
|
||||||
|
button.addEventListener('click', function(e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -43,13 +43,21 @@
|
|||||||
<div class="product-grid">
|
<div class="product-grid">
|
||||||
<!-- Product 1 -->
|
<!-- Product 1 -->
|
||||||
<div class="product-card">
|
<div class="product-card">
|
||||||
<img src="https://images.unsplash.com/photo-1615971677499-5467cbab01c0?w=300&h=300&fit=crop" alt="Gạch men" class="product-image">
|
<img src="https://www.eurotile.vn/pictures/catalog/product/0-gachkholon/cat-tuong/CAT-S01G-1.jpg" alt="Gạch men" class="product-image" onclick="window.location.href='product-detail.html'">
|
||||||
<div class="product-info">
|
<div class="product-info">
|
||||||
<div class="product-name">Gạch men cao cấp 60x60</div>
|
<div class="product-name" onclick="window.location.href='product-detail.html'">Gạch Cát Tường 1200x1200</div>
|
||||||
<div class="product-price">450.000đ/m²</div>
|
<div class="product-price">450.000đ/m²</div>
|
||||||
<button class="btn btn-primary btn-sm btn-block">
|
<div class="product-actions">
|
||||||
|
<button class="btn btn-primary btn-sm btn-add-cart">
|
||||||
|
<i class="fas fa-cart-plus"></i> Thêm vào giỏ
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary btn-sm btn-360" onclick="window.location.href='https://design.eurotile.vn/pub/tool/panorama/show?obsPlanId=3FO3H1VE59R5&locale=en_US&_gl=1*1udzqeo*_gcl_au*MTI3NjIxMzY1NS4xNzU5NzE2Mjg5'">
|
||||||
|
<i class="fas fa-cube"></i> Phối cảnh 360°
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!--<button class="btn btn-primary btn-sm btn-block">
|
||||||
<i class="fas fa-cart-plus"></i> Thêm vào giỏ
|
<i class="fas fa-cart-plus"></i> Thêm vào giỏ
|
||||||
</button>
|
</button>-->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -62,18 +70,25 @@
|
|||||||
<button class="btn btn-primary btn-sm btn-block">
|
<button class="btn btn-primary btn-sm btn-block">
|
||||||
<i class="fas fa-cart-plus"></i> Thêm vào giỏ
|
<i class="fas fa-cart-plus"></i> Thêm vào giỏ
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn btn-secondary btn-sm btn-360" onclick="window.location.href='https://design.eurotile.vn/pub/tool/panorama/show?obsPlanId=3FO3H1VE59R&locale=en_US&_gl=1*1udzqeo*_gcl_au*MTI3NjIxMzY1NS4xNzU5NzE2Mjg5'">
|
||||||
|
<i class="fas fa-cube"></i> Phối cảnh 360°
|
||||||
|
</button>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Product 3 -->
|
<!-- Product 3 -->
|
||||||
<div class="product-card">
|
<div class="product-card">
|
||||||
<img src="https://images.unsplash.com/photo-1600607687644-aac4c3eac7f4?w=300&h=300&fit=crop" alt="Gạch mosaic" class="product-image">
|
<img src="https://images.unsplash.com/photo-1615971677499-5467cbab01c0?w=300&h=300&fit=crop" alt="Gạch mosaic" class="product-image">
|
||||||
<div class="product-info">
|
<div class="product-info">
|
||||||
<div class="product-name">Gạch mosaic trang trí</div>
|
<div class="product-name">Gạch mosaic trang trí</div>
|
||||||
<div class="product-price">320.000đ/m²</div>
|
<div class="product-price">320.000đ/m²</div>
|
||||||
<button class="btn btn-primary btn-sm btn-block">
|
<button class="btn btn-primary btn-sm btn-block">
|
||||||
<i class="fas fa-cart-plus"></i> Thêm vào giỏ
|
<i class="fas fa-cart-plus"></i> Thêm vào giỏ
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn btn-secondary btn-sm btn-360" onclick="window.location.href='https://design.eurotile.vn/pub/tool/panorama/show?obsPlanId=3FO3H1VE59R5&locale=en_US&_gl=1*1udzqeo*_gcl_au*MTI3NjIxMzY1NS4xNzU5NzE2Mjg5'">
|
||||||
|
<i class="fas fa-cube"></i> Phối cảnh 360°
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -86,6 +101,9 @@
|
|||||||
<button class="btn btn-primary btn-sm btn-block">
|
<button class="btn btn-primary btn-sm btn-block">
|
||||||
<i class="fas fa-cart-plus"></i> Thêm vào giỏ
|
<i class="fas fa-cart-plus"></i> Thêm vào giỏ
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn btn-secondary btn-sm btn-360" onclick="window.location.href='https://design.eurotile.vn/pub/tool/panorama/show?obsPlanId=3FO3H1VE59R5&locale=en_US&_gl=1*1udzqeo*_gcl_au*MTI3NjIxMzY1NS4xNzU5NzE2Mjg5'">
|
||||||
|
<i class="fas fa-cube"></i> Phối cảnh 360°
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -98,6 +116,9 @@
|
|||||||
<button class="btn btn-primary btn-sm btn-block">
|
<button class="btn btn-primary btn-sm btn-block">
|
||||||
<i class="fas fa-cart-plus"></i> Thêm vào giỏ
|
<i class="fas fa-cart-plus"></i> Thêm vào giỏ
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn btn-secondary btn-sm btn-360" onclick="window.location.href='https://design.eurotile.vn/pub/tool/panorama/show?obsPlanId=3FO3H1VE59R5&locale=en_US&_gl=1*1udzqeo*_gcl_au*MTI3NjIxMzY1NS4xNzU5NzE2Mjg5'">
|
||||||
|
<i class="fas fa-cube"></i> Phối cảnh 360°
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
289
html/project-submission-list.html
Normal file
289
html/project-submission-list.html
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="vi">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Danh sách Dự án - EuroTile Worker</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<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">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page-wrapper">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="header">
|
||||||
|
<a href="index.html" class="back-button">
|
||||||
|
<i class="fas fa-arrow-left"></i>
|
||||||
|
</a>
|
||||||
|
<h1 class="header-title">Danh sách Dự án</h1>
|
||||||
|
<button class="back-button" onclick="createNewProject()">
|
||||||
|
<i class="fas fa-plus"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<!-- Search Bar -->
|
||||||
|
<div class="search-bar">
|
||||||
|
<i class="fas fa-search search-icon"></i>
|
||||||
|
<input type="text" class="search-input" placeholder="Mã dự án hoặc tên dự án" id="searchInput" onkeyup="filterProjects()">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status Filters -->
|
||||||
|
<div class="tab-nav mb-3">
|
||||||
|
<button class="tab-item active" data-status="">Tất cả</button>
|
||||||
|
<button class="tab-item" data-status="pending">Chờ duyệt</button>
|
||||||
|
<button class="tab-item" data-status="approved">Đã duyệt</button>
|
||||||
|
<button class="tab-item" data-status="rejected">Bị từ chối</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Projects List -->
|
||||||
|
<div class="orders-list" id="projectsList">
|
||||||
|
<!-- Projects will be populated by JavaScript -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom Navigation -->
|
||||||
|
<div class="bottom-nav">
|
||||||
|
<a href="index.html" class="nav-item">
|
||||||
|
<i class="fas fa-home"></i>
|
||||||
|
<span>Trang chủ</span>
|
||||||
|
</a>
|
||||||
|
<a href="loyalty.html" class="nav-item">
|
||||||
|
<i class="fas fa-star"></i>
|
||||||
|
<span>Hội viên</span>
|
||||||
|
</a>
|
||||||
|
<a href="promotions.html" class="nav-item">
|
||||||
|
<i class="fas fa-tags"></i>
|
||||||
|
<span>Khuyến mãi</span>
|
||||||
|
</a>
|
||||||
|
<a href="notifications.html" class="nav-item">
|
||||||
|
<i class="fas fa-bell"></i>
|
||||||
|
<span>Thông báo</span>
|
||||||
|
</a>
|
||||||
|
<a href="account.html" class="nav-item active">
|
||||||
|
<i class="fas fa-user"></i>
|
||||||
|
<span>Cài đặt</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Sample project data
|
||||||
|
const projectsData = [
|
||||||
|
{
|
||||||
|
id: 'DA001',
|
||||||
|
name: 'Chung cư Vinhomes Grand Park - Block A1',
|
||||||
|
type: 'residential',
|
||||||
|
customer: 'Công ty TNHH Vingroup',
|
||||||
|
status: 'approved',
|
||||||
|
submittedDate: '2023-11-15',
|
||||||
|
approvedDate: '2023-11-20',
|
||||||
|
area: '2,500m²',
|
||||||
|
budget: '850,000,000',
|
||||||
|
progress: 75,
|
||||||
|
description: 'Gạch granite cao cấp cho khu vực lobby và hành lang'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'DA002',
|
||||||
|
name: 'Trung tâm thương mại Bitexco',
|
||||||
|
type: 'commercial',
|
||||||
|
customer: 'Tập đoàn Bitexco',
|
||||||
|
status: 'pending',
|
||||||
|
submittedDate: '2023-11-25',
|
||||||
|
area: '8,000m²',
|
||||||
|
budget: '2,200,000,000',
|
||||||
|
progress: 25,
|
||||||
|
description: 'Gạch porcelain 80x80 cho sảnh chính và khu mua sắm'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'DA003',
|
||||||
|
name: 'Biệt thự sinh thái Ecopark',
|
||||||
|
type: 'residential',
|
||||||
|
customer: 'Ecopark Group',
|
||||||
|
status: 'approved',
|
||||||
|
submittedDate: '2023-10-10',
|
||||||
|
approvedDate: '2023-10-15',
|
||||||
|
completedDate: '2023-11-30',
|
||||||
|
area: '1,200m²',
|
||||||
|
budget: '420,000,000',
|
||||||
|
progress: 100,
|
||||||
|
description: 'Gạch ceramic vân gỗ cho khu vực phòng khách và sân vườn'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'DA004',
|
||||||
|
name: 'Nhà xưởng sản xuất ABC',
|
||||||
|
type: 'industrial',
|
||||||
|
customer: 'Công ty TNHH ABC Manufacturing',
|
||||||
|
status: 'rejected',
|
||||||
|
submittedDate: '2023-11-20',
|
||||||
|
rejectedDate: '2023-11-28',
|
||||||
|
area: '5,000m²',
|
||||||
|
budget: '1,500,000,000',
|
||||||
|
progress: 0,
|
||||||
|
rejectionReason: 'Thiếu giấy phép xây dựng và báo cáo tác động môi trường',
|
||||||
|
description: 'Gạch chống trơn cho khu vực sản xuất và kho bãi'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'DA005',
|
||||||
|
name: 'Khách sạn 5 sao Diamond Plaza',
|
||||||
|
type: 'commercial',
|
||||||
|
customer: 'Diamond Hospitality Group',
|
||||||
|
status: 'pending',
|
||||||
|
submittedDate: '2023-12-01',
|
||||||
|
area: '12,000m²',
|
||||||
|
budget: '5,800,000,000',
|
||||||
|
progress: 10,
|
||||||
|
description: 'Gạch marble tự nhiên cho lobby và phòng suite'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'DA006',
|
||||||
|
name: 'Khu đô thị thông minh Smart City',
|
||||||
|
type: 'residential',
|
||||||
|
customer: 'Smart City Development',
|
||||||
|
status: 'approved',
|
||||||
|
submittedDate: '2023-11-10',
|
||||||
|
approvedDate: '2023-11-18',
|
||||||
|
area: '25,000m²',
|
||||||
|
budget: '8,500,000,000',
|
||||||
|
progress: 45,
|
||||||
|
description: 'Gạch granite và ceramic cho toàn bộ khu vực công cộng'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
let filteredProjects = [...projectsData];
|
||||||
|
let currentFilter = '';
|
||||||
|
|
||||||
|
// Initialize page
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
setupTabNavigation();
|
||||||
|
renderProjects();
|
||||||
|
});
|
||||||
|
|
||||||
|
function setupTabNavigation() {
|
||||||
|
const tabItems = document.querySelectorAll('.tab-item');
|
||||||
|
|
||||||
|
tabItems.forEach(tab => {
|
||||||
|
tab.addEventListener('click', function() {
|
||||||
|
// Remove active class from all tabs
|
||||||
|
tabItems.forEach(t => t.classList.remove('active'));
|
||||||
|
|
||||||
|
// Add active class to clicked tab
|
||||||
|
this.classList.add('active');
|
||||||
|
|
||||||
|
// Update current filter
|
||||||
|
currentFilter = this.dataset.status || '';
|
||||||
|
|
||||||
|
// Filter and render projects
|
||||||
|
filterProjects();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProjects() {
|
||||||
|
const container = document.getElementById('projectsList');
|
||||||
|
|
||||||
|
if (filteredProjects.length === 0) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="empty-state text-center py-16">
|
||||||
|
<i class="fas fa-folder-open text-4xl text-gray-300 mb-4"></i>
|
||||||
|
<h3 class="text-lg font-semibold text-gray-600 mb-2">Không có dự án nào</h3>
|
||||||
|
<p class="text-gray-500">Không tìm thấy dự án phù hợp với bộ lọc hiện tại</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = filteredProjects.map(project => `
|
||||||
|
<div class="order-card ${project.status}" onclick="viewProjectDetail('${project.id}')">
|
||||||
|
<div class="order-status-indicator"></div>
|
||||||
|
<div class="order-content">
|
||||||
|
<div class="d-flex justify-between align-start mb-2">
|
||||||
|
<h4 class="order-id">#${project.id}</h4>
|
||||||
|
<span class="order-amount">${formatCurrency(project.budget)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="order-details">
|
||||||
|
<p class="order-date">Ngày nộp: ${formatDate(project.submittedDate)}</p>
|
||||||
|
<p class="order-customer">Khách hàng: ${project.customer}</p>
|
||||||
|
<p class="order-status-text">
|
||||||
|
<span class="status-badge ${project.status}">${getStatusText(project.status)}</span>
|
||||||
|
${project.status === 'approved' || project.status === 'completed' ? `
|
||||||
|
<span class="ml-2 text-xs text-gray-500">${project.progress}% hoàn thành</span>
|
||||||
|
` : ''}
|
||||||
|
</p>
|
||||||
|
<p class="order-note">${project.name} - Diện tích: ${project.area}</p>
|
||||||
|
${project.description ? `
|
||||||
|
<p class="text-xs text-gray-600 mt-1">${project.description}</p>
|
||||||
|
` : ''}
|
||||||
|
${project.status === 'rejected' && project.rejectionReason ? `
|
||||||
|
<p class="text-xs text-red-600 mt-2 bg-red-50 p-2 rounded">
|
||||||
|
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||||
|
${project.rejectionReason}
|
||||||
|
</p>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusText(status) {
|
||||||
|
const statusMap = {
|
||||||
|
'pending': 'Chờ duyệt',
|
||||||
|
'reviewing': 'Đang xem xét',
|
||||||
|
'approved': 'Đã duyệt',
|
||||||
|
'rejected': 'Bị từ chối',
|
||||||
|
'completed': 'Hoàn thành'
|
||||||
|
};
|
||||||
|
return statusMap[status] || status;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterProjects() {
|
||||||
|
const searchTerm = document.getElementById('searchInput').value.toLowerCase();
|
||||||
|
|
||||||
|
filteredProjects = projectsData.filter(project => {
|
||||||
|
// Status filter
|
||||||
|
if (currentFilter && project.status !== currentFilter) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search filter
|
||||||
|
if (searchTerm) {
|
||||||
|
const searchableText = `${project.name} ${project.id} ${project.customer}`.toLowerCase();
|
||||||
|
if (!searchableText.includes(searchTerm)) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
renderProjects();
|
||||||
|
}
|
||||||
|
|
||||||
|
function viewProjectDetail(projectId) {
|
||||||
|
// Navigate to project detail page
|
||||||
|
localStorage.setItem('selectedProjectId', projectId);
|
||||||
|
window.location.href = 'project-submission-detail.html';
|
||||||
|
}
|
||||||
|
|
||||||
|
function createNewProject() {
|
||||||
|
// Navigate to new project creation page
|
||||||
|
window.location.href = 'project-submission.html';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility functions
|
||||||
|
function formatCurrency(amount) {
|
||||||
|
const num = typeof amount === 'string' ? parseInt(amount) : amount;
|
||||||
|
return new Intl.NumberFormat('vi-VN', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'VND',
|
||||||
|
minimumFractionDigits: 0
|
||||||
|
}).format(num);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateString) {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString('vi-VN');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
996
html/quote-detail.html
Normal file
996
html/quote-detail.html
Normal file
@@ -0,0 +1,996 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="vi">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Chi tiết báo giá #YC001234 - EuroTile Worker</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<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">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page-wrapper">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="header">
|
||||||
|
<a href="quotes-list.html" class="back-button">
|
||||||
|
<i class="fas fa-arrow-left"></i>
|
||||||
|
</a>
|
||||||
|
<h1 class="header-title">Chi tiết báo giá</h1>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button class="header-action-btn" onclick="shareQuote()">
|
||||||
|
<i class="fas fa-share"></i>
|
||||||
|
</button>
|
||||||
|
<button class="header-action-btn" onclick="printQuote()">
|
||||||
|
<i class="fas fa-print"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="quote-detail-content">
|
||||||
|
<!-- Status Card -->
|
||||||
|
<div class="quote-status-card">
|
||||||
|
<div class="quote-header-info">
|
||||||
|
<h2 class="quote-number">#YC001234</h2>
|
||||||
|
<span class="current-quote-status negotiating" id="currentStatus">Đang đàm phán</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="quote-basic-info">
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Dự án:</span>
|
||||||
|
<span class="info-value">Villa Thủ Đức - Giai đoạn 2</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Khách hàng:</span>
|
||||||
|
<span class="info-value">Nguyễn Văn A - 0901234567</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Ngày tạo:</span>
|
||||||
|
<span class="info-value">05/08/2023</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab Navigation -->
|
||||||
|
<div class="quote-tabs-section">
|
||||||
|
<div class="quote-tab-navigation">
|
||||||
|
<button class="quote-tab-button active" onclick="switchQuoteTab('details', this)">
|
||||||
|
<i class="fas fa-list"></i>
|
||||||
|
Chi tiết báo giá
|
||||||
|
</button>
|
||||||
|
<button class="quote-tab-button" onclick="switchQuoteTab('history', this)">
|
||||||
|
<i class="fas fa-history"></i>
|
||||||
|
Lịch sử trao đổi
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab 1: Chi tiết báo giá -->
|
||||||
|
<div class="quote-tab-content active" id="details">
|
||||||
|
<div class="quote-products-section">
|
||||||
|
<h3>Danh sách sản phẩm</h3>
|
||||||
|
|
||||||
|
<!-- Product Item 1 -->
|
||||||
|
<div class="quote-product-item">
|
||||||
|
<div class="product-image">
|
||||||
|
<img src="https://placehold.co/60x60/F5F5F5/005B9A/png?text=G1" alt="Gạch granite">
|
||||||
|
</div>
|
||||||
|
<div class="product-details">
|
||||||
|
<h4 class="product-name">Gạch granite Eurotile 60x60</h4>
|
||||||
|
<div class="product-specs">
|
||||||
|
<span class="spec">SKU: ET-GR-001</span>
|
||||||
|
<span class="spec">Màu: Xám nhạt</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="product-quantity">
|
||||||
|
<span class="qty-label">Số lượng:</span>
|
||||||
|
<span class="qty-value">100 m²</span>
|
||||||
|
</div>
|
||||||
|
<div class="product-pricing">
|
||||||
|
<div class="unit-price">285.000đ/m²</div>
|
||||||
|
<div class="total-price">28.500.000đ</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Product Item 2 -->
|
||||||
|
<div class="quote-product-item">
|
||||||
|
<div class="product-image">
|
||||||
|
<img src="https://placehold.co/60x60/E8E8E8/005B9A/png?text=G2" alt="Gạch ceramic">
|
||||||
|
</div>
|
||||||
|
<div class="product-details">
|
||||||
|
<h4 class="product-name">Gạch ceramic Vasta 30x60</h4>
|
||||||
|
<div class="product-specs">
|
||||||
|
<span class="spec">SKU: VS-CR-002</span>
|
||||||
|
<span class="spec">Màu: Trắng sữa</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="product-quantity">
|
||||||
|
<span class="qty-label">Số lượng:</span>
|
||||||
|
<span class="qty-value">80 m²</span>
|
||||||
|
</div>
|
||||||
|
<div class="product-pricing">
|
||||||
|
<div class="unit-price">180.000đ/m²</div>
|
||||||
|
<div class="total-price">14.400.000đ</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Product Item 3 -->
|
||||||
|
<div class="quote-product-item">
|
||||||
|
<div class="product-image">
|
||||||
|
<img src="https://placehold.co/60x60/DDDDDD/005B9A/png?text=G3" alt="Gạch mosaic">
|
||||||
|
</div>
|
||||||
|
<div class="product-details">
|
||||||
|
<h4 class="product-name">Gạch mosaic trang trí</h4>
|
||||||
|
<div class="product-specs">
|
||||||
|
<span class="spec">SKU: ET-MS-003</span>
|
||||||
|
<span class="spec">Màu: Đa sắc</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="product-quantity">
|
||||||
|
<span class="qty-label">Số lượng:</span>
|
||||||
|
<span class="qty-value">20 m²</span>
|
||||||
|
</div>
|
||||||
|
<div class="product-pricing">
|
||||||
|
<div class="unit-price">450.000đ/m²</div>
|
||||||
|
<div class="total-price">9.000.000đ</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quote Summary -->
|
||||||
|
<div class="quote-summary">
|
||||||
|
<div class="summary-row">
|
||||||
|
<span class="summary-label">Tạm tính:</span>
|
||||||
|
<span class="summary-value">51.900.000đ</span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-row">
|
||||||
|
<span class="summary-label">Chiết khấu:</span>
|
||||||
|
<span class="summary-value discount">-2.595.000đ (5%)</span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-row">
|
||||||
|
<span class="summary-label">Phí vận chuyển:</span>
|
||||||
|
<span class="summary-value">500.000đ</span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-row total">
|
||||||
|
<span class="summary-label">Tổng cộng:</span>
|
||||||
|
<span class="summary-value">49.805.000đ</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Terms & Conditions -->
|
||||||
|
<div class="quote-terms">
|
||||||
|
<h4>Điều khoản & Điều kiện</h4>
|
||||||
|
<ul>
|
||||||
|
<li>Báo giá có hiệu lực trong 30 ngày</li>
|
||||||
|
<li>Thanh toán: 50% đặt cọc, 50% khi giao hàng</li>
|
||||||
|
<li>Thời gian giao hàng: 7-10 ngày làm việc</li>
|
||||||
|
<li>Bảo hành sản phẩm: 15 năm</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab 2: Lịch sử trao đổi -->
|
||||||
|
<div class="quote-tab-content" id="history">
|
||||||
|
<div class="timeline-history">
|
||||||
|
<div class="timeline-item completed">
|
||||||
|
<div class="timeline-icon">
|
||||||
|
<i class="fas fa-plus"></i>
|
||||||
|
</div>
|
||||||
|
<div class="timeline-content">
|
||||||
|
<div class="timeline-title">Yêu cầu báo giá được tạo</div>
|
||||||
|
<div class="timeline-date">05/08/2023 - 09:00</div>
|
||||||
|
<div class="timeline-description">Khách hàng Nguyễn Văn A tạo yêu cầu báo giá</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="timeline-item completed">
|
||||||
|
<div class="timeline-icon">
|
||||||
|
<i class="fas fa-paper-plane"></i>
|
||||||
|
</div>
|
||||||
|
<div class="timeline-content">
|
||||||
|
<div class="timeline-title">Báo giá được gửi</div>
|
||||||
|
<div class="timeline-date">05/08/2023 - 14:30</div>
|
||||||
|
<div class="timeline-description">Báo giá gốc: 51.900.000đ gửi cho khách hàng</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="timeline-item active">
|
||||||
|
<div class="timeline-icon">
|
||||||
|
<i class="fas fa-handshake"></i>
|
||||||
|
</div>
|
||||||
|
<div class="timeline-content">
|
||||||
|
<div class="timeline-title">Đàm phán giá</div>
|
||||||
|
<div class="timeline-date">06/08/2023 - 10:15</div>
|
||||||
|
<div class="timeline-description">Khách hàng yêu cầu giảm giá 5%. Đang thương lượng.</div>
|
||||||
|
<button class="timeline-chat-btn" onclick="openQuoteChat()">
|
||||||
|
<i class="fas fa-comments"></i>
|
||||||
|
Xem cuộc trò chuyện
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dynamic Action Buttons -->
|
||||||
|
<div class="quote-actions" id="quoteActions">
|
||||||
|
<!-- Actions for status: Đang đàm phán -->
|
||||||
|
<div class="action-group negotiating" style="display: flex;">
|
||||||
|
<button class="action-btn secondary" onclick="requestQuoteChat()">
|
||||||
|
<i class="fas fa-comments"></i>
|
||||||
|
Trao đổi về báo giá
|
||||||
|
</button>
|
||||||
|
<button class="action-btn primary" onclick="finalizeQuote()">
|
||||||
|
<i class="fas fa-handshake"></i>
|
||||||
|
Chốt báo giá
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions for status: Đã gửi (Hidden by default) -->
|
||||||
|
<div class="action-group sent" style="display: none;">
|
||||||
|
<button class="action-btn secondary" onclick="requestQuoteChat()">
|
||||||
|
<i class="fas fa-comments"></i>
|
||||||
|
Trao đổi về báo giá
|
||||||
|
</button>
|
||||||
|
<button class="action-btn secondary" onclick="requestModification()">
|
||||||
|
<i class="fas fa-edit"></i>
|
||||||
|
Yêu cầu sửa đổi
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions for status: Đã chốt (Hidden by default) -->
|
||||||
|
<div class="action-group finalized" style="display: none;">
|
||||||
|
<button class="action-btn primary large" onclick="createOrderFromQuote()">
|
||||||
|
<i class="fas fa-shopping-cart"></i>
|
||||||
|
TẠO ĐƠN HÀNG TỪ BÁO GIÁ
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modification Request Modal -->
|
||||||
|
<div class="modal-overlay" id="modificationModal" style="display: none;">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Yêu cầu sửa đổi báo giá</h3>
|
||||||
|
<button class="modal-close" onclick="closeModificationModal()">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Nội dung yêu cầu thay đổi *</label>
|
||||||
|
<textarea class="form-textarea" rows="4" placeholder="Vui lòng mô tả chi tiết những thay đổi bạn muốn..." id="modificationContent"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Đính kèm file (tùy chọn)</label>
|
||||||
|
<div class="file-upload-area" onclick="document.getElementById('modificationFile').click()">
|
||||||
|
<i class="fas fa-cloud-upload-alt"></i>
|
||||||
|
<span>Nhấn để tải file đính kèm</span>
|
||||||
|
</div>
|
||||||
|
<input type="file" id="modificationFile" style="display: none;" multiple>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" onclick="closeModificationModal()">Hủy</button>
|
||||||
|
<button class="btn btn-primary" onclick="submitModificationRequest()">Gửi yêu cầu</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.quote-detail-content {
|
||||||
|
padding: 0 0 100px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Quote Status Card */
|
||||||
|
.quote-status-card {
|
||||||
|
background: var(--white);
|
||||||
|
margin: 16px;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: var(--shadow-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-header-info {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-number {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary-blue);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-quote-status {
|
||||||
|
padding: 6px 16px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-quote-status.pending {
|
||||||
|
background: var(--warning-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-quote-status.sent {
|
||||||
|
background: var(--primary-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-quote-status.negotiating {
|
||||||
|
background: #ff9800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-quote-status.finalized {
|
||||||
|
background: var(--success-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-quote-status.cancelled {
|
||||||
|
background: var(--text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-quote-status.converted {
|
||||||
|
background: #9c27b0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-basic-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
color: var(--text-light);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-dark);
|
||||||
|
text-align: right;
|
||||||
|
flex: 1;
|
||||||
|
margin-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tab Navigation */
|
||||||
|
.quote-tabs-section {
|
||||||
|
background: var(--white);
|
||||||
|
margin: 0 16px 16px 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: var(--shadow-light);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-tab-navigation {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-tab-button {
|
||||||
|
flex: 1;
|
||||||
|
padding: 16px 12px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-light);
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-tab-button.active {
|
||||||
|
color: var(--primary-blue);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-tab-button.active::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--primary-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-tab-content {
|
||||||
|
display: none;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-tab-content.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Quote Products Section */
|
||||||
|
.quote-products-section h3 {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-dark);
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-product-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 60px 1fr auto auto;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-image img {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-details {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-dark);
|
||||||
|
margin: 0 0 4px 0;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-specs {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-quantity {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qty-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-light);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qty-value {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-pricing {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unit-price {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-light);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-price {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Quote Summary */
|
||||||
|
.quote-summary {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--background-gray);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-row:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-row.total {
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
padding-top: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-label {
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-row.total .summary-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-value {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-value.discount {
|
||||||
|
color: var(--success-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-row.total .summary-value {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--danger-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Quote Terms */
|
||||||
|
.quote-terms {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-terms h4 {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-dark);
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-terms ul {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-terms li {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-dark);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
padding-left: 16px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-terms li::before {
|
||||||
|
content: '•';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
color: var(--primary-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Timeline History */
|
||||||
|
.timeline-history {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-history::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 12px;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 2px;
|
||||||
|
background: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 40px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-icon {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 10px;
|
||||||
|
background: var(--border-color);
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item.completed .timeline-icon {
|
||||||
|
background: var(--success-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item.active .timeline-icon {
|
||||||
|
background: var(--primary-blue);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-dark);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-date {
|
||||||
|
color: var(--text-light);
|
||||||
|
font-size: 12px;
|
||||||
|
margin: 2px 0 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-description {
|
||||||
|
color: var(--text-dark);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-chat-btn {
|
||||||
|
background: var(--primary-blue);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 8px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-chat-btn:hover {
|
||||||
|
background: var(--light-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Action Buttons */
|
||||||
|
.quote-actions {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: var(--white);
|
||||||
|
padding: 16px;
|
||||||
|
box-shadow: 0 -4px 15px rgba(0, 0, 0, 0.1);
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.secondary {
|
||||||
|
background: var(--border-color);
|
||||||
|
color: var(--text-dark);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.secondary:hover {
|
||||||
|
background: #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.primary {
|
||||||
|
background: var(--primary-blue);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.primary:hover {
|
||||||
|
background: var(--light-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.large {
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal Styles */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: var(--white);
|
||||||
|
border-radius: 12px;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 500px;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
padding: 20px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h3 {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-dark);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
background: var(--background-gray);
|
||||||
|
color: var(--text-dark);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 20px;
|
||||||
|
max-height: 60vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-upload-area {
|
||||||
|
border: 2px dashed var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-upload-area:hover {
|
||||||
|
border-color: var(--primary-blue);
|
||||||
|
background: var(--background-gray);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-upload-area i {
|
||||||
|
font-size: 24px;
|
||||||
|
color: var(--primary-blue);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile Responsiveness */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.quote-status-card,
|
||||||
|
.quote-tabs-section {
|
||||||
|
margin: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-product-item {
|
||||||
|
grid-template-columns: 50px 1fr;
|
||||||
|
grid-template-rows: auto auto;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-quantity {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 2;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-pricing {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 2;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
text-align: left;
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Get quote ID from URL
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const quoteId = urlParams.get('id') || 'YC001234';
|
||||||
|
|
||||||
|
// Simulate different quote statuses for demo
|
||||||
|
const quoteStatuses = {
|
||||||
|
'YC001234': 'negotiating',
|
||||||
|
'YC001233': 'finalized',
|
||||||
|
'YC001232': 'converted',
|
||||||
|
'YC001231': 'sent',
|
||||||
|
'YC001230': 'pending',
|
||||||
|
'YC001229': 'cancelled'
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentStatus = quoteStatuses[quoteId] || 'negotiating';
|
||||||
|
|
||||||
|
function switchQuoteTab(tabName, button) {
|
||||||
|
// Update tab buttons
|
||||||
|
document.querySelectorAll('.quote-tab-button').forEach(t => t.classList.remove('active'));
|
||||||
|
button.classList.add('active');
|
||||||
|
|
||||||
|
// Update tab content
|
||||||
|
document.querySelectorAll('.quote-tab-content').forEach(c => c.classList.remove('active'));
|
||||||
|
document.getElementById(tabName).classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateQuoteStatus(newStatus) {
|
||||||
|
// Update status display
|
||||||
|
const statusElement = document.getElementById('currentStatus');
|
||||||
|
const statusText = {
|
||||||
|
'pending': 'Chờ duyệt',
|
||||||
|
'sent': 'Đã gửi',
|
||||||
|
'negotiating': 'Đang đàm phán',
|
||||||
|
'finalized': 'Đã chốt',
|
||||||
|
'cancelled': 'Đã hủy',
|
||||||
|
'converted': 'Đã thành đơn hàng'
|
||||||
|
};
|
||||||
|
|
||||||
|
statusElement.textContent = statusText[newStatus];
|
||||||
|
statusElement.className = `current-quote-status ${newStatus}`;
|
||||||
|
|
||||||
|
// Update action buttons
|
||||||
|
document.querySelectorAll('.action-group').forEach(group => {
|
||||||
|
group.style.display = 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
const targetGroup = document.querySelector(`.action-group.${newStatus}`);
|
||||||
|
if (targetGroup) {
|
||||||
|
targetGroup.style.display = 'flex';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestQuoteChat() {
|
||||||
|
// Navigate to chat with quote context
|
||||||
|
window.location.href = `chat-detail.html?id=conv001"e=${quoteId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestModification() {
|
||||||
|
document.getElementById('modificationModal').style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModificationModal() {
|
||||||
|
document.getElementById('modificationModal').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitModificationRequest() {
|
||||||
|
const content = document.getElementById('modificationContent').value.trim();
|
||||||
|
if (!content) {
|
||||||
|
alert('Vui lòng nhập nội dung yêu cầu thay đổi');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
alert('Yêu cầu sửa đổi đã được gửi thành công!\\nChúng tôi sẽ phản hồi trong vòng 24h.');
|
||||||
|
closeModificationModal();
|
||||||
|
updateQuoteStatus('negotiating');
|
||||||
|
}
|
||||||
|
|
||||||
|
function finalizeQuote() {
|
||||||
|
if (confirm('Xác nhận chốt báo giá này?\\nSau khi chốt, báo giá có thể được chuyển thành đơn hàng.')) {
|
||||||
|
alert('Báo giá đã được chốt thành công!\\nBây giờ bạn có thể tạo đơn hàng từ báo giá này.');
|
||||||
|
updateQuoteStatus('finalized');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createOrderFromQuote() {
|
||||||
|
if (confirm('Tạo đơn hàng từ báo giá này?\\nTất cả sản phẩm sẽ được thêm vào giỏ hàng với giá đã thỏa thuận.')) {
|
||||||
|
// Clear existing cart and add quote items
|
||||||
|
localStorage.setItem('cartFromQuote', JSON.stringify({
|
||||||
|
quoteId: quoteId,
|
||||||
|
items: [
|
||||||
|
{ id: 'ET-GR-001', name: 'Gạch granite Eurotile 60x60', price: 285000, quantity: 100 },
|
||||||
|
{ id: 'VS-CR-002', name: 'Gạch ceramic Vasta 30x60', price: 180000, quantity: 80 },
|
||||||
|
{ id: 'ET-MS-003', name: 'Gạch mosaic trang trí', price: 450000, quantity: 20 }
|
||||||
|
]
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Update quote status to converted
|
||||||
|
updateQuoteStatus('converted');
|
||||||
|
|
||||||
|
// Navigate to cart
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = 'cart.html';
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openQuoteChat() {
|
||||||
|
window.location.href = `chat-detail.html?id=conv001"e=${quoteId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shareQuote() {
|
||||||
|
if (navigator.share) {
|
||||||
|
navigator.share({
|
||||||
|
title: `Báo giá ${quoteId}`,
|
||||||
|
text: 'Xem chi tiết báo giá này',
|
||||||
|
url: window.location.href
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
navigator.clipboard.writeText(window.location.href);
|
||||||
|
alert('Đã sao chép link báo giá!');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function printQuote() {
|
||||||
|
window.print();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize page based on current status
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
updateQuoteStatus(currentStatus);
|
||||||
|
|
||||||
|
// Update page title
|
||||||
|
document.title = `Chi tiết báo giá ${quoteId} - EuroTile Worker`;
|
||||||
|
document.querySelector('.quote-number').textContent = `#${quoteId}`;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -38,8 +38,8 @@
|
|||||||
|
|
||||||
<!-- Quote Requests List -->
|
<!-- Quote Requests List -->
|
||||||
<div class="quote-requests-list">
|
<div class="quote-requests-list">
|
||||||
<!-- Quote Request 1 - New -->
|
<!-- Quote Request 1 - Đang đàm phán -->
|
||||||
<div class="quote-request-card new">
|
<div class="quote-request-card negotiating" onclick="viewQuoteDetail('YC001234')">
|
||||||
<div class="quote-request-status-indicator"></div>
|
<div class="quote-request-status-indicator"></div>
|
||||||
<div class="quote-request-content">
|
<div class="quote-request-content">
|
||||||
<div class="d-flex justify-between align-start mb-2">
|
<div class="d-flex justify-between align-start mb-2">
|
||||||
@@ -51,15 +51,15 @@
|
|||||||
<p class="quote-request-project">Dự án: Villa Thủ Đức - Giai đoạn 2</p>
|
<p class="quote-request-project">Dự án: Villa Thủ Đức - Giai đoạn 2</p>
|
||||||
<p class="quote-request-items">5 sản phẩm - Diện tích: 200m²</p>
|
<p class="quote-request-items">5 sản phẩm - Diện tích: 200m²</p>
|
||||||
<p class="quote-request-status-text">
|
<p class="quote-request-status-text">
|
||||||
<span class="status-badge new">Mới tạo</span>
|
<span class="status-badge negotiating">Đang đàm phán</span>
|
||||||
</p>
|
</p>
|
||||||
<p class="quote-request-note">Yêu cầu báo giá cho gạch granite cao cấp</p>
|
<p class="quote-request-note">Khách hàng yêu cầu giảm giá 5%</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Quote Request 2 - Waiting Response -->
|
<!-- Quote Request 2 - Đã chốt -->
|
||||||
<div class="quote-request-card waiting">
|
<div class="quote-request-card finalized" onclick="viewQuoteDetail('YC001233')">
|
||||||
<div class="quote-request-status-indicator"></div>
|
<div class="quote-request-status-indicator"></div>
|
||||||
<div class="quote-request-content">
|
<div class="quote-request-content">
|
||||||
<div class="d-flex justify-between align-start mb-2">
|
<div class="d-flex justify-between align-start mb-2">
|
||||||
@@ -71,15 +71,15 @@
|
|||||||
<p class="quote-request-project">Dự án: Chung cư Landmark Center</p>
|
<p class="quote-request-project">Dự án: Chung cư Landmark Center</p>
|
||||||
<p class="quote-request-items">8 sản phẩm - Diện tích: 500m²</p>
|
<p class="quote-request-items">8 sản phẩm - Diện tích: 500m²</p>
|
||||||
<p class="quote-request-status-text">
|
<p class="quote-request-status-text">
|
||||||
<span class="status-badge waiting">Chờ phản hồi</span>
|
<span class="status-badge finalized">Đã chốt</span>
|
||||||
</p>
|
</p>
|
||||||
<p class="quote-request-note">Báo giá cho sảnh chính và hành lang</p>
|
<p class="quote-request-note">Tổng giá trị: 125.500.000 VND</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Quote Request 3 - Has Quote -->
|
<!-- Quote Request 3 - Đã thành đơn hàng -->
|
||||||
<div class="quote-request-card quoted">
|
<div class="quote-request-card converted" onclick="viewQuoteDetail('YC001232')">
|
||||||
<div class="quote-request-status-indicator"></div>
|
<div class="quote-request-status-indicator"></div>
|
||||||
<div class="quote-request-content">
|
<div class="quote-request-content">
|
||||||
<div class="d-flex justify-between align-start mb-2">
|
<div class="d-flex justify-between align-start mb-2">
|
||||||
@@ -91,15 +91,15 @@
|
|||||||
<p class="quote-request-project">Dự án: Nhà phố Bình Thạnh</p>
|
<p class="quote-request-project">Dự án: Nhà phố Bình Thạnh</p>
|
||||||
<p class="quote-request-items">3 sản phẩm - Diện tích: 120m²</p>
|
<p class="quote-request-items">3 sản phẩm - Diện tích: 120m²</p>
|
||||||
<p class="quote-request-status-text">
|
<p class="quote-request-status-text">
|
||||||
<span class="status-badge quoted">Đã có báo giá</span>
|
<span class="status-badge converted">Đã thành đơn hàng</span>
|
||||||
</p>
|
</p>
|
||||||
<p class="quote-request-note">Tổng giá trị: 28.900.000 VND</p>
|
<p class="quote-request-note">Mã đơn hàng: #DH005432</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Quote Request 4 - New -->
|
<!-- Quote Request 4 - Đã gửi -->
|
||||||
<div class="quote-request-card new">
|
<div class="quote-request-card sent" onclick="viewQuoteDetail('YC001231')">
|
||||||
<div class="quote-request-status-indicator"></div>
|
<div class="quote-request-status-indicator"></div>
|
||||||
<div class="quote-request-content">
|
<div class="quote-request-content">
|
||||||
<div class="d-flex justify-between align-start mb-2">
|
<div class="d-flex justify-between align-start mb-2">
|
||||||
@@ -111,15 +111,15 @@
|
|||||||
<p class="quote-request-project">Dự án: Văn phòng Quận 7</p>
|
<p class="quote-request-project">Dự án: Văn phòng Quận 7</p>
|
||||||
<p class="quote-request-items">4 sản phẩm - Diện tích: 300m²</p>
|
<p class="quote-request-items">4 sản phẩm - Diện tích: 300m²</p>
|
||||||
<p class="quote-request-status-text">
|
<p class="quote-request-status-text">
|
||||||
<span class="status-badge new">Mới tạo</span>
|
<span class="status-badge sent">Đã gửi</span>
|
||||||
</p>
|
</p>
|
||||||
<p class="quote-request-note">Gạch porcelain cho khu vực làm việc</p>
|
<p class="quote-request-note">Chờ khách hàng phản hồi</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Quote Request 5 - Waiting Response -->
|
<!-- Quote Request 5 - Chờ duyệt -->
|
||||||
<div class="quote-request-card waiting">
|
<div class="quote-request-card pending" onclick="viewQuoteDetail('YC001230')">
|
||||||
<div class="quote-request-status-indicator"></div>
|
<div class="quote-request-status-indicator"></div>
|
||||||
<div class="quote-request-content">
|
<div class="quote-request-content">
|
||||||
<div class="d-flex justify-between align-start mb-2">
|
<div class="d-flex justify-between align-start mb-2">
|
||||||
@@ -131,12 +131,32 @@
|
|||||||
<p class="quote-request-project">Dự án: Resort Vũng Tàu</p>
|
<p class="quote-request-project">Dự án: Resort Vũng Tàu</p>
|
||||||
<p class="quote-request-items">12 sản phẩm - Diện tích: 800m²</p>
|
<p class="quote-request-items">12 sản phẩm - Diện tích: 800m²</p>
|
||||||
<p class="quote-request-status-text">
|
<p class="quote-request-status-text">
|
||||||
<span class="status-badge waiting">Chờ phản hồi</span>
|
<span class="status-badge pending">Chờ duyệt</span>
|
||||||
</p>
|
</p>
|
||||||
<p class="quote-request-note">Yêu cầu báo giá cho khu vực pool và spa</p>
|
<p class="quote-request-note">Yêu cầu báo giá cho khu vực pool và spa</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Quote Request 6 - Đã hủy -->
|
||||||
|
<div class="quote-request-card cancelled" onclick="viewQuoteDetail('YC001229')">
|
||||||
|
<div class="quote-request-status-indicator"></div>
|
||||||
|
<div class="quote-request-content">
|
||||||
|
<div class="d-flex justify-between align-start mb-2">
|
||||||
|
<h4 class="quote-request-id">#YC001229</h4>
|
||||||
|
<span class="quote-request-date">25/07/2023</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="quote-request-details">
|
||||||
|
<p class="quote-request-project">Dự án: Showroom Quận 1</p>
|
||||||
|
<p class="quote-request-items">6 sản phẩm - Diện tích: 250m²</p>
|
||||||
|
<p class="quote-request-status-text">
|
||||||
|
<span class="status-badge cancelled">Đã hủy</span>
|
||||||
|
</p>
|
||||||
|
<p class="quote-request-note">Khách hàng hủy dự án</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -167,7 +187,13 @@
|
|||||||
<i class="fas fa-user"></i>
|
<i class="fas fa-user"></i>
|
||||||
<span>Cài đặt</span>
|
<span>Cài đặt</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>-->
|
||||||
</div>-->
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function viewQuoteDetail(quoteId) {
|
||||||
|
window.location.href = `quote-detail.html?id=${quoteId}`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
919
html/receivables-management.html
Normal file
919
html/receivables-management.html
Normal file
@@ -0,0 +1,919 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="vi">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Quản lý Công nợ - EuroTile Dealer</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<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">
|
||||||
|
<style>
|
||||||
|
.receivables-container {
|
||||||
|
min-height: calc(100vh - 120px);
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-cards {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
gap: 15px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 20px;
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 8px 25px rgba(102, 126, 234, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card.warning {
|
||||||
|
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card.success {
|
||||||
|
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-title {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-amount {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-detail {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-section {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 15px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #374151;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-select,
|
||||||
|
.filter-input {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 2px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: border-color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-select:focus,
|
||||||
|
.filter-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.receivables-list {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.receivable-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px;
|
||||||
|
border-bottom: 1px solid #f3f4f6;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.receivable-item:hover {
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.receivable-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customer-avatar {
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
margin-right: 15px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.receivable-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customer-name {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f2937;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-details {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 15px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-id {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-date {
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overdue-badge {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #dc2626;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.due-soon-badge {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #d97706;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.receivable-amount {
|
||||||
|
text-align: right;
|
||||||
|
margin-left: 15px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount-value {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f2937;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount-overdue {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount-due-soon {
|
||||||
|
color: #d97706;
|
||||||
|
}
|
||||||
|
|
||||||
|
.due-date {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-left: 15px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-action {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-remind {
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-remind:hover {
|
||||||
|
background: #dbeafe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-collect {
|
||||||
|
background: #f0fdf4;
|
||||||
|
color: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-collect:hover {
|
||||||
|
background: #dcfce7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-view {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-view:hover {
|
||||||
|
background: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state i {
|
||||||
|
font-size: 3rem;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
color: #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-action {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 90px;
|
||||||
|
right: 20px;
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
box-shadow: 0 8px 25px rgba(102, 126, 234, 0.4);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-action:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: 0 12px 35px rgba(102, 126, 234, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0,0,0,0.5);
|
||||||
|
z-index: 1000;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal.show {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: white;
|
||||||
|
border-radius: 16px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 500px;
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
padding: 20px 20px 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: #6b7280;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
display: block;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #374151;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input,
|
||||||
|
.form-select,
|
||||||
|
.form-textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
border: 2px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: border-color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:focus,
|
||||||
|
.form-select:focus,
|
||||||
|
.form-textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-textarea {
|
||||||
|
height: 100px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
padding: 0 20px 20px;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel {
|
||||||
|
padding: 12px 20px;
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #374151;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-submit {
|
||||||
|
padding: 12px 20px;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.receivables-container {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-cards {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.receivable-item {
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-details {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-action {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 6px 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page-wrapper">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="header">
|
||||||
|
<a href="index.html" class="back-button">
|
||||||
|
<i class="fas fa-arrow-left"></i>
|
||||||
|
</a>
|
||||||
|
<h1 class="header-title">Quản lý Công nợ</h1>
|
||||||
|
<button class="icon-button" onclick="exportReceivables()">
|
||||||
|
<i class="fas fa-download"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="receivables-container">
|
||||||
|
<!-- Summary Cards -->
|
||||||
|
<div class="summary-cards">
|
||||||
|
<div class="summary-card">
|
||||||
|
<div class="summary-title">Tổng công nợ</div>
|
||||||
|
<div class="summary-amount">892.500.000đ</div>
|
||||||
|
<div class="summary-detail">
|
||||||
|
<i class="fas fa-users"></i>
|
||||||
|
<span>45 khách hàng</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="summary-card warning">
|
||||||
|
<div class="summary-title">Quá hạn thanh toán</div>
|
||||||
|
<div class="summary-amount">156.000.000đ</div>
|
||||||
|
<div class="summary-detail">
|
||||||
|
<i class="fas fa-exclamation-triangle"></i>
|
||||||
|
<span>12 đơn hàng</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="summary-card success">
|
||||||
|
<div class="summary-title">Sắp đến hạn</div>
|
||||||
|
<div class="summary-amount">234.750.000đ</div>
|
||||||
|
<div class="summary-detail">
|
||||||
|
<i class="fas fa-clock"></i>
|
||||||
|
<span>18 đơn hàng (7 ngày tới)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filter Section -->
|
||||||
|
<div class="filter-section">
|
||||||
|
<div class="filter-row">
|
||||||
|
<div class="filter-group">
|
||||||
|
<label class="filter-label">Trạng thái</label>
|
||||||
|
<select class="filter-select" id="statusFilter" onchange="filterReceivables()">
|
||||||
|
<option value="">Tất cả</option>
|
||||||
|
<option value="overdue">Quá hạn</option>
|
||||||
|
<option value="due-soon">Sắp đến hạn</option>
|
||||||
|
<option value="normal">Bình thường</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-group">
|
||||||
|
<label class="filter-label">Khoảng tiền</label>
|
||||||
|
<select class="filter-select" id="amountFilter" onchange="filterReceivables()">
|
||||||
|
<option value="">Tất cả</option>
|
||||||
|
<option value="small">Dưới 10 triệu</option>
|
||||||
|
<option value="medium">10 - 50 triệu</option>
|
||||||
|
<option value="large">Trên 50 triệu</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-group">
|
||||||
|
<label class="filter-label">Tìm kiếm khách hàng</label>
|
||||||
|
<input type="text" class="filter-input" id="searchInput" placeholder="Tên hoặc mã đơn hàng..." onkeyup="filterReceivables()">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Receivables List -->
|
||||||
|
<div class="receivables-list" id="receivablesList">
|
||||||
|
<!-- Receivable Items will be populated by JavaScript -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom Navigation -->
|
||||||
|
<div class="bottom-nav">
|
||||||
|
<a href="index.html" class="nav-item">
|
||||||
|
<i class="fas fa-home"></i>
|
||||||
|
<span>Trang chủ</span>
|
||||||
|
</a>
|
||||||
|
<a href="loyalty.html" class="nav-item">
|
||||||
|
<i class="fas fa-star"></i>
|
||||||
|
<span>Hội viên</span>
|
||||||
|
</a>
|
||||||
|
<a href="promotions.html" class="nav-item">
|
||||||
|
<i class="fas fa-tags"></i>
|
||||||
|
<span>Khuyến mãi</span>
|
||||||
|
</a>
|
||||||
|
<a href="notifications.html" class="nav-item">
|
||||||
|
<i class="fas fa-bell"></i>
|
||||||
|
<span>Thông báo</span>
|
||||||
|
</a>
|
||||||
|
<a href="account.html" class="nav-item active">
|
||||||
|
<i class="fas fa-user"></i>
|
||||||
|
<span>Cài đặt</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Floating Action Button -->
|
||||||
|
<div class="floating-action" onclick="openAddReceivableModal()">
|
||||||
|
<i class="fas fa-plus"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Reminder Modal -->
|
||||||
|
<div class="modal" id="reminderModal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 class="modal-title">Gửi nhắc nợ</h3>
|
||||||
|
<button class="modal-close" onclick="closeModal('reminderModal')">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Khách hàng</label>
|
||||||
|
<input type="text" class="form-input" id="reminderCustomer" readonly>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Số tiền nợ</label>
|
||||||
|
<input type="text" class="form-input" id="reminderAmount" readonly>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Phương thức nhắc nhở</label>
|
||||||
|
<select class="form-select" id="reminderMethod">
|
||||||
|
<option value="sms">Tin nhắn SMS</option>
|
||||||
|
<option value="call">Gọi điện thoại</option>
|
||||||
|
<option value="email">Email</option>
|
||||||
|
<option value="visit">Đến thăm trực tiếp</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Nội dung nhắc nhở</label>
|
||||||
|
<textarea class="form-textarea" id="reminderMessage" placeholder="Nhập nội dung nhắc nở...">Kính chào anh/chị,
|
||||||
|
|
||||||
|
EuroTile xin nhắc nhở về việc thanh toán công nợ đơn hàng. Vui lòng liên hệ với chúng tôi để sắp xếp thanh toán.
|
||||||
|
|
||||||
|
Xin cảm ơn!</textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn-cancel" onclick="closeModal('reminderModal')">Hủy</button>
|
||||||
|
<button class="btn-submit" onclick="sendReminder()">Gửi nhắc nhở</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Collection Modal -->
|
||||||
|
<div class="modal" id="collectionModal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 class="modal-title">Thu tiền</h3>
|
||||||
|
<button class="modal-close" onclick="closeModal('collectionModal')">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Khách hàng</label>
|
||||||
|
<input type="text" class="form-input" id="collectionCustomer" readonly>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Tổng số tiền nợ</label>
|
||||||
|
<input type="text" class="form-input" id="collectionTotalAmount" readonly>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Số tiền thu</label>
|
||||||
|
<input type="number" class="form-input" id="collectionAmount" placeholder="Nhập số tiền thu được...">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Phương thức thanh toán</label>
|
||||||
|
<select class="form-select" id="paymentMethod">
|
||||||
|
<option value="cash">Tiền mặt</option>
|
||||||
|
<option value="transfer">Chuyển khoản</option>
|
||||||
|
<option value="check">Séc</option>
|
||||||
|
<option value="card">Thẻ tín dụng</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Ghi chú</label>
|
||||||
|
<textarea class="form-textarea" id="collectionNote" placeholder="Ghi chú về việc thu tiền..."></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn-cancel" onclick="closeModal('collectionModal')">Hủy</button>
|
||||||
|
<button class="btn-submit" onclick="recordPayment()">Ghi nhận thanh toán</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Sample receivables data
|
||||||
|
const receivablesData = [
|
||||||
|
{
|
||||||
|
id: 'DH001234',
|
||||||
|
customer: 'Công ty TNHH Xây dựng Minh An',
|
||||||
|
customerCode: 'KH001',
|
||||||
|
phone: '0912345678',
|
||||||
|
orderDate: '2023-10-15',
|
||||||
|
dueDate: '2023-11-15',
|
||||||
|
amount: 85000000,
|
||||||
|
status: 'overdue',
|
||||||
|
daysOverdue: 15
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'DH001235',
|
||||||
|
customer: 'Anh Nguyễn Văn Hùng',
|
||||||
|
customerCode: 'KH002',
|
||||||
|
phone: '0987654321',
|
||||||
|
orderDate: '2023-11-20',
|
||||||
|
dueDate: '2023-12-20',
|
||||||
|
amount: 25750000,
|
||||||
|
status: 'due-soon',
|
||||||
|
daysOverdue: 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'DH001236',
|
||||||
|
customer: 'Chị Trần Thị Mai',
|
||||||
|
customerCode: 'KH003',
|
||||||
|
phone: '0923456789',
|
||||||
|
orderDate: '2023-11-25',
|
||||||
|
dueDate: '2023-12-25',
|
||||||
|
amount: 12500000,
|
||||||
|
status: 'normal',
|
||||||
|
daysOverdue: 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'DH001237',
|
||||||
|
customer: 'Công ty CP Đầu tư Bất động sản ABC',
|
||||||
|
customerCode: 'KH004',
|
||||||
|
phone: '0945678912',
|
||||||
|
orderDate: '2023-10-10',
|
||||||
|
dueDate: '2023-11-10',
|
||||||
|
amount: 156000000,
|
||||||
|
status: 'overdue',
|
||||||
|
daysOverdue: 20
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'DH001238',
|
||||||
|
customer: 'Anh Lê Minh Tuấn',
|
||||||
|
customerCode: 'KH005',
|
||||||
|
phone: '0934567890',
|
||||||
|
orderDate: '2023-11-28',
|
||||||
|
dueDate: '2023-12-28',
|
||||||
|
amount: 18900000,
|
||||||
|
status: 'due-soon',
|
||||||
|
daysOverdue: 0
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
let filteredReceivables = [...receivablesData];
|
||||||
|
|
||||||
|
// Initialize page
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
renderReceivables();
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderReceivables() {
|
||||||
|
const container = document.getElementById('receivablesList');
|
||||||
|
|
||||||
|
if (filteredReceivables.length === 0) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<i class="fas fa-receipt"></i>
|
||||||
|
<h3>Không có công nợ nào</h3>
|
||||||
|
<p>Không tìm thấy công nợ phù hợp với bộ lọc hiện tại</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = filteredReceivables.map(receivable => `
|
||||||
|
<div class="receivable-item" onclick="viewReceivableDetail('${receivable.id}')">
|
||||||
|
<div class="customer-avatar">
|
||||||
|
${receivable.customer.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="receivable-info">
|
||||||
|
<div class="customer-name">${receivable.customer}</div>
|
||||||
|
<div class="order-details">
|
||||||
|
<span class="order-id">Đơn hàng: ${receivable.id}</span>
|
||||||
|
<span class="order-date">Ngày đặt: ${formatDate(receivable.orderDate)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="payment-info">
|
||||||
|
${getStatusBadge(receivable.status, receivable.daysOverdue)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="receivable-amount">
|
||||||
|
<div class="amount-value ${getAmountClass(receivable.status)}">
|
||||||
|
${formatCurrency(receivable.amount)}
|
||||||
|
</div>
|
||||||
|
<div class="due-date">Hạn: ${formatDate(receivable.dueDate)}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="action-buttons">
|
||||||
|
<button class="btn-action btn-remind" onclick="event.stopPropagation(); openReminderModal('${receivable.id}')">
|
||||||
|
<i class="fas fa-bell"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn-action btn-collect" onclick="event.stopPropagation(); openCollectionModal('${receivable.id}')">
|
||||||
|
<i class="fas fa-money-bill-wave"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn-action btn-view" onclick="event.stopPropagation(); viewReceivableDetail('${receivable.id}')">
|
||||||
|
<i class="fas fa-eye"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusBadge(status, daysOverdue) {
|
||||||
|
switch(status) {
|
||||||
|
case 'overdue':
|
||||||
|
return `<span class="overdue-badge">Quá hạn ${daysOverdue} ngày</span>`;
|
||||||
|
case 'due-soon':
|
||||||
|
return `<span class="due-soon-badge">Sắp đến hạn</span>`;
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAmountClass(status) {
|
||||||
|
switch(status) {
|
||||||
|
case 'overdue':
|
||||||
|
return 'amount-overdue';
|
||||||
|
case 'due-soon':
|
||||||
|
return 'amount-due-soon';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterReceivables() {
|
||||||
|
const statusFilter = document.getElementById('statusFilter').value;
|
||||||
|
const amountFilter = document.getElementById('amountFilter').value;
|
||||||
|
const searchTerm = document.getElementById('searchInput').value.toLowerCase();
|
||||||
|
|
||||||
|
filteredReceivables = receivablesData.filter(receivable => {
|
||||||
|
// Status filter
|
||||||
|
if (statusFilter && receivable.status !== statusFilter) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Amount filter
|
||||||
|
if (amountFilter) {
|
||||||
|
const amount = receivable.amount;
|
||||||
|
if (amountFilter === 'small' && amount >= 10000000) return false;
|
||||||
|
if (amountFilter === 'medium' && (amount < 10000000 || amount > 50000000)) return false;
|
||||||
|
if (amountFilter === 'large' && amount <= 50000000) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search filter
|
||||||
|
if (searchTerm) {
|
||||||
|
const searchableText = `${receivable.customer} ${receivable.id}`.toLowerCase();
|
||||||
|
if (!searchableText.includes(searchTerm)) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
renderReceivables();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openReminderModal(receivableId) {
|
||||||
|
const receivable = receivablesData.find(r => r.id === receivableId);
|
||||||
|
if (!receivable) return;
|
||||||
|
|
||||||
|
document.getElementById('reminderCustomer').value = receivable.customer;
|
||||||
|
document.getElementById('reminderAmount').value = formatCurrency(receivable.amount);
|
||||||
|
document.getElementById('reminderModal').classList.add('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCollectionModal(receivableId) {
|
||||||
|
const receivable = receivablesData.find(r => r.id === receivableId);
|
||||||
|
if (!receivable) return;
|
||||||
|
|
||||||
|
document.getElementById('collectionCustomer').value = receivable.customer;
|
||||||
|
document.getElementById('collectionTotalAmount').value = formatCurrency(receivable.amount);
|
||||||
|
document.getElementById('collectionAmount').value = '';
|
||||||
|
document.getElementById('collectionModal').classList.add('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal(modalId) {
|
||||||
|
document.getElementById(modalId).classList.remove('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendReminder() {
|
||||||
|
const method = document.getElementById('reminderMethod').value;
|
||||||
|
const message = document.getElementById('reminderMessage').value;
|
||||||
|
|
||||||
|
if (!message.trim()) {
|
||||||
|
alert('Vui lòng nhập nội dung nhắc nhở');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate sending reminder
|
||||||
|
alert('Đã gửi nhắc nhở thành công!');
|
||||||
|
closeModal('reminderModal');
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordPayment() {
|
||||||
|
const amount = document.getElementById('collectionAmount').value;
|
||||||
|
const method = document.getElementById('paymentMethod').value;
|
||||||
|
|
||||||
|
if (!amount || amount <= 0) {
|
||||||
|
alert('Vui lòng nhập số tiền hợp lệ');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate recording payment
|
||||||
|
alert('Đã ghi nhận thanh toán thành công!');
|
||||||
|
closeModal('collectionModal');
|
||||||
|
|
||||||
|
// Refresh the list (in a real app, this would update the backend)
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function viewReceivableDetail(receivableId) {
|
||||||
|
// Navigate to receivable detail page
|
||||||
|
alert(`Xem chi tiết công nợ ${receivableId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAddReceivableModal() {
|
||||||
|
alert('Chức năng thêm công nợ mới sẽ được phát triển');
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportReceivables() {
|
||||||
|
// Simulate export functionality
|
||||||
|
alert('Đang xuất báo cáo công nợ...');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility functions
|
||||||
|
function formatCurrency(amount) {
|
||||||
|
return new Intl.NumberFormat('vi-VN', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'VND',
|
||||||
|
minimumFractionDigits: 0
|
||||||
|
}).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateString) {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString('vi-VN');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modals when clicking outside
|
||||||
|
window.onclick = function(event) {
|
||||||
|
const modals = document.querySelectorAll('.modal');
|
||||||
|
modals.forEach(modal => {
|
||||||
|
if (event.target === modal) {
|
||||||
|
modal.classList.remove('show');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
425
html/vr360-viewer-section.html
Normal file
425
html/vr360-viewer-section.html
Normal file
@@ -0,0 +1,425 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="vi">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Nhà mẫu 360° - EuroTile Worker</title>
|
||||||
|
<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">
|
||||||
|
<style>
|
||||||
|
/* VR360 Container Styles */
|
||||||
|
.vr360-section {
|
||||||
|
background: var(--white);
|
||||||
|
padding: 16px;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vr360-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
box-shadow: var(--shadow-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Option 1: Click to View Style */
|
||||||
|
.vr360-preview {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 48px 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--white);
|
||||||
|
position: relative;
|
||||||
|
background: linear-gradient(135deg, rgba(0, 91, 154, 0.9) 0%, rgba(56, 182, 255, 0.9) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vr360-preview:hover {
|
||||||
|
transform: scale(1.02);
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 91, 154, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vr360-icon-wrapper {
|
||||||
|
position: relative;
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vr360-icon {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 50%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vr360-icon::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 3px solid rgba(255, 255, 255, 0.4);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: pulse360 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vr360-icon::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: 120%;
|
||||||
|
height: 120%;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: pulse360 2s infinite 0.5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vr360-icon .main-icon {
|
||||||
|
font-size: 36px;
|
||||||
|
color: var(--white);
|
||||||
|
z-index: 1;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vr360-arrow {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vr360-arrow svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
animation: rotate360 4s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vr360-title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--white);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
text-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vr360-subtitle {
|
||||||
|
font-size: 14px;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vr360-button {
|
||||||
|
padding: 12px 32px;
|
||||||
|
background: var(--white);
|
||||||
|
color: var(--primary-blue);
|
||||||
|
border-radius: 24px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vr360-preview:hover .vr360-button {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 20px rgba(0,0,0,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Option 2: Embedded iFrame Style */
|
||||||
|
.vr360-embed {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
padding-bottom: 75%; /* 4:3 Aspect Ratio */
|
||||||
|
background: var(--background-gray);
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vr360-iframe {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vr360-loading {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vr360-loading .spinner {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 3px solid var(--border-color);
|
||||||
|
border-top-color: var(--primary-blue);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin: 0 auto 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toggle Switch for View Options */
|
||||||
|
.view-options {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-option-btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: var(--white);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-dark);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-option-btn.active {
|
||||||
|
background: var(--primary-blue);
|
||||||
|
color: var(--white);
|
||||||
|
border-color: var(--primary-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animations */
|
||||||
|
@keyframes pulse360 {
|
||||||
|
0% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.3);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1.6);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rotate360 {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fullscreen Button */
|
||||||
|
.vr360-fullscreen-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
color: var(--white);
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 10;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vr360-fullscreen-btn:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.9);
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page-wrapper">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="header">
|
||||||
|
<a href="index.html" class="back-button">
|
||||||
|
<i class="fas fa-arrow-left"></i>
|
||||||
|
</a>
|
||||||
|
<h1 class="header-title">Nhà mẫu 360°</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<!-- View Options Toggle -->
|
||||||
|
<div class="view-options">
|
||||||
|
<button class="view-option-btn active" onclick="showPreview()">
|
||||||
|
<i class="fas fa-image"></i> Xem trước
|
||||||
|
</button>
|
||||||
|
<button class="view-option-btn" onclick="showEmbed()">
|
||||||
|
<i class="fas fa-play"></i> Xem trực tiếp
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- VR360 Section -->
|
||||||
|
<div class="vr360-section">
|
||||||
|
<!-- Option 1: Preview with Link -->
|
||||||
|
<div id="previewMode" class="vr360-container">
|
||||||
|
<a href="https://vr.house3d.com/web/panorama-player/H00179549"
|
||||||
|
target="_blank"
|
||||||
|
class="vr360-preview">
|
||||||
|
<div class="vr360-icon-wrapper">
|
||||||
|
<div class="vr360-icon">
|
||||||
|
<span class="main-icon">360°</span>
|
||||||
|
</div>
|
||||||
|
<div class="vr360-arrow">
|
||||||
|
<svg viewBox="0 0 120 120" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="60" cy="60" r="50" fill="none" stroke="rgba(255,255,255,0.3)" stroke-width="2" stroke-dasharray="10 5"/>
|
||||||
|
<path d="M 60 15 L 65 20 M 65 20 L 60 25" stroke="rgba(255,255,255,0.8)" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h2 class="vr360-title">360°</h2>
|
||||||
|
<p class="vr360-subtitle">Khám phá không gian nhà mẫu toàn cảnh</p>
|
||||||
|
<div class="vr360-button">
|
||||||
|
<i class="fas fa-external-link-alt"></i>
|
||||||
|
<span>Mở chế độ xem 360°</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Option 2: Embedded iFrame (Hidden by default) -->
|
||||||
|
<div id="embedMode" class="vr360-container" style="display: none;">
|
||||||
|
<div class="vr360-embed">
|
||||||
|
<div class="vr360-loading" id="loadingState">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<span>Đang tải mô hình 360°...</span>
|
||||||
|
</div>
|
||||||
|
<iframe
|
||||||
|
id="vr360iframe"
|
||||||
|
class="vr360-iframe"
|
||||||
|
src=""
|
||||||
|
title="Mô hình 360° Nhà mẫu"
|
||||||
|
allowfullscreen
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||||
|
onload="hideLoading()"
|
||||||
|
style="display: none;">
|
||||||
|
</iframe>
|
||||||
|
<button class="vr360-fullscreen-btn" onclick="goFullscreen()">
|
||||||
|
<i class="fas fa-expand"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Additional Info -->
|
||||||
|
<div class="card">
|
||||||
|
<h3 class="card-title">Về nhà mẫu này</h3>
|
||||||
|
<p style="color: var(--text-light); font-size: 14px; line-height: 1.6;">
|
||||||
|
Trải nghiệm không gian sống hiện đại với công nghệ xem 360°.
|
||||||
|
Di chuyển chuột hoặc vuốt màn hình để khám phá mọi góc nhìn của căn nhà.
|
||||||
|
</p>
|
||||||
|
<ul style="padding-left: 20px; margin-top: 12px;">
|
||||||
|
<li style="color: var(--text-light); font-size: 14px; margin-bottom: 8px;">
|
||||||
|
<i class="fas fa-mouse" style="color: var(--primary-blue); margin-right: 8px;"></i>
|
||||||
|
Kéo chuột để xoay góc nhìn
|
||||||
|
</li>
|
||||||
|
<li style="color: var(--text-light); font-size: 14px; margin-bottom: 8px;">
|
||||||
|
<i class="fas fa-search-plus" style="color: var(--primary-blue); margin-right: 8px;"></i>
|
||||||
|
Scroll để zoom in/out
|
||||||
|
</li>
|
||||||
|
<li style="color: var(--text-light); font-size: 14px;">
|
||||||
|
<i class="fas fa-hand-point-up" style="color: var(--primary-blue); margin-right: 8px;"></i>
|
||||||
|
Click vào các điểm nóng để di chuyển
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const VR360_URL = "https://vr.house3d.com/web/panorama-player/H00179549";
|
||||||
|
|
||||||
|
// Show preview mode
|
||||||
|
function showPreview() {
|
||||||
|
document.getElementById('previewMode').style.display = 'block';
|
||||||
|
document.getElementById('embedMode').style.display = 'none';
|
||||||
|
|
||||||
|
// Update buttons
|
||||||
|
document.querySelectorAll('.view-option-btn').forEach(btn => {
|
||||||
|
btn.classList.remove('active');
|
||||||
|
});
|
||||||
|
event.target.classList.add('active');
|
||||||
|
|
||||||
|
// Clear iframe src to stop loading
|
||||||
|
document.getElementById('vr360iframe').src = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show embedded mode
|
||||||
|
function showEmbed() {
|
||||||
|
document.getElementById('previewMode').style.display = 'none';
|
||||||
|
document.getElementById('embedMode').style.display = 'block';
|
||||||
|
|
||||||
|
// Update buttons
|
||||||
|
document.querySelectorAll('.view-option-btn').forEach(btn => {
|
||||||
|
btn.classList.remove('active');
|
||||||
|
});
|
||||||
|
event.target.classList.add('active');
|
||||||
|
|
||||||
|
// Load iframe
|
||||||
|
const iframe = document.getElementById('vr360iframe');
|
||||||
|
if (!iframe.src) {
|
||||||
|
iframe.src = VR360_URL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide loading state when iframe loads
|
||||||
|
function hideLoading() {
|
||||||
|
document.getElementById('loadingState').style.display = 'none';
|
||||||
|
document.getElementById('vr360iframe').style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fullscreen function
|
||||||
|
function goFullscreen() {
|
||||||
|
const container = document.getElementById('embedMode');
|
||||||
|
if (container.requestFullscreen) {
|
||||||
|
container.requestFullscreen();
|
||||||
|
} else if (container.webkitRequestFullscreen) {
|
||||||
|
container.webkitRequestFullscreen();
|
||||||
|
} else if (container.mozRequestFullScreen) {
|
||||||
|
container.mozRequestFullScreen();
|
||||||
|
} else if (container.msRequestFullscreen) {
|
||||||
|
container.msRequestFullscreen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional: Auto-load embed after delay
|
||||||
|
// setTimeout(() => {
|
||||||
|
// if (window.innerWidth > 768) {
|
||||||
|
// showEmbed();
|
||||||
|
// }
|
||||||
|
// }, 2000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -79,41 +79,77 @@ class HiveTypeIds {
|
|||||||
HiveTypeIds._();
|
HiveTypeIds._();
|
||||||
|
|
||||||
// Core Models (0-9)
|
// Core Models (0-9)
|
||||||
static const int user = 0;
|
static const int userModel = 0;
|
||||||
static const int product = 1;
|
static const int userSessionModel = 1;
|
||||||
static const int cartItem = 2;
|
static const int productModel = 2;
|
||||||
static const int order = 3;
|
static const int stockLevelModel = 3;
|
||||||
static const int project = 4;
|
static const int cartModel = 4;
|
||||||
static const int loyaltyTransaction = 5;
|
static const int cartItemModel = 5;
|
||||||
|
static const int orderModel = 6;
|
||||||
|
static const int orderItemModel = 7;
|
||||||
|
static const int invoiceModel = 8;
|
||||||
|
static const int paymentLineModel = 9;
|
||||||
|
|
||||||
// Extended Models (10-19)
|
// Loyalty Models (10-19)
|
||||||
static const int orderItem = 10;
|
static const int loyaltyPointEntryModel = 10;
|
||||||
static const int address = 11;
|
static const int giftCatalogModel = 11;
|
||||||
static const int category = 12;
|
static const int redeemedGiftModel = 12;
|
||||||
static const int reward = 13;
|
static const int pointsRecordModel = 13;
|
||||||
static const int gift = 14;
|
|
||||||
static const int notification = 15;
|
|
||||||
static const int quote = 16;
|
|
||||||
static const int payment = 17;
|
|
||||||
static const int promotion = 18;
|
|
||||||
static const int referral = 19;
|
|
||||||
|
|
||||||
// Enums (20-29)
|
// Project & Quote Models (14-17)
|
||||||
static const int memberTier = 20;
|
static const int projectSubmissionModel = 14;
|
||||||
static const int userType = 21;
|
static const int designRequestModel = 15;
|
||||||
static const int orderStatus = 22;
|
static const int quoteModel = 16;
|
||||||
static const int projectStatus = 23;
|
static const int quoteItemModel = 17;
|
||||||
static const int projectType = 24;
|
|
||||||
static const int transactionType = 25;
|
|
||||||
static const int giftStatus = 26;
|
|
||||||
static const int paymentStatus = 27;
|
|
||||||
static const int notificationType = 28;
|
|
||||||
static const int paymentMethod = 29;
|
|
||||||
|
|
||||||
// Cache & Sync Models (30-39)
|
// Chat Models (18-19)
|
||||||
static const int cachedData = 30;
|
static const int chatRoomModel = 18;
|
||||||
static const int syncState = 31;
|
static const int messageModel = 19;
|
||||||
static const int offlineRequest = 32;
|
|
||||||
|
// Extended Models (20-29)
|
||||||
|
static const int notificationModel = 20;
|
||||||
|
static const int showroomModel = 21;
|
||||||
|
static const int showroomProductModel = 22;
|
||||||
|
static const int paymentReminderModel = 23;
|
||||||
|
static const int auditLogModel = 24;
|
||||||
|
static const int memberCardModel = 25;
|
||||||
|
static const int promotionModel = 26;
|
||||||
|
static const int categoryModel = 27;
|
||||||
|
|
||||||
|
// Enums (30-59)
|
||||||
|
static const int userRole = 30;
|
||||||
|
static const int userStatus = 31;
|
||||||
|
static const int loyaltyTier = 32;
|
||||||
|
static const int orderStatus = 33;
|
||||||
|
static const int invoiceType = 34;
|
||||||
|
static const int invoiceStatus = 35;
|
||||||
|
static const int paymentMethod = 36;
|
||||||
|
static const int paymentStatus = 37;
|
||||||
|
static const int entryType = 38;
|
||||||
|
static const int entrySource = 39;
|
||||||
|
static const int complaintStatus = 40;
|
||||||
|
static const int giftCategory = 41;
|
||||||
|
static const int giftStatus = 42;
|
||||||
|
static const int pointsStatus = 43;
|
||||||
|
static const int projectType = 44;
|
||||||
|
static const int submissionStatus = 45;
|
||||||
|
static const int designStatus = 46;
|
||||||
|
static const int quoteStatus = 47;
|
||||||
|
static const int roomType = 48;
|
||||||
|
static const int contentType = 49;
|
||||||
|
static const int reminderType = 50;
|
||||||
|
static const int notificationType = 51;
|
||||||
|
|
||||||
|
// Aliases for backward compatibility and clarity
|
||||||
|
static const int memberTier = loyaltyTier; // Alias for loyaltyTier
|
||||||
|
static const int userType = userRole; // Alias for userRole
|
||||||
|
static const int projectStatus = submissionStatus; // Alias for submissionStatus
|
||||||
|
static const int transactionType = entryType; // Alias for entryType
|
||||||
|
|
||||||
|
// Cache & Sync Models (60-69)
|
||||||
|
static const int cachedData = 60;
|
||||||
|
static const int syncState = 61;
|
||||||
|
static const int offlineRequest = 62;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Hive Storage Keys
|
/// Hive Storage Keys
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ part of 'cached_data.dart';
|
|||||||
|
|
||||||
class CachedDataAdapter extends TypeAdapter<CachedData> {
|
class CachedDataAdapter extends TypeAdapter<CachedData> {
|
||||||
@override
|
@override
|
||||||
final typeId = 30;
|
final typeId = 60;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
CachedData read(BinaryReader reader) {
|
CachedData read(BinaryReader reader) {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
47
lib/features/account/data/models/audit_log_model.dart
Normal file
47
lib/features/account/data/models/audit_log_model.dart
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:hive_ce/hive.dart';
|
||||||
|
import 'package:worker/core/constants/storage_constants.dart';
|
||||||
|
|
||||||
|
part 'audit_log_model.g.dart';
|
||||||
|
|
||||||
|
@HiveType(typeId: HiveTypeIds.auditLogModel)
|
||||||
|
class AuditLogModel extends HiveObject {
|
||||||
|
AuditLogModel({required this.logId, required this.userId, required this.action, required this.entityType, required this.entityId, this.oldValue, this.newValue, this.ipAddress, this.userAgent, required this.timestamp});
|
||||||
|
|
||||||
|
@HiveField(0) final int logId;
|
||||||
|
@HiveField(1) final String userId;
|
||||||
|
@HiveField(2) final String action;
|
||||||
|
@HiveField(3) final String entityType;
|
||||||
|
@HiveField(4) final String entityId;
|
||||||
|
@HiveField(5) final String? oldValue;
|
||||||
|
@HiveField(6) final String? newValue;
|
||||||
|
@HiveField(7) final String? ipAddress;
|
||||||
|
@HiveField(8) final String? userAgent;
|
||||||
|
@HiveField(9) final DateTime timestamp;
|
||||||
|
|
||||||
|
factory AuditLogModel.fromJson(Map<String, dynamic> json) => AuditLogModel(
|
||||||
|
logId: json['log_id'] as int,
|
||||||
|
userId: json['user_id'] as String,
|
||||||
|
action: json['action'] as String,
|
||||||
|
entityType: json['entity_type'] as String,
|
||||||
|
entityId: json['entity_id'] as String,
|
||||||
|
oldValue: json['old_value'] != null ? jsonEncode(json['old_value']) : null,
|
||||||
|
newValue: json['new_value'] != null ? jsonEncode(json['new_value']) : null,
|
||||||
|
ipAddress: json['ip_address'] as String?,
|
||||||
|
userAgent: json['user_agent'] as String?,
|
||||||
|
timestamp: DateTime.parse(json['timestamp'] as String),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'log_id': logId,
|
||||||
|
'user_id': userId,
|
||||||
|
'action': action,
|
||||||
|
'entity_type': entityType,
|
||||||
|
'entity_id': entityId,
|
||||||
|
'old_value': oldValue != null ? jsonDecode(oldValue!) : null,
|
||||||
|
'new_value': newValue != null ? jsonDecode(newValue!) : null,
|
||||||
|
'ip_address': ipAddress,
|
||||||
|
'user_agent': userAgent,
|
||||||
|
'timestamp': timestamp.toIso8601String(),
|
||||||
|
};
|
||||||
|
}
|
||||||
68
lib/features/account/data/models/audit_log_model.g.dart
Normal file
68
lib/features/account/data/models/audit_log_model.g.dart
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'audit_log_model.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// TypeAdapterGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
class AuditLogModelAdapter extends TypeAdapter<AuditLogModel> {
|
||||||
|
@override
|
||||||
|
final typeId = 24;
|
||||||
|
|
||||||
|
@override
|
||||||
|
AuditLogModel read(BinaryReader reader) {
|
||||||
|
final numOfFields = reader.readByte();
|
||||||
|
final fields = <int, dynamic>{
|
||||||
|
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||||
|
};
|
||||||
|
return AuditLogModel(
|
||||||
|
logId: (fields[0] as num).toInt(),
|
||||||
|
userId: fields[1] as String,
|
||||||
|
action: fields[2] as String,
|
||||||
|
entityType: fields[3] as String,
|
||||||
|
entityId: fields[4] as String,
|
||||||
|
oldValue: fields[5] as String?,
|
||||||
|
newValue: fields[6] as String?,
|
||||||
|
ipAddress: fields[7] as String?,
|
||||||
|
userAgent: fields[8] as String?,
|
||||||
|
timestamp: fields[9] as DateTime,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void write(BinaryWriter writer, AuditLogModel obj) {
|
||||||
|
writer
|
||||||
|
..writeByte(10)
|
||||||
|
..writeByte(0)
|
||||||
|
..write(obj.logId)
|
||||||
|
..writeByte(1)
|
||||||
|
..write(obj.userId)
|
||||||
|
..writeByte(2)
|
||||||
|
..write(obj.action)
|
||||||
|
..writeByte(3)
|
||||||
|
..write(obj.entityType)
|
||||||
|
..writeByte(4)
|
||||||
|
..write(obj.entityId)
|
||||||
|
..writeByte(5)
|
||||||
|
..write(obj.oldValue)
|
||||||
|
..writeByte(6)
|
||||||
|
..write(obj.newValue)
|
||||||
|
..writeByte(7)
|
||||||
|
..write(obj.ipAddress)
|
||||||
|
..writeByte(8)
|
||||||
|
..write(obj.userAgent)
|
||||||
|
..writeByte(9)
|
||||||
|
..write(obj.timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => typeId.hashCode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
other is AuditLogModelAdapter &&
|
||||||
|
runtimeType == other.runtimeType &&
|
||||||
|
typeId == other.typeId;
|
||||||
|
}
|
||||||
50
lib/features/account/data/models/payment_reminder_model.dart
Normal file
50
lib/features/account/data/models/payment_reminder_model.dart
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import 'package:hive_ce/hive.dart';
|
||||||
|
import 'package:worker/core/constants/storage_constants.dart';
|
||||||
|
import 'package:worker/core/database/models/enums.dart';
|
||||||
|
|
||||||
|
part 'payment_reminder_model.g.dart';
|
||||||
|
|
||||||
|
@HiveType(typeId: HiveTypeIds.paymentReminderModel)
|
||||||
|
class PaymentReminderModel extends HiveObject {
|
||||||
|
PaymentReminderModel({required this.reminderId, required this.invoiceId, required this.userId, required this.reminderType, required this.subject, required this.message, required this.isRead, required this.isSent, this.scheduledAt, this.sentAt, this.readAt});
|
||||||
|
|
||||||
|
@HiveField(0) final String reminderId;
|
||||||
|
@HiveField(1) final String invoiceId;
|
||||||
|
@HiveField(2) final String userId;
|
||||||
|
@HiveField(3) final ReminderType reminderType;
|
||||||
|
@HiveField(4) final String subject;
|
||||||
|
@HiveField(5) final String message;
|
||||||
|
@HiveField(6) final bool isRead;
|
||||||
|
@HiveField(7) final bool isSent;
|
||||||
|
@HiveField(8) final DateTime? scheduledAt;
|
||||||
|
@HiveField(9) final DateTime? sentAt;
|
||||||
|
@HiveField(10) final DateTime? readAt;
|
||||||
|
|
||||||
|
factory PaymentReminderModel.fromJson(Map<String, dynamic> json) => PaymentReminderModel(
|
||||||
|
reminderId: json['reminder_id'] as String,
|
||||||
|
invoiceId: json['invoice_id'] as String,
|
||||||
|
userId: json['user_id'] as String,
|
||||||
|
reminderType: ReminderType.values.firstWhere((e) => e.name == json['reminder_type']),
|
||||||
|
subject: json['subject'] as String,
|
||||||
|
message: json['message'] as String,
|
||||||
|
isRead: json['is_read'] as bool? ?? false,
|
||||||
|
isSent: json['is_sent'] as bool? ?? false,
|
||||||
|
scheduledAt: json['scheduled_at'] != null ? DateTime.parse(json['scheduled_at']?.toString() ?? '') : null,
|
||||||
|
sentAt: json['sent_at'] != null ? DateTime.parse(json['sent_at']?.toString() ?? '') : null,
|
||||||
|
readAt: json['read_at'] != null ? DateTime.parse(json['read_at']?.toString() ?? '') : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'reminder_id': reminderId,
|
||||||
|
'invoice_id': invoiceId,
|
||||||
|
'user_id': userId,
|
||||||
|
'reminder_type': reminderType.name,
|
||||||
|
'subject': subject,
|
||||||
|
'message': message,
|
||||||
|
'is_read': isRead,
|
||||||
|
'is_sent': isSent,
|
||||||
|
'scheduled_at': scheduledAt?.toIso8601String(),
|
||||||
|
'sent_at': sentAt?.toIso8601String(),
|
||||||
|
'read_at': readAt?.toIso8601String(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'payment_reminder_model.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// TypeAdapterGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
class PaymentReminderModelAdapter extends TypeAdapter<PaymentReminderModel> {
|
||||||
|
@override
|
||||||
|
final typeId = 23;
|
||||||
|
|
||||||
|
@override
|
||||||
|
PaymentReminderModel read(BinaryReader reader) {
|
||||||
|
final numOfFields = reader.readByte();
|
||||||
|
final fields = <int, dynamic>{
|
||||||
|
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||||
|
};
|
||||||
|
return PaymentReminderModel(
|
||||||
|
reminderId: fields[0] as String,
|
||||||
|
invoiceId: fields[1] as String,
|
||||||
|
userId: fields[2] as String,
|
||||||
|
reminderType: fields[3] as ReminderType,
|
||||||
|
subject: fields[4] as String,
|
||||||
|
message: fields[5] as String,
|
||||||
|
isRead: fields[6] as bool,
|
||||||
|
isSent: fields[7] as bool,
|
||||||
|
scheduledAt: fields[8] as DateTime?,
|
||||||
|
sentAt: fields[9] as DateTime?,
|
||||||
|
readAt: fields[10] as DateTime?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void write(BinaryWriter writer, PaymentReminderModel obj) {
|
||||||
|
writer
|
||||||
|
..writeByte(11)
|
||||||
|
..writeByte(0)
|
||||||
|
..write(obj.reminderId)
|
||||||
|
..writeByte(1)
|
||||||
|
..write(obj.invoiceId)
|
||||||
|
..writeByte(2)
|
||||||
|
..write(obj.userId)
|
||||||
|
..writeByte(3)
|
||||||
|
..write(obj.reminderType)
|
||||||
|
..writeByte(4)
|
||||||
|
..write(obj.subject)
|
||||||
|
..writeByte(5)
|
||||||
|
..write(obj.message)
|
||||||
|
..writeByte(6)
|
||||||
|
..write(obj.isRead)
|
||||||
|
..writeByte(7)
|
||||||
|
..write(obj.isSent)
|
||||||
|
..writeByte(8)
|
||||||
|
..write(obj.scheduledAt)
|
||||||
|
..writeByte(9)
|
||||||
|
..write(obj.sentAt)
|
||||||
|
..writeByte(10)
|
||||||
|
..write(obj.readAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => typeId.hashCode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
other is PaymentReminderModelAdapter &&
|
||||||
|
runtimeType == other.runtimeType &&
|
||||||
|
typeId == other.typeId;
|
||||||
|
}
|
||||||
151
lib/features/account/domain/entities/audit_log.dart
Normal file
151
lib/features/account/domain/entities/audit_log.dart
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
/// Domain Entity: Audit Log
|
||||||
|
///
|
||||||
|
/// Represents an audit trail entry for system activities.
|
||||||
|
library;
|
||||||
|
|
||||||
|
/// Audit Log Entity
|
||||||
|
///
|
||||||
|
/// Contains information about a system action:
|
||||||
|
/// - User and action details
|
||||||
|
/// - Entity affected
|
||||||
|
/// - Change tracking
|
||||||
|
/// - Session information
|
||||||
|
class AuditLog {
|
||||||
|
/// Unique log identifier
|
||||||
|
final String logId;
|
||||||
|
|
||||||
|
/// User ID who performed the action
|
||||||
|
final String? userId;
|
||||||
|
|
||||||
|
/// Action performed (create, update, delete, login, etc.)
|
||||||
|
final String action;
|
||||||
|
|
||||||
|
/// Entity type affected (user, order, product, etc.)
|
||||||
|
final String? entityType;
|
||||||
|
|
||||||
|
/// Entity ID affected
|
||||||
|
final String? entityId;
|
||||||
|
|
||||||
|
/// Old value (before change)
|
||||||
|
final Map<String, dynamic>? oldValue;
|
||||||
|
|
||||||
|
/// New value (after change)
|
||||||
|
final Map<String, dynamic>? newValue;
|
||||||
|
|
||||||
|
/// IP address of the user
|
||||||
|
final String? ipAddress;
|
||||||
|
|
||||||
|
/// User agent string
|
||||||
|
final String? userAgent;
|
||||||
|
|
||||||
|
/// Timestamp of the action
|
||||||
|
final DateTime timestamp;
|
||||||
|
|
||||||
|
const AuditLog({
|
||||||
|
required this.logId,
|
||||||
|
this.userId,
|
||||||
|
required this.action,
|
||||||
|
this.entityType,
|
||||||
|
this.entityId,
|
||||||
|
this.oldValue,
|
||||||
|
this.newValue,
|
||||||
|
this.ipAddress,
|
||||||
|
this.userAgent,
|
||||||
|
required this.timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Check if log has old value
|
||||||
|
bool get hasOldValue => oldValue != null && oldValue!.isNotEmpty;
|
||||||
|
|
||||||
|
/// Check if log has new value
|
||||||
|
bool get hasNewValue => newValue != null && newValue!.isNotEmpty;
|
||||||
|
|
||||||
|
/// Check if action is create
|
||||||
|
bool get isCreate => action.toLowerCase() == 'create';
|
||||||
|
|
||||||
|
/// Check if action is update
|
||||||
|
bool get isUpdate => action.toLowerCase() == 'update';
|
||||||
|
|
||||||
|
/// Check if action is delete
|
||||||
|
bool get isDelete => action.toLowerCase() == 'delete';
|
||||||
|
|
||||||
|
/// Check if action is login
|
||||||
|
bool get isLogin => action.toLowerCase() == 'login';
|
||||||
|
|
||||||
|
/// Check if action is logout
|
||||||
|
bool get isLogout => action.toLowerCase() == 'logout';
|
||||||
|
|
||||||
|
/// Get changed fields
|
||||||
|
List<String> get changedFields {
|
||||||
|
if (!hasOldValue || !hasNewValue) return [];
|
||||||
|
|
||||||
|
final changed = <String>[];
|
||||||
|
for (final key in newValue!.keys) {
|
||||||
|
if (oldValue!.containsKey(key) && oldValue![key] != newValue![key]) {
|
||||||
|
changed.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get time since action
|
||||||
|
Duration get timeSinceAction {
|
||||||
|
return DateTime.now().difference(timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy with method for immutability
|
||||||
|
AuditLog copyWith({
|
||||||
|
String? logId,
|
||||||
|
String? userId,
|
||||||
|
String? action,
|
||||||
|
String? entityType,
|
||||||
|
String? entityId,
|
||||||
|
Map<String, dynamic>? oldValue,
|
||||||
|
Map<String, dynamic>? newValue,
|
||||||
|
String? ipAddress,
|
||||||
|
String? userAgent,
|
||||||
|
DateTime? timestamp,
|
||||||
|
}) {
|
||||||
|
return AuditLog(
|
||||||
|
logId: logId ?? this.logId,
|
||||||
|
userId: userId ?? this.userId,
|
||||||
|
action: action ?? this.action,
|
||||||
|
entityType: entityType ?? this.entityType,
|
||||||
|
entityId: entityId ?? this.entityId,
|
||||||
|
oldValue: oldValue ?? this.oldValue,
|
||||||
|
newValue: newValue ?? this.newValue,
|
||||||
|
ipAddress: ipAddress ?? this.ipAddress,
|
||||||
|
userAgent: userAgent ?? this.userAgent,
|
||||||
|
timestamp: timestamp ?? this.timestamp,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (identical(this, other)) return true;
|
||||||
|
|
||||||
|
return other is AuditLog &&
|
||||||
|
other.logId == logId &&
|
||||||
|
other.userId == userId &&
|
||||||
|
other.action == action &&
|
||||||
|
other.entityType == entityType &&
|
||||||
|
other.entityId == entityId;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode {
|
||||||
|
return Object.hash(
|
||||||
|
logId,
|
||||||
|
userId,
|
||||||
|
action,
|
||||||
|
entityType,
|
||||||
|
entityId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'AuditLog(logId: $logId, userId: $userId, action: $action, '
|
||||||
|
'entityType: $entityType, entityId: $entityId, timestamp: $timestamp)';
|
||||||
|
}
|
||||||
|
}
|
||||||
179
lib/features/account/domain/entities/payment_reminder.dart
Normal file
179
lib/features/account/domain/entities/payment_reminder.dart
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
/// Domain Entity: Payment Reminder
|
||||||
|
///
|
||||||
|
/// Represents a payment reminder for an unpaid invoice.
|
||||||
|
library;
|
||||||
|
|
||||||
|
/// Reminder type enum
|
||||||
|
enum ReminderType {
|
||||||
|
/// Initial reminder before due date
|
||||||
|
initial,
|
||||||
|
|
||||||
|
/// Reminder on due date
|
||||||
|
dueDate,
|
||||||
|
|
||||||
|
/// First reminder after due date
|
||||||
|
firstOverdue,
|
||||||
|
|
||||||
|
/// Second reminder after due date
|
||||||
|
secondOverdue,
|
||||||
|
|
||||||
|
/// Final warning
|
||||||
|
finalWarning;
|
||||||
|
|
||||||
|
/// Get display name for reminder type
|
||||||
|
String get displayName {
|
||||||
|
switch (this) {
|
||||||
|
case ReminderType.initial:
|
||||||
|
return 'Initial Reminder';
|
||||||
|
case ReminderType.dueDate:
|
||||||
|
return 'Due Date Reminder';
|
||||||
|
case ReminderType.firstOverdue:
|
||||||
|
return 'First Overdue';
|
||||||
|
case ReminderType.secondOverdue:
|
||||||
|
return 'Second Overdue';
|
||||||
|
case ReminderType.finalWarning:
|
||||||
|
return 'Final Warning';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Payment Reminder Entity
|
||||||
|
///
|
||||||
|
/// Contains information about a payment reminder:
|
||||||
|
/// - Invoice reference
|
||||||
|
/// - Reminder content
|
||||||
|
/// - Delivery status
|
||||||
|
/// - Scheduling
|
||||||
|
class PaymentReminder {
|
||||||
|
/// Unique reminder identifier
|
||||||
|
final String reminderId;
|
||||||
|
|
||||||
|
/// Invoice ID this reminder is for
|
||||||
|
final String invoiceId;
|
||||||
|
|
||||||
|
/// User ID receiving the reminder
|
||||||
|
final String userId;
|
||||||
|
|
||||||
|
/// Reminder type
|
||||||
|
final ReminderType reminderType;
|
||||||
|
|
||||||
|
/// Reminder subject
|
||||||
|
final String subject;
|
||||||
|
|
||||||
|
/// Reminder message
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
/// Reminder has been read
|
||||||
|
final bool isRead;
|
||||||
|
|
||||||
|
/// Reminder has been sent
|
||||||
|
final bool isSent;
|
||||||
|
|
||||||
|
/// Scheduled send timestamp
|
||||||
|
final DateTime? scheduledAt;
|
||||||
|
|
||||||
|
/// Actual send timestamp
|
||||||
|
final DateTime? sentAt;
|
||||||
|
|
||||||
|
/// Read timestamp
|
||||||
|
final DateTime? readAt;
|
||||||
|
|
||||||
|
const PaymentReminder({
|
||||||
|
required this.reminderId,
|
||||||
|
required this.invoiceId,
|
||||||
|
required this.userId,
|
||||||
|
required this.reminderType,
|
||||||
|
required this.subject,
|
||||||
|
required this.message,
|
||||||
|
required this.isRead,
|
||||||
|
required this.isSent,
|
||||||
|
this.scheduledAt,
|
||||||
|
this.sentAt,
|
||||||
|
this.readAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Check if reminder is pending (scheduled but not sent)
|
||||||
|
bool get isPending => !isSent && scheduledAt != null;
|
||||||
|
|
||||||
|
/// Check if reminder is overdue to be sent
|
||||||
|
bool get isOverdueToSend {
|
||||||
|
if (isSent || scheduledAt == null) return false;
|
||||||
|
return DateTime.now().isAfter(scheduledAt!);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if reminder is unread
|
||||||
|
bool get isUnread => !isRead;
|
||||||
|
|
||||||
|
/// Get time until scheduled send
|
||||||
|
Duration? get timeUntilSend {
|
||||||
|
if (scheduledAt == null || isSent) return null;
|
||||||
|
final duration = scheduledAt!.difference(DateTime.now());
|
||||||
|
return duration.isNegative ? null : duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get time since sent
|
||||||
|
Duration? get timeSinceSent {
|
||||||
|
if (sentAt == null) return null;
|
||||||
|
return DateTime.now().difference(sentAt!);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy with method for immutability
|
||||||
|
PaymentReminder copyWith({
|
||||||
|
String? reminderId,
|
||||||
|
String? invoiceId,
|
||||||
|
String? userId,
|
||||||
|
ReminderType? reminderType,
|
||||||
|
String? subject,
|
||||||
|
String? message,
|
||||||
|
bool? isRead,
|
||||||
|
bool? isSent,
|
||||||
|
DateTime? scheduledAt,
|
||||||
|
DateTime? sentAt,
|
||||||
|
DateTime? readAt,
|
||||||
|
}) {
|
||||||
|
return PaymentReminder(
|
||||||
|
reminderId: reminderId ?? this.reminderId,
|
||||||
|
invoiceId: invoiceId ?? this.invoiceId,
|
||||||
|
userId: userId ?? this.userId,
|
||||||
|
reminderType: reminderType ?? this.reminderType,
|
||||||
|
subject: subject ?? this.subject,
|
||||||
|
message: message ?? this.message,
|
||||||
|
isRead: isRead ?? this.isRead,
|
||||||
|
isSent: isSent ?? this.isSent,
|
||||||
|
scheduledAt: scheduledAt ?? this.scheduledAt,
|
||||||
|
sentAt: sentAt ?? this.sentAt,
|
||||||
|
readAt: readAt ?? this.readAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (identical(this, other)) return true;
|
||||||
|
|
||||||
|
return other is PaymentReminder &&
|
||||||
|
other.reminderId == reminderId &&
|
||||||
|
other.invoiceId == invoiceId &&
|
||||||
|
other.userId == userId &&
|
||||||
|
other.reminderType == reminderType &&
|
||||||
|
other.isRead == isRead &&
|
||||||
|
other.isSent == isSent;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode {
|
||||||
|
return Object.hash(
|
||||||
|
reminderId,
|
||||||
|
invoiceId,
|
||||||
|
userId,
|
||||||
|
reminderType,
|
||||||
|
isRead,
|
||||||
|
isSent,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'PaymentReminder(reminderId: $reminderId, invoiceId: $invoiceId, '
|
||||||
|
'reminderType: $reminderType, isSent: $isSent, isRead: $isRead)';
|
||||||
|
}
|
||||||
|
}
|
||||||
300
lib/features/auth/data/models/user_model.dart
Normal file
300
lib/features/auth/data/models/user_model.dart
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:hive_ce/hive.dart';
|
||||||
|
|
||||||
|
import 'package:worker/core/constants/storage_constants.dart';
|
||||||
|
import 'package:worker/core/database/models/enums.dart';
|
||||||
|
|
||||||
|
part 'user_model.g.dart';
|
||||||
|
|
||||||
|
/// User Model
|
||||||
|
///
|
||||||
|
/// Hive CE model for caching user data locally.
|
||||||
|
/// Maps to the 'users' table in the database.
|
||||||
|
///
|
||||||
|
/// Type ID: 0
|
||||||
|
@HiveType(typeId: HiveTypeIds.userModel)
|
||||||
|
class UserModel extends HiveObject {
|
||||||
|
UserModel({
|
||||||
|
required this.userId,
|
||||||
|
required this.phoneNumber,
|
||||||
|
this.passwordHash,
|
||||||
|
required this.fullName,
|
||||||
|
this.email,
|
||||||
|
required this.role,
|
||||||
|
required this.status,
|
||||||
|
required this.loyaltyTier,
|
||||||
|
required this.totalPoints,
|
||||||
|
this.companyInfo,
|
||||||
|
this.cccd,
|
||||||
|
this.attachments,
|
||||||
|
this.address,
|
||||||
|
this.avatarUrl,
|
||||||
|
this.referralCode,
|
||||||
|
this.referredBy,
|
||||||
|
this.erpnextCustomerId,
|
||||||
|
required this.createdAt,
|
||||||
|
this.updatedAt,
|
||||||
|
this.lastLoginAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// User ID (Primary Key)
|
||||||
|
@HiveField(0)
|
||||||
|
final String userId;
|
||||||
|
|
||||||
|
/// Phone number (unique, used for login)
|
||||||
|
@HiveField(1)
|
||||||
|
final String phoneNumber;
|
||||||
|
|
||||||
|
/// Password hash (stored encrypted)
|
||||||
|
@HiveField(2)
|
||||||
|
final String? passwordHash;
|
||||||
|
|
||||||
|
/// Full name of the user
|
||||||
|
@HiveField(3)
|
||||||
|
final String fullName;
|
||||||
|
|
||||||
|
/// Email address
|
||||||
|
@HiveField(4)
|
||||||
|
final String? email;
|
||||||
|
|
||||||
|
/// User role (customer, distributor, admin, staff)
|
||||||
|
@HiveField(5)
|
||||||
|
final UserRole role;
|
||||||
|
|
||||||
|
/// Account status (active, inactive, suspended, pending)
|
||||||
|
@HiveField(6)
|
||||||
|
final UserStatus status;
|
||||||
|
|
||||||
|
/// Loyalty tier (bronze, silver, gold, platinum, diamond, titan)
|
||||||
|
@HiveField(7)
|
||||||
|
final LoyaltyTier loyaltyTier;
|
||||||
|
|
||||||
|
/// Total accumulated loyalty points
|
||||||
|
@HiveField(8)
|
||||||
|
final int totalPoints;
|
||||||
|
|
||||||
|
/// Company information (JSON encoded)
|
||||||
|
/// Contains: company_name, tax_id, business_type, etc.
|
||||||
|
@HiveField(9)
|
||||||
|
final String? companyInfo;
|
||||||
|
|
||||||
|
/// Citizen ID (CCCD/CMND)
|
||||||
|
@HiveField(10)
|
||||||
|
final String? cccd;
|
||||||
|
|
||||||
|
/// Attachments (JSON encoded list)
|
||||||
|
/// Contains: identity_card_images, business_license, etc.
|
||||||
|
@HiveField(11)
|
||||||
|
final String? attachments;
|
||||||
|
|
||||||
|
/// Address
|
||||||
|
@HiveField(12)
|
||||||
|
final String? address;
|
||||||
|
|
||||||
|
/// Avatar URL
|
||||||
|
@HiveField(13)
|
||||||
|
final String? avatarUrl;
|
||||||
|
|
||||||
|
/// Referral code for this user
|
||||||
|
@HiveField(14)
|
||||||
|
final String? referralCode;
|
||||||
|
|
||||||
|
/// ID of user who referred this user
|
||||||
|
@HiveField(15)
|
||||||
|
final String? referredBy;
|
||||||
|
|
||||||
|
/// ERPNext customer ID for integration
|
||||||
|
@HiveField(16)
|
||||||
|
final String? erpnextCustomerId;
|
||||||
|
|
||||||
|
/// Account creation timestamp
|
||||||
|
@HiveField(17)
|
||||||
|
final DateTime createdAt;
|
||||||
|
|
||||||
|
/// Last update timestamp
|
||||||
|
@HiveField(18)
|
||||||
|
final DateTime? updatedAt;
|
||||||
|
|
||||||
|
/// Last login timestamp
|
||||||
|
@HiveField(19)
|
||||||
|
final DateTime? lastLoginAt;
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// JSON SERIALIZATION
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/// Create UserModel from JSON
|
||||||
|
factory UserModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
return UserModel(
|
||||||
|
userId: json['user_id'] as String,
|
||||||
|
phoneNumber: json['phone_number'] as String,
|
||||||
|
passwordHash: json['password_hash'] as String?,
|
||||||
|
fullName: json['full_name'] as String,
|
||||||
|
email: json['email'] as String?,
|
||||||
|
role: UserRole.values.firstWhere(
|
||||||
|
(e) => e.name == (json['role'] as String),
|
||||||
|
orElse: () => UserRole.customer,
|
||||||
|
),
|
||||||
|
status: UserStatus.values.firstWhere(
|
||||||
|
(e) => e.name == (json['status'] as String),
|
||||||
|
orElse: () => UserStatus.pending,
|
||||||
|
),
|
||||||
|
loyaltyTier: LoyaltyTier.values.firstWhere(
|
||||||
|
(e) => e.name == (json['loyalty_tier'] as String),
|
||||||
|
orElse: () => LoyaltyTier.bronze,
|
||||||
|
),
|
||||||
|
totalPoints: json['total_points'] as int? ?? 0,
|
||||||
|
companyInfo: json['company_info'] != null
|
||||||
|
? jsonEncode(json['company_info'])
|
||||||
|
: null,
|
||||||
|
cccd: json['cccd'] as String?,
|
||||||
|
attachments: json['attachments'] != null
|
||||||
|
? jsonEncode(json['attachments'])
|
||||||
|
: null,
|
||||||
|
address: json['address'] as String?,
|
||||||
|
avatarUrl: json['avatar_url'] as String?,
|
||||||
|
referralCode: json['referral_code'] as String?,
|
||||||
|
referredBy: json['referred_by'] as String?,
|
||||||
|
erpnextCustomerId: json['erpnext_customer_id'] as String?,
|
||||||
|
createdAt: DateTime.parse(json['created_at'] as String),
|
||||||
|
updatedAt: json['updated_at'] != null
|
||||||
|
? DateTime.parse(json['updated_at'] as String)
|
||||||
|
: null,
|
||||||
|
lastLoginAt: json['last_login_at'] != null
|
||||||
|
? DateTime.parse(json['last_login_at'] as String)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert UserModel to JSON
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'user_id': userId,
|
||||||
|
'phone_number': phoneNumber,
|
||||||
|
'password_hash': passwordHash,
|
||||||
|
'full_name': fullName,
|
||||||
|
'email': email,
|
||||||
|
'role': role.name,
|
||||||
|
'status': status.name,
|
||||||
|
'loyalty_tier': loyaltyTier.name,
|
||||||
|
'total_points': totalPoints,
|
||||||
|
'company_info': companyInfo != null ? jsonDecode(companyInfo!) : null,
|
||||||
|
'cccd': cccd,
|
||||||
|
'attachments': attachments != null ? jsonDecode(attachments!) : null,
|
||||||
|
'address': address,
|
||||||
|
'avatar_url': avatarUrl,
|
||||||
|
'referral_code': referralCode,
|
||||||
|
'referred_by': referredBy,
|
||||||
|
'erpnext_customer_id': erpnextCustomerId,
|
||||||
|
'created_at': createdAt.toIso8601String(),
|
||||||
|
'updated_at': updatedAt?.toIso8601String(),
|
||||||
|
'last_login_at': lastLoginAt?.toIso8601String(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// HELPER METHODS
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/// Get company info as Map
|
||||||
|
Map<String, dynamic>? get companyInfoMap {
|
||||||
|
if (companyInfo == null) return null;
|
||||||
|
try {
|
||||||
|
return jsonDecode(companyInfo!) as Map<String, dynamic>;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get attachments as List
|
||||||
|
List<dynamic>? get attachmentsList {
|
||||||
|
if (attachments == null) return null;
|
||||||
|
try {
|
||||||
|
return jsonDecode(attachments!) as List<dynamic>;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if user is active
|
||||||
|
bool get isActive => status == UserStatus.active;
|
||||||
|
|
||||||
|
/// Check if user is admin or staff
|
||||||
|
bool get isStaff => role == UserRole.admin || role == UserRole.staff;
|
||||||
|
|
||||||
|
/// Get user initials for avatar
|
||||||
|
String get initials {
|
||||||
|
final parts = fullName.trim().split(' ');
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
return '${parts.first[0]}${parts.last[0]}'.toUpperCase();
|
||||||
|
}
|
||||||
|
return fullName.isNotEmpty ? fullName[0].toUpperCase() : '?';
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// COPY WITH
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/// Create a copy with updated fields
|
||||||
|
UserModel copyWith({
|
||||||
|
String? userId,
|
||||||
|
String? phoneNumber,
|
||||||
|
String? passwordHash,
|
||||||
|
String? fullName,
|
||||||
|
String? email,
|
||||||
|
UserRole? role,
|
||||||
|
UserStatus? status,
|
||||||
|
LoyaltyTier? loyaltyTier,
|
||||||
|
int? totalPoints,
|
||||||
|
String? companyInfo,
|
||||||
|
String? cccd,
|
||||||
|
String? attachments,
|
||||||
|
String? address,
|
||||||
|
String? avatarUrl,
|
||||||
|
String? referralCode,
|
||||||
|
String? referredBy,
|
||||||
|
String? erpnextCustomerId,
|
||||||
|
DateTime? createdAt,
|
||||||
|
DateTime? updatedAt,
|
||||||
|
DateTime? lastLoginAt,
|
||||||
|
}) {
|
||||||
|
return UserModel(
|
||||||
|
userId: userId ?? this.userId,
|
||||||
|
phoneNumber: phoneNumber ?? this.phoneNumber,
|
||||||
|
passwordHash: passwordHash ?? this.passwordHash,
|
||||||
|
fullName: fullName ?? this.fullName,
|
||||||
|
email: email ?? this.email,
|
||||||
|
role: role ?? this.role,
|
||||||
|
status: status ?? this.status,
|
||||||
|
loyaltyTier: loyaltyTier ?? this.loyaltyTier,
|
||||||
|
totalPoints: totalPoints ?? this.totalPoints,
|
||||||
|
companyInfo: companyInfo ?? this.companyInfo,
|
||||||
|
cccd: cccd ?? this.cccd,
|
||||||
|
attachments: attachments ?? this.attachments,
|
||||||
|
address: address ?? this.address,
|
||||||
|
avatarUrl: avatarUrl ?? this.avatarUrl,
|
||||||
|
referralCode: referralCode ?? this.referralCode,
|
||||||
|
referredBy: referredBy ?? this.referredBy,
|
||||||
|
erpnextCustomerId: erpnextCustomerId ?? this.erpnextCustomerId,
|
||||||
|
createdAt: createdAt ?? this.createdAt,
|
||||||
|
updatedAt: updatedAt ?? this.updatedAt,
|
||||||
|
lastLoginAt: lastLoginAt ?? this.lastLoginAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'UserModel(userId: $userId, fullName: $fullName, role: $role, tier: $loyaltyTier, points: $totalPoints)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (identical(this, other)) return true;
|
||||||
|
|
||||||
|
return other is UserModel && other.userId == userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => userId.hashCode;
|
||||||
|
}
|
||||||
98
lib/features/auth/data/models/user_model.g.dart
Normal file
98
lib/features/auth/data/models/user_model.g.dart
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'user_model.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// TypeAdapterGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
class UserModelAdapter extends TypeAdapter<UserModel> {
|
||||||
|
@override
|
||||||
|
final typeId = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
UserModel read(BinaryReader reader) {
|
||||||
|
final numOfFields = reader.readByte();
|
||||||
|
final fields = <int, dynamic>{
|
||||||
|
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||||
|
};
|
||||||
|
return UserModel(
|
||||||
|
userId: fields[0] as String,
|
||||||
|
phoneNumber: fields[1] as String,
|
||||||
|
passwordHash: fields[2] as String?,
|
||||||
|
fullName: fields[3] as String,
|
||||||
|
email: fields[4] as String?,
|
||||||
|
role: fields[5] as UserRole,
|
||||||
|
status: fields[6] as UserStatus,
|
||||||
|
loyaltyTier: fields[7] as LoyaltyTier,
|
||||||
|
totalPoints: (fields[8] as num).toInt(),
|
||||||
|
companyInfo: fields[9] as String?,
|
||||||
|
cccd: fields[10] as String?,
|
||||||
|
attachments: fields[11] as String?,
|
||||||
|
address: fields[12] as String?,
|
||||||
|
avatarUrl: fields[13] as String?,
|
||||||
|
referralCode: fields[14] as String?,
|
||||||
|
referredBy: fields[15] as String?,
|
||||||
|
erpnextCustomerId: fields[16] as String?,
|
||||||
|
createdAt: fields[17] as DateTime,
|
||||||
|
updatedAt: fields[18] as DateTime?,
|
||||||
|
lastLoginAt: fields[19] as DateTime?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void write(BinaryWriter writer, UserModel obj) {
|
||||||
|
writer
|
||||||
|
..writeByte(20)
|
||||||
|
..writeByte(0)
|
||||||
|
..write(obj.userId)
|
||||||
|
..writeByte(1)
|
||||||
|
..write(obj.phoneNumber)
|
||||||
|
..writeByte(2)
|
||||||
|
..write(obj.passwordHash)
|
||||||
|
..writeByte(3)
|
||||||
|
..write(obj.fullName)
|
||||||
|
..writeByte(4)
|
||||||
|
..write(obj.email)
|
||||||
|
..writeByte(5)
|
||||||
|
..write(obj.role)
|
||||||
|
..writeByte(6)
|
||||||
|
..write(obj.status)
|
||||||
|
..writeByte(7)
|
||||||
|
..write(obj.loyaltyTier)
|
||||||
|
..writeByte(8)
|
||||||
|
..write(obj.totalPoints)
|
||||||
|
..writeByte(9)
|
||||||
|
..write(obj.companyInfo)
|
||||||
|
..writeByte(10)
|
||||||
|
..write(obj.cccd)
|
||||||
|
..writeByte(11)
|
||||||
|
..write(obj.attachments)
|
||||||
|
..writeByte(12)
|
||||||
|
..write(obj.address)
|
||||||
|
..writeByte(13)
|
||||||
|
..write(obj.avatarUrl)
|
||||||
|
..writeByte(14)
|
||||||
|
..write(obj.referralCode)
|
||||||
|
..writeByte(15)
|
||||||
|
..write(obj.referredBy)
|
||||||
|
..writeByte(16)
|
||||||
|
..write(obj.erpnextCustomerId)
|
||||||
|
..writeByte(17)
|
||||||
|
..write(obj.createdAt)
|
||||||
|
..writeByte(18)
|
||||||
|
..write(obj.updatedAt)
|
||||||
|
..writeByte(19)
|
||||||
|
..write(obj.lastLoginAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => typeId.hashCode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
other is UserModelAdapter &&
|
||||||
|
runtimeType == other.runtimeType &&
|
||||||
|
typeId == other.typeId;
|
||||||
|
}
|
||||||
185
lib/features/auth/data/models/user_session_model.dart
Normal file
185
lib/features/auth/data/models/user_session_model.dart
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import 'package:hive_ce/hive.dart';
|
||||||
|
|
||||||
|
import 'package:worker/core/constants/storage_constants.dart';
|
||||||
|
|
||||||
|
part 'user_session_model.g.dart';
|
||||||
|
|
||||||
|
/// User Session Model
|
||||||
|
///
|
||||||
|
/// Hive CE model for caching user session data locally.
|
||||||
|
/// Maps to the 'user_sessions' table in the database.
|
||||||
|
///
|
||||||
|
/// Type ID: 1
|
||||||
|
@HiveType(typeId: HiveTypeIds.userSessionModel)
|
||||||
|
class UserSessionModel extends HiveObject {
|
||||||
|
UserSessionModel({
|
||||||
|
required this.sessionId,
|
||||||
|
required this.userId,
|
||||||
|
required this.deviceId,
|
||||||
|
this.deviceType,
|
||||||
|
this.deviceName,
|
||||||
|
this.ipAddress,
|
||||||
|
this.userAgent,
|
||||||
|
this.refreshToken,
|
||||||
|
required this.expiresAt,
|
||||||
|
required this.createdAt,
|
||||||
|
this.lastActivity,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Session ID (Primary Key)
|
||||||
|
@HiveField(0)
|
||||||
|
final String sessionId;
|
||||||
|
|
||||||
|
/// User ID (Foreign Key to users)
|
||||||
|
@HiveField(1)
|
||||||
|
final String userId;
|
||||||
|
|
||||||
|
/// Device ID (unique identifier for the device)
|
||||||
|
@HiveField(2)
|
||||||
|
final String deviceId;
|
||||||
|
|
||||||
|
/// Device type (android, ios, web, etc.)
|
||||||
|
@HiveField(3)
|
||||||
|
final String? deviceType;
|
||||||
|
|
||||||
|
/// Device name (e.g., "Samsung Galaxy S21")
|
||||||
|
@HiveField(4)
|
||||||
|
final String? deviceName;
|
||||||
|
|
||||||
|
/// IP address of the device
|
||||||
|
@HiveField(5)
|
||||||
|
final String? ipAddress;
|
||||||
|
|
||||||
|
/// User agent string
|
||||||
|
@HiveField(6)
|
||||||
|
final String? userAgent;
|
||||||
|
|
||||||
|
/// Refresh token for session renewal
|
||||||
|
@HiveField(7)
|
||||||
|
final String? refreshToken;
|
||||||
|
|
||||||
|
/// Session expiration timestamp
|
||||||
|
@HiveField(8)
|
||||||
|
final DateTime expiresAt;
|
||||||
|
|
||||||
|
/// Session creation timestamp
|
||||||
|
@HiveField(9)
|
||||||
|
final DateTime createdAt;
|
||||||
|
|
||||||
|
/// Last activity timestamp
|
||||||
|
@HiveField(10)
|
||||||
|
final DateTime? lastActivity;
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// JSON SERIALIZATION
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/// Create UserSessionModel from JSON
|
||||||
|
factory UserSessionModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
return UserSessionModel(
|
||||||
|
sessionId: json['session_id'] as String,
|
||||||
|
userId: json['user_id'] as String,
|
||||||
|
deviceId: json['device_id'] as String,
|
||||||
|
deviceType: json['device_type'] as String?,
|
||||||
|
deviceName: json['device_name'] as String?,
|
||||||
|
ipAddress: json['ip_address'] as String?,
|
||||||
|
userAgent: json['user_agent'] as String?,
|
||||||
|
refreshToken: json['refresh_token'] as String?,
|
||||||
|
expiresAt: DateTime.parse(json['expires_at'] as String),
|
||||||
|
createdAt: DateTime.parse(json['created_at'] as String),
|
||||||
|
lastActivity: json['last_activity'] != null
|
||||||
|
? DateTime.parse(json['last_activity'] as String)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert UserSessionModel to JSON
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'session_id': sessionId,
|
||||||
|
'user_id': userId,
|
||||||
|
'device_id': deviceId,
|
||||||
|
'device_type': deviceType,
|
||||||
|
'device_name': deviceName,
|
||||||
|
'ip_address': ipAddress,
|
||||||
|
'user_agent': userAgent,
|
||||||
|
'refresh_token': refreshToken,
|
||||||
|
'expires_at': expiresAt.toIso8601String(),
|
||||||
|
'created_at': createdAt.toIso8601String(),
|
||||||
|
'last_activity': lastActivity?.toIso8601String(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// HELPER METHODS
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/// Check if session is expired
|
||||||
|
bool get isExpired => DateTime.now().isAfter(expiresAt);
|
||||||
|
|
||||||
|
/// Check if session is valid (not expired)
|
||||||
|
bool get isValid => !isExpired;
|
||||||
|
|
||||||
|
/// Get session duration
|
||||||
|
Duration get duration => DateTime.now().difference(createdAt);
|
||||||
|
|
||||||
|
/// Get time until expiration
|
||||||
|
Duration get timeUntilExpiration => expiresAt.difference(DateTime.now());
|
||||||
|
|
||||||
|
/// Get session age
|
||||||
|
Duration get age => DateTime.now().difference(createdAt);
|
||||||
|
|
||||||
|
/// Get time since last activity
|
||||||
|
Duration? get timeSinceLastActivity {
|
||||||
|
if (lastActivity == null) return null;
|
||||||
|
return DateTime.now().difference(lastActivity!);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// COPY WITH
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/// Create a copy with updated fields
|
||||||
|
UserSessionModel copyWith({
|
||||||
|
String? sessionId,
|
||||||
|
String? userId,
|
||||||
|
String? deviceId,
|
||||||
|
String? deviceType,
|
||||||
|
String? deviceName,
|
||||||
|
String? ipAddress,
|
||||||
|
String? userAgent,
|
||||||
|
String? refreshToken,
|
||||||
|
DateTime? expiresAt,
|
||||||
|
DateTime? createdAt,
|
||||||
|
DateTime? lastActivity,
|
||||||
|
}) {
|
||||||
|
return UserSessionModel(
|
||||||
|
sessionId: sessionId ?? this.sessionId,
|
||||||
|
userId: userId ?? this.userId,
|
||||||
|
deviceId: deviceId ?? this.deviceId,
|
||||||
|
deviceType: deviceType ?? this.deviceType,
|
||||||
|
deviceName: deviceName ?? this.deviceName,
|
||||||
|
ipAddress: ipAddress ?? this.ipAddress,
|
||||||
|
userAgent: userAgent ?? this.userAgent,
|
||||||
|
refreshToken: refreshToken ?? this.refreshToken,
|
||||||
|
expiresAt: expiresAt ?? this.expiresAt,
|
||||||
|
createdAt: createdAt ?? this.createdAt,
|
||||||
|
lastActivity: lastActivity ?? this.lastActivity,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'UserSessionModel(sessionId: $sessionId, userId: $userId, isValid: $isValid)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (identical(this, other)) return true;
|
||||||
|
|
||||||
|
return other is UserSessionModel && other.sessionId == sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => sessionId.hashCode;
|
||||||
|
}
|
||||||
71
lib/features/auth/data/models/user_session_model.g.dart
Normal file
71
lib/features/auth/data/models/user_session_model.g.dart
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'user_session_model.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// TypeAdapterGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
class UserSessionModelAdapter extends TypeAdapter<UserSessionModel> {
|
||||||
|
@override
|
||||||
|
final typeId = 1;
|
||||||
|
|
||||||
|
@override
|
||||||
|
UserSessionModel read(BinaryReader reader) {
|
||||||
|
final numOfFields = reader.readByte();
|
||||||
|
final fields = <int, dynamic>{
|
||||||
|
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||||
|
};
|
||||||
|
return UserSessionModel(
|
||||||
|
sessionId: fields[0] as String,
|
||||||
|
userId: fields[1] as String,
|
||||||
|
deviceId: fields[2] as String,
|
||||||
|
deviceType: fields[3] as String?,
|
||||||
|
deviceName: fields[4] as String?,
|
||||||
|
ipAddress: fields[5] as String?,
|
||||||
|
userAgent: fields[6] as String?,
|
||||||
|
refreshToken: fields[7] as String?,
|
||||||
|
expiresAt: fields[8] as DateTime,
|
||||||
|
createdAt: fields[9] as DateTime,
|
||||||
|
lastActivity: fields[10] as DateTime?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void write(BinaryWriter writer, UserSessionModel obj) {
|
||||||
|
writer
|
||||||
|
..writeByte(11)
|
||||||
|
..writeByte(0)
|
||||||
|
..write(obj.sessionId)
|
||||||
|
..writeByte(1)
|
||||||
|
..write(obj.userId)
|
||||||
|
..writeByte(2)
|
||||||
|
..write(obj.deviceId)
|
||||||
|
..writeByte(3)
|
||||||
|
..write(obj.deviceType)
|
||||||
|
..writeByte(4)
|
||||||
|
..write(obj.deviceName)
|
||||||
|
..writeByte(5)
|
||||||
|
..write(obj.ipAddress)
|
||||||
|
..writeByte(6)
|
||||||
|
..write(obj.userAgent)
|
||||||
|
..writeByte(7)
|
||||||
|
..write(obj.refreshToken)
|
||||||
|
..writeByte(8)
|
||||||
|
..write(obj.expiresAt)
|
||||||
|
..writeByte(9)
|
||||||
|
..write(obj.createdAt)
|
||||||
|
..writeByte(10)
|
||||||
|
..write(obj.lastActivity);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => typeId.hashCode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
other is UserSessionModelAdapter &&
|
||||||
|
runtimeType == other.runtimeType &&
|
||||||
|
typeId == other.typeId;
|
||||||
|
}
|
||||||
314
lib/features/auth/domain/entities/user.dart
Normal file
314
lib/features/auth/domain/entities/user.dart
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
/// Domain Entity: User
|
||||||
|
///
|
||||||
|
/// Represents a user account in the Worker application.
|
||||||
|
/// Contains authentication, profile, and loyalty information.
|
||||||
|
library;
|
||||||
|
|
||||||
|
/// User role enum
|
||||||
|
enum UserRole {
|
||||||
|
/// Customer/worker user
|
||||||
|
customer,
|
||||||
|
|
||||||
|
/// Sales representative
|
||||||
|
sales,
|
||||||
|
|
||||||
|
/// System administrator
|
||||||
|
admin,
|
||||||
|
|
||||||
|
/// Accountant
|
||||||
|
accountant,
|
||||||
|
|
||||||
|
/// Designer
|
||||||
|
designer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// User status enum
|
||||||
|
enum UserStatus {
|
||||||
|
/// Account pending approval
|
||||||
|
pending,
|
||||||
|
|
||||||
|
/// Active account
|
||||||
|
active,
|
||||||
|
|
||||||
|
/// Suspended account
|
||||||
|
suspended,
|
||||||
|
|
||||||
|
/// Rejected account
|
||||||
|
rejected;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loyalty tier enum
|
||||||
|
enum LoyaltyTier {
|
||||||
|
/// No tier
|
||||||
|
none,
|
||||||
|
|
||||||
|
/// Gold tier (entry level)
|
||||||
|
gold,
|
||||||
|
|
||||||
|
/// Platinum tier (mid level)
|
||||||
|
platinum,
|
||||||
|
|
||||||
|
/// Diamond tier (highest level)
|
||||||
|
diamond;
|
||||||
|
|
||||||
|
/// Get display name for tier
|
||||||
|
String get displayName {
|
||||||
|
switch (this) {
|
||||||
|
case LoyaltyTier.none:
|
||||||
|
return 'NONE';
|
||||||
|
case LoyaltyTier.gold:
|
||||||
|
return 'GOLD';
|
||||||
|
case LoyaltyTier.platinum:
|
||||||
|
return 'PLATINUM';
|
||||||
|
case LoyaltyTier.diamond:
|
||||||
|
return 'DIAMOND';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Company information
|
||||||
|
class CompanyInfo {
|
||||||
|
/// Company name
|
||||||
|
final String? name;
|
||||||
|
|
||||||
|
/// Tax identification number
|
||||||
|
final String? taxId;
|
||||||
|
|
||||||
|
/// Company address
|
||||||
|
final String? address;
|
||||||
|
|
||||||
|
/// Business type
|
||||||
|
final String? businessType;
|
||||||
|
|
||||||
|
/// Business license number
|
||||||
|
final String? licenseNumber;
|
||||||
|
|
||||||
|
const CompanyInfo({
|
||||||
|
this.name,
|
||||||
|
this.taxId,
|
||||||
|
this.address,
|
||||||
|
this.businessType,
|
||||||
|
this.licenseNumber,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Create from JSON map
|
||||||
|
factory CompanyInfo.fromJson(Map<String, dynamic> json) {
|
||||||
|
return CompanyInfo(
|
||||||
|
name: json['name'] as String?,
|
||||||
|
taxId: json['tax_id'] as String?,
|
||||||
|
address: json['address'] as String?,
|
||||||
|
businessType: json['business_type'] as String?,
|
||||||
|
licenseNumber: json['license_number'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert to JSON map
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'name': name,
|
||||||
|
'tax_id': taxId,
|
||||||
|
'address': address,
|
||||||
|
'business_type': businessType,
|
||||||
|
'license_number': licenseNumber,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// User Entity
|
||||||
|
///
|
||||||
|
/// Represents a complete user profile including:
|
||||||
|
/// - Authentication credentials
|
||||||
|
/// - Personal information
|
||||||
|
/// - Company details (if applicable)
|
||||||
|
/// - Loyalty program membership
|
||||||
|
/// - Referral information
|
||||||
|
class User {
|
||||||
|
/// Unique user identifier
|
||||||
|
final String userId;
|
||||||
|
|
||||||
|
/// Phone number (used for login)
|
||||||
|
final String phoneNumber;
|
||||||
|
|
||||||
|
/// Full name
|
||||||
|
final String fullName;
|
||||||
|
|
||||||
|
/// Email address
|
||||||
|
final String? email;
|
||||||
|
|
||||||
|
/// User role
|
||||||
|
final UserRole role;
|
||||||
|
|
||||||
|
/// Account status
|
||||||
|
final UserStatus status;
|
||||||
|
|
||||||
|
/// Current loyalty tier
|
||||||
|
final LoyaltyTier loyaltyTier;
|
||||||
|
|
||||||
|
/// Total loyalty points
|
||||||
|
final int totalPoints;
|
||||||
|
|
||||||
|
/// Company information (optional)
|
||||||
|
final CompanyInfo? companyInfo;
|
||||||
|
|
||||||
|
/// CCCD/ID card number
|
||||||
|
final String? cccd;
|
||||||
|
|
||||||
|
/// Attachment URLs (ID cards, licenses, etc.)
|
||||||
|
final List<String> attachments;
|
||||||
|
|
||||||
|
/// Address
|
||||||
|
final String? address;
|
||||||
|
|
||||||
|
/// Avatar URL
|
||||||
|
final String? avatarUrl;
|
||||||
|
|
||||||
|
/// Referral code (unique for this user)
|
||||||
|
final String? referralCode;
|
||||||
|
|
||||||
|
/// ID of user who referred this user
|
||||||
|
final String? referredBy;
|
||||||
|
|
||||||
|
/// ERPNext customer ID
|
||||||
|
final String? erpnextCustomerId;
|
||||||
|
|
||||||
|
/// Account creation timestamp
|
||||||
|
final DateTime createdAt;
|
||||||
|
|
||||||
|
/// Last update timestamp
|
||||||
|
final DateTime updatedAt;
|
||||||
|
|
||||||
|
/// Last login timestamp
|
||||||
|
final DateTime? lastLoginAt;
|
||||||
|
|
||||||
|
const User({
|
||||||
|
required this.userId,
|
||||||
|
required this.phoneNumber,
|
||||||
|
required this.fullName,
|
||||||
|
this.email,
|
||||||
|
required this.role,
|
||||||
|
required this.status,
|
||||||
|
required this.loyaltyTier,
|
||||||
|
required this.totalPoints,
|
||||||
|
this.companyInfo,
|
||||||
|
this.cccd,
|
||||||
|
required this.attachments,
|
||||||
|
this.address,
|
||||||
|
this.avatarUrl,
|
||||||
|
this.referralCode,
|
||||||
|
this.referredBy,
|
||||||
|
this.erpnextCustomerId,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.updatedAt,
|
||||||
|
this.lastLoginAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Check if user is active
|
||||||
|
bool get isActive => status == UserStatus.active;
|
||||||
|
|
||||||
|
/// Check if user is pending approval
|
||||||
|
bool get isPending => status == UserStatus.pending;
|
||||||
|
|
||||||
|
/// Check if user has company info
|
||||||
|
bool get hasCompanyInfo => companyInfo != null && companyInfo!.name != null;
|
||||||
|
|
||||||
|
/// Check if user is an admin
|
||||||
|
bool get isAdmin => role == UserRole.admin;
|
||||||
|
|
||||||
|
/// Check if user is a customer
|
||||||
|
bool get isCustomer => role == UserRole.customer;
|
||||||
|
|
||||||
|
/// Get display name for user
|
||||||
|
String get displayName => fullName;
|
||||||
|
|
||||||
|
/// Copy with method for immutability
|
||||||
|
User copyWith({
|
||||||
|
String? userId,
|
||||||
|
String? phoneNumber,
|
||||||
|
String? fullName,
|
||||||
|
String? email,
|
||||||
|
UserRole? role,
|
||||||
|
UserStatus? status,
|
||||||
|
LoyaltyTier? loyaltyTier,
|
||||||
|
int? totalPoints,
|
||||||
|
CompanyInfo? companyInfo,
|
||||||
|
String? cccd,
|
||||||
|
List<String>? attachments,
|
||||||
|
String? address,
|
||||||
|
String? avatarUrl,
|
||||||
|
String? referralCode,
|
||||||
|
String? referredBy,
|
||||||
|
String? erpnextCustomerId,
|
||||||
|
DateTime? createdAt,
|
||||||
|
DateTime? updatedAt,
|
||||||
|
DateTime? lastLoginAt,
|
||||||
|
}) {
|
||||||
|
return User(
|
||||||
|
userId: userId ?? this.userId,
|
||||||
|
phoneNumber: phoneNumber ?? this.phoneNumber,
|
||||||
|
fullName: fullName ?? this.fullName,
|
||||||
|
email: email ?? this.email,
|
||||||
|
role: role ?? this.role,
|
||||||
|
status: status ?? this.status,
|
||||||
|
loyaltyTier: loyaltyTier ?? this.loyaltyTier,
|
||||||
|
totalPoints: totalPoints ?? this.totalPoints,
|
||||||
|
companyInfo: companyInfo ?? this.companyInfo,
|
||||||
|
cccd: cccd ?? this.cccd,
|
||||||
|
attachments: attachments ?? this.attachments,
|
||||||
|
address: address ?? this.address,
|
||||||
|
avatarUrl: avatarUrl ?? this.avatarUrl,
|
||||||
|
referralCode: referralCode ?? this.referralCode,
|
||||||
|
referredBy: referredBy ?? this.referredBy,
|
||||||
|
erpnextCustomerId: erpnextCustomerId ?? this.erpnextCustomerId,
|
||||||
|
createdAt: createdAt ?? this.createdAt,
|
||||||
|
updatedAt: updatedAt ?? this.updatedAt,
|
||||||
|
lastLoginAt: lastLoginAt ?? this.lastLoginAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (identical(this, other)) return true;
|
||||||
|
|
||||||
|
return other is User &&
|
||||||
|
other.userId == userId &&
|
||||||
|
other.phoneNumber == phoneNumber &&
|
||||||
|
other.fullName == fullName &&
|
||||||
|
other.email == email &&
|
||||||
|
other.role == role &&
|
||||||
|
other.status == status &&
|
||||||
|
other.loyaltyTier == loyaltyTier &&
|
||||||
|
other.totalPoints == totalPoints &&
|
||||||
|
other.cccd == cccd &&
|
||||||
|
other.address == address &&
|
||||||
|
other.avatarUrl == avatarUrl &&
|
||||||
|
other.referralCode == referralCode &&
|
||||||
|
other.referredBy == referredBy &&
|
||||||
|
other.erpnextCustomerId == erpnextCustomerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode {
|
||||||
|
return Object.hash(
|
||||||
|
userId,
|
||||||
|
phoneNumber,
|
||||||
|
fullName,
|
||||||
|
email,
|
||||||
|
role,
|
||||||
|
status,
|
||||||
|
loyaltyTier,
|
||||||
|
totalPoints,
|
||||||
|
cccd,
|
||||||
|
address,
|
||||||
|
avatarUrl,
|
||||||
|
referralCode,
|
||||||
|
referredBy,
|
||||||
|
erpnextCustomerId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'User(userId: $userId, phoneNumber: $phoneNumber, fullName: $fullName, '
|
||||||
|
'role: $role, status: $status, loyaltyTier: $loyaltyTier, totalPoints: $totalPoints)';
|
||||||
|
}
|
||||||
|
}
|
||||||
142
lib/features/auth/domain/entities/user_session.dart
Normal file
142
lib/features/auth/domain/entities/user_session.dart
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
/// Domain Entity: User Session
|
||||||
|
///
|
||||||
|
/// Represents an active user session with device and authentication information.
|
||||||
|
library;
|
||||||
|
|
||||||
|
/// User Session Entity
|
||||||
|
///
|
||||||
|
/// Contains information about an active user session:
|
||||||
|
/// - Device details
|
||||||
|
/// - Authentication tokens
|
||||||
|
/// - Session timing
|
||||||
|
class UserSession {
|
||||||
|
/// Unique session identifier
|
||||||
|
final String sessionId;
|
||||||
|
|
||||||
|
/// User ID associated with this session
|
||||||
|
final String userId;
|
||||||
|
|
||||||
|
/// Unique device identifier
|
||||||
|
final String deviceId;
|
||||||
|
|
||||||
|
/// Device type (ios, android, web)
|
||||||
|
final String? deviceType;
|
||||||
|
|
||||||
|
/// Device name
|
||||||
|
final String? deviceName;
|
||||||
|
|
||||||
|
/// IP address
|
||||||
|
final String? ipAddress;
|
||||||
|
|
||||||
|
/// User agent string
|
||||||
|
final String? userAgent;
|
||||||
|
|
||||||
|
/// Refresh token for renewing access
|
||||||
|
final String? refreshToken;
|
||||||
|
|
||||||
|
/// Session expiration timestamp
|
||||||
|
final DateTime expiresAt;
|
||||||
|
|
||||||
|
/// Session creation timestamp
|
||||||
|
final DateTime createdAt;
|
||||||
|
|
||||||
|
/// Last activity timestamp
|
||||||
|
final DateTime? lastActivity;
|
||||||
|
|
||||||
|
const UserSession({
|
||||||
|
required this.sessionId,
|
||||||
|
required this.userId,
|
||||||
|
required this.deviceId,
|
||||||
|
this.deviceType,
|
||||||
|
this.deviceName,
|
||||||
|
this.ipAddress,
|
||||||
|
this.userAgent,
|
||||||
|
this.refreshToken,
|
||||||
|
required this.expiresAt,
|
||||||
|
required this.createdAt,
|
||||||
|
this.lastActivity,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Check if session is expired
|
||||||
|
bool get isExpired => DateTime.now().isAfter(expiresAt);
|
||||||
|
|
||||||
|
/// Check if session is expiring soon (within 1 hour)
|
||||||
|
bool get isExpiringSoon {
|
||||||
|
final hoursUntilExpiry = expiresAt.difference(DateTime.now()).inHours;
|
||||||
|
return hoursUntilExpiry > 0 && hoursUntilExpiry <= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if session is active
|
||||||
|
bool get isActive => !isExpired;
|
||||||
|
|
||||||
|
/// Get device display name
|
||||||
|
String get deviceDisplayName => deviceName ?? deviceType ?? 'Unknown Device';
|
||||||
|
|
||||||
|
/// Get time since last activity
|
||||||
|
Duration? get timeSinceLastActivity {
|
||||||
|
if (lastActivity == null) return null;
|
||||||
|
return DateTime.now().difference(lastActivity!);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy with method for immutability
|
||||||
|
UserSession copyWith({
|
||||||
|
String? sessionId,
|
||||||
|
String? userId,
|
||||||
|
String? deviceId,
|
||||||
|
String? deviceType,
|
||||||
|
String? deviceName,
|
||||||
|
String? ipAddress,
|
||||||
|
String? userAgent,
|
||||||
|
String? refreshToken,
|
||||||
|
DateTime? expiresAt,
|
||||||
|
DateTime? createdAt,
|
||||||
|
DateTime? lastActivity,
|
||||||
|
}) {
|
||||||
|
return UserSession(
|
||||||
|
sessionId: sessionId ?? this.sessionId,
|
||||||
|
userId: userId ?? this.userId,
|
||||||
|
deviceId: deviceId ?? this.deviceId,
|
||||||
|
deviceType: deviceType ?? this.deviceType,
|
||||||
|
deviceName: deviceName ?? this.deviceName,
|
||||||
|
ipAddress: ipAddress ?? this.ipAddress,
|
||||||
|
userAgent: userAgent ?? this.userAgent,
|
||||||
|
refreshToken: refreshToken ?? this.refreshToken,
|
||||||
|
expiresAt: expiresAt ?? this.expiresAt,
|
||||||
|
createdAt: createdAt ?? this.createdAt,
|
||||||
|
lastActivity: lastActivity ?? this.lastActivity,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (identical(this, other)) return true;
|
||||||
|
|
||||||
|
return other is UserSession &&
|
||||||
|
other.sessionId == sessionId &&
|
||||||
|
other.userId == userId &&
|
||||||
|
other.deviceId == deviceId &&
|
||||||
|
other.deviceType == deviceType &&
|
||||||
|
other.deviceName == deviceName &&
|
||||||
|
other.ipAddress == ipAddress &&
|
||||||
|
other.refreshToken == refreshToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode {
|
||||||
|
return Object.hash(
|
||||||
|
sessionId,
|
||||||
|
userId,
|
||||||
|
deviceId,
|
||||||
|
deviceType,
|
||||||
|
deviceName,
|
||||||
|
ipAddress,
|
||||||
|
refreshToken,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'UserSession(sessionId: $sessionId, userId: $userId, deviceId: $deviceId, '
|
||||||
|
'deviceType: $deviceType, expiresAt: $expiresAt, isActive: $isActive)';
|
||||||
|
}
|
||||||
|
}
|
||||||
79
lib/features/cart/data/models/cart_item_model.dart
Normal file
79
lib/features/cart/data/models/cart_item_model.dart
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import 'package:hive_ce/hive.dart';
|
||||||
|
import 'package:worker/core/constants/storage_constants.dart';
|
||||||
|
|
||||||
|
part 'cart_item_model.g.dart';
|
||||||
|
|
||||||
|
/// Cart Item Model - Type ID: 5
|
||||||
|
@HiveType(typeId: HiveTypeIds.cartItemModel)
|
||||||
|
class CartItemModel extends HiveObject {
|
||||||
|
CartItemModel({
|
||||||
|
required this.cartItemId,
|
||||||
|
required this.cartId,
|
||||||
|
required this.productId,
|
||||||
|
required this.quantity,
|
||||||
|
required this.unitPrice,
|
||||||
|
required this.subtotal,
|
||||||
|
required this.addedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
@HiveField(0)
|
||||||
|
final String cartItemId;
|
||||||
|
|
||||||
|
@HiveField(1)
|
||||||
|
final String cartId;
|
||||||
|
|
||||||
|
@HiveField(2)
|
||||||
|
final String productId;
|
||||||
|
|
||||||
|
@HiveField(3)
|
||||||
|
final double quantity;
|
||||||
|
|
||||||
|
@HiveField(4)
|
||||||
|
final double unitPrice;
|
||||||
|
|
||||||
|
@HiveField(5)
|
||||||
|
final double subtotal;
|
||||||
|
|
||||||
|
@HiveField(6)
|
||||||
|
final DateTime addedAt;
|
||||||
|
|
||||||
|
factory CartItemModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
return CartItemModel(
|
||||||
|
cartItemId: json['cart_item_id'] as String,
|
||||||
|
cartId: json['cart_id'] as String,
|
||||||
|
productId: json['product_id'] as String,
|
||||||
|
quantity: (json['quantity'] as num).toDouble(),
|
||||||
|
unitPrice: (json['unit_price'] as num).toDouble(),
|
||||||
|
subtotal: (json['subtotal'] as num).toDouble(),
|
||||||
|
addedAt: DateTime.parse(json['added_at'] as String),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'cart_item_id': cartItemId,
|
||||||
|
'cart_id': cartId,
|
||||||
|
'product_id': productId,
|
||||||
|
'quantity': quantity,
|
||||||
|
'unit_price': unitPrice,
|
||||||
|
'subtotal': subtotal,
|
||||||
|
'added_at': addedAt.toIso8601String(),
|
||||||
|
};
|
||||||
|
|
||||||
|
CartItemModel copyWith({
|
||||||
|
String? cartItemId,
|
||||||
|
String? cartId,
|
||||||
|
String? productId,
|
||||||
|
double? quantity,
|
||||||
|
double? unitPrice,
|
||||||
|
double? subtotal,
|
||||||
|
DateTime? addedAt,
|
||||||
|
}) => CartItemModel(
|
||||||
|
cartItemId: cartItemId ?? this.cartItemId,
|
||||||
|
cartId: cartId ?? this.cartId,
|
||||||
|
productId: productId ?? this.productId,
|
||||||
|
quantity: quantity ?? this.quantity,
|
||||||
|
unitPrice: unitPrice ?? this.unitPrice,
|
||||||
|
subtotal: subtotal ?? this.subtotal,
|
||||||
|
addedAt: addedAt ?? this.addedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
59
lib/features/cart/data/models/cart_item_model.g.dart
Normal file
59
lib/features/cart/data/models/cart_item_model.g.dart
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'cart_item_model.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// TypeAdapterGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
class CartItemModelAdapter extends TypeAdapter<CartItemModel> {
|
||||||
|
@override
|
||||||
|
final typeId = 5;
|
||||||
|
|
||||||
|
@override
|
||||||
|
CartItemModel read(BinaryReader reader) {
|
||||||
|
final numOfFields = reader.readByte();
|
||||||
|
final fields = <int, dynamic>{
|
||||||
|
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||||
|
};
|
||||||
|
return CartItemModel(
|
||||||
|
cartItemId: fields[0] as String,
|
||||||
|
cartId: fields[1] as String,
|
||||||
|
productId: fields[2] as String,
|
||||||
|
quantity: (fields[3] as num).toDouble(),
|
||||||
|
unitPrice: (fields[4] as num).toDouble(),
|
||||||
|
subtotal: (fields[5] as num).toDouble(),
|
||||||
|
addedAt: fields[6] as DateTime,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void write(BinaryWriter writer, CartItemModel obj) {
|
||||||
|
writer
|
||||||
|
..writeByte(7)
|
||||||
|
..writeByte(0)
|
||||||
|
..write(obj.cartItemId)
|
||||||
|
..writeByte(1)
|
||||||
|
..write(obj.cartId)
|
||||||
|
..writeByte(2)
|
||||||
|
..write(obj.productId)
|
||||||
|
..writeByte(3)
|
||||||
|
..write(obj.quantity)
|
||||||
|
..writeByte(4)
|
||||||
|
..write(obj.unitPrice)
|
||||||
|
..writeByte(5)
|
||||||
|
..write(obj.subtotal)
|
||||||
|
..writeByte(6)
|
||||||
|
..write(obj.addedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => typeId.hashCode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
other is CartItemModelAdapter &&
|
||||||
|
runtimeType == other.runtimeType &&
|
||||||
|
typeId == other.typeId;
|
||||||
|
}
|
||||||
71
lib/features/cart/data/models/cart_model.dart
Normal file
71
lib/features/cart/data/models/cart_model.dart
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import 'package:hive_ce/hive.dart';
|
||||||
|
import 'package:worker/core/constants/storage_constants.dart';
|
||||||
|
|
||||||
|
part 'cart_model.g.dart';
|
||||||
|
|
||||||
|
/// Cart Model - Type ID: 4
|
||||||
|
@HiveType(typeId: HiveTypeIds.cartModel)
|
||||||
|
class CartModel extends HiveObject {
|
||||||
|
CartModel({
|
||||||
|
required this.cartId,
|
||||||
|
required this.userId,
|
||||||
|
required this.totalAmount,
|
||||||
|
required this.isSynced,
|
||||||
|
required this.lastModified,
|
||||||
|
required this.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
@HiveField(0)
|
||||||
|
final String cartId;
|
||||||
|
|
||||||
|
@HiveField(1)
|
||||||
|
final String userId;
|
||||||
|
|
||||||
|
@HiveField(2)
|
||||||
|
final double totalAmount;
|
||||||
|
|
||||||
|
@HiveField(3)
|
||||||
|
final bool isSynced;
|
||||||
|
|
||||||
|
@HiveField(4)
|
||||||
|
final DateTime lastModified;
|
||||||
|
|
||||||
|
@HiveField(5)
|
||||||
|
final DateTime createdAt;
|
||||||
|
|
||||||
|
factory CartModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
return CartModel(
|
||||||
|
cartId: json['cart_id'] as String,
|
||||||
|
userId: json['user_id'] as String,
|
||||||
|
totalAmount: (json['total_amount'] as num).toDouble(),
|
||||||
|
isSynced: json['is_synced'] as bool? ?? false,
|
||||||
|
lastModified: DateTime.parse(json['last_modified'] as String),
|
||||||
|
createdAt: DateTime.parse(json['created_at'] as String),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'cart_id': cartId,
|
||||||
|
'user_id': userId,
|
||||||
|
'total_amount': totalAmount,
|
||||||
|
'is_synced': isSynced,
|
||||||
|
'last_modified': lastModified.toIso8601String(),
|
||||||
|
'created_at': createdAt.toIso8601String(),
|
||||||
|
};
|
||||||
|
|
||||||
|
CartModel copyWith({
|
||||||
|
String? cartId,
|
||||||
|
String? userId,
|
||||||
|
double? totalAmount,
|
||||||
|
bool? isSynced,
|
||||||
|
DateTime? lastModified,
|
||||||
|
DateTime? createdAt,
|
||||||
|
}) => CartModel(
|
||||||
|
cartId: cartId ?? this.cartId,
|
||||||
|
userId: userId ?? this.userId,
|
||||||
|
totalAmount: totalAmount ?? this.totalAmount,
|
||||||
|
isSynced: isSynced ?? this.isSynced,
|
||||||
|
lastModified: lastModified ?? this.lastModified,
|
||||||
|
createdAt: createdAt ?? this.createdAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
56
lib/features/cart/data/models/cart_model.g.dart
Normal file
56
lib/features/cart/data/models/cart_model.g.dart
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'cart_model.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// TypeAdapterGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
class CartModelAdapter extends TypeAdapter<CartModel> {
|
||||||
|
@override
|
||||||
|
final typeId = 4;
|
||||||
|
|
||||||
|
@override
|
||||||
|
CartModel read(BinaryReader reader) {
|
||||||
|
final numOfFields = reader.readByte();
|
||||||
|
final fields = <int, dynamic>{
|
||||||
|
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||||
|
};
|
||||||
|
return CartModel(
|
||||||
|
cartId: fields[0] as String,
|
||||||
|
userId: fields[1] as String,
|
||||||
|
totalAmount: (fields[2] as num).toDouble(),
|
||||||
|
isSynced: fields[3] as bool,
|
||||||
|
lastModified: fields[4] as DateTime,
|
||||||
|
createdAt: fields[5] as DateTime,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void write(BinaryWriter writer, CartModel obj) {
|
||||||
|
writer
|
||||||
|
..writeByte(6)
|
||||||
|
..writeByte(0)
|
||||||
|
..write(obj.cartId)
|
||||||
|
..writeByte(1)
|
||||||
|
..write(obj.userId)
|
||||||
|
..writeByte(2)
|
||||||
|
..write(obj.totalAmount)
|
||||||
|
..writeByte(3)
|
||||||
|
..write(obj.isSynced)
|
||||||
|
..writeByte(4)
|
||||||
|
..write(obj.lastModified)
|
||||||
|
..writeByte(5)
|
||||||
|
..write(obj.createdAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => typeId.hashCode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
other is CartModelAdapter &&
|
||||||
|
runtimeType == other.runtimeType &&
|
||||||
|
typeId == other.typeId;
|
||||||
|
}
|
||||||
92
lib/features/cart/domain/entities/cart.dart
Normal file
92
lib/features/cart/domain/entities/cart.dart
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
/// Domain Entity: Cart
|
||||||
|
///
|
||||||
|
/// Represents a shopping cart for a user.
|
||||||
|
library;
|
||||||
|
|
||||||
|
/// Cart Entity
|
||||||
|
///
|
||||||
|
/// Contains cart-level information:
|
||||||
|
/// - User ID
|
||||||
|
/// - Total amount
|
||||||
|
/// - Sync status
|
||||||
|
/// - Timestamps
|
||||||
|
class Cart {
|
||||||
|
/// Unique cart identifier
|
||||||
|
final String cartId;
|
||||||
|
|
||||||
|
/// User ID who owns this cart
|
||||||
|
final String userId;
|
||||||
|
|
||||||
|
/// Total cart amount
|
||||||
|
final double totalAmount;
|
||||||
|
|
||||||
|
/// Whether cart is synced with backend
|
||||||
|
final bool isSynced;
|
||||||
|
|
||||||
|
/// Last modification timestamp
|
||||||
|
final DateTime lastModified;
|
||||||
|
|
||||||
|
/// Cart creation timestamp
|
||||||
|
final DateTime createdAt;
|
||||||
|
|
||||||
|
const Cart({
|
||||||
|
required this.cartId,
|
||||||
|
required this.userId,
|
||||||
|
required this.totalAmount,
|
||||||
|
required this.isSynced,
|
||||||
|
required this.lastModified,
|
||||||
|
required this.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Check if cart is empty
|
||||||
|
bool get isEmpty => totalAmount == 0;
|
||||||
|
|
||||||
|
/// Check if cart needs sync
|
||||||
|
bool get needsSync => !isSynced;
|
||||||
|
|
||||||
|
/// Copy with method for immutability
|
||||||
|
Cart copyWith({
|
||||||
|
String? cartId,
|
||||||
|
String? userId,
|
||||||
|
double? totalAmount,
|
||||||
|
bool? isSynced,
|
||||||
|
DateTime? lastModified,
|
||||||
|
DateTime? createdAt,
|
||||||
|
}) {
|
||||||
|
return Cart(
|
||||||
|
cartId: cartId ?? this.cartId,
|
||||||
|
userId: userId ?? this.userId,
|
||||||
|
totalAmount: totalAmount ?? this.totalAmount,
|
||||||
|
isSynced: isSynced ?? this.isSynced,
|
||||||
|
lastModified: lastModified ?? this.lastModified,
|
||||||
|
createdAt: createdAt ?? this.createdAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (identical(this, other)) return true;
|
||||||
|
|
||||||
|
return other is Cart &&
|
||||||
|
other.cartId == cartId &&
|
||||||
|
other.userId == userId &&
|
||||||
|
other.totalAmount == totalAmount &&
|
||||||
|
other.isSynced == isSynced;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode {
|
||||||
|
return Object.hash(
|
||||||
|
cartId,
|
||||||
|
userId,
|
||||||
|
totalAmount,
|
||||||
|
isSynced,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'Cart(cartId: $cartId, userId: $userId, totalAmount: $totalAmount, '
|
||||||
|
'isSynced: $isSynced, lastModified: $lastModified)';
|
||||||
|
}
|
||||||
|
}
|
||||||
98
lib/features/cart/domain/entities/cart_item.dart
Normal file
98
lib/features/cart/domain/entities/cart_item.dart
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
/// Domain Entity: Cart Item
|
||||||
|
///
|
||||||
|
/// Represents a single item in a shopping cart.
|
||||||
|
library;
|
||||||
|
|
||||||
|
/// Cart Item Entity
|
||||||
|
///
|
||||||
|
/// Contains item-level information:
|
||||||
|
/// - Product reference
|
||||||
|
/// - Quantity
|
||||||
|
/// - Pricing
|
||||||
|
class CartItem {
|
||||||
|
/// Unique cart item identifier
|
||||||
|
final String cartItemId;
|
||||||
|
|
||||||
|
/// Cart ID this item belongs to
|
||||||
|
final String cartId;
|
||||||
|
|
||||||
|
/// Product ID
|
||||||
|
final String productId;
|
||||||
|
|
||||||
|
/// Quantity ordered
|
||||||
|
final double quantity;
|
||||||
|
|
||||||
|
/// Unit price at time of adding to cart
|
||||||
|
final double unitPrice;
|
||||||
|
|
||||||
|
/// Subtotal (quantity * unitPrice)
|
||||||
|
final double subtotal;
|
||||||
|
|
||||||
|
/// Timestamp when item was added
|
||||||
|
final DateTime addedAt;
|
||||||
|
|
||||||
|
const CartItem({
|
||||||
|
required this.cartItemId,
|
||||||
|
required this.cartId,
|
||||||
|
required this.productId,
|
||||||
|
required this.quantity,
|
||||||
|
required this.unitPrice,
|
||||||
|
required this.subtotal,
|
||||||
|
required this.addedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Calculate subtotal (for verification)
|
||||||
|
double get calculatedSubtotal => quantity * unitPrice;
|
||||||
|
|
||||||
|
/// Copy with method for immutability
|
||||||
|
CartItem copyWith({
|
||||||
|
String? cartItemId,
|
||||||
|
String? cartId,
|
||||||
|
String? productId,
|
||||||
|
double? quantity,
|
||||||
|
double? unitPrice,
|
||||||
|
double? subtotal,
|
||||||
|
DateTime? addedAt,
|
||||||
|
}) {
|
||||||
|
return CartItem(
|
||||||
|
cartItemId: cartItemId ?? this.cartItemId,
|
||||||
|
cartId: cartId ?? this.cartId,
|
||||||
|
productId: productId ?? this.productId,
|
||||||
|
quantity: quantity ?? this.quantity,
|
||||||
|
unitPrice: unitPrice ?? this.unitPrice,
|
||||||
|
subtotal: subtotal ?? this.subtotal,
|
||||||
|
addedAt: addedAt ?? this.addedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (identical(this, other)) return true;
|
||||||
|
|
||||||
|
return other is CartItem &&
|
||||||
|
other.cartItemId == cartItemId &&
|
||||||
|
other.cartId == cartId &&
|
||||||
|
other.productId == productId &&
|
||||||
|
other.quantity == quantity &&
|
||||||
|
other.unitPrice == unitPrice &&
|
||||||
|
other.subtotal == subtotal;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode {
|
||||||
|
return Object.hash(
|
||||||
|
cartItemId,
|
||||||
|
cartId,
|
||||||
|
productId,
|
||||||
|
quantity,
|
||||||
|
unitPrice,
|
||||||
|
subtotal,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'CartItem(cartItemId: $cartItemId, productId: $productId, '
|
||||||
|
'quantity: $quantity, unitPrice: $unitPrice, subtotal: $subtotal)';
|
||||||
|
}
|
||||||
|
}
|
||||||
57
lib/features/chat/data/models/chat_room_model.dart
Normal file
57
lib/features/chat/data/models/chat_room_model.dart
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:hive_ce/hive.dart';
|
||||||
|
import 'package:worker/core/constants/storage_constants.dart';
|
||||||
|
import 'package:worker/core/database/models/enums.dart';
|
||||||
|
|
||||||
|
part 'chat_room_model.g.dart';
|
||||||
|
|
||||||
|
@HiveType(typeId: HiveTypeIds.chatRoomModel)
|
||||||
|
class ChatRoomModel extends HiveObject {
|
||||||
|
ChatRoomModel({required this.chatRoomId, required this.roomType, this.relatedQuoteId, this.relatedOrderId, required this.participants, this.roomName, required this.isActive, this.lastActivity, required this.createdAt, this.createdBy});
|
||||||
|
|
||||||
|
@HiveField(0) final String chatRoomId;
|
||||||
|
@HiveField(1) final RoomType roomType;
|
||||||
|
@HiveField(2) final String? relatedQuoteId;
|
||||||
|
@HiveField(3) final String? relatedOrderId;
|
||||||
|
@HiveField(4) final String participants;
|
||||||
|
@HiveField(5) final String? roomName;
|
||||||
|
@HiveField(6) final bool isActive;
|
||||||
|
@HiveField(7) final DateTime? lastActivity;
|
||||||
|
@HiveField(8) final DateTime createdAt;
|
||||||
|
@HiveField(9) final String? createdBy;
|
||||||
|
|
||||||
|
factory ChatRoomModel.fromJson(Map<String, dynamic> json) => ChatRoomModel(
|
||||||
|
chatRoomId: json['chat_room_id'] as String,
|
||||||
|
roomType: RoomType.values.firstWhere((e) => e.name == json['room_type']),
|
||||||
|
relatedQuoteId: json['related_quote_id'] as String?,
|
||||||
|
relatedOrderId: json['related_order_id'] as String?,
|
||||||
|
participants: jsonEncode(json['participants']),
|
||||||
|
roomName: json['room_name'] as String?,
|
||||||
|
isActive: json['is_active'] as bool? ?? true,
|
||||||
|
lastActivity: json['last_activity'] != null ? DateTime.parse(json['last_activity']?.toString() ?? '') : null,
|
||||||
|
createdAt: DateTime.parse(json['created_at']?.toString() ?? ''),
|
||||||
|
createdBy: json['created_by'] as String?,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'chat_room_id': chatRoomId,
|
||||||
|
'room_type': roomType.name,
|
||||||
|
'related_quote_id': relatedQuoteId,
|
||||||
|
'related_order_id': relatedOrderId,
|
||||||
|
'participants': jsonDecode(participants),
|
||||||
|
'room_name': roomName,
|
||||||
|
'is_active': isActive,
|
||||||
|
'last_activity': lastActivity?.toIso8601String(),
|
||||||
|
'created_at': createdAt.toIso8601String(),
|
||||||
|
'created_by': createdBy,
|
||||||
|
};
|
||||||
|
|
||||||
|
List<String>? get participantsList {
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(participants) as List;
|
||||||
|
return decoded.map((e) => e.toString()).toList();
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
68
lib/features/chat/data/models/chat_room_model.g.dart
Normal file
68
lib/features/chat/data/models/chat_room_model.g.dart
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'chat_room_model.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// TypeAdapterGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
class ChatRoomModelAdapter extends TypeAdapter<ChatRoomModel> {
|
||||||
|
@override
|
||||||
|
final typeId = 18;
|
||||||
|
|
||||||
|
@override
|
||||||
|
ChatRoomModel read(BinaryReader reader) {
|
||||||
|
final numOfFields = reader.readByte();
|
||||||
|
final fields = <int, dynamic>{
|
||||||
|
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||||
|
};
|
||||||
|
return ChatRoomModel(
|
||||||
|
chatRoomId: fields[0] as String,
|
||||||
|
roomType: fields[1] as RoomType,
|
||||||
|
relatedQuoteId: fields[2] as String?,
|
||||||
|
relatedOrderId: fields[3] as String?,
|
||||||
|
participants: fields[4] as String,
|
||||||
|
roomName: fields[5] as String?,
|
||||||
|
isActive: fields[6] as bool,
|
||||||
|
lastActivity: fields[7] as DateTime?,
|
||||||
|
createdAt: fields[8] as DateTime,
|
||||||
|
createdBy: fields[9] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void write(BinaryWriter writer, ChatRoomModel obj) {
|
||||||
|
writer
|
||||||
|
..writeByte(10)
|
||||||
|
..writeByte(0)
|
||||||
|
..write(obj.chatRoomId)
|
||||||
|
..writeByte(1)
|
||||||
|
..write(obj.roomType)
|
||||||
|
..writeByte(2)
|
||||||
|
..write(obj.relatedQuoteId)
|
||||||
|
..writeByte(3)
|
||||||
|
..write(obj.relatedOrderId)
|
||||||
|
..writeByte(4)
|
||||||
|
..write(obj.participants)
|
||||||
|
..writeByte(5)
|
||||||
|
..write(obj.roomName)
|
||||||
|
..writeByte(6)
|
||||||
|
..write(obj.isActive)
|
||||||
|
..writeByte(7)
|
||||||
|
..write(obj.lastActivity)
|
||||||
|
..writeByte(8)
|
||||||
|
..write(obj.createdAt)
|
||||||
|
..writeByte(9)
|
||||||
|
..write(obj.createdBy);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => typeId.hashCode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
other is ChatRoomModelAdapter &&
|
||||||
|
runtimeType == other.runtimeType &&
|
||||||
|
typeId == other.typeId;
|
||||||
|
}
|
||||||
67
lib/features/chat/data/models/message_model.dart
Normal file
67
lib/features/chat/data/models/message_model.dart
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:hive_ce/hive.dart';
|
||||||
|
import 'package:worker/core/constants/storage_constants.dart';
|
||||||
|
import 'package:worker/core/database/models/enums.dart';
|
||||||
|
|
||||||
|
part 'message_model.g.dart';
|
||||||
|
|
||||||
|
@HiveType(typeId: HiveTypeIds.messageModel)
|
||||||
|
class MessageModel extends HiveObject {
|
||||||
|
MessageModel({required this.messageId, required this.chatRoomId, required this.senderId, required this.contentType, required this.content, this.attachmentUrl, this.productReference, required this.isRead, required this.isEdited, required this.isDeleted, this.readBy, required this.timestamp, this.editedAt});
|
||||||
|
|
||||||
|
@HiveField(0) final String messageId;
|
||||||
|
@HiveField(1) final String chatRoomId;
|
||||||
|
@HiveField(2) final String senderId;
|
||||||
|
@HiveField(3) final ContentType contentType;
|
||||||
|
@HiveField(4) final String content;
|
||||||
|
@HiveField(5) final String? attachmentUrl;
|
||||||
|
@HiveField(6) final String? productReference;
|
||||||
|
@HiveField(7) final bool isRead;
|
||||||
|
@HiveField(8) final bool isEdited;
|
||||||
|
@HiveField(9) final bool isDeleted;
|
||||||
|
@HiveField(10) final String? readBy;
|
||||||
|
@HiveField(11) final DateTime timestamp;
|
||||||
|
@HiveField(12) final DateTime? editedAt;
|
||||||
|
|
||||||
|
factory MessageModel.fromJson(Map<String, dynamic> json) => MessageModel(
|
||||||
|
messageId: json['message_id'] as String,
|
||||||
|
chatRoomId: json['chat_room_id'] as String,
|
||||||
|
senderId: json['sender_id'] as String,
|
||||||
|
contentType: ContentType.values.firstWhere((e) => e.name == json['content_type']),
|
||||||
|
content: json['content'] as String,
|
||||||
|
attachmentUrl: json['attachment_url'] as String?,
|
||||||
|
productReference: json['product_reference'] as String?,
|
||||||
|
isRead: json['is_read'] as bool? ?? false,
|
||||||
|
isEdited: json['is_edited'] as bool? ?? false,
|
||||||
|
isDeleted: json['is_deleted'] as bool? ?? false,
|
||||||
|
readBy: json['read_by'] != null ? jsonEncode(json['read_by']) : null,
|
||||||
|
timestamp: DateTime.parse(json['timestamp']?.toString() ?? ''),
|
||||||
|
editedAt: json['edited_at'] != null ? DateTime.parse(json['edited_at']?.toString() ?? '') : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'message_id': messageId,
|
||||||
|
'chat_room_id': chatRoomId,
|
||||||
|
'sender_id': senderId,
|
||||||
|
'content_type': contentType.name,
|
||||||
|
'content': content,
|
||||||
|
'attachment_url': attachmentUrl,
|
||||||
|
'product_reference': productReference,
|
||||||
|
'is_read': isRead,
|
||||||
|
'is_edited': isEdited,
|
||||||
|
'is_deleted': isDeleted,
|
||||||
|
'read_by': readBy != null ? jsonDecode(readBy!) : null,
|
||||||
|
'timestamp': timestamp.toIso8601String(),
|
||||||
|
'edited_at': editedAt?.toIso8601String(),
|
||||||
|
};
|
||||||
|
|
||||||
|
List<String>? get readByList {
|
||||||
|
if (readBy == null) return null;
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(readBy!) as List;
|
||||||
|
return decoded.map((e) => e.toString()).toList();
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
77
lib/features/chat/data/models/message_model.g.dart
Normal file
77
lib/features/chat/data/models/message_model.g.dart
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'message_model.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// TypeAdapterGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
class MessageModelAdapter extends TypeAdapter<MessageModel> {
|
||||||
|
@override
|
||||||
|
final typeId = 19;
|
||||||
|
|
||||||
|
@override
|
||||||
|
MessageModel read(BinaryReader reader) {
|
||||||
|
final numOfFields = reader.readByte();
|
||||||
|
final fields = <int, dynamic>{
|
||||||
|
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||||
|
};
|
||||||
|
return MessageModel(
|
||||||
|
messageId: fields[0] as String,
|
||||||
|
chatRoomId: fields[1] as String,
|
||||||
|
senderId: fields[2] as String,
|
||||||
|
contentType: fields[3] as ContentType,
|
||||||
|
content: fields[4] as String,
|
||||||
|
attachmentUrl: fields[5] as String?,
|
||||||
|
productReference: fields[6] as String?,
|
||||||
|
isRead: fields[7] as bool,
|
||||||
|
isEdited: fields[8] as bool,
|
||||||
|
isDeleted: fields[9] as bool,
|
||||||
|
readBy: fields[10] as String?,
|
||||||
|
timestamp: fields[11] as DateTime,
|
||||||
|
editedAt: fields[12] as DateTime?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void write(BinaryWriter writer, MessageModel obj) {
|
||||||
|
writer
|
||||||
|
..writeByte(13)
|
||||||
|
..writeByte(0)
|
||||||
|
..write(obj.messageId)
|
||||||
|
..writeByte(1)
|
||||||
|
..write(obj.chatRoomId)
|
||||||
|
..writeByte(2)
|
||||||
|
..write(obj.senderId)
|
||||||
|
..writeByte(3)
|
||||||
|
..write(obj.contentType)
|
||||||
|
..writeByte(4)
|
||||||
|
..write(obj.content)
|
||||||
|
..writeByte(5)
|
||||||
|
..write(obj.attachmentUrl)
|
||||||
|
..writeByte(6)
|
||||||
|
..write(obj.productReference)
|
||||||
|
..writeByte(7)
|
||||||
|
..write(obj.isRead)
|
||||||
|
..writeByte(8)
|
||||||
|
..write(obj.isEdited)
|
||||||
|
..writeByte(9)
|
||||||
|
..write(obj.isDeleted)
|
||||||
|
..writeByte(10)
|
||||||
|
..write(obj.readBy)
|
||||||
|
..writeByte(11)
|
||||||
|
..write(obj.timestamp)
|
||||||
|
..writeByte(12)
|
||||||
|
..write(obj.editedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => typeId.hashCode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
other is MessageModelAdapter &&
|
||||||
|
runtimeType == other.runtimeType &&
|
||||||
|
typeId == other.typeId;
|
||||||
|
}
|
||||||
186
lib/features/chat/domain/entities/chat_room.dart
Normal file
186
lib/features/chat/domain/entities/chat_room.dart
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
/// Domain Entity: Chat Room
|
||||||
|
///
|
||||||
|
/// Represents a chat conversation room.
|
||||||
|
library;
|
||||||
|
|
||||||
|
/// Room type enum
|
||||||
|
enum RoomType {
|
||||||
|
/// Direct message between two users
|
||||||
|
direct,
|
||||||
|
|
||||||
|
/// Group chat
|
||||||
|
group,
|
||||||
|
|
||||||
|
/// Support chat with staff
|
||||||
|
support,
|
||||||
|
|
||||||
|
/// Order-related chat
|
||||||
|
order,
|
||||||
|
|
||||||
|
/// Quote-related chat
|
||||||
|
quote;
|
||||||
|
|
||||||
|
/// Get display name for room type
|
||||||
|
String get displayName {
|
||||||
|
switch (this) {
|
||||||
|
case RoomType.direct:
|
||||||
|
return 'Direct';
|
||||||
|
case RoomType.group:
|
||||||
|
return 'Group';
|
||||||
|
case RoomType.support:
|
||||||
|
return 'Support';
|
||||||
|
case RoomType.order:
|
||||||
|
return 'Order';
|
||||||
|
case RoomType.quote:
|
||||||
|
return 'Quote';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Chat Room Entity
|
||||||
|
///
|
||||||
|
/// Contains information about a chat room:
|
||||||
|
/// - Room type and participants
|
||||||
|
/// - Related entities (order, quote)
|
||||||
|
/// - Activity tracking
|
||||||
|
class ChatRoom {
|
||||||
|
/// Unique chat room identifier
|
||||||
|
final String chatRoomId;
|
||||||
|
|
||||||
|
/// Room type
|
||||||
|
final RoomType roomType;
|
||||||
|
|
||||||
|
/// Related quote ID (if quote chat)
|
||||||
|
final String? relatedQuoteId;
|
||||||
|
|
||||||
|
/// Related order ID (if order chat)
|
||||||
|
final String? relatedOrderId;
|
||||||
|
|
||||||
|
/// Participant user IDs
|
||||||
|
final List<String> participants;
|
||||||
|
|
||||||
|
/// Room name (for group chats)
|
||||||
|
final String? roomName;
|
||||||
|
|
||||||
|
/// Room is active
|
||||||
|
final bool isActive;
|
||||||
|
|
||||||
|
/// Last activity timestamp
|
||||||
|
final DateTime? lastActivity;
|
||||||
|
|
||||||
|
/// Room creation timestamp
|
||||||
|
final DateTime createdAt;
|
||||||
|
|
||||||
|
/// User ID who created the room
|
||||||
|
final String? createdBy;
|
||||||
|
|
||||||
|
const ChatRoom({
|
||||||
|
required this.chatRoomId,
|
||||||
|
required this.roomType,
|
||||||
|
this.relatedQuoteId,
|
||||||
|
this.relatedOrderId,
|
||||||
|
required this.participants,
|
||||||
|
this.roomName,
|
||||||
|
required this.isActive,
|
||||||
|
this.lastActivity,
|
||||||
|
required this.createdAt,
|
||||||
|
this.createdBy,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Check if room is direct message
|
||||||
|
bool get isDirect => roomType == RoomType.direct;
|
||||||
|
|
||||||
|
/// Check if room is group chat
|
||||||
|
bool get isGroup => roomType == RoomType.group;
|
||||||
|
|
||||||
|
/// Check if room is support chat
|
||||||
|
bool get isSupport => roomType == RoomType.support;
|
||||||
|
|
||||||
|
/// Check if room has order context
|
||||||
|
bool get hasOrderContext =>
|
||||||
|
roomType == RoomType.order && relatedOrderId != null;
|
||||||
|
|
||||||
|
/// Check if room has quote context
|
||||||
|
bool get hasQuoteContext =>
|
||||||
|
roomType == RoomType.quote && relatedQuoteId != null;
|
||||||
|
|
||||||
|
/// Get number of participants
|
||||||
|
int get participantCount => participants.length;
|
||||||
|
|
||||||
|
/// Get display name for room
|
||||||
|
String get displayName {
|
||||||
|
if (roomName != null && roomName!.isNotEmpty) return roomName!;
|
||||||
|
if (isSupport) return 'Customer Support';
|
||||||
|
if (hasOrderContext) return 'Order Chat';
|
||||||
|
if (hasQuoteContext) return 'Quote Discussion';
|
||||||
|
return 'Chat';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get time since last activity
|
||||||
|
Duration? get timeSinceLastActivity {
|
||||||
|
if (lastActivity == null) return null;
|
||||||
|
return DateTime.now().difference(lastActivity!);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if user is participant
|
||||||
|
bool isParticipant(String userId) {
|
||||||
|
return participants.contains(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy with method for immutability
|
||||||
|
ChatRoom copyWith({
|
||||||
|
String? chatRoomId,
|
||||||
|
RoomType? roomType,
|
||||||
|
String? relatedQuoteId,
|
||||||
|
String? relatedOrderId,
|
||||||
|
List<String>? participants,
|
||||||
|
String? roomName,
|
||||||
|
bool? isActive,
|
||||||
|
DateTime? lastActivity,
|
||||||
|
DateTime? createdAt,
|
||||||
|
String? createdBy,
|
||||||
|
}) {
|
||||||
|
return ChatRoom(
|
||||||
|
chatRoomId: chatRoomId ?? this.chatRoomId,
|
||||||
|
roomType: roomType ?? this.roomType,
|
||||||
|
relatedQuoteId: relatedQuoteId ?? this.relatedQuoteId,
|
||||||
|
relatedOrderId: relatedOrderId ?? this.relatedOrderId,
|
||||||
|
participants: participants ?? this.participants,
|
||||||
|
roomName: roomName ?? this.roomName,
|
||||||
|
isActive: isActive ?? this.isActive,
|
||||||
|
lastActivity: lastActivity ?? this.lastActivity,
|
||||||
|
createdAt: createdAt ?? this.createdAt,
|
||||||
|
createdBy: createdBy ?? this.createdBy,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (identical(this, other)) return true;
|
||||||
|
|
||||||
|
return other is ChatRoom &&
|
||||||
|
other.chatRoomId == chatRoomId &&
|
||||||
|
other.roomType == roomType &&
|
||||||
|
other.relatedQuoteId == relatedQuoteId &&
|
||||||
|
other.relatedOrderId == relatedOrderId &&
|
||||||
|
other.isActive == isActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode {
|
||||||
|
return Object.hash(
|
||||||
|
chatRoomId,
|
||||||
|
roomType,
|
||||||
|
relatedQuoteId,
|
||||||
|
relatedOrderId,
|
||||||
|
isActive,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'ChatRoom(chatRoomId: $chatRoomId, roomType: $roomType, '
|
||||||
|
'displayName: $displayName, participantCount: $participantCount, '
|
||||||
|
'isActive: $isActive)';
|
||||||
|
}
|
||||||
|
}
|
||||||
212
lib/features/chat/domain/entities/message.dart
Normal file
212
lib/features/chat/domain/entities/message.dart
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
/// Domain Entity: Message
|
||||||
|
///
|
||||||
|
/// Represents a chat message in a conversation.
|
||||||
|
library;
|
||||||
|
|
||||||
|
/// Content type enum
|
||||||
|
enum ContentType {
|
||||||
|
/// Plain text message
|
||||||
|
text,
|
||||||
|
|
||||||
|
/// Image message
|
||||||
|
image,
|
||||||
|
|
||||||
|
/// File attachment
|
||||||
|
file,
|
||||||
|
|
||||||
|
/// Product reference
|
||||||
|
product,
|
||||||
|
|
||||||
|
/// System notification
|
||||||
|
system;
|
||||||
|
|
||||||
|
/// Get display name for content type
|
||||||
|
String get displayName {
|
||||||
|
switch (this) {
|
||||||
|
case ContentType.text:
|
||||||
|
return 'Text';
|
||||||
|
case ContentType.image:
|
||||||
|
return 'Image';
|
||||||
|
case ContentType.file:
|
||||||
|
return 'File';
|
||||||
|
case ContentType.product:
|
||||||
|
return 'Product';
|
||||||
|
case ContentType.system:
|
||||||
|
return 'System';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Message Entity
|
||||||
|
///
|
||||||
|
/// Contains information about a chat message:
|
||||||
|
/// - Message content
|
||||||
|
/// - Sender information
|
||||||
|
/// - Attachments
|
||||||
|
/// - Read status
|
||||||
|
/// - Edit history
|
||||||
|
class Message {
|
||||||
|
/// Unique message identifier
|
||||||
|
final String messageId;
|
||||||
|
|
||||||
|
/// Chat room ID this message belongs to
|
||||||
|
final String chatRoomId;
|
||||||
|
|
||||||
|
/// Sender user ID
|
||||||
|
final String senderId;
|
||||||
|
|
||||||
|
/// Content type
|
||||||
|
final ContentType contentType;
|
||||||
|
|
||||||
|
/// Message content/text
|
||||||
|
final String content;
|
||||||
|
|
||||||
|
/// Attachment URL (for images/files)
|
||||||
|
final String? attachmentUrl;
|
||||||
|
|
||||||
|
/// Product reference ID (if product message)
|
||||||
|
final String? productReference;
|
||||||
|
|
||||||
|
/// Message is read
|
||||||
|
final bool isRead;
|
||||||
|
|
||||||
|
/// Message has been edited
|
||||||
|
final bool isEdited;
|
||||||
|
|
||||||
|
/// Message has been deleted
|
||||||
|
final bool isDeleted;
|
||||||
|
|
||||||
|
/// User IDs who have read this message
|
||||||
|
final List<String> readBy;
|
||||||
|
|
||||||
|
/// Message timestamp
|
||||||
|
final DateTime timestamp;
|
||||||
|
|
||||||
|
/// Edit timestamp
|
||||||
|
final DateTime? editedAt;
|
||||||
|
|
||||||
|
const Message({
|
||||||
|
required this.messageId,
|
||||||
|
required this.chatRoomId,
|
||||||
|
required this.senderId,
|
||||||
|
required this.contentType,
|
||||||
|
required this.content,
|
||||||
|
this.attachmentUrl,
|
||||||
|
this.productReference,
|
||||||
|
required this.isRead,
|
||||||
|
required this.isEdited,
|
||||||
|
required this.isDeleted,
|
||||||
|
required this.readBy,
|
||||||
|
required this.timestamp,
|
||||||
|
this.editedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Check if message is text
|
||||||
|
bool get isText => contentType == ContentType.text;
|
||||||
|
|
||||||
|
/// Check if message is image
|
||||||
|
bool get isImage => contentType == ContentType.image;
|
||||||
|
|
||||||
|
/// Check if message is file
|
||||||
|
bool get isFile => contentType == ContentType.file;
|
||||||
|
|
||||||
|
/// Check if message is product reference
|
||||||
|
bool get isProductReference => contentType == ContentType.product;
|
||||||
|
|
||||||
|
/// Check if message is system notification
|
||||||
|
bool get isSystemMessage => contentType == ContentType.system;
|
||||||
|
|
||||||
|
/// Check if message has attachment
|
||||||
|
bool get hasAttachment =>
|
||||||
|
attachmentUrl != null && attachmentUrl!.isNotEmpty;
|
||||||
|
|
||||||
|
/// Check if message references a product
|
||||||
|
bool get hasProductReference =>
|
||||||
|
productReference != null && productReference!.isNotEmpty;
|
||||||
|
|
||||||
|
/// Get number of readers
|
||||||
|
int get readerCount => readBy.length;
|
||||||
|
|
||||||
|
/// Check if user has read this message
|
||||||
|
bool isReadBy(String userId) {
|
||||||
|
return readBy.contains(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if message is sent by user
|
||||||
|
bool isSentBy(String userId) {
|
||||||
|
return senderId == userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get time since message was sent
|
||||||
|
Duration get timeSinceSent {
|
||||||
|
return DateTime.now().difference(timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy with method for immutability
|
||||||
|
Message copyWith({
|
||||||
|
String? messageId,
|
||||||
|
String? chatRoomId,
|
||||||
|
String? senderId,
|
||||||
|
ContentType? contentType,
|
||||||
|
String? content,
|
||||||
|
String? attachmentUrl,
|
||||||
|
String? productReference,
|
||||||
|
bool? isRead,
|
||||||
|
bool? isEdited,
|
||||||
|
bool? isDeleted,
|
||||||
|
List<String>? readBy,
|
||||||
|
DateTime? timestamp,
|
||||||
|
DateTime? editedAt,
|
||||||
|
}) {
|
||||||
|
return Message(
|
||||||
|
messageId: messageId ?? this.messageId,
|
||||||
|
chatRoomId: chatRoomId ?? this.chatRoomId,
|
||||||
|
senderId: senderId ?? this.senderId,
|
||||||
|
contentType: contentType ?? this.contentType,
|
||||||
|
content: content ?? this.content,
|
||||||
|
attachmentUrl: attachmentUrl ?? this.attachmentUrl,
|
||||||
|
productReference: productReference ?? this.productReference,
|
||||||
|
isRead: isRead ?? this.isRead,
|
||||||
|
isEdited: isEdited ?? this.isEdited,
|
||||||
|
isDeleted: isDeleted ?? this.isDeleted,
|
||||||
|
readBy: readBy ?? this.readBy,
|
||||||
|
timestamp: timestamp ?? this.timestamp,
|
||||||
|
editedAt: editedAt ?? this.editedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (identical(this, other)) return true;
|
||||||
|
|
||||||
|
return other is Message &&
|
||||||
|
other.messageId == messageId &&
|
||||||
|
other.chatRoomId == chatRoomId &&
|
||||||
|
other.senderId == senderId &&
|
||||||
|
other.contentType == contentType &&
|
||||||
|
other.content == content &&
|
||||||
|
other.isRead == isRead &&
|
||||||
|
other.isEdited == isEdited &&
|
||||||
|
other.isDeleted == isDeleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode {
|
||||||
|
return Object.hash(
|
||||||
|
messageId,
|
||||||
|
chatRoomId,
|
||||||
|
senderId,
|
||||||
|
contentType,
|
||||||
|
content,
|
||||||
|
isRead,
|
||||||
|
isEdited,
|
||||||
|
isDeleted,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'Message(messageId: $messageId, senderId: $senderId, '
|
||||||
|
'contentType: $contentType, isRead: $isRead, timestamp: $timestamp)';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:hive_ce/hive.dart';
|
import 'package:hive_ce/hive.dart';
|
||||||
|
import 'package:worker/core/constants/storage_constants.dart';
|
||||||
import 'package:worker/features/home/domain/entities/member_card.dart';
|
import 'package:worker/features/home/domain/entities/member_card.dart';
|
||||||
|
|
||||||
part 'member_card_model.g.dart';
|
part 'member_card_model.g.dart';
|
||||||
@@ -20,8 +21,8 @@ part 'member_card_model.g.dart';
|
|||||||
/// - Hive local database storage
|
/// - Hive local database storage
|
||||||
/// - Converting to/from domain entity
|
/// - Converting to/from domain entity
|
||||||
///
|
///
|
||||||
/// Hive Type ID: 10 (ensure this doesn't conflict with other models)
|
/// Hive Type ID: 25 (from HiveTypeIds.memberCardModel)
|
||||||
@HiveType(typeId: 10)
|
@HiveType(typeId: HiveTypeIds.memberCardModel)
|
||||||
class MemberCardModel extends HiveObject {
|
class MemberCardModel extends HiveObject {
|
||||||
/// Member ID
|
/// Member ID
|
||||||
@HiveField(0)
|
@HiveField(0)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ part of 'member_card_model.dart';
|
|||||||
|
|
||||||
class MemberCardModelAdapter extends TypeAdapter<MemberCardModel> {
|
class MemberCardModelAdapter extends TypeAdapter<MemberCardModel> {
|
||||||
@override
|
@override
|
||||||
final typeId = 10;
|
final typeId = 25;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
MemberCardModel read(BinaryReader reader) {
|
MemberCardModel read(BinaryReader reader) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:hive_ce/hive.dart';
|
import 'package:hive_ce/hive.dart';
|
||||||
|
import 'package:worker/core/constants/storage_constants.dart';
|
||||||
import 'package:worker/features/home/domain/entities/promotion.dart';
|
import 'package:worker/features/home/domain/entities/promotion.dart';
|
||||||
|
|
||||||
part 'promotion_model.g.dart';
|
part 'promotion_model.g.dart';
|
||||||
@@ -20,8 +21,8 @@ part 'promotion_model.g.dart';
|
|||||||
/// - Hive local database storage
|
/// - Hive local database storage
|
||||||
/// - Converting to/from domain entity
|
/// - Converting to/from domain entity
|
||||||
///
|
///
|
||||||
/// Hive Type ID: 11 (ensure this doesn't conflict with other models)
|
/// Hive Type ID: 26 (from HiveTypeIds.promotionModel)
|
||||||
@HiveType(typeId: 11)
|
@HiveType(typeId: HiveTypeIds.promotionModel)
|
||||||
class PromotionModel extends HiveObject {
|
class PromotionModel extends HiveObject {
|
||||||
/// Promotion ID
|
/// Promotion ID
|
||||||
@HiveField(0)
|
@HiveField(0)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ part of 'promotion_model.dart';
|
|||||||
|
|
||||||
class PromotionModelAdapter extends TypeAdapter<PromotionModel> {
|
class PromotionModelAdapter extends TypeAdapter<PromotionModel> {
|
||||||
@override
|
@override
|
||||||
final typeId = 11;
|
final typeId = 26;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
PromotionModel read(BinaryReader reader) {
|
PromotionModel read(BinaryReader reader) {
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ class HomePage extends ConsumerWidget {
|
|||||||
QuickAction(
|
QuickAction(
|
||||||
icon: Icons.grid_view,
|
icon: Icons.grid_view,
|
||||||
label: l10n.products,
|
label: l10n.products,
|
||||||
onTap: () => context.go(RouteNames.products),
|
onTap: () => context.pushNamed(RouteNames.products),
|
||||||
),
|
),
|
||||||
QuickAction(
|
QuickAction(
|
||||||
icon: Icons.shopping_cart,
|
icon: Icons.shopping_cart,
|
||||||
|
|||||||
70
lib/features/loyalty/data/models/gift_catalog_model.dart
Normal file
70
lib/features/loyalty/data/models/gift_catalog_model.dart
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import 'package:hive_ce/hive.dart';
|
||||||
|
import 'package:worker/core/constants/storage_constants.dart';
|
||||||
|
import 'package:worker/core/database/models/enums.dart';
|
||||||
|
|
||||||
|
part 'gift_catalog_model.g.dart';
|
||||||
|
|
||||||
|
@HiveType(typeId: HiveTypeIds.giftCatalogModel)
|
||||||
|
class GiftCatalogModel extends HiveObject {
|
||||||
|
GiftCatalogModel({required this.catalogId, required this.name, required this.description, this.imageUrl, required this.category, required this.pointsCost, required this.cashValue, required this.quantityAvailable, required this.quantityRedeemed, this.termsConditions, required this.isActive, this.validFrom, this.validUntil, required this.createdAt, this.updatedAt});
|
||||||
|
|
||||||
|
@HiveField(0) final String catalogId;
|
||||||
|
@HiveField(1) final String name;
|
||||||
|
@HiveField(2) final String description;
|
||||||
|
@HiveField(3) final String? imageUrl;
|
||||||
|
@HiveField(4) final GiftCategory category;
|
||||||
|
@HiveField(5) final int pointsCost;
|
||||||
|
@HiveField(6) final double cashValue;
|
||||||
|
@HiveField(7) final int quantityAvailable;
|
||||||
|
@HiveField(8) final int quantityRedeemed;
|
||||||
|
@HiveField(9) final String? termsConditions;
|
||||||
|
@HiveField(10) final bool isActive;
|
||||||
|
@HiveField(11) final DateTime? validFrom;
|
||||||
|
@HiveField(12) final DateTime? validUntil;
|
||||||
|
@HiveField(13) final DateTime createdAt;
|
||||||
|
@HiveField(14) final DateTime? updatedAt;
|
||||||
|
|
||||||
|
factory GiftCatalogModel.fromJson(Map<String, dynamic> json) => GiftCatalogModel(
|
||||||
|
catalogId: json['catalog_id'] as String,
|
||||||
|
name: json['name'] as String,
|
||||||
|
description: json['description'] as String,
|
||||||
|
imageUrl: json['image_url'] as String?,
|
||||||
|
category: GiftCategory.values.firstWhere((e) => e.name == json['category']),
|
||||||
|
pointsCost: json['points_cost'] as int,
|
||||||
|
cashValue: (json['cash_value'] as num).toDouble(),
|
||||||
|
quantityAvailable: json['quantity_available'] as int,
|
||||||
|
quantityRedeemed: json['quantity_redeemed'] as int? ?? 0,
|
||||||
|
termsConditions: json['terms_conditions'] as String?,
|
||||||
|
isActive: json['is_active'] as bool? ?? true,
|
||||||
|
validFrom: json['valid_from'] != null ? DateTime.parse(json['valid_from']?.toString() ?? '') : null,
|
||||||
|
validUntil: json['valid_until'] != null ? DateTime.parse(json['valid_until']?.toString() ?? '') : null,
|
||||||
|
createdAt: DateTime.parse(json['created_at']?.toString() ?? ''),
|
||||||
|
updatedAt: json['updated_at'] != null ? DateTime.parse(json['updated_at']?.toString() ?? '') : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'catalog_id': catalogId,
|
||||||
|
'name': name,
|
||||||
|
'description': description,
|
||||||
|
'image_url': imageUrl,
|
||||||
|
'category': category.name,
|
||||||
|
'points_cost': pointsCost,
|
||||||
|
'cash_value': cashValue,
|
||||||
|
'quantity_available': quantityAvailable,
|
||||||
|
'quantity_redeemed': quantityRedeemed,
|
||||||
|
'terms_conditions': termsConditions,
|
||||||
|
'is_active': isActive,
|
||||||
|
'valid_from': validFrom?.toIso8601String(),
|
||||||
|
'valid_until': validUntil?.toIso8601String(),
|
||||||
|
'created_at': createdAt.toIso8601String(),
|
||||||
|
'updated_at': updatedAt?.toIso8601String(),
|
||||||
|
};
|
||||||
|
|
||||||
|
bool get isAvailable => isActive && quantityAvailable > quantityRedeemed;
|
||||||
|
bool get isValid {
|
||||||
|
final now = DateTime.now();
|
||||||
|
if (validFrom != null && now.isBefore(validFrom!)) return false;
|
||||||
|
if (validUntil != null && now.isAfter(validUntil!)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
83
lib/features/loyalty/data/models/gift_catalog_model.g.dart
Normal file
83
lib/features/loyalty/data/models/gift_catalog_model.g.dart
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'gift_catalog_model.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// TypeAdapterGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
class GiftCatalogModelAdapter extends TypeAdapter<GiftCatalogModel> {
|
||||||
|
@override
|
||||||
|
final typeId = 11;
|
||||||
|
|
||||||
|
@override
|
||||||
|
GiftCatalogModel read(BinaryReader reader) {
|
||||||
|
final numOfFields = reader.readByte();
|
||||||
|
final fields = <int, dynamic>{
|
||||||
|
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||||
|
};
|
||||||
|
return GiftCatalogModel(
|
||||||
|
catalogId: fields[0] as String,
|
||||||
|
name: fields[1] as String,
|
||||||
|
description: fields[2] as String,
|
||||||
|
imageUrl: fields[3] as String?,
|
||||||
|
category: fields[4] as GiftCategory,
|
||||||
|
pointsCost: (fields[5] as num).toInt(),
|
||||||
|
cashValue: (fields[6] as num).toDouble(),
|
||||||
|
quantityAvailable: (fields[7] as num).toInt(),
|
||||||
|
quantityRedeemed: (fields[8] as num).toInt(),
|
||||||
|
termsConditions: fields[9] as String?,
|
||||||
|
isActive: fields[10] as bool,
|
||||||
|
validFrom: fields[11] as DateTime?,
|
||||||
|
validUntil: fields[12] as DateTime?,
|
||||||
|
createdAt: fields[13] as DateTime,
|
||||||
|
updatedAt: fields[14] as DateTime?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void write(BinaryWriter writer, GiftCatalogModel obj) {
|
||||||
|
writer
|
||||||
|
..writeByte(15)
|
||||||
|
..writeByte(0)
|
||||||
|
..write(obj.catalogId)
|
||||||
|
..writeByte(1)
|
||||||
|
..write(obj.name)
|
||||||
|
..writeByte(2)
|
||||||
|
..write(obj.description)
|
||||||
|
..writeByte(3)
|
||||||
|
..write(obj.imageUrl)
|
||||||
|
..writeByte(4)
|
||||||
|
..write(obj.category)
|
||||||
|
..writeByte(5)
|
||||||
|
..write(obj.pointsCost)
|
||||||
|
..writeByte(6)
|
||||||
|
..write(obj.cashValue)
|
||||||
|
..writeByte(7)
|
||||||
|
..write(obj.quantityAvailable)
|
||||||
|
..writeByte(8)
|
||||||
|
..write(obj.quantityRedeemed)
|
||||||
|
..writeByte(9)
|
||||||
|
..write(obj.termsConditions)
|
||||||
|
..writeByte(10)
|
||||||
|
..write(obj.isActive)
|
||||||
|
..writeByte(11)
|
||||||
|
..write(obj.validFrom)
|
||||||
|
..writeByte(12)
|
||||||
|
..write(obj.validUntil)
|
||||||
|
..writeByte(13)
|
||||||
|
..write(obj.createdAt)
|
||||||
|
..writeByte(14)
|
||||||
|
..write(obj.updatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => typeId.hashCode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
other is GiftCatalogModelAdapter &&
|
||||||
|
runtimeType == other.runtimeType &&
|
||||||
|
typeId == other.typeId;
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:hive_ce/hive.dart';
|
||||||
|
import 'package:worker/core/constants/storage_constants.dart';
|
||||||
|
import 'package:worker/core/database/models/enums.dart';
|
||||||
|
|
||||||
|
part 'loyalty_point_entry_model.g.dart';
|
||||||
|
|
||||||
|
@HiveType(typeId: HiveTypeIds.loyaltyPointEntryModel)
|
||||||
|
class LoyaltyPointEntryModel extends HiveObject {
|
||||||
|
LoyaltyPointEntryModel({required this.entryId, required this.userId, required this.points, required this.entryType, required this.source, required this.description, this.referenceId, this.referenceType, this.complaint, required this.complaintStatus, required this.balanceAfter, this.expiryDate, required this.timestamp, this.erpnextEntryId});
|
||||||
|
|
||||||
|
@HiveField(0) final String entryId;
|
||||||
|
@HiveField(1) final String userId;
|
||||||
|
@HiveField(2) final int points;
|
||||||
|
@HiveField(3) final EntryType entryType;
|
||||||
|
@HiveField(4) final EntrySource source;
|
||||||
|
@HiveField(5) final String description;
|
||||||
|
@HiveField(6) final String? referenceId;
|
||||||
|
@HiveField(7) final String? referenceType;
|
||||||
|
@HiveField(8) final String? complaint;
|
||||||
|
@HiveField(9) final ComplaintStatus complaintStatus;
|
||||||
|
@HiveField(10) final int balanceAfter;
|
||||||
|
@HiveField(11) final DateTime? expiryDate;
|
||||||
|
@HiveField(12) final DateTime timestamp;
|
||||||
|
@HiveField(13) final String? erpnextEntryId;
|
||||||
|
|
||||||
|
factory LoyaltyPointEntryModel.fromJson(Map<String, dynamic> json) => LoyaltyPointEntryModel(
|
||||||
|
entryId: json['entry_id'] as String,
|
||||||
|
userId: json['user_id'] as String,
|
||||||
|
points: json['points'] as int,
|
||||||
|
entryType: EntryType.values.firstWhere((e) => e.name == json['entry_type']),
|
||||||
|
source: EntrySource.values.firstWhere((e) => e.name == json['source']),
|
||||||
|
description: json['description'] as String,
|
||||||
|
referenceId: json['reference_id'] as String?,
|
||||||
|
referenceType: json['reference_type'] as String?,
|
||||||
|
complaint: json['complaint'] != null ? jsonEncode(json['complaint']) : null,
|
||||||
|
complaintStatus: ComplaintStatus.values.firstWhere((e) => e.name == (json['complaint_status'] ?? 'none')),
|
||||||
|
balanceAfter: json['balance_after'] as int,
|
||||||
|
expiryDate: json['expiry_date'] != null ? DateTime.parse(json['expiry_date']?.toString() ?? '') : null,
|
||||||
|
timestamp: DateTime.parse(json['timestamp']?.toString() ?? ''),
|
||||||
|
erpnextEntryId: json['erpnext_entry_id'] as String?,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'entry_id': entryId,
|
||||||
|
'user_id': userId,
|
||||||
|
'points': points,
|
||||||
|
'entry_type': entryType.name,
|
||||||
|
'source': source.name,
|
||||||
|
'description': description,
|
||||||
|
'reference_id': referenceId,
|
||||||
|
'reference_type': referenceType,
|
||||||
|
'complaint': complaint != null ? jsonDecode(complaint!) : null,
|
||||||
|
'complaint_status': complaintStatus.name,
|
||||||
|
'balance_after': balanceAfter,
|
||||||
|
'expiry_date': expiryDate?.toIso8601String(),
|
||||||
|
'timestamp': timestamp.toIso8601String(),
|
||||||
|
'erpnext_entry_id': erpnextEntryId,
|
||||||
|
};
|
||||||
|
|
||||||
|
Map<String, dynamic>? get complaintMap {
|
||||||
|
if (complaint == null) return null;
|
||||||
|
try {
|
||||||
|
return jsonDecode(complaint!) as Map<String, dynamic>;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get isExpired => expiryDate != null && DateTime.now().isAfter(expiryDate!);
|
||||||
|
bool get hasComplaint => complaintStatus != ComplaintStatus.none;
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'loyalty_point_entry_model.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// TypeAdapterGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
class LoyaltyPointEntryModelAdapter
|
||||||
|
extends TypeAdapter<LoyaltyPointEntryModel> {
|
||||||
|
@override
|
||||||
|
final typeId = 10;
|
||||||
|
|
||||||
|
@override
|
||||||
|
LoyaltyPointEntryModel read(BinaryReader reader) {
|
||||||
|
final numOfFields = reader.readByte();
|
||||||
|
final fields = <int, dynamic>{
|
||||||
|
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||||
|
};
|
||||||
|
return LoyaltyPointEntryModel(
|
||||||
|
entryId: fields[0] as String,
|
||||||
|
userId: fields[1] as String,
|
||||||
|
points: (fields[2] as num).toInt(),
|
||||||
|
entryType: fields[3] as EntryType,
|
||||||
|
source: fields[4] as EntrySource,
|
||||||
|
description: fields[5] as String,
|
||||||
|
referenceId: fields[6] as String?,
|
||||||
|
referenceType: fields[7] as String?,
|
||||||
|
complaint: fields[8] as String?,
|
||||||
|
complaintStatus: fields[9] as ComplaintStatus,
|
||||||
|
balanceAfter: (fields[10] as num).toInt(),
|
||||||
|
expiryDate: fields[11] as DateTime?,
|
||||||
|
timestamp: fields[12] as DateTime,
|
||||||
|
erpnextEntryId: fields[13] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void write(BinaryWriter writer, LoyaltyPointEntryModel obj) {
|
||||||
|
writer
|
||||||
|
..writeByte(14)
|
||||||
|
..writeByte(0)
|
||||||
|
..write(obj.entryId)
|
||||||
|
..writeByte(1)
|
||||||
|
..write(obj.userId)
|
||||||
|
..writeByte(2)
|
||||||
|
..write(obj.points)
|
||||||
|
..writeByte(3)
|
||||||
|
..write(obj.entryType)
|
||||||
|
..writeByte(4)
|
||||||
|
..write(obj.source)
|
||||||
|
..writeByte(5)
|
||||||
|
..write(obj.description)
|
||||||
|
..writeByte(6)
|
||||||
|
..write(obj.referenceId)
|
||||||
|
..writeByte(7)
|
||||||
|
..write(obj.referenceType)
|
||||||
|
..writeByte(8)
|
||||||
|
..write(obj.complaint)
|
||||||
|
..writeByte(9)
|
||||||
|
..write(obj.complaintStatus)
|
||||||
|
..writeByte(10)
|
||||||
|
..write(obj.balanceAfter)
|
||||||
|
..writeByte(11)
|
||||||
|
..write(obj.expiryDate)
|
||||||
|
..writeByte(12)
|
||||||
|
..write(obj.timestamp)
|
||||||
|
..writeByte(13)
|
||||||
|
..write(obj.erpnextEntryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => typeId.hashCode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
other is LoyaltyPointEntryModelAdapter &&
|
||||||
|
runtimeType == other.runtimeType &&
|
||||||
|
typeId == other.typeId;
|
||||||
|
}
|
||||||
70
lib/features/loyalty/data/models/points_record_model.dart
Normal file
70
lib/features/loyalty/data/models/points_record_model.dart
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:hive_ce/hive.dart';
|
||||||
|
import 'package:worker/core/constants/storage_constants.dart';
|
||||||
|
import 'package:worker/core/database/models/enums.dart';
|
||||||
|
|
||||||
|
part 'points_record_model.g.dart';
|
||||||
|
|
||||||
|
@HiveType(typeId: HiveTypeIds.pointsRecordModel)
|
||||||
|
class PointsRecordModel extends HiveObject {
|
||||||
|
PointsRecordModel({required this.recordId, required this.userId, required this.invoiceNumber, required this.storeName, required this.transactionDate, required this.invoiceAmount, this.notes, this.attachments, required this.status, this.rejectReason, this.pointsEarned, required this.submittedAt, this.processedAt, this.processedBy});
|
||||||
|
|
||||||
|
@HiveField(0) final String recordId;
|
||||||
|
@HiveField(1) final String userId;
|
||||||
|
@HiveField(2) final String invoiceNumber;
|
||||||
|
@HiveField(3) final String storeName;
|
||||||
|
@HiveField(4) final DateTime transactionDate;
|
||||||
|
@HiveField(5) final double invoiceAmount;
|
||||||
|
@HiveField(6) final String? notes;
|
||||||
|
@HiveField(7) final String? attachments;
|
||||||
|
@HiveField(8) final PointsStatus status;
|
||||||
|
@HiveField(9) final String? rejectReason;
|
||||||
|
@HiveField(10) final int? pointsEarned;
|
||||||
|
@HiveField(11) final DateTime submittedAt;
|
||||||
|
@HiveField(12) final DateTime? processedAt;
|
||||||
|
@HiveField(13) final String? processedBy;
|
||||||
|
|
||||||
|
factory PointsRecordModel.fromJson(Map<String, dynamic> json) => PointsRecordModel(
|
||||||
|
recordId: json['record_id'] as String,
|
||||||
|
userId: json['user_id'] as String,
|
||||||
|
invoiceNumber: json['invoice_number'] as String,
|
||||||
|
storeName: json['store_name'] as String,
|
||||||
|
transactionDate: DateTime.parse(json['transaction_date']?.toString() ?? ''),
|
||||||
|
invoiceAmount: (json['invoice_amount'] as num).toDouble(),
|
||||||
|
notes: json['notes'] as String?,
|
||||||
|
attachments: json['attachments'] != null ? jsonEncode(json['attachments']) : null,
|
||||||
|
status: PointsStatus.values.firstWhere((e) => e.name == json['status']),
|
||||||
|
rejectReason: json['reject_reason'] as String?,
|
||||||
|
pointsEarned: json['points_earned'] as int?,
|
||||||
|
submittedAt: DateTime.parse(json['submitted_at']?.toString() ?? ''),
|
||||||
|
processedAt: json['processed_at'] != null ? DateTime.parse(json['processed_at']?.toString() ?? '') : null,
|
||||||
|
processedBy: json['processed_by'] as String?,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'record_id': recordId,
|
||||||
|
'user_id': userId,
|
||||||
|
'invoice_number': invoiceNumber,
|
||||||
|
'store_name': storeName,
|
||||||
|
'transaction_date': transactionDate.toIso8601String(),
|
||||||
|
'invoice_amount': invoiceAmount,
|
||||||
|
'notes': notes,
|
||||||
|
'attachments': attachments != null ? jsonDecode(attachments!) : null,
|
||||||
|
'status': status.name,
|
||||||
|
'reject_reason': rejectReason,
|
||||||
|
'points_earned': pointsEarned,
|
||||||
|
'submitted_at': submittedAt.toIso8601String(),
|
||||||
|
'processed_at': processedAt?.toIso8601String(),
|
||||||
|
'processed_by': processedBy,
|
||||||
|
};
|
||||||
|
|
||||||
|
List<String>? get attachmentsList {
|
||||||
|
if (attachments == null) return null;
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(attachments!) as List;
|
||||||
|
return decoded.map((e) => e.toString()).toList();
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
80
lib/features/loyalty/data/models/points_record_model.g.dart
Normal file
80
lib/features/loyalty/data/models/points_record_model.g.dart
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'points_record_model.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// TypeAdapterGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
class PointsRecordModelAdapter extends TypeAdapter<PointsRecordModel> {
|
||||||
|
@override
|
||||||
|
final typeId = 13;
|
||||||
|
|
||||||
|
@override
|
||||||
|
PointsRecordModel read(BinaryReader reader) {
|
||||||
|
final numOfFields = reader.readByte();
|
||||||
|
final fields = <int, dynamic>{
|
||||||
|
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||||
|
};
|
||||||
|
return PointsRecordModel(
|
||||||
|
recordId: fields[0] as String,
|
||||||
|
userId: fields[1] as String,
|
||||||
|
invoiceNumber: fields[2] as String,
|
||||||
|
storeName: fields[3] as String,
|
||||||
|
transactionDate: fields[4] as DateTime,
|
||||||
|
invoiceAmount: (fields[5] as num).toDouble(),
|
||||||
|
notes: fields[6] as String?,
|
||||||
|
attachments: fields[7] as String?,
|
||||||
|
status: fields[8] as PointsStatus,
|
||||||
|
rejectReason: fields[9] as String?,
|
||||||
|
pointsEarned: (fields[10] as num?)?.toInt(),
|
||||||
|
submittedAt: fields[11] as DateTime,
|
||||||
|
processedAt: fields[12] as DateTime?,
|
||||||
|
processedBy: fields[13] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void write(BinaryWriter writer, PointsRecordModel obj) {
|
||||||
|
writer
|
||||||
|
..writeByte(14)
|
||||||
|
..writeByte(0)
|
||||||
|
..write(obj.recordId)
|
||||||
|
..writeByte(1)
|
||||||
|
..write(obj.userId)
|
||||||
|
..writeByte(2)
|
||||||
|
..write(obj.invoiceNumber)
|
||||||
|
..writeByte(3)
|
||||||
|
..write(obj.storeName)
|
||||||
|
..writeByte(4)
|
||||||
|
..write(obj.transactionDate)
|
||||||
|
..writeByte(5)
|
||||||
|
..write(obj.invoiceAmount)
|
||||||
|
..writeByte(6)
|
||||||
|
..write(obj.notes)
|
||||||
|
..writeByte(7)
|
||||||
|
..write(obj.attachments)
|
||||||
|
..writeByte(8)
|
||||||
|
..write(obj.status)
|
||||||
|
..writeByte(9)
|
||||||
|
..write(obj.rejectReason)
|
||||||
|
..writeByte(10)
|
||||||
|
..write(obj.pointsEarned)
|
||||||
|
..writeByte(11)
|
||||||
|
..write(obj.submittedAt)
|
||||||
|
..writeByte(12)
|
||||||
|
..write(obj.processedAt)
|
||||||
|
..writeByte(13)
|
||||||
|
..write(obj.processedBy);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => typeId.hashCode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
other is PointsRecordModelAdapter &&
|
||||||
|
runtimeType == other.runtimeType &&
|
||||||
|
typeId == other.typeId;
|
||||||
|
}
|
||||||
69
lib/features/loyalty/data/models/redeemed_gift_model.dart
Normal file
69
lib/features/loyalty/data/models/redeemed_gift_model.dart
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import 'package:hive_ce/hive.dart';
|
||||||
|
import 'package:worker/core/constants/storage_constants.dart';
|
||||||
|
import 'package:worker/core/database/models/enums.dart';
|
||||||
|
|
||||||
|
part 'redeemed_gift_model.g.dart';
|
||||||
|
|
||||||
|
@HiveType(typeId: HiveTypeIds.redeemedGiftModel)
|
||||||
|
class RedeemedGiftModel extends HiveObject {
|
||||||
|
RedeemedGiftModel({required this.giftId, required this.userId, required this.catalogId, required this.name, required this.description, this.voucherCode, this.qrCodeImage, required this.giftType, required this.pointsCost, required this.cashValue, this.expiryDate, required this.status, required this.redeemedAt, this.usedAt, this.usedLocation, this.usedReference});
|
||||||
|
|
||||||
|
@HiveField(0) final String giftId;
|
||||||
|
@HiveField(1) final String userId;
|
||||||
|
@HiveField(2) final String catalogId;
|
||||||
|
@HiveField(3) final String name;
|
||||||
|
@HiveField(4) final String description;
|
||||||
|
@HiveField(5) final String? voucherCode;
|
||||||
|
@HiveField(6) final String? qrCodeImage;
|
||||||
|
@HiveField(7) final GiftCategory giftType;
|
||||||
|
@HiveField(8) final int pointsCost;
|
||||||
|
@HiveField(9) final double cashValue;
|
||||||
|
@HiveField(10) final DateTime? expiryDate;
|
||||||
|
@HiveField(11) final GiftStatus status;
|
||||||
|
@HiveField(12) final DateTime redeemedAt;
|
||||||
|
@HiveField(13) final DateTime? usedAt;
|
||||||
|
@HiveField(14) final String? usedLocation;
|
||||||
|
@HiveField(15) final String? usedReference;
|
||||||
|
|
||||||
|
factory RedeemedGiftModel.fromJson(Map<String, dynamic> json) => RedeemedGiftModel(
|
||||||
|
giftId: json['gift_id'] as String,
|
||||||
|
userId: json['user_id'] as String,
|
||||||
|
catalogId: json['catalog_id'] as String,
|
||||||
|
name: json['name'] as String,
|
||||||
|
description: json['description'] as String,
|
||||||
|
voucherCode: json['voucher_code'] as String?,
|
||||||
|
qrCodeImage: json['qr_code_image'] as String?,
|
||||||
|
giftType: GiftCategory.values.firstWhere((e) => e.name == json['gift_type']),
|
||||||
|
pointsCost: json['points_cost'] as int,
|
||||||
|
cashValue: (json['cash_value'] as num).toDouble(),
|
||||||
|
expiryDate: json['expiry_date'] != null ? DateTime.parse(json['expiry_date']?.toString() ?? '') : null,
|
||||||
|
status: GiftStatus.values.firstWhere((e) => e.name == json['status']),
|
||||||
|
redeemedAt: DateTime.parse(json['redeemed_at']?.toString() ?? ''),
|
||||||
|
usedAt: json['used_at'] != null ? DateTime.parse(json['used_at']?.toString() ?? '') : null,
|
||||||
|
usedLocation: json['used_location'] as String?,
|
||||||
|
usedReference: json['used_reference'] as String?,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'gift_id': giftId,
|
||||||
|
'user_id': userId,
|
||||||
|
'catalog_id': catalogId,
|
||||||
|
'name': name,
|
||||||
|
'description': description,
|
||||||
|
'voucher_code': voucherCode,
|
||||||
|
'qr_code_image': qrCodeImage,
|
||||||
|
'gift_type': giftType.name,
|
||||||
|
'points_cost': pointsCost,
|
||||||
|
'cash_value': cashValue,
|
||||||
|
'expiry_date': expiryDate?.toIso8601String(),
|
||||||
|
'status': status.name,
|
||||||
|
'redeemed_at': redeemedAt.toIso8601String(),
|
||||||
|
'used_at': usedAt?.toIso8601String(),
|
||||||
|
'used_location': usedLocation,
|
||||||
|
'used_reference': usedReference,
|
||||||
|
};
|
||||||
|
|
||||||
|
bool get isExpired => expiryDate != null && DateTime.now().isAfter(expiryDate!);
|
||||||
|
bool get isUsed => status == GiftStatus.used;
|
||||||
|
bool get isActive => status == GiftStatus.active && !isExpired;
|
||||||
|
}
|
||||||
86
lib/features/loyalty/data/models/redeemed_gift_model.g.dart
Normal file
86
lib/features/loyalty/data/models/redeemed_gift_model.g.dart
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'redeemed_gift_model.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// TypeAdapterGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
class RedeemedGiftModelAdapter extends TypeAdapter<RedeemedGiftModel> {
|
||||||
|
@override
|
||||||
|
final typeId = 12;
|
||||||
|
|
||||||
|
@override
|
||||||
|
RedeemedGiftModel read(BinaryReader reader) {
|
||||||
|
final numOfFields = reader.readByte();
|
||||||
|
final fields = <int, dynamic>{
|
||||||
|
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||||
|
};
|
||||||
|
return RedeemedGiftModel(
|
||||||
|
giftId: fields[0] as String,
|
||||||
|
userId: fields[1] as String,
|
||||||
|
catalogId: fields[2] as String,
|
||||||
|
name: fields[3] as String,
|
||||||
|
description: fields[4] as String,
|
||||||
|
voucherCode: fields[5] as String?,
|
||||||
|
qrCodeImage: fields[6] as String?,
|
||||||
|
giftType: fields[7] as GiftCategory,
|
||||||
|
pointsCost: (fields[8] as num).toInt(),
|
||||||
|
cashValue: (fields[9] as num).toDouble(),
|
||||||
|
expiryDate: fields[10] as DateTime?,
|
||||||
|
status: fields[11] as GiftStatus,
|
||||||
|
redeemedAt: fields[12] as DateTime,
|
||||||
|
usedAt: fields[13] as DateTime?,
|
||||||
|
usedLocation: fields[14] as String?,
|
||||||
|
usedReference: fields[15] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void write(BinaryWriter writer, RedeemedGiftModel obj) {
|
||||||
|
writer
|
||||||
|
..writeByte(16)
|
||||||
|
..writeByte(0)
|
||||||
|
..write(obj.giftId)
|
||||||
|
..writeByte(1)
|
||||||
|
..write(obj.userId)
|
||||||
|
..writeByte(2)
|
||||||
|
..write(obj.catalogId)
|
||||||
|
..writeByte(3)
|
||||||
|
..write(obj.name)
|
||||||
|
..writeByte(4)
|
||||||
|
..write(obj.description)
|
||||||
|
..writeByte(5)
|
||||||
|
..write(obj.voucherCode)
|
||||||
|
..writeByte(6)
|
||||||
|
..write(obj.qrCodeImage)
|
||||||
|
..writeByte(7)
|
||||||
|
..write(obj.giftType)
|
||||||
|
..writeByte(8)
|
||||||
|
..write(obj.pointsCost)
|
||||||
|
..writeByte(9)
|
||||||
|
..write(obj.cashValue)
|
||||||
|
..writeByte(10)
|
||||||
|
..write(obj.expiryDate)
|
||||||
|
..writeByte(11)
|
||||||
|
..write(obj.status)
|
||||||
|
..writeByte(12)
|
||||||
|
..write(obj.redeemedAt)
|
||||||
|
..writeByte(13)
|
||||||
|
..write(obj.usedAt)
|
||||||
|
..writeByte(14)
|
||||||
|
..write(obj.usedLocation)
|
||||||
|
..writeByte(15)
|
||||||
|
..write(obj.usedReference);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => typeId.hashCode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
other is RedeemedGiftModelAdapter &&
|
||||||
|
runtimeType == other.runtimeType &&
|
||||||
|
typeId == other.typeId;
|
||||||
|
}
|
||||||
212
lib/features/loyalty/domain/entities/gift_catalog.dart
Normal file
212
lib/features/loyalty/domain/entities/gift_catalog.dart
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
/// Domain Entity: Gift Catalog
|
||||||
|
///
|
||||||
|
/// Represents a redeemable gift in the loyalty program catalog.
|
||||||
|
library;
|
||||||
|
|
||||||
|
/// Gift category enum
|
||||||
|
enum GiftCategory {
|
||||||
|
/// Voucher gift
|
||||||
|
voucher,
|
||||||
|
|
||||||
|
/// Physical product
|
||||||
|
product,
|
||||||
|
|
||||||
|
/// Service
|
||||||
|
service,
|
||||||
|
|
||||||
|
/// Discount coupon
|
||||||
|
discount,
|
||||||
|
|
||||||
|
/// Other type
|
||||||
|
other;
|
||||||
|
|
||||||
|
/// Get display name for category
|
||||||
|
String get displayName {
|
||||||
|
switch (this) {
|
||||||
|
case GiftCategory.voucher:
|
||||||
|
return 'Voucher';
|
||||||
|
case GiftCategory.product:
|
||||||
|
return 'Product';
|
||||||
|
case GiftCategory.service:
|
||||||
|
return 'Service';
|
||||||
|
case GiftCategory.discount:
|
||||||
|
return 'Discount';
|
||||||
|
case GiftCategory.other:
|
||||||
|
return 'Other';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gift Catalog Entity
|
||||||
|
///
|
||||||
|
/// Contains information about a redeemable gift:
|
||||||
|
/// - Gift details (name, description, image)
|
||||||
|
/// - Pricing in points
|
||||||
|
/// - Availability
|
||||||
|
/// - Terms and conditions
|
||||||
|
class GiftCatalog {
|
||||||
|
/// Unique catalog item identifier
|
||||||
|
final String catalogId;
|
||||||
|
|
||||||
|
/// Gift name
|
||||||
|
final String name;
|
||||||
|
|
||||||
|
/// Gift description
|
||||||
|
final String? description;
|
||||||
|
|
||||||
|
/// Gift image URL
|
||||||
|
final String? imageUrl;
|
||||||
|
|
||||||
|
/// Gift category
|
||||||
|
final GiftCategory category;
|
||||||
|
|
||||||
|
/// Points cost to redeem
|
||||||
|
final int pointsCost;
|
||||||
|
|
||||||
|
/// Cash value equivalent
|
||||||
|
final double? cashValue;
|
||||||
|
|
||||||
|
/// Quantity available for redemption
|
||||||
|
final int quantityAvailable;
|
||||||
|
|
||||||
|
/// Quantity already redeemed
|
||||||
|
final int quantityRedeemed;
|
||||||
|
|
||||||
|
/// Terms and conditions
|
||||||
|
final String? termsConditions;
|
||||||
|
|
||||||
|
/// Gift is active and available
|
||||||
|
final bool isActive;
|
||||||
|
|
||||||
|
/// Valid from date
|
||||||
|
final DateTime? validFrom;
|
||||||
|
|
||||||
|
/// Valid until date
|
||||||
|
final DateTime? validUntil;
|
||||||
|
|
||||||
|
/// Creation timestamp
|
||||||
|
final DateTime createdAt;
|
||||||
|
|
||||||
|
/// Last update timestamp
|
||||||
|
final DateTime updatedAt;
|
||||||
|
|
||||||
|
const GiftCatalog({
|
||||||
|
required this.catalogId,
|
||||||
|
required this.name,
|
||||||
|
this.description,
|
||||||
|
this.imageUrl,
|
||||||
|
required this.category,
|
||||||
|
required this.pointsCost,
|
||||||
|
this.cashValue,
|
||||||
|
required this.quantityAvailable,
|
||||||
|
required this.quantityRedeemed,
|
||||||
|
this.termsConditions,
|
||||||
|
required this.isActive,
|
||||||
|
this.validFrom,
|
||||||
|
this.validUntil,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Check if gift is available for redemption
|
||||||
|
bool get isAvailable => isActive && quantityRemaining > 0 && isCurrentlyValid;
|
||||||
|
|
||||||
|
/// Get remaining quantity
|
||||||
|
int get quantityRemaining => quantityAvailable - quantityRedeemed;
|
||||||
|
|
||||||
|
/// Check if gift is in stock
|
||||||
|
bool get isInStock => quantityRemaining > 0;
|
||||||
|
|
||||||
|
/// Check if gift is currently valid (date range)
|
||||||
|
bool get isCurrentlyValid {
|
||||||
|
final now = DateTime.now();
|
||||||
|
if (validFrom != null && now.isBefore(validFrom!)) return false;
|
||||||
|
if (validUntil != null && now.isAfter(validUntil!)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if gift is coming soon
|
||||||
|
bool get isComingSoon {
|
||||||
|
if (validFrom == null) return false;
|
||||||
|
return DateTime.now().isBefore(validFrom!);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if gift has expired
|
||||||
|
bool get hasExpired {
|
||||||
|
if (validUntil == null) return false;
|
||||||
|
return DateTime.now().isAfter(validUntil!);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get redemption percentage
|
||||||
|
double get redemptionPercentage {
|
||||||
|
if (quantityAvailable == 0) return 0;
|
||||||
|
return (quantityRedeemed / quantityAvailable) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy with method for immutability
|
||||||
|
GiftCatalog copyWith({
|
||||||
|
String? catalogId,
|
||||||
|
String? name,
|
||||||
|
String? description,
|
||||||
|
String? imageUrl,
|
||||||
|
GiftCategory? category,
|
||||||
|
int? pointsCost,
|
||||||
|
double? cashValue,
|
||||||
|
int? quantityAvailable,
|
||||||
|
int? quantityRedeemed,
|
||||||
|
String? termsConditions,
|
||||||
|
bool? isActive,
|
||||||
|
DateTime? validFrom,
|
||||||
|
DateTime? validUntil,
|
||||||
|
DateTime? createdAt,
|
||||||
|
DateTime? updatedAt,
|
||||||
|
}) {
|
||||||
|
return GiftCatalog(
|
||||||
|
catalogId: catalogId ?? this.catalogId,
|
||||||
|
name: name ?? this.name,
|
||||||
|
description: description ?? this.description,
|
||||||
|
imageUrl: imageUrl ?? this.imageUrl,
|
||||||
|
category: category ?? this.category,
|
||||||
|
pointsCost: pointsCost ?? this.pointsCost,
|
||||||
|
cashValue: cashValue ?? this.cashValue,
|
||||||
|
quantityAvailable: quantityAvailable ?? this.quantityAvailable,
|
||||||
|
quantityRedeemed: quantityRedeemed ?? this.quantityRedeemed,
|
||||||
|
termsConditions: termsConditions ?? this.termsConditions,
|
||||||
|
isActive: isActive ?? this.isActive,
|
||||||
|
validFrom: validFrom ?? this.validFrom,
|
||||||
|
validUntil: validUntil ?? this.validUntil,
|
||||||
|
createdAt: createdAt ?? this.createdAt,
|
||||||
|
updatedAt: updatedAt ?? this.updatedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (identical(this, other)) return true;
|
||||||
|
|
||||||
|
return other is GiftCatalog &&
|
||||||
|
other.catalogId == catalogId &&
|
||||||
|
other.name == name &&
|
||||||
|
other.category == category &&
|
||||||
|
other.pointsCost == pointsCost &&
|
||||||
|
other.isActive == isActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode {
|
||||||
|
return Object.hash(
|
||||||
|
catalogId,
|
||||||
|
name,
|
||||||
|
category,
|
||||||
|
pointsCost,
|
||||||
|
isActive,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'GiftCatalog(catalogId: $catalogId, name: $name, category: $category, '
|
||||||
|
'pointsCost: $pointsCost, quantityRemaining: $quantityRemaining, '
|
||||||
|
'isAvailable: $isAvailable)';
|
||||||
|
}
|
||||||
|
}
|
||||||
232
lib/features/loyalty/domain/entities/loyalty_point_entry.dart
Normal file
232
lib/features/loyalty/domain/entities/loyalty_point_entry.dart
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
/// Domain Entity: Loyalty Point Entry
|
||||||
|
///
|
||||||
|
/// Represents a single loyalty points transaction.
|
||||||
|
library;
|
||||||
|
|
||||||
|
/// Entry type enum
|
||||||
|
enum EntryType {
|
||||||
|
/// Points earned
|
||||||
|
earn,
|
||||||
|
|
||||||
|
/// Points spent/redeemed
|
||||||
|
redeem,
|
||||||
|
|
||||||
|
/// Points adjusted by admin
|
||||||
|
adjustment,
|
||||||
|
|
||||||
|
/// Points expired
|
||||||
|
expiry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Entry source enum
|
||||||
|
enum EntrySource {
|
||||||
|
/// Points from order purchase
|
||||||
|
order,
|
||||||
|
|
||||||
|
/// Points from referral
|
||||||
|
referral,
|
||||||
|
|
||||||
|
/// Points from gift redemption
|
||||||
|
redemption,
|
||||||
|
|
||||||
|
/// Points from project submission
|
||||||
|
project,
|
||||||
|
|
||||||
|
/// Points from points record
|
||||||
|
pointsRecord,
|
||||||
|
|
||||||
|
/// Manual adjustment by admin
|
||||||
|
manual,
|
||||||
|
|
||||||
|
/// Birthday bonus
|
||||||
|
birthday,
|
||||||
|
|
||||||
|
/// Welcome bonus
|
||||||
|
welcome,
|
||||||
|
|
||||||
|
/// Other source
|
||||||
|
other;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Complaint status enum
|
||||||
|
enum ComplaintStatus {
|
||||||
|
/// No complaint
|
||||||
|
none,
|
||||||
|
|
||||||
|
/// Complaint submitted
|
||||||
|
submitted,
|
||||||
|
|
||||||
|
/// Complaint under review
|
||||||
|
reviewing,
|
||||||
|
|
||||||
|
/// Complaint approved
|
||||||
|
approved,
|
||||||
|
|
||||||
|
/// Complaint rejected
|
||||||
|
rejected;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loyalty Point Entry Entity
|
||||||
|
///
|
||||||
|
/// Contains information about a single points transaction:
|
||||||
|
/// - Points amount (positive for earn, negative for redeem)
|
||||||
|
/// - Transaction type and source
|
||||||
|
/// - Reference to related entity
|
||||||
|
/// - Complaint handling
|
||||||
|
class LoyaltyPointEntry {
|
||||||
|
/// Unique entry identifier
|
||||||
|
final String entryId;
|
||||||
|
|
||||||
|
/// User ID
|
||||||
|
final String userId;
|
||||||
|
|
||||||
|
/// Points amount (positive for earn, negative for redeem)
|
||||||
|
final int points;
|
||||||
|
|
||||||
|
/// Entry type
|
||||||
|
final EntryType entryType;
|
||||||
|
|
||||||
|
/// Source of the points
|
||||||
|
final EntrySource source;
|
||||||
|
|
||||||
|
/// Description of the transaction
|
||||||
|
final String? description;
|
||||||
|
|
||||||
|
/// Reference ID to related entity (order ID, gift ID, etc.)
|
||||||
|
final String? referenceId;
|
||||||
|
|
||||||
|
/// Reference type (order, gift, project, etc.)
|
||||||
|
final String? referenceType;
|
||||||
|
|
||||||
|
/// Complaint details (if any)
|
||||||
|
final Map<String, dynamic>? complaint;
|
||||||
|
|
||||||
|
/// Complaint status
|
||||||
|
final ComplaintStatus complaintStatus;
|
||||||
|
|
||||||
|
/// Balance after this transaction
|
||||||
|
final int balanceAfter;
|
||||||
|
|
||||||
|
/// Points expiry date
|
||||||
|
final DateTime? expiryDate;
|
||||||
|
|
||||||
|
/// Transaction timestamp
|
||||||
|
final DateTime timestamp;
|
||||||
|
|
||||||
|
/// ERPNext entry ID
|
||||||
|
final String? erpnextEntryId;
|
||||||
|
|
||||||
|
const LoyaltyPointEntry({
|
||||||
|
required this.entryId,
|
||||||
|
required this.userId,
|
||||||
|
required this.points,
|
||||||
|
required this.entryType,
|
||||||
|
required this.source,
|
||||||
|
this.description,
|
||||||
|
this.referenceId,
|
||||||
|
this.referenceType,
|
||||||
|
this.complaint,
|
||||||
|
required this.complaintStatus,
|
||||||
|
required this.balanceAfter,
|
||||||
|
this.expiryDate,
|
||||||
|
required this.timestamp,
|
||||||
|
this.erpnextEntryId,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Check if points are earned (positive)
|
||||||
|
bool get isEarn => points > 0 && entryType == EntryType.earn;
|
||||||
|
|
||||||
|
/// Check if points are spent (negative)
|
||||||
|
bool get isRedeem => points < 0 && entryType == EntryType.redeem;
|
||||||
|
|
||||||
|
/// Check if entry has complaint
|
||||||
|
bool get hasComplaint => complaintStatus != ComplaintStatus.none;
|
||||||
|
|
||||||
|
/// Check if complaint is pending
|
||||||
|
bool get isComplaintPending =>
|
||||||
|
complaintStatus == ComplaintStatus.submitted ||
|
||||||
|
complaintStatus == ComplaintStatus.reviewing;
|
||||||
|
|
||||||
|
/// Check if points are expired
|
||||||
|
bool get isExpired {
|
||||||
|
if (expiryDate == null) return false;
|
||||||
|
return DateTime.now().isAfter(expiryDate!);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if points are expiring soon (within 30 days)
|
||||||
|
bool get isExpiringSoon {
|
||||||
|
if (expiryDate == null) return false;
|
||||||
|
final daysUntilExpiry = expiryDate!.difference(DateTime.now()).inDays;
|
||||||
|
return daysUntilExpiry > 0 && daysUntilExpiry <= 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get absolute points value
|
||||||
|
int get absolutePoints => points.abs();
|
||||||
|
|
||||||
|
/// Copy with method for immutability
|
||||||
|
LoyaltyPointEntry copyWith({
|
||||||
|
String? entryId,
|
||||||
|
String? userId,
|
||||||
|
int? points,
|
||||||
|
EntryType? entryType,
|
||||||
|
EntrySource? source,
|
||||||
|
String? description,
|
||||||
|
String? referenceId,
|
||||||
|
String? referenceType,
|
||||||
|
Map<String, dynamic>? complaint,
|
||||||
|
ComplaintStatus? complaintStatus,
|
||||||
|
int? balanceAfter,
|
||||||
|
DateTime? expiryDate,
|
||||||
|
DateTime? timestamp,
|
||||||
|
String? erpnextEntryId,
|
||||||
|
}) {
|
||||||
|
return LoyaltyPointEntry(
|
||||||
|
entryId: entryId ?? this.entryId,
|
||||||
|
userId: userId ?? this.userId,
|
||||||
|
points: points ?? this.points,
|
||||||
|
entryType: entryType ?? this.entryType,
|
||||||
|
source: source ?? this.source,
|
||||||
|
description: description ?? this.description,
|
||||||
|
referenceId: referenceId ?? this.referenceId,
|
||||||
|
referenceType: referenceType ?? this.referenceType,
|
||||||
|
complaint: complaint ?? this.complaint,
|
||||||
|
complaintStatus: complaintStatus ?? this.complaintStatus,
|
||||||
|
balanceAfter: balanceAfter ?? this.balanceAfter,
|
||||||
|
expiryDate: expiryDate ?? this.expiryDate,
|
||||||
|
timestamp: timestamp ?? this.timestamp,
|
||||||
|
erpnextEntryId: erpnextEntryId ?? this.erpnextEntryId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (identical(this, other)) return true;
|
||||||
|
|
||||||
|
return other is LoyaltyPointEntry &&
|
||||||
|
other.entryId == entryId &&
|
||||||
|
other.userId == userId &&
|
||||||
|
other.points == points &&
|
||||||
|
other.entryType == entryType &&
|
||||||
|
other.source == source &&
|
||||||
|
other.balanceAfter == balanceAfter;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode {
|
||||||
|
return Object.hash(
|
||||||
|
entryId,
|
||||||
|
userId,
|
||||||
|
points,
|
||||||
|
entryType,
|
||||||
|
source,
|
||||||
|
balanceAfter,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'LoyaltyPointEntry(entryId: $entryId, userId: $userId, points: $points, '
|
||||||
|
'entryType: $entryType, source: $source, balanceAfter: $balanceAfter, '
|
||||||
|
'timestamp: $timestamp)';
|
||||||
|
}
|
||||||
|
}
|
||||||
182
lib/features/loyalty/domain/entities/points_record.dart
Normal file
182
lib/features/loyalty/domain/entities/points_record.dart
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
/// Domain Entity: Points Record
|
||||||
|
///
|
||||||
|
/// Represents a user-submitted invoice for points earning.
|
||||||
|
library;
|
||||||
|
|
||||||
|
/// Points record status enum
|
||||||
|
enum PointsStatus {
|
||||||
|
/// Record submitted, pending review
|
||||||
|
pending,
|
||||||
|
|
||||||
|
/// Record approved, points awarded
|
||||||
|
approved,
|
||||||
|
|
||||||
|
/// Record rejected
|
||||||
|
rejected;
|
||||||
|
|
||||||
|
/// Get display name for status
|
||||||
|
String get displayName {
|
||||||
|
switch (this) {
|
||||||
|
case PointsStatus.pending:
|
||||||
|
return 'Pending';
|
||||||
|
case PointsStatus.approved:
|
||||||
|
return 'Approved';
|
||||||
|
case PointsStatus.rejected:
|
||||||
|
return 'Rejected';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Points Record Entity
|
||||||
|
///
|
||||||
|
/// Contains information about a user-submitted invoice:
|
||||||
|
/// - Invoice details
|
||||||
|
/// - Submission files/attachments
|
||||||
|
/// - Review status
|
||||||
|
/// - Points calculation
|
||||||
|
class PointsRecord {
|
||||||
|
/// Unique record identifier
|
||||||
|
final String recordId;
|
||||||
|
|
||||||
|
/// User ID who submitted
|
||||||
|
final String userId;
|
||||||
|
|
||||||
|
/// Invoice number
|
||||||
|
final String invoiceNumber;
|
||||||
|
|
||||||
|
/// Store/vendor name
|
||||||
|
final String storeName;
|
||||||
|
|
||||||
|
/// Transaction date
|
||||||
|
final DateTime transactionDate;
|
||||||
|
|
||||||
|
/// Invoice amount
|
||||||
|
final double invoiceAmount;
|
||||||
|
|
||||||
|
/// Additional notes
|
||||||
|
final String? notes;
|
||||||
|
|
||||||
|
/// Attachment URLs (invoice photos, receipts)
|
||||||
|
final List<String> attachments;
|
||||||
|
|
||||||
|
/// Record status
|
||||||
|
final PointsStatus status;
|
||||||
|
|
||||||
|
/// Rejection reason (if rejected)
|
||||||
|
final String? rejectReason;
|
||||||
|
|
||||||
|
/// Points earned (if approved)
|
||||||
|
final int? pointsEarned;
|
||||||
|
|
||||||
|
/// Submission timestamp
|
||||||
|
final DateTime submittedAt;
|
||||||
|
|
||||||
|
/// Processing timestamp
|
||||||
|
final DateTime? processedAt;
|
||||||
|
|
||||||
|
/// ID of admin who processed
|
||||||
|
final String? processedBy;
|
||||||
|
|
||||||
|
const PointsRecord({
|
||||||
|
required this.recordId,
|
||||||
|
required this.userId,
|
||||||
|
required this.invoiceNumber,
|
||||||
|
required this.storeName,
|
||||||
|
required this.transactionDate,
|
||||||
|
required this.invoiceAmount,
|
||||||
|
this.notes,
|
||||||
|
required this.attachments,
|
||||||
|
required this.status,
|
||||||
|
this.rejectReason,
|
||||||
|
this.pointsEarned,
|
||||||
|
required this.submittedAt,
|
||||||
|
this.processedAt,
|
||||||
|
this.processedBy,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Check if record is pending review
|
||||||
|
bool get isPending => status == PointsStatus.pending;
|
||||||
|
|
||||||
|
/// Check if record is approved
|
||||||
|
bool get isApproved => status == PointsStatus.approved;
|
||||||
|
|
||||||
|
/// Check if record is rejected
|
||||||
|
bool get isRejected => status == PointsStatus.rejected;
|
||||||
|
|
||||||
|
/// Check if record has been processed
|
||||||
|
bool get isProcessed => status != PointsStatus.pending;
|
||||||
|
|
||||||
|
/// Check if record has attachments
|
||||||
|
bool get hasAttachments => attachments.isNotEmpty;
|
||||||
|
|
||||||
|
/// Get processing time duration
|
||||||
|
Duration? get processingDuration {
|
||||||
|
if (processedAt == null) return null;
|
||||||
|
return processedAt!.difference(submittedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy with method for immutability
|
||||||
|
PointsRecord copyWith({
|
||||||
|
String? recordId,
|
||||||
|
String? userId,
|
||||||
|
String? invoiceNumber,
|
||||||
|
String? storeName,
|
||||||
|
DateTime? transactionDate,
|
||||||
|
double? invoiceAmount,
|
||||||
|
String? notes,
|
||||||
|
List<String>? attachments,
|
||||||
|
PointsStatus? status,
|
||||||
|
String? rejectReason,
|
||||||
|
int? pointsEarned,
|
||||||
|
DateTime? submittedAt,
|
||||||
|
DateTime? processedAt,
|
||||||
|
String? processedBy,
|
||||||
|
}) {
|
||||||
|
return PointsRecord(
|
||||||
|
recordId: recordId ?? this.recordId,
|
||||||
|
userId: userId ?? this.userId,
|
||||||
|
invoiceNumber: invoiceNumber ?? this.invoiceNumber,
|
||||||
|
storeName: storeName ?? this.storeName,
|
||||||
|
transactionDate: transactionDate ?? this.transactionDate,
|
||||||
|
invoiceAmount: invoiceAmount ?? this.invoiceAmount,
|
||||||
|
notes: notes ?? this.notes,
|
||||||
|
attachments: attachments ?? this.attachments,
|
||||||
|
status: status ?? this.status,
|
||||||
|
rejectReason: rejectReason ?? this.rejectReason,
|
||||||
|
pointsEarned: pointsEarned ?? this.pointsEarned,
|
||||||
|
submittedAt: submittedAt ?? this.submittedAt,
|
||||||
|
processedAt: processedAt ?? this.processedAt,
|
||||||
|
processedBy: processedBy ?? this.processedBy,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (identical(this, other)) return true;
|
||||||
|
|
||||||
|
return other is PointsRecord &&
|
||||||
|
other.recordId == recordId &&
|
||||||
|
other.userId == userId &&
|
||||||
|
other.invoiceNumber == invoiceNumber &&
|
||||||
|
other.invoiceAmount == invoiceAmount &&
|
||||||
|
other.status == status;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode {
|
||||||
|
return Object.hash(
|
||||||
|
recordId,
|
||||||
|
userId,
|
||||||
|
invoiceNumber,
|
||||||
|
invoiceAmount,
|
||||||
|
status,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'PointsRecord(recordId: $recordId, invoiceNumber: $invoiceNumber, '
|
||||||
|
'storeName: $storeName, invoiceAmount: $invoiceAmount, status: $status, '
|
||||||
|
'pointsEarned: $pointsEarned)';
|
||||||
|
}
|
||||||
|
}
|
||||||
207
lib/features/loyalty/domain/entities/redeemed_gift.dart
Normal file
207
lib/features/loyalty/domain/entities/redeemed_gift.dart
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
/// Domain Entity: Redeemed Gift
|
||||||
|
///
|
||||||
|
/// Represents a gift that has been redeemed by a user.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'gift_catalog.dart';
|
||||||
|
|
||||||
|
/// Gift status enum
|
||||||
|
enum GiftStatus {
|
||||||
|
/// Gift is active and can be used
|
||||||
|
active,
|
||||||
|
|
||||||
|
/// Gift has been used
|
||||||
|
used,
|
||||||
|
|
||||||
|
/// Gift has expired
|
||||||
|
expired,
|
||||||
|
|
||||||
|
/// Gift has been cancelled
|
||||||
|
cancelled;
|
||||||
|
|
||||||
|
/// Get display name for status
|
||||||
|
String get displayName {
|
||||||
|
switch (this) {
|
||||||
|
case GiftStatus.active:
|
||||||
|
return 'Active';
|
||||||
|
case GiftStatus.used:
|
||||||
|
return 'Used';
|
||||||
|
case GiftStatus.expired:
|
||||||
|
return 'Expired';
|
||||||
|
case GiftStatus.cancelled:
|
||||||
|
return 'Cancelled';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Redeemed Gift Entity
|
||||||
|
///
|
||||||
|
/// Contains information about a redeemed gift:
|
||||||
|
/// - Gift details
|
||||||
|
/// - Voucher code and QR code
|
||||||
|
/// - Usage tracking
|
||||||
|
/// - Expiry dates
|
||||||
|
class RedeemedGift {
|
||||||
|
/// Unique gift identifier
|
||||||
|
final String giftId;
|
||||||
|
|
||||||
|
/// User ID who redeemed the gift
|
||||||
|
final String userId;
|
||||||
|
|
||||||
|
/// Catalog ID of the gift
|
||||||
|
final String catalogId;
|
||||||
|
|
||||||
|
/// Gift name (snapshot at redemption time)
|
||||||
|
final String name;
|
||||||
|
|
||||||
|
/// Gift description
|
||||||
|
final String? description;
|
||||||
|
|
||||||
|
/// Voucher code
|
||||||
|
final String? voucherCode;
|
||||||
|
|
||||||
|
/// QR code image URL
|
||||||
|
final String? qrCodeImage;
|
||||||
|
|
||||||
|
/// Gift type/category
|
||||||
|
final GiftCategory giftType;
|
||||||
|
|
||||||
|
/// Points cost (snapshot at redemption time)
|
||||||
|
final int pointsCost;
|
||||||
|
|
||||||
|
/// Cash value (snapshot at redemption time)
|
||||||
|
final double? cashValue;
|
||||||
|
|
||||||
|
/// Expiry date
|
||||||
|
final DateTime? expiryDate;
|
||||||
|
|
||||||
|
/// Gift status
|
||||||
|
final GiftStatus status;
|
||||||
|
|
||||||
|
/// Redemption timestamp
|
||||||
|
final DateTime redeemedAt;
|
||||||
|
|
||||||
|
/// Usage timestamp
|
||||||
|
final DateTime? usedAt;
|
||||||
|
|
||||||
|
/// Location where gift was used
|
||||||
|
final String? usedLocation;
|
||||||
|
|
||||||
|
/// Reference number when used (e.g., order ID)
|
||||||
|
final String? usedReference;
|
||||||
|
|
||||||
|
const RedeemedGift({
|
||||||
|
required this.giftId,
|
||||||
|
required this.userId,
|
||||||
|
required this.catalogId,
|
||||||
|
required this.name,
|
||||||
|
this.description,
|
||||||
|
this.voucherCode,
|
||||||
|
this.qrCodeImage,
|
||||||
|
required this.giftType,
|
||||||
|
required this.pointsCost,
|
||||||
|
this.cashValue,
|
||||||
|
this.expiryDate,
|
||||||
|
required this.status,
|
||||||
|
required this.redeemedAt,
|
||||||
|
this.usedAt,
|
||||||
|
this.usedLocation,
|
||||||
|
this.usedReference,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Check if gift is active
|
||||||
|
bool get isActive => status == GiftStatus.active;
|
||||||
|
|
||||||
|
/// Check if gift is used
|
||||||
|
bool get isUsed => status == GiftStatus.used;
|
||||||
|
|
||||||
|
/// Check if gift is expired
|
||||||
|
bool get isExpired =>
|
||||||
|
status == GiftStatus.expired ||
|
||||||
|
(expiryDate != null && DateTime.now().isAfter(expiryDate!));
|
||||||
|
|
||||||
|
/// Check if gift can be used
|
||||||
|
bool get canBeUsed => isActive && !isExpired;
|
||||||
|
|
||||||
|
/// Check if gift is expiring soon (within 7 days)
|
||||||
|
bool get isExpiringSoon {
|
||||||
|
if (expiryDate == null || isExpired) return false;
|
||||||
|
final daysUntilExpiry = expiryDate!.difference(DateTime.now()).inDays;
|
||||||
|
return daysUntilExpiry > 0 && daysUntilExpiry <= 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get days until expiry
|
||||||
|
int? get daysUntilExpiry {
|
||||||
|
if (expiryDate == null) return null;
|
||||||
|
final days = expiryDate!.difference(DateTime.now()).inDays;
|
||||||
|
return days > 0 ? days : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy with method for immutability
|
||||||
|
RedeemedGift copyWith({
|
||||||
|
String? giftId,
|
||||||
|
String? userId,
|
||||||
|
String? catalogId,
|
||||||
|
String? name,
|
||||||
|
String? description,
|
||||||
|
String? voucherCode,
|
||||||
|
String? qrCodeImage,
|
||||||
|
GiftCategory? giftType,
|
||||||
|
int? pointsCost,
|
||||||
|
double? cashValue,
|
||||||
|
DateTime? expiryDate,
|
||||||
|
GiftStatus? status,
|
||||||
|
DateTime? redeemedAt,
|
||||||
|
DateTime? usedAt,
|
||||||
|
String? usedLocation,
|
||||||
|
String? usedReference,
|
||||||
|
}) {
|
||||||
|
return RedeemedGift(
|
||||||
|
giftId: giftId ?? this.giftId,
|
||||||
|
userId: userId ?? this.userId,
|
||||||
|
catalogId: catalogId ?? this.catalogId,
|
||||||
|
name: name ?? this.name,
|
||||||
|
description: description ?? this.description,
|
||||||
|
voucherCode: voucherCode ?? this.voucherCode,
|
||||||
|
qrCodeImage: qrCodeImage ?? this.qrCodeImage,
|
||||||
|
giftType: giftType ?? this.giftType,
|
||||||
|
pointsCost: pointsCost ?? this.pointsCost,
|
||||||
|
cashValue: cashValue ?? this.cashValue,
|
||||||
|
expiryDate: expiryDate ?? this.expiryDate,
|
||||||
|
status: status ?? this.status,
|
||||||
|
redeemedAt: redeemedAt ?? this.redeemedAt,
|
||||||
|
usedAt: usedAt ?? this.usedAt,
|
||||||
|
usedLocation: usedLocation ?? this.usedLocation,
|
||||||
|
usedReference: usedReference ?? this.usedReference,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (identical(this, other)) return true;
|
||||||
|
|
||||||
|
return other is RedeemedGift &&
|
||||||
|
other.giftId == giftId &&
|
||||||
|
other.userId == userId &&
|
||||||
|
other.catalogId == catalogId &&
|
||||||
|
other.voucherCode == voucherCode &&
|
||||||
|
other.status == status;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode {
|
||||||
|
return Object.hash(
|
||||||
|
giftId,
|
||||||
|
userId,
|
||||||
|
catalogId,
|
||||||
|
voucherCode,
|
||||||
|
status,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'RedeemedGift(giftId: $giftId, name: $name, voucherCode: $voucherCode, '
|
||||||
|
'status: $status, redeemedAt: $redeemedAt)';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:hive_ce/hive.dart';
|
||||||
|
import 'package:worker/core/constants/storage_constants.dart';
|
||||||
|
|
||||||
|
part 'notification_model.g.dart';
|
||||||
|
|
||||||
|
@HiveType(typeId: HiveTypeIds.notificationModel)
|
||||||
|
class NotificationModel extends HiveObject {
|
||||||
|
NotificationModel({required this.notificationId, required this.userId, required this.type, required this.title, required this.message, this.data, required this.isRead, required this.isPushed, required this.createdAt, this.readAt});
|
||||||
|
|
||||||
|
@HiveField(0) final String notificationId;
|
||||||
|
@HiveField(1) final String userId;
|
||||||
|
@HiveField(2) final String type;
|
||||||
|
@HiveField(3) final String title;
|
||||||
|
@HiveField(4) final String message;
|
||||||
|
@HiveField(5) final String? data;
|
||||||
|
@HiveField(6) final bool isRead;
|
||||||
|
@HiveField(7) final bool isPushed;
|
||||||
|
@HiveField(8) final DateTime createdAt;
|
||||||
|
@HiveField(9) final DateTime? readAt;
|
||||||
|
|
||||||
|
factory NotificationModel.fromJson(Map<String, dynamic> json) => NotificationModel(
|
||||||
|
notificationId: json['notification_id'] as String,
|
||||||
|
userId: json['user_id'] as String,
|
||||||
|
type: json['type'] as String,
|
||||||
|
title: json['title'] as String,
|
||||||
|
message: json['message'] as String,
|
||||||
|
data: json['data'] != null ? jsonEncode(json['data']) : null,
|
||||||
|
isRead: json['is_read'] as bool? ?? false,
|
||||||
|
isPushed: json['is_pushed'] as bool? ?? false,
|
||||||
|
createdAt: DateTime.parse(json['created_at']?.toString() ?? ''),
|
||||||
|
readAt: json['read_at'] != null ? DateTime.parse(json['read_at']?.toString() ?? '') : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'notification_id': notificationId,
|
||||||
|
'user_id': userId,
|
||||||
|
'type': type,
|
||||||
|
'title': title,
|
||||||
|
'message': message,
|
||||||
|
'data': data != null ? jsonDecode(data!) : null,
|
||||||
|
'is_read': isRead,
|
||||||
|
'is_pushed': isPushed,
|
||||||
|
'created_at': createdAt.toIso8601String(),
|
||||||
|
'read_at': readAt?.toIso8601String(),
|
||||||
|
};
|
||||||
|
|
||||||
|
Map<String, dynamic>? get dataMap {
|
||||||
|
if (data == null) return null;
|
||||||
|
try {
|
||||||
|
return jsonDecode(data!) as Map<String, dynamic>;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'notification_model.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// TypeAdapterGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
class NotificationModelAdapter extends TypeAdapter<NotificationModel> {
|
||||||
|
@override
|
||||||
|
final typeId = 20;
|
||||||
|
|
||||||
|
@override
|
||||||
|
NotificationModel read(BinaryReader reader) {
|
||||||
|
final numOfFields = reader.readByte();
|
||||||
|
final fields = <int, dynamic>{
|
||||||
|
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||||
|
};
|
||||||
|
return NotificationModel(
|
||||||
|
notificationId: fields[0] as String,
|
||||||
|
userId: fields[1] as String,
|
||||||
|
type: fields[2] as String,
|
||||||
|
title: fields[3] as String,
|
||||||
|
message: fields[4] as String,
|
||||||
|
data: fields[5] as String?,
|
||||||
|
isRead: fields[6] as bool,
|
||||||
|
isPushed: fields[7] as bool,
|
||||||
|
createdAt: fields[8] as DateTime,
|
||||||
|
readAt: fields[9] as DateTime?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void write(BinaryWriter writer, NotificationModel obj) {
|
||||||
|
writer
|
||||||
|
..writeByte(10)
|
||||||
|
..writeByte(0)
|
||||||
|
..write(obj.notificationId)
|
||||||
|
..writeByte(1)
|
||||||
|
..write(obj.userId)
|
||||||
|
..writeByte(2)
|
||||||
|
..write(obj.type)
|
||||||
|
..writeByte(3)
|
||||||
|
..write(obj.title)
|
||||||
|
..writeByte(4)
|
||||||
|
..write(obj.message)
|
||||||
|
..writeByte(5)
|
||||||
|
..write(obj.data)
|
||||||
|
..writeByte(6)
|
||||||
|
..write(obj.isRead)
|
||||||
|
..writeByte(7)
|
||||||
|
..write(obj.isPushed)
|
||||||
|
..writeByte(8)
|
||||||
|
..write(obj.createdAt)
|
||||||
|
..writeByte(9)
|
||||||
|
..write(obj.readAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => typeId.hashCode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
other is NotificationModelAdapter &&
|
||||||
|
runtimeType == other.runtimeType &&
|
||||||
|
typeId == other.typeId;
|
||||||
|
}
|
||||||
162
lib/features/notifications/domain/entities/notification.dart
Normal file
162
lib/features/notifications/domain/entities/notification.dart
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
/// Domain Entity: Notification
|
||||||
|
///
|
||||||
|
/// Represents a notification sent to a user.
|
||||||
|
library;
|
||||||
|
|
||||||
|
/// Notification Entity
|
||||||
|
///
|
||||||
|
/// Contains information about a notification:
|
||||||
|
/// - Notification type and content
|
||||||
|
/// - Associated data
|
||||||
|
/// - Read and push status
|
||||||
|
class Notification {
|
||||||
|
/// Unique notification identifier
|
||||||
|
final String notificationId;
|
||||||
|
|
||||||
|
/// User ID receiving the notification
|
||||||
|
final String userId;
|
||||||
|
|
||||||
|
/// Notification type (order, loyalty, promotion, system, etc.)
|
||||||
|
final String type;
|
||||||
|
|
||||||
|
/// Notification title
|
||||||
|
final String title;
|
||||||
|
|
||||||
|
/// Notification message/body
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
/// Additional data (JSON object with context-specific information)
|
||||||
|
final Map<String, dynamic>? data;
|
||||||
|
|
||||||
|
/// Notification has been read
|
||||||
|
final bool isRead;
|
||||||
|
|
||||||
|
/// Push notification has been sent
|
||||||
|
final bool isPushed;
|
||||||
|
|
||||||
|
/// Notification creation timestamp
|
||||||
|
final DateTime createdAt;
|
||||||
|
|
||||||
|
/// Read timestamp
|
||||||
|
final DateTime? readAt;
|
||||||
|
|
||||||
|
const Notification({
|
||||||
|
required this.notificationId,
|
||||||
|
required this.userId,
|
||||||
|
required this.type,
|
||||||
|
required this.title,
|
||||||
|
required this.message,
|
||||||
|
this.data,
|
||||||
|
required this.isRead,
|
||||||
|
required this.isPushed,
|
||||||
|
required this.createdAt,
|
||||||
|
this.readAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Check if notification is unread
|
||||||
|
bool get isUnread => !isRead;
|
||||||
|
|
||||||
|
/// Check if notification is order-related
|
||||||
|
bool get isOrderNotification => type.toLowerCase().contains('order');
|
||||||
|
|
||||||
|
/// Check if notification is loyalty-related
|
||||||
|
bool get isLoyaltyNotification => type.toLowerCase().contains('loyalty') ||
|
||||||
|
type.toLowerCase().contains('points');
|
||||||
|
|
||||||
|
/// Check if notification is promotion-related
|
||||||
|
bool get isPromotionNotification =>
|
||||||
|
type.toLowerCase().contains('promotion') ||
|
||||||
|
type.toLowerCase().contains('discount');
|
||||||
|
|
||||||
|
/// Check if notification is system-related
|
||||||
|
bool get isSystemNotification => type.toLowerCase().contains('system');
|
||||||
|
|
||||||
|
/// Get related entity ID from data
|
||||||
|
String? get relatedEntityId {
|
||||||
|
if (data == null) return null;
|
||||||
|
return data!['entity_id'] as String? ??
|
||||||
|
data!['order_id'] as String? ??
|
||||||
|
data!['quote_id'] as String?;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get related entity type from data
|
||||||
|
String? get relatedEntityType {
|
||||||
|
if (data == null) return null;
|
||||||
|
return data!['entity_type'] as String?;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get time since notification was created
|
||||||
|
Duration get timeSinceCreated {
|
||||||
|
return DateTime.now().difference(createdAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if notification is recent (less than 24 hours)
|
||||||
|
bool get isRecent {
|
||||||
|
return timeSinceCreated.inHours < 24;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if notification is old (more than 7 days)
|
||||||
|
bool get isOld {
|
||||||
|
return timeSinceCreated.inDays > 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy with method for immutability
|
||||||
|
Notification copyWith({
|
||||||
|
String? notificationId,
|
||||||
|
String? userId,
|
||||||
|
String? type,
|
||||||
|
String? title,
|
||||||
|
String? message,
|
||||||
|
Map<String, dynamic>? data,
|
||||||
|
bool? isRead,
|
||||||
|
bool? isPushed,
|
||||||
|
DateTime? createdAt,
|
||||||
|
DateTime? readAt,
|
||||||
|
}) {
|
||||||
|
return Notification(
|
||||||
|
notificationId: notificationId ?? this.notificationId,
|
||||||
|
userId: userId ?? this.userId,
|
||||||
|
type: type ?? this.type,
|
||||||
|
title: title ?? this.title,
|
||||||
|
message: message ?? this.message,
|
||||||
|
data: data ?? this.data,
|
||||||
|
isRead: isRead ?? this.isRead,
|
||||||
|
isPushed: isPushed ?? this.isPushed,
|
||||||
|
createdAt: createdAt ?? this.createdAt,
|
||||||
|
readAt: readAt ?? this.readAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (identical(this, other)) return true;
|
||||||
|
|
||||||
|
return other is Notification &&
|
||||||
|
other.notificationId == notificationId &&
|
||||||
|
other.userId == userId &&
|
||||||
|
other.type == type &&
|
||||||
|
other.title == title &&
|
||||||
|
other.message == message &&
|
||||||
|
other.isRead == isRead &&
|
||||||
|
other.isPushed == isPushed;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode {
|
||||||
|
return Object.hash(
|
||||||
|
notificationId,
|
||||||
|
userId,
|
||||||
|
type,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
isRead,
|
||||||
|
isPushed,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'Notification(notificationId: $notificationId, type: $type, '
|
||||||
|
'title: $title, isRead: $isRead, createdAt: $createdAt)';
|
||||||
|
}
|
||||||
|
}
|
||||||
86
lib/features/orders/data/models/invoice_model.dart
Normal file
86
lib/features/orders/data/models/invoice_model.dart
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import 'package:hive_ce/hive.dart';
|
||||||
|
import 'package:worker/core/constants/storage_constants.dart';
|
||||||
|
import 'package:worker/core/database/models/enums.dart';
|
||||||
|
|
||||||
|
part 'invoice_model.g.dart';
|
||||||
|
|
||||||
|
@HiveType(typeId: HiveTypeIds.invoiceModel)
|
||||||
|
class InvoiceModel extends HiveObject {
|
||||||
|
InvoiceModel({required this.invoiceId, required this.invoiceNumber, required this.userId, this.orderId, required this.invoiceType, required this.issueDate, required this.dueDate, required this.currency, required this.subtotalAmount, required this.taxAmount, required this.discountAmount, required this.shippingAmount, required this.totalAmount, required this.amountPaid, required this.amountRemaining, required this.status, this.paymentTerms, this.notes, this.erpnextInvoice, required this.createdAt, this.updatedAt, this.lastReminderSent});
|
||||||
|
|
||||||
|
@HiveField(0) final String invoiceId;
|
||||||
|
@HiveField(1) final String invoiceNumber;
|
||||||
|
@HiveField(2) final String userId;
|
||||||
|
@HiveField(3) final String? orderId;
|
||||||
|
@HiveField(4) final InvoiceType invoiceType;
|
||||||
|
@HiveField(5) final DateTime issueDate;
|
||||||
|
@HiveField(6) final DateTime dueDate;
|
||||||
|
@HiveField(7) final String currency;
|
||||||
|
@HiveField(8) final double subtotalAmount;
|
||||||
|
@HiveField(9) final double taxAmount;
|
||||||
|
@HiveField(10) final double discountAmount;
|
||||||
|
@HiveField(11) final double shippingAmount;
|
||||||
|
@HiveField(12) final double totalAmount;
|
||||||
|
@HiveField(13) final double amountPaid;
|
||||||
|
@HiveField(14) final double amountRemaining;
|
||||||
|
@HiveField(15) final InvoiceStatus status;
|
||||||
|
@HiveField(16) final String? paymentTerms;
|
||||||
|
@HiveField(17) final String? notes;
|
||||||
|
@HiveField(18) final String? erpnextInvoice;
|
||||||
|
@HiveField(19) final DateTime createdAt;
|
||||||
|
@HiveField(20) final DateTime? updatedAt;
|
||||||
|
@HiveField(21) final DateTime? lastReminderSent;
|
||||||
|
|
||||||
|
factory InvoiceModel.fromJson(Map<String, dynamic> json) => InvoiceModel(
|
||||||
|
invoiceId: json['invoice_id'] as String,
|
||||||
|
invoiceNumber: json['invoice_number'] as String,
|
||||||
|
userId: json['user_id'] as String,
|
||||||
|
orderId: json['order_id'] as String?,
|
||||||
|
invoiceType: InvoiceType.values.firstWhere((e) => e.name == json['invoice_type']),
|
||||||
|
issueDate: DateTime.parse(json['issue_date']?.toString() ?? ''),
|
||||||
|
dueDate: DateTime.parse(json['due_date']?.toString() ?? ''),
|
||||||
|
currency: json['currency'] as String? ?? 'VND',
|
||||||
|
subtotalAmount: (json['subtotal_amount'] as num).toDouble(),
|
||||||
|
taxAmount: (json['tax_amount'] as num).toDouble(),
|
||||||
|
discountAmount: (json['discount_amount'] as num).toDouble(),
|
||||||
|
shippingAmount: (json['shipping_amount'] as num).toDouble(),
|
||||||
|
totalAmount: (json['total_amount'] as num).toDouble(),
|
||||||
|
amountPaid: (json['amount_paid'] as num).toDouble(),
|
||||||
|
amountRemaining: (json['amount_remaining'] as num).toDouble(),
|
||||||
|
status: InvoiceStatus.values.firstWhere((e) => e.name == json['status']),
|
||||||
|
paymentTerms: json['payment_terms'] as String?,
|
||||||
|
notes: json['notes'] as String?,
|
||||||
|
erpnextInvoice: json['erpnext_invoice'] as String?,
|
||||||
|
createdAt: DateTime.parse(json['created_at']?.toString() ?? ''),
|
||||||
|
updatedAt: json['updated_at'] != null ? DateTime.parse(json['updated_at']?.toString() ?? '') : null,
|
||||||
|
lastReminderSent: json['last_reminder_sent'] != null ? DateTime.parse(json['last_reminder_sent']?.toString() ?? '') : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'invoice_id': invoiceId,
|
||||||
|
'invoice_number': invoiceNumber,
|
||||||
|
'user_id': userId,
|
||||||
|
'order_id': orderId,
|
||||||
|
'invoice_type': invoiceType.name,
|
||||||
|
'issue_date': issueDate.toIso8601String(),
|
||||||
|
'due_date': dueDate.toIso8601String(),
|
||||||
|
'currency': currency,
|
||||||
|
'subtotal_amount': subtotalAmount,
|
||||||
|
'tax_amount': taxAmount,
|
||||||
|
'discount_amount': discountAmount,
|
||||||
|
'shipping_amount': shippingAmount,
|
||||||
|
'total_amount': totalAmount,
|
||||||
|
'amount_paid': amountPaid,
|
||||||
|
'amount_remaining': amountRemaining,
|
||||||
|
'status': status.name,
|
||||||
|
'payment_terms': paymentTerms,
|
||||||
|
'notes': notes,
|
||||||
|
'erpnext_invoice': erpnextInvoice,
|
||||||
|
'created_at': createdAt.toIso8601String(),
|
||||||
|
'updated_at': updatedAt?.toIso8601String(),
|
||||||
|
'last_reminder_sent': lastReminderSent?.toIso8601String(),
|
||||||
|
};
|
||||||
|
|
||||||
|
bool get isOverdue => DateTime.now().isAfter(dueDate) && status != InvoiceStatus.paid;
|
||||||
|
bool get isPaid => status == InvoiceStatus.paid;
|
||||||
|
}
|
||||||
104
lib/features/orders/data/models/invoice_model.g.dart
Normal file
104
lib/features/orders/data/models/invoice_model.g.dart
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'invoice_model.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// TypeAdapterGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
class InvoiceModelAdapter extends TypeAdapter<InvoiceModel> {
|
||||||
|
@override
|
||||||
|
final typeId = 8;
|
||||||
|
|
||||||
|
@override
|
||||||
|
InvoiceModel read(BinaryReader reader) {
|
||||||
|
final numOfFields = reader.readByte();
|
||||||
|
final fields = <int, dynamic>{
|
||||||
|
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||||
|
};
|
||||||
|
return InvoiceModel(
|
||||||
|
invoiceId: fields[0] as String,
|
||||||
|
invoiceNumber: fields[1] as String,
|
||||||
|
userId: fields[2] as String,
|
||||||
|
orderId: fields[3] as String?,
|
||||||
|
invoiceType: fields[4] as InvoiceType,
|
||||||
|
issueDate: fields[5] as DateTime,
|
||||||
|
dueDate: fields[6] as DateTime,
|
||||||
|
currency: fields[7] as String,
|
||||||
|
subtotalAmount: (fields[8] as num).toDouble(),
|
||||||
|
taxAmount: (fields[9] as num).toDouble(),
|
||||||
|
discountAmount: (fields[10] as num).toDouble(),
|
||||||
|
shippingAmount: (fields[11] as num).toDouble(),
|
||||||
|
totalAmount: (fields[12] as num).toDouble(),
|
||||||
|
amountPaid: (fields[13] as num).toDouble(),
|
||||||
|
amountRemaining: (fields[14] as num).toDouble(),
|
||||||
|
status: fields[15] as InvoiceStatus,
|
||||||
|
paymentTerms: fields[16] as String?,
|
||||||
|
notes: fields[17] as String?,
|
||||||
|
erpnextInvoice: fields[18] as String?,
|
||||||
|
createdAt: fields[19] as DateTime,
|
||||||
|
updatedAt: fields[20] as DateTime?,
|
||||||
|
lastReminderSent: fields[21] as DateTime?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void write(BinaryWriter writer, InvoiceModel obj) {
|
||||||
|
writer
|
||||||
|
..writeByte(22)
|
||||||
|
..writeByte(0)
|
||||||
|
..write(obj.invoiceId)
|
||||||
|
..writeByte(1)
|
||||||
|
..write(obj.invoiceNumber)
|
||||||
|
..writeByte(2)
|
||||||
|
..write(obj.userId)
|
||||||
|
..writeByte(3)
|
||||||
|
..write(obj.orderId)
|
||||||
|
..writeByte(4)
|
||||||
|
..write(obj.invoiceType)
|
||||||
|
..writeByte(5)
|
||||||
|
..write(obj.issueDate)
|
||||||
|
..writeByte(6)
|
||||||
|
..write(obj.dueDate)
|
||||||
|
..writeByte(7)
|
||||||
|
..write(obj.currency)
|
||||||
|
..writeByte(8)
|
||||||
|
..write(obj.subtotalAmount)
|
||||||
|
..writeByte(9)
|
||||||
|
..write(obj.taxAmount)
|
||||||
|
..writeByte(10)
|
||||||
|
..write(obj.discountAmount)
|
||||||
|
..writeByte(11)
|
||||||
|
..write(obj.shippingAmount)
|
||||||
|
..writeByte(12)
|
||||||
|
..write(obj.totalAmount)
|
||||||
|
..writeByte(13)
|
||||||
|
..write(obj.amountPaid)
|
||||||
|
..writeByte(14)
|
||||||
|
..write(obj.amountRemaining)
|
||||||
|
..writeByte(15)
|
||||||
|
..write(obj.status)
|
||||||
|
..writeByte(16)
|
||||||
|
..write(obj.paymentTerms)
|
||||||
|
..writeByte(17)
|
||||||
|
..write(obj.notes)
|
||||||
|
..writeByte(18)
|
||||||
|
..write(obj.erpnextInvoice)
|
||||||
|
..writeByte(19)
|
||||||
|
..write(obj.createdAt)
|
||||||
|
..writeByte(20)
|
||||||
|
..write(obj.updatedAt)
|
||||||
|
..writeByte(21)
|
||||||
|
..write(obj.lastReminderSent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => typeId.hashCode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
other is InvoiceModelAdapter &&
|
||||||
|
runtimeType == other.runtimeType &&
|
||||||
|
typeId == other.typeId;
|
||||||
|
}
|
||||||
40
lib/features/orders/data/models/order_item_model.dart
Normal file
40
lib/features/orders/data/models/order_item_model.dart
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import 'package:hive_ce/hive.dart';
|
||||||
|
import 'package:worker/core/constants/storage_constants.dart';
|
||||||
|
|
||||||
|
part 'order_item_model.g.dart';
|
||||||
|
|
||||||
|
@HiveType(typeId: HiveTypeIds.orderItemModel)
|
||||||
|
class OrderItemModel extends HiveObject {
|
||||||
|
OrderItemModel({required this.orderItemId, required this.orderId, required this.productId, required this.quantity, required this.unitPrice, required this.discountPercent, required this.subtotal, this.notes});
|
||||||
|
|
||||||
|
@HiveField(0) final String orderItemId;
|
||||||
|
@HiveField(1) final String orderId;
|
||||||
|
@HiveField(2) final String productId;
|
||||||
|
@HiveField(3) final double quantity;
|
||||||
|
@HiveField(4) final double unitPrice;
|
||||||
|
@HiveField(5) final double discountPercent;
|
||||||
|
@HiveField(6) final double subtotal;
|
||||||
|
@HiveField(7) final String? notes;
|
||||||
|
|
||||||
|
factory OrderItemModel.fromJson(Map<String, dynamic> json) => OrderItemModel(
|
||||||
|
orderItemId: json['order_item_id'] as String,
|
||||||
|
orderId: json['order_id'] as String,
|
||||||
|
productId: json['product_id'] as String,
|
||||||
|
quantity: (json['quantity'] as num).toDouble(),
|
||||||
|
unitPrice: (json['unit_price'] as num).toDouble(),
|
||||||
|
discountPercent: (json['discount_percent'] as num).toDouble(),
|
||||||
|
subtotal: (json['subtotal'] as num).toDouble(),
|
||||||
|
notes: json['notes'] as String?,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'order_item_id': orderItemId,
|
||||||
|
'order_id': orderId,
|
||||||
|
'product_id': productId,
|
||||||
|
'quantity': quantity,
|
||||||
|
'unit_price': unitPrice,
|
||||||
|
'discount_percent': discountPercent,
|
||||||
|
'subtotal': subtotal,
|
||||||
|
'notes': notes,
|
||||||
|
};
|
||||||
|
}
|
||||||
62
lib/features/orders/data/models/order_item_model.g.dart
Normal file
62
lib/features/orders/data/models/order_item_model.g.dart
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'order_item_model.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// TypeAdapterGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
class OrderItemModelAdapter extends TypeAdapter<OrderItemModel> {
|
||||||
|
@override
|
||||||
|
final typeId = 7;
|
||||||
|
|
||||||
|
@override
|
||||||
|
OrderItemModel read(BinaryReader reader) {
|
||||||
|
final numOfFields = reader.readByte();
|
||||||
|
final fields = <int, dynamic>{
|
||||||
|
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||||
|
};
|
||||||
|
return OrderItemModel(
|
||||||
|
orderItemId: fields[0] as String,
|
||||||
|
orderId: fields[1] as String,
|
||||||
|
productId: fields[2] as String,
|
||||||
|
quantity: (fields[3] as num).toDouble(),
|
||||||
|
unitPrice: (fields[4] as num).toDouble(),
|
||||||
|
discountPercent: (fields[5] as num).toDouble(),
|
||||||
|
subtotal: (fields[6] as num).toDouble(),
|
||||||
|
notes: fields[7] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void write(BinaryWriter writer, OrderItemModel obj) {
|
||||||
|
writer
|
||||||
|
..writeByte(8)
|
||||||
|
..writeByte(0)
|
||||||
|
..write(obj.orderItemId)
|
||||||
|
..writeByte(1)
|
||||||
|
..write(obj.orderId)
|
||||||
|
..writeByte(2)
|
||||||
|
..write(obj.productId)
|
||||||
|
..writeByte(3)
|
||||||
|
..write(obj.quantity)
|
||||||
|
..writeByte(4)
|
||||||
|
..write(obj.unitPrice)
|
||||||
|
..writeByte(5)
|
||||||
|
..write(obj.discountPercent)
|
||||||
|
..writeByte(6)
|
||||||
|
..write(obj.subtotal)
|
||||||
|
..writeByte(7)
|
||||||
|
..write(obj.notes);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => typeId.hashCode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
other is OrderItemModelAdapter &&
|
||||||
|
runtimeType == other.runtimeType &&
|
||||||
|
typeId == other.typeId;
|
||||||
|
}
|
||||||
147
lib/features/orders/data/models/order_model.dart
Normal file
147
lib/features/orders/data/models/order_model.dart
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:hive_ce/hive.dart';
|
||||||
|
import 'package:worker/core/constants/storage_constants.dart';
|
||||||
|
import 'package:worker/core/database/models/enums.dart';
|
||||||
|
|
||||||
|
part 'order_model.g.dart';
|
||||||
|
|
||||||
|
/// Order Model - Type ID: 6
|
||||||
|
@HiveType(typeId: HiveTypeIds.orderModel)
|
||||||
|
class OrderModel extends HiveObject {
|
||||||
|
OrderModel({
|
||||||
|
required this.orderId,
|
||||||
|
required this.orderNumber,
|
||||||
|
required this.userId,
|
||||||
|
required this.status,
|
||||||
|
required this.totalAmount,
|
||||||
|
required this.discountAmount,
|
||||||
|
required this.taxAmount,
|
||||||
|
required this.shippingFee,
|
||||||
|
required this.finalAmount,
|
||||||
|
this.shippingAddress,
|
||||||
|
this.billingAddress,
|
||||||
|
this.expectedDeliveryDate,
|
||||||
|
this.actualDeliveryDate,
|
||||||
|
this.notes,
|
||||||
|
this.cancellationReason,
|
||||||
|
this.erpnextSalesOrder,
|
||||||
|
required this.createdAt,
|
||||||
|
this.updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
@HiveField(0)
|
||||||
|
final String orderId;
|
||||||
|
|
||||||
|
@HiveField(1)
|
||||||
|
final String orderNumber;
|
||||||
|
|
||||||
|
@HiveField(2)
|
||||||
|
final String userId;
|
||||||
|
|
||||||
|
@HiveField(3)
|
||||||
|
final OrderStatus status;
|
||||||
|
|
||||||
|
@HiveField(4)
|
||||||
|
final double totalAmount;
|
||||||
|
|
||||||
|
@HiveField(5)
|
||||||
|
final double discountAmount;
|
||||||
|
|
||||||
|
@HiveField(6)
|
||||||
|
final double taxAmount;
|
||||||
|
|
||||||
|
@HiveField(7)
|
||||||
|
final double shippingFee;
|
||||||
|
|
||||||
|
@HiveField(8)
|
||||||
|
final double finalAmount;
|
||||||
|
|
||||||
|
@HiveField(9)
|
||||||
|
final String? shippingAddress;
|
||||||
|
|
||||||
|
@HiveField(10)
|
||||||
|
final String? billingAddress;
|
||||||
|
|
||||||
|
@HiveField(11)
|
||||||
|
final DateTime? expectedDeliveryDate;
|
||||||
|
|
||||||
|
@HiveField(12)
|
||||||
|
final DateTime? actualDeliveryDate;
|
||||||
|
|
||||||
|
@HiveField(13)
|
||||||
|
final String? notes;
|
||||||
|
|
||||||
|
@HiveField(14)
|
||||||
|
final String? cancellationReason;
|
||||||
|
|
||||||
|
@HiveField(15)
|
||||||
|
final String? erpnextSalesOrder;
|
||||||
|
|
||||||
|
@HiveField(16)
|
||||||
|
final DateTime createdAt;
|
||||||
|
|
||||||
|
@HiveField(17)
|
||||||
|
final DateTime? updatedAt;
|
||||||
|
|
||||||
|
factory OrderModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
return OrderModel(
|
||||||
|
orderId: json['order_id'] as String,
|
||||||
|
orderNumber: json['order_number'] as String,
|
||||||
|
userId: json['user_id'] as String,
|
||||||
|
status: OrderStatus.values.firstWhere((e) => e.name == json['status']),
|
||||||
|
totalAmount: (json['total_amount'] as num).toDouble(),
|
||||||
|
discountAmount: (json['discount_amount'] as num).toDouble(),
|
||||||
|
taxAmount: (json['tax_amount'] as num).toDouble(),
|
||||||
|
shippingFee: (json['shipping_fee'] as num).toDouble(),
|
||||||
|
finalAmount: (json['final_amount'] as num).toDouble(),
|
||||||
|
shippingAddress: json['shipping_address'] != null ? jsonEncode(json['shipping_address']) : null,
|
||||||
|
billingAddress: json['billing_address'] != null ? jsonEncode(json['billing_address']) : null,
|
||||||
|
expectedDeliveryDate: json['expected_delivery_date'] != null ? DateTime.parse(json['expected_delivery_date']?.toString() ?? '') : null,
|
||||||
|
actualDeliveryDate: json['actual_delivery_date'] != null ? DateTime.parse(json['actual_delivery_date']?.toString() ?? '') : null,
|
||||||
|
notes: json['notes'] as String?,
|
||||||
|
cancellationReason: json['cancellation_reason'] as String?,
|
||||||
|
erpnextSalesOrder: json['erpnext_sales_order'] as String?,
|
||||||
|
createdAt: DateTime.parse(json['created_at']?.toString() ?? ''),
|
||||||
|
updatedAt: json['updated_at'] != null ? DateTime.parse(json['updated_at']?.toString() ?? '') : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'order_id': orderId,
|
||||||
|
'order_number': orderNumber,
|
||||||
|
'user_id': userId,
|
||||||
|
'status': status.name,
|
||||||
|
'total_amount': totalAmount,
|
||||||
|
'discount_amount': discountAmount,
|
||||||
|
'tax_amount': taxAmount,
|
||||||
|
'shipping_fee': shippingFee,
|
||||||
|
'final_amount': finalAmount,
|
||||||
|
'shipping_address': shippingAddress != null ? jsonDecode(shippingAddress!) : null,
|
||||||
|
'billing_address': billingAddress != null ? jsonDecode(billingAddress!) : null,
|
||||||
|
'expected_delivery_date': expectedDeliveryDate?.toIso8601String(),
|
||||||
|
'actual_delivery_date': actualDeliveryDate?.toIso8601String(),
|
||||||
|
'notes': notes,
|
||||||
|
'cancellation_reason': cancellationReason,
|
||||||
|
'erpnext_sales_order': erpnextSalesOrder,
|
||||||
|
'created_at': createdAt.toIso8601String(),
|
||||||
|
'updated_at': updatedAt?.toIso8601String(),
|
||||||
|
};
|
||||||
|
|
||||||
|
Map<String, dynamic>? get shippingAddressMap {
|
||||||
|
if (shippingAddress == null) return null;
|
||||||
|
try {
|
||||||
|
return jsonDecode(shippingAddress!) as Map<String, dynamic>;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic>? get billingAddressMap {
|
||||||
|
if (billingAddress == null) return null;
|
||||||
|
try {
|
||||||
|
return jsonDecode(billingAddress!) as Map<String, dynamic>;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
92
lib/features/orders/data/models/order_model.g.dart
Normal file
92
lib/features/orders/data/models/order_model.g.dart
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'order_model.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// TypeAdapterGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
class OrderModelAdapter extends TypeAdapter<OrderModel> {
|
||||||
|
@override
|
||||||
|
final typeId = 6;
|
||||||
|
|
||||||
|
@override
|
||||||
|
OrderModel read(BinaryReader reader) {
|
||||||
|
final numOfFields = reader.readByte();
|
||||||
|
final fields = <int, dynamic>{
|
||||||
|
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||||
|
};
|
||||||
|
return OrderModel(
|
||||||
|
orderId: fields[0] as String,
|
||||||
|
orderNumber: fields[1] as String,
|
||||||
|
userId: fields[2] as String,
|
||||||
|
status: fields[3] as OrderStatus,
|
||||||
|
totalAmount: (fields[4] as num).toDouble(),
|
||||||
|
discountAmount: (fields[5] as num).toDouble(),
|
||||||
|
taxAmount: (fields[6] as num).toDouble(),
|
||||||
|
shippingFee: (fields[7] as num).toDouble(),
|
||||||
|
finalAmount: (fields[8] as num).toDouble(),
|
||||||
|
shippingAddress: fields[9] as String?,
|
||||||
|
billingAddress: fields[10] as String?,
|
||||||
|
expectedDeliveryDate: fields[11] as DateTime?,
|
||||||
|
actualDeliveryDate: fields[12] as DateTime?,
|
||||||
|
notes: fields[13] as String?,
|
||||||
|
cancellationReason: fields[14] as String?,
|
||||||
|
erpnextSalesOrder: fields[15] as String?,
|
||||||
|
createdAt: fields[16] as DateTime,
|
||||||
|
updatedAt: fields[17] as DateTime?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void write(BinaryWriter writer, OrderModel obj) {
|
||||||
|
writer
|
||||||
|
..writeByte(18)
|
||||||
|
..writeByte(0)
|
||||||
|
..write(obj.orderId)
|
||||||
|
..writeByte(1)
|
||||||
|
..write(obj.orderNumber)
|
||||||
|
..writeByte(2)
|
||||||
|
..write(obj.userId)
|
||||||
|
..writeByte(3)
|
||||||
|
..write(obj.status)
|
||||||
|
..writeByte(4)
|
||||||
|
..write(obj.totalAmount)
|
||||||
|
..writeByte(5)
|
||||||
|
..write(obj.discountAmount)
|
||||||
|
..writeByte(6)
|
||||||
|
..write(obj.taxAmount)
|
||||||
|
..writeByte(7)
|
||||||
|
..write(obj.shippingFee)
|
||||||
|
..writeByte(8)
|
||||||
|
..write(obj.finalAmount)
|
||||||
|
..writeByte(9)
|
||||||
|
..write(obj.shippingAddress)
|
||||||
|
..writeByte(10)
|
||||||
|
..write(obj.billingAddress)
|
||||||
|
..writeByte(11)
|
||||||
|
..write(obj.expectedDeliveryDate)
|
||||||
|
..writeByte(12)
|
||||||
|
..write(obj.actualDeliveryDate)
|
||||||
|
..writeByte(13)
|
||||||
|
..write(obj.notes)
|
||||||
|
..writeByte(14)
|
||||||
|
..write(obj.cancellationReason)
|
||||||
|
..writeByte(15)
|
||||||
|
..write(obj.erpnextSalesOrder)
|
||||||
|
..writeByte(16)
|
||||||
|
..write(obj.createdAt)
|
||||||
|
..writeByte(17)
|
||||||
|
..write(obj.updatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => typeId.hashCode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
other is OrderModelAdapter &&
|
||||||
|
runtimeType == other.runtimeType &&
|
||||||
|
typeId == other.typeId;
|
||||||
|
}
|
||||||
62
lib/features/orders/data/models/payment_line_model.dart
Normal file
62
lib/features/orders/data/models/payment_line_model.dart
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import 'package:hive_ce/hive.dart';
|
||||||
|
import 'package:worker/core/constants/storage_constants.dart';
|
||||||
|
import 'package:worker/core/database/models/enums.dart';
|
||||||
|
|
||||||
|
part 'payment_line_model.g.dart';
|
||||||
|
|
||||||
|
@HiveType(typeId: HiveTypeIds.paymentLineModel)
|
||||||
|
class PaymentLineModel extends HiveObject {
|
||||||
|
PaymentLineModel({required this.paymentLineId, required this.invoiceId, required this.paymentNumber, required this.paymentDate, required this.amount, required this.paymentMethod, this.bankName, this.bankAccount, this.referenceNumber, this.notes, required this.status, this.receiptUrl, this.erpnextPaymentEntry, required this.createdAt, this.processedAt});
|
||||||
|
|
||||||
|
@HiveField(0) final String paymentLineId;
|
||||||
|
@HiveField(1) final String invoiceId;
|
||||||
|
@HiveField(2) final String paymentNumber;
|
||||||
|
@HiveField(3) final DateTime paymentDate;
|
||||||
|
@HiveField(4) final double amount;
|
||||||
|
@HiveField(5) final PaymentMethod paymentMethod;
|
||||||
|
@HiveField(6) final String? bankName;
|
||||||
|
@HiveField(7) final String? bankAccount;
|
||||||
|
@HiveField(8) final String? referenceNumber;
|
||||||
|
@HiveField(9) final String? notes;
|
||||||
|
@HiveField(10) final PaymentStatus status;
|
||||||
|
@HiveField(11) final String? receiptUrl;
|
||||||
|
@HiveField(12) final String? erpnextPaymentEntry;
|
||||||
|
@HiveField(13) final DateTime createdAt;
|
||||||
|
@HiveField(14) final DateTime? processedAt;
|
||||||
|
|
||||||
|
factory PaymentLineModel.fromJson(Map<String, dynamic> json) => PaymentLineModel(
|
||||||
|
paymentLineId: json['payment_line_id'] as String,
|
||||||
|
invoiceId: json['invoice_id'] as String,
|
||||||
|
paymentNumber: json['payment_number'] as String,
|
||||||
|
paymentDate: DateTime.parse(json['payment_date']?.toString() ?? ''),
|
||||||
|
amount: (json['amount'] as num).toDouble(),
|
||||||
|
paymentMethod: PaymentMethod.values.firstWhere((e) => e.name == json['payment_method']),
|
||||||
|
bankName: json['bank_name'] as String?,
|
||||||
|
bankAccount: json['bank_account'] as String?,
|
||||||
|
referenceNumber: json['reference_number'] as String?,
|
||||||
|
notes: json['notes'] as String?,
|
||||||
|
status: PaymentStatus.values.firstWhere((e) => e.name == json['status']),
|
||||||
|
receiptUrl: json['receipt_url'] as String?,
|
||||||
|
erpnextPaymentEntry: json['erpnext_payment_entry'] as String?,
|
||||||
|
createdAt: DateTime.parse(json['created_at']?.toString() ?? ''),
|
||||||
|
processedAt: json['processed_at'] != null ? DateTime.parse(json['processed_at']?.toString() ?? '') : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'payment_line_id': paymentLineId,
|
||||||
|
'invoice_id': invoiceId,
|
||||||
|
'payment_number': paymentNumber,
|
||||||
|
'payment_date': paymentDate.toIso8601String(),
|
||||||
|
'amount': amount,
|
||||||
|
'payment_method': paymentMethod.name,
|
||||||
|
'bank_name': bankName,
|
||||||
|
'bank_account': bankAccount,
|
||||||
|
'reference_number': referenceNumber,
|
||||||
|
'notes': notes,
|
||||||
|
'status': status.name,
|
||||||
|
'receipt_url': receiptUrl,
|
||||||
|
'erpnext_payment_entry': erpnextPaymentEntry,
|
||||||
|
'created_at': createdAt.toIso8601String(),
|
||||||
|
'processed_at': processedAt?.toIso8601String(),
|
||||||
|
};
|
||||||
|
}
|
||||||
83
lib/features/orders/data/models/payment_line_model.g.dart
Normal file
83
lib/features/orders/data/models/payment_line_model.g.dart
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'payment_line_model.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// TypeAdapterGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
class PaymentLineModelAdapter extends TypeAdapter<PaymentLineModel> {
|
||||||
|
@override
|
||||||
|
final typeId = 9;
|
||||||
|
|
||||||
|
@override
|
||||||
|
PaymentLineModel read(BinaryReader reader) {
|
||||||
|
final numOfFields = reader.readByte();
|
||||||
|
final fields = <int, dynamic>{
|
||||||
|
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||||
|
};
|
||||||
|
return PaymentLineModel(
|
||||||
|
paymentLineId: fields[0] as String,
|
||||||
|
invoiceId: fields[1] as String,
|
||||||
|
paymentNumber: fields[2] as String,
|
||||||
|
paymentDate: fields[3] as DateTime,
|
||||||
|
amount: (fields[4] as num).toDouble(),
|
||||||
|
paymentMethod: fields[5] as PaymentMethod,
|
||||||
|
bankName: fields[6] as String?,
|
||||||
|
bankAccount: fields[7] as String?,
|
||||||
|
referenceNumber: fields[8] as String?,
|
||||||
|
notes: fields[9] as String?,
|
||||||
|
status: fields[10] as PaymentStatus,
|
||||||
|
receiptUrl: fields[11] as String?,
|
||||||
|
erpnextPaymentEntry: fields[12] as String?,
|
||||||
|
createdAt: fields[13] as DateTime,
|
||||||
|
processedAt: fields[14] as DateTime?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void write(BinaryWriter writer, PaymentLineModel obj) {
|
||||||
|
writer
|
||||||
|
..writeByte(15)
|
||||||
|
..writeByte(0)
|
||||||
|
..write(obj.paymentLineId)
|
||||||
|
..writeByte(1)
|
||||||
|
..write(obj.invoiceId)
|
||||||
|
..writeByte(2)
|
||||||
|
..write(obj.paymentNumber)
|
||||||
|
..writeByte(3)
|
||||||
|
..write(obj.paymentDate)
|
||||||
|
..writeByte(4)
|
||||||
|
..write(obj.amount)
|
||||||
|
..writeByte(5)
|
||||||
|
..write(obj.paymentMethod)
|
||||||
|
..writeByte(6)
|
||||||
|
..write(obj.bankName)
|
||||||
|
..writeByte(7)
|
||||||
|
..write(obj.bankAccount)
|
||||||
|
..writeByte(8)
|
||||||
|
..write(obj.referenceNumber)
|
||||||
|
..writeByte(9)
|
||||||
|
..write(obj.notes)
|
||||||
|
..writeByte(10)
|
||||||
|
..write(obj.status)
|
||||||
|
..writeByte(11)
|
||||||
|
..write(obj.receiptUrl)
|
||||||
|
..writeByte(12)
|
||||||
|
..write(obj.erpnextPaymentEntry)
|
||||||
|
..writeByte(13)
|
||||||
|
..write(obj.createdAt)
|
||||||
|
..writeByte(14)
|
||||||
|
..write(obj.processedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => typeId.hashCode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
other is PaymentLineModelAdapter &&
|
||||||
|
runtimeType == other.runtimeType &&
|
||||||
|
typeId == other.typeId;
|
||||||
|
}
|
||||||
273
lib/features/orders/domain/entities/invoice.dart
Normal file
273
lib/features/orders/domain/entities/invoice.dart
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
/// Domain Entity: Invoice
|
||||||
|
///
|
||||||
|
/// Represents an invoice for an order.
|
||||||
|
library;
|
||||||
|
|
||||||
|
/// Invoice type enum
|
||||||
|
enum InvoiceType {
|
||||||
|
/// Standard invoice
|
||||||
|
standard,
|
||||||
|
|
||||||
|
/// Proforma invoice
|
||||||
|
proforma,
|
||||||
|
|
||||||
|
/// Credit note
|
||||||
|
creditNote,
|
||||||
|
|
||||||
|
/// Debit note
|
||||||
|
debitNote;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Invoice status enum
|
||||||
|
enum InvoiceStatus {
|
||||||
|
/// Draft invoice
|
||||||
|
draft,
|
||||||
|
|
||||||
|
/// Invoice has been submitted
|
||||||
|
submitted,
|
||||||
|
|
||||||
|
/// Partially paid
|
||||||
|
partiallyPaid,
|
||||||
|
|
||||||
|
/// Fully paid
|
||||||
|
paid,
|
||||||
|
|
||||||
|
/// Overdue invoice
|
||||||
|
overdue,
|
||||||
|
|
||||||
|
/// Cancelled invoice
|
||||||
|
cancelled;
|
||||||
|
|
||||||
|
/// Get display name for status
|
||||||
|
String get displayName {
|
||||||
|
switch (this) {
|
||||||
|
case InvoiceStatus.draft:
|
||||||
|
return 'Draft';
|
||||||
|
case InvoiceStatus.submitted:
|
||||||
|
return 'Submitted';
|
||||||
|
case InvoiceStatus.partiallyPaid:
|
||||||
|
return 'Partially Paid';
|
||||||
|
case InvoiceStatus.paid:
|
||||||
|
return 'Paid';
|
||||||
|
case InvoiceStatus.overdue:
|
||||||
|
return 'Overdue';
|
||||||
|
case InvoiceStatus.cancelled:
|
||||||
|
return 'Cancelled';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Invoice Entity
|
||||||
|
///
|
||||||
|
/// Contains complete invoice information:
|
||||||
|
/// - Invoice identification
|
||||||
|
/// - Associated order
|
||||||
|
/// - Amounts and calculations
|
||||||
|
/// - Payment tracking
|
||||||
|
/// - Status and dates
|
||||||
|
class Invoice {
|
||||||
|
/// Unique invoice identifier
|
||||||
|
final String invoiceId;
|
||||||
|
|
||||||
|
/// Invoice number (human-readable)
|
||||||
|
final String invoiceNumber;
|
||||||
|
|
||||||
|
/// User ID
|
||||||
|
final String userId;
|
||||||
|
|
||||||
|
/// Order ID
|
||||||
|
final String? orderId;
|
||||||
|
|
||||||
|
/// Invoice type
|
||||||
|
final InvoiceType invoiceType;
|
||||||
|
|
||||||
|
/// Issue date
|
||||||
|
final DateTime issueDate;
|
||||||
|
|
||||||
|
/// Due date
|
||||||
|
final DateTime dueDate;
|
||||||
|
|
||||||
|
/// Currency code (e.g., VND, USD)
|
||||||
|
final String currency;
|
||||||
|
|
||||||
|
/// Subtotal amount
|
||||||
|
final double subtotalAmount;
|
||||||
|
|
||||||
|
/// Tax amount
|
||||||
|
final double taxAmount;
|
||||||
|
|
||||||
|
/// Discount amount
|
||||||
|
final double discountAmount;
|
||||||
|
|
||||||
|
/// Shipping amount
|
||||||
|
final double shippingAmount;
|
||||||
|
|
||||||
|
/// Total amount
|
||||||
|
final double totalAmount;
|
||||||
|
|
||||||
|
/// Amount paid so far
|
||||||
|
final double amountPaid;
|
||||||
|
|
||||||
|
/// Amount remaining to be paid
|
||||||
|
final double amountRemaining;
|
||||||
|
|
||||||
|
/// Invoice status
|
||||||
|
final InvoiceStatus status;
|
||||||
|
|
||||||
|
/// Payment terms
|
||||||
|
final String? paymentTerms;
|
||||||
|
|
||||||
|
/// Invoice notes
|
||||||
|
final String? notes;
|
||||||
|
|
||||||
|
/// ERPNext invoice reference
|
||||||
|
final String? erpnextInvoice;
|
||||||
|
|
||||||
|
/// Creation timestamp
|
||||||
|
final DateTime createdAt;
|
||||||
|
|
||||||
|
/// Last update timestamp
|
||||||
|
final DateTime updatedAt;
|
||||||
|
|
||||||
|
/// Last reminder sent timestamp
|
||||||
|
final DateTime? lastReminderSent;
|
||||||
|
|
||||||
|
const Invoice({
|
||||||
|
required this.invoiceId,
|
||||||
|
required this.invoiceNumber,
|
||||||
|
required this.userId,
|
||||||
|
this.orderId,
|
||||||
|
required this.invoiceType,
|
||||||
|
required this.issueDate,
|
||||||
|
required this.dueDate,
|
||||||
|
required this.currency,
|
||||||
|
required this.subtotalAmount,
|
||||||
|
required this.taxAmount,
|
||||||
|
required this.discountAmount,
|
||||||
|
required this.shippingAmount,
|
||||||
|
required this.totalAmount,
|
||||||
|
required this.amountPaid,
|
||||||
|
required this.amountRemaining,
|
||||||
|
required this.status,
|
||||||
|
this.paymentTerms,
|
||||||
|
this.notes,
|
||||||
|
this.erpnextInvoice,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.updatedAt,
|
||||||
|
this.lastReminderSent,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Check if invoice is fully paid
|
||||||
|
bool get isPaid => status == InvoiceStatus.paid || amountRemaining <= 0;
|
||||||
|
|
||||||
|
/// Check if invoice is overdue
|
||||||
|
bool get isOverdue =>
|
||||||
|
status == InvoiceStatus.overdue ||
|
||||||
|
(!isPaid && DateTime.now().isAfter(dueDate));
|
||||||
|
|
||||||
|
/// Check if invoice is partially paid
|
||||||
|
bool get isPartiallyPaid =>
|
||||||
|
amountPaid > 0 && amountPaid < totalAmount;
|
||||||
|
|
||||||
|
/// Get payment percentage
|
||||||
|
double get paymentPercentage {
|
||||||
|
if (totalAmount == 0) return 0;
|
||||||
|
return (amountPaid / totalAmount) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get days until due
|
||||||
|
int get daysUntilDue => dueDate.difference(DateTime.now()).inDays;
|
||||||
|
|
||||||
|
/// Get days overdue
|
||||||
|
int get daysOverdue {
|
||||||
|
if (!isOverdue) return 0;
|
||||||
|
return DateTime.now().difference(dueDate).inDays;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy with method for immutability
|
||||||
|
Invoice copyWith({
|
||||||
|
String? invoiceId,
|
||||||
|
String? invoiceNumber,
|
||||||
|
String? userId,
|
||||||
|
String? orderId,
|
||||||
|
InvoiceType? invoiceType,
|
||||||
|
DateTime? issueDate,
|
||||||
|
DateTime? dueDate,
|
||||||
|
String? currency,
|
||||||
|
double? subtotalAmount,
|
||||||
|
double? taxAmount,
|
||||||
|
double? discountAmount,
|
||||||
|
double? shippingAmount,
|
||||||
|
double? totalAmount,
|
||||||
|
double? amountPaid,
|
||||||
|
double? amountRemaining,
|
||||||
|
InvoiceStatus? status,
|
||||||
|
String? paymentTerms,
|
||||||
|
String? notes,
|
||||||
|
String? erpnextInvoice,
|
||||||
|
DateTime? createdAt,
|
||||||
|
DateTime? updatedAt,
|
||||||
|
DateTime? lastReminderSent,
|
||||||
|
}) {
|
||||||
|
return Invoice(
|
||||||
|
invoiceId: invoiceId ?? this.invoiceId,
|
||||||
|
invoiceNumber: invoiceNumber ?? this.invoiceNumber,
|
||||||
|
userId: userId ?? this.userId,
|
||||||
|
orderId: orderId ?? this.orderId,
|
||||||
|
invoiceType: invoiceType ?? this.invoiceType,
|
||||||
|
issueDate: issueDate ?? this.issueDate,
|
||||||
|
dueDate: dueDate ?? this.dueDate,
|
||||||
|
currency: currency ?? this.currency,
|
||||||
|
subtotalAmount: subtotalAmount ?? this.subtotalAmount,
|
||||||
|
taxAmount: taxAmount ?? this.taxAmount,
|
||||||
|
discountAmount: discountAmount ?? this.discountAmount,
|
||||||
|
shippingAmount: shippingAmount ?? this.shippingAmount,
|
||||||
|
totalAmount: totalAmount ?? this.totalAmount,
|
||||||
|
amountPaid: amountPaid ?? this.amountPaid,
|
||||||
|
amountRemaining: amountRemaining ?? this.amountRemaining,
|
||||||
|
status: status ?? this.status,
|
||||||
|
paymentTerms: paymentTerms ?? this.paymentTerms,
|
||||||
|
notes: notes ?? this.notes,
|
||||||
|
erpnextInvoice: erpnextInvoice ?? this.erpnextInvoice,
|
||||||
|
createdAt: createdAt ?? this.createdAt,
|
||||||
|
updatedAt: updatedAt ?? this.updatedAt,
|
||||||
|
lastReminderSent: lastReminderSent ?? this.lastReminderSent,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (identical(this, other)) return true;
|
||||||
|
|
||||||
|
return other is Invoice &&
|
||||||
|
other.invoiceId == invoiceId &&
|
||||||
|
other.invoiceNumber == invoiceNumber &&
|
||||||
|
other.userId == userId &&
|
||||||
|
other.orderId == orderId &&
|
||||||
|
other.invoiceType == invoiceType &&
|
||||||
|
other.totalAmount == totalAmount &&
|
||||||
|
other.amountPaid == amountPaid &&
|
||||||
|
other.status == status;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode {
|
||||||
|
return Object.hash(
|
||||||
|
invoiceId,
|
||||||
|
invoiceNumber,
|
||||||
|
userId,
|
||||||
|
orderId,
|
||||||
|
invoiceType,
|
||||||
|
totalAmount,
|
||||||
|
amountPaid,
|
||||||
|
status,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'Invoice(invoiceId: $invoiceId, invoiceNumber: $invoiceNumber, '
|
||||||
|
'status: $status, totalAmount: $totalAmount, amountPaid: $amountPaid, '
|
||||||
|
'amountRemaining: $amountRemaining)';
|
||||||
|
}
|
||||||
|
}
|
||||||
321
lib/features/orders/domain/entities/order.dart
Normal file
321
lib/features/orders/domain/entities/order.dart
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
/// Domain Entity: Order
|
||||||
|
///
|
||||||
|
/// Represents a customer order.
|
||||||
|
library;
|
||||||
|
|
||||||
|
/// Order status enum
|
||||||
|
enum OrderStatus {
|
||||||
|
/// Order has been created but not confirmed
|
||||||
|
draft,
|
||||||
|
|
||||||
|
/// Order has been confirmed
|
||||||
|
confirmed,
|
||||||
|
|
||||||
|
/// Order is being processed
|
||||||
|
processing,
|
||||||
|
|
||||||
|
/// Order is ready for shipping
|
||||||
|
ready,
|
||||||
|
|
||||||
|
/// Order has been shipped
|
||||||
|
shipped,
|
||||||
|
|
||||||
|
/// Order has been delivered
|
||||||
|
delivered,
|
||||||
|
|
||||||
|
/// Order has been completed
|
||||||
|
completed,
|
||||||
|
|
||||||
|
/// Order has been cancelled
|
||||||
|
cancelled,
|
||||||
|
|
||||||
|
/// Order has been returned
|
||||||
|
returned;
|
||||||
|
|
||||||
|
/// Get display name for status
|
||||||
|
String get displayName {
|
||||||
|
switch (this) {
|
||||||
|
case OrderStatus.draft:
|
||||||
|
return 'Draft';
|
||||||
|
case OrderStatus.confirmed:
|
||||||
|
return 'Confirmed';
|
||||||
|
case OrderStatus.processing:
|
||||||
|
return 'Processing';
|
||||||
|
case OrderStatus.ready:
|
||||||
|
return 'Ready';
|
||||||
|
case OrderStatus.shipped:
|
||||||
|
return 'Shipped';
|
||||||
|
case OrderStatus.delivered:
|
||||||
|
return 'Delivered';
|
||||||
|
case OrderStatus.completed:
|
||||||
|
return 'Completed';
|
||||||
|
case OrderStatus.cancelled:
|
||||||
|
return 'Cancelled';
|
||||||
|
case OrderStatus.returned:
|
||||||
|
return 'Returned';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Address information
|
||||||
|
class Address {
|
||||||
|
/// Recipient name
|
||||||
|
final String? name;
|
||||||
|
|
||||||
|
/// Phone number
|
||||||
|
final String? phone;
|
||||||
|
|
||||||
|
/// Street address
|
||||||
|
final String? street;
|
||||||
|
|
||||||
|
/// Ward/commune
|
||||||
|
final String? ward;
|
||||||
|
|
||||||
|
/// District
|
||||||
|
final String? district;
|
||||||
|
|
||||||
|
/// City/province
|
||||||
|
final String? city;
|
||||||
|
|
||||||
|
/// Postal code
|
||||||
|
final String? postalCode;
|
||||||
|
|
||||||
|
const Address({
|
||||||
|
this.name,
|
||||||
|
this.phone,
|
||||||
|
this.street,
|
||||||
|
this.ward,
|
||||||
|
this.district,
|
||||||
|
this.city,
|
||||||
|
this.postalCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Get full address string
|
||||||
|
String get fullAddress {
|
||||||
|
final parts = [
|
||||||
|
street,
|
||||||
|
ward,
|
||||||
|
district,
|
||||||
|
city,
|
||||||
|
postalCode,
|
||||||
|
].where((part) => part != null && part.isNotEmpty).toList();
|
||||||
|
|
||||||
|
return parts.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create from JSON map
|
||||||
|
factory Address.fromJson(Map<String, dynamic> json) {
|
||||||
|
return Address(
|
||||||
|
name: json['name'] as String?,
|
||||||
|
phone: json['phone'] as String?,
|
||||||
|
street: json['street'] as String?,
|
||||||
|
ward: json['ward'] as String?,
|
||||||
|
district: json['district'] as String?,
|
||||||
|
city: json['city'] as String?,
|
||||||
|
postalCode: json['postal_code'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert to JSON map
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'name': name,
|
||||||
|
'phone': phone,
|
||||||
|
'street': street,
|
||||||
|
'ward': ward,
|
||||||
|
'district': district,
|
||||||
|
'city': city,
|
||||||
|
'postal_code': postalCode,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Order Entity
|
||||||
|
///
|
||||||
|
/// Contains complete order information:
|
||||||
|
/// - Order identification
|
||||||
|
/// - Customer details
|
||||||
|
/// - Pricing and discounts
|
||||||
|
/// - Shipping information
|
||||||
|
/// - Status tracking
|
||||||
|
class Order {
|
||||||
|
/// Unique order identifier
|
||||||
|
final String orderId;
|
||||||
|
|
||||||
|
/// Human-readable order number
|
||||||
|
final String orderNumber;
|
||||||
|
|
||||||
|
/// User ID who placed the order
|
||||||
|
final String userId;
|
||||||
|
|
||||||
|
/// Current order status
|
||||||
|
final OrderStatus status;
|
||||||
|
|
||||||
|
/// Total order amount before discounts
|
||||||
|
final double totalAmount;
|
||||||
|
|
||||||
|
/// Discount amount applied
|
||||||
|
final double discountAmount;
|
||||||
|
|
||||||
|
/// Tax amount
|
||||||
|
final double taxAmount;
|
||||||
|
|
||||||
|
/// Shipping fee
|
||||||
|
final double shippingFee;
|
||||||
|
|
||||||
|
/// Final amount to pay
|
||||||
|
final double finalAmount;
|
||||||
|
|
||||||
|
/// Shipping address
|
||||||
|
final Address? shippingAddress;
|
||||||
|
|
||||||
|
/// Billing address
|
||||||
|
final Address? billingAddress;
|
||||||
|
|
||||||
|
/// Expected delivery date
|
||||||
|
final DateTime? expectedDeliveryDate;
|
||||||
|
|
||||||
|
/// Actual delivery date
|
||||||
|
final DateTime? actualDeliveryDate;
|
||||||
|
|
||||||
|
/// Order notes
|
||||||
|
final String? notes;
|
||||||
|
|
||||||
|
/// Cancellation reason
|
||||||
|
final String? cancellationReason;
|
||||||
|
|
||||||
|
/// ERPNext sales order reference
|
||||||
|
final String? erpnextSalesOrder;
|
||||||
|
|
||||||
|
/// Order creation timestamp
|
||||||
|
final DateTime createdAt;
|
||||||
|
|
||||||
|
/// Last update timestamp
|
||||||
|
final DateTime updatedAt;
|
||||||
|
|
||||||
|
const Order({
|
||||||
|
required this.orderId,
|
||||||
|
required this.orderNumber,
|
||||||
|
required this.userId,
|
||||||
|
required this.status,
|
||||||
|
required this.totalAmount,
|
||||||
|
required this.discountAmount,
|
||||||
|
required this.taxAmount,
|
||||||
|
required this.shippingFee,
|
||||||
|
required this.finalAmount,
|
||||||
|
this.shippingAddress,
|
||||||
|
this.billingAddress,
|
||||||
|
this.expectedDeliveryDate,
|
||||||
|
this.actualDeliveryDate,
|
||||||
|
this.notes,
|
||||||
|
this.cancellationReason,
|
||||||
|
this.erpnextSalesOrder,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Check if order is active (not cancelled or completed)
|
||||||
|
bool get isActive =>
|
||||||
|
status != OrderStatus.cancelled &&
|
||||||
|
status != OrderStatus.completed &&
|
||||||
|
status != OrderStatus.returned;
|
||||||
|
|
||||||
|
/// Check if order can be cancelled
|
||||||
|
bool get canBeCancelled =>
|
||||||
|
status == OrderStatus.draft ||
|
||||||
|
status == OrderStatus.confirmed ||
|
||||||
|
status == OrderStatus.processing;
|
||||||
|
|
||||||
|
/// Check if order is delivered
|
||||||
|
bool get isDelivered =>
|
||||||
|
status == OrderStatus.delivered || status == OrderStatus.completed;
|
||||||
|
|
||||||
|
/// Check if order is cancelled
|
||||||
|
bool get isCancelled => status == OrderStatus.cancelled;
|
||||||
|
|
||||||
|
/// Get discount percentage
|
||||||
|
double get discountPercentage {
|
||||||
|
if (totalAmount == 0) return 0;
|
||||||
|
return (discountAmount / totalAmount) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy with method for immutability
|
||||||
|
Order copyWith({
|
||||||
|
String? orderId,
|
||||||
|
String? orderNumber,
|
||||||
|
String? userId,
|
||||||
|
OrderStatus? status,
|
||||||
|
double? totalAmount,
|
||||||
|
double? discountAmount,
|
||||||
|
double? taxAmount,
|
||||||
|
double? shippingFee,
|
||||||
|
double? finalAmount,
|
||||||
|
Address? shippingAddress,
|
||||||
|
Address? billingAddress,
|
||||||
|
DateTime? expectedDeliveryDate,
|
||||||
|
DateTime? actualDeliveryDate,
|
||||||
|
String? notes,
|
||||||
|
String? cancellationReason,
|
||||||
|
String? erpnextSalesOrder,
|
||||||
|
DateTime? createdAt,
|
||||||
|
DateTime? updatedAt,
|
||||||
|
}) {
|
||||||
|
return Order(
|
||||||
|
orderId: orderId ?? this.orderId,
|
||||||
|
orderNumber: orderNumber ?? this.orderNumber,
|
||||||
|
userId: userId ?? this.userId,
|
||||||
|
status: status ?? this.status,
|
||||||
|
totalAmount: totalAmount ?? this.totalAmount,
|
||||||
|
discountAmount: discountAmount ?? this.discountAmount,
|
||||||
|
taxAmount: taxAmount ?? this.taxAmount,
|
||||||
|
shippingFee: shippingFee ?? this.shippingFee,
|
||||||
|
finalAmount: finalAmount ?? this.finalAmount,
|
||||||
|
shippingAddress: shippingAddress ?? this.shippingAddress,
|
||||||
|
billingAddress: billingAddress ?? this.billingAddress,
|
||||||
|
expectedDeliveryDate: expectedDeliveryDate ?? this.expectedDeliveryDate,
|
||||||
|
actualDeliveryDate: actualDeliveryDate ?? this.actualDeliveryDate,
|
||||||
|
notes: notes ?? this.notes,
|
||||||
|
cancellationReason: cancellationReason ?? this.cancellationReason,
|
||||||
|
erpnextSalesOrder: erpnextSalesOrder ?? this.erpnextSalesOrder,
|
||||||
|
createdAt: createdAt ?? this.createdAt,
|
||||||
|
updatedAt: updatedAt ?? this.updatedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (identical(this, other)) return true;
|
||||||
|
|
||||||
|
return other is Order &&
|
||||||
|
other.orderId == orderId &&
|
||||||
|
other.orderNumber == orderNumber &&
|
||||||
|
other.userId == userId &&
|
||||||
|
other.status == status &&
|
||||||
|
other.totalAmount == totalAmount &&
|
||||||
|
other.discountAmount == discountAmount &&
|
||||||
|
other.taxAmount == taxAmount &&
|
||||||
|
other.shippingFee == shippingFee &&
|
||||||
|
other.finalAmount == finalAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode {
|
||||||
|
return Object.hash(
|
||||||
|
orderId,
|
||||||
|
orderNumber,
|
||||||
|
userId,
|
||||||
|
status,
|
||||||
|
totalAmount,
|
||||||
|
discountAmount,
|
||||||
|
taxAmount,
|
||||||
|
shippingFee,
|
||||||
|
finalAmount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'Order(orderId: $orderId, orderNumber: $orderNumber, status: $status, '
|
||||||
|
'finalAmount: $finalAmount, createdAt: $createdAt)';
|
||||||
|
}
|
||||||
|
}
|
||||||
117
lib/features/orders/domain/entities/order_item.dart
Normal file
117
lib/features/orders/domain/entities/order_item.dart
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
/// Domain Entity: Order Item
|
||||||
|
///
|
||||||
|
/// Represents a single line item in an order.
|
||||||
|
library;
|
||||||
|
|
||||||
|
/// Order Item Entity
|
||||||
|
///
|
||||||
|
/// Contains item-level information in an order:
|
||||||
|
/// - Product reference
|
||||||
|
/// - Quantity and pricing
|
||||||
|
/// - Discounts
|
||||||
|
/// - Notes
|
||||||
|
class OrderItem {
|
||||||
|
/// Unique order item identifier
|
||||||
|
final String orderItemId;
|
||||||
|
|
||||||
|
/// Order ID this item belongs to
|
||||||
|
final String orderId;
|
||||||
|
|
||||||
|
/// Product ID
|
||||||
|
final String productId;
|
||||||
|
|
||||||
|
/// Quantity ordered
|
||||||
|
final double quantity;
|
||||||
|
|
||||||
|
/// Unit price at time of order
|
||||||
|
final double unitPrice;
|
||||||
|
|
||||||
|
/// Discount percentage applied
|
||||||
|
final double discountPercent;
|
||||||
|
|
||||||
|
/// Subtotal (quantity * unitPrice * (1 - discountPercent/100))
|
||||||
|
final double subtotal;
|
||||||
|
|
||||||
|
/// Item notes
|
||||||
|
final String? notes;
|
||||||
|
|
||||||
|
const OrderItem({
|
||||||
|
required this.orderItemId,
|
||||||
|
required this.orderId,
|
||||||
|
required this.productId,
|
||||||
|
required this.quantity,
|
||||||
|
required this.unitPrice,
|
||||||
|
required this.discountPercent,
|
||||||
|
required this.subtotal,
|
||||||
|
this.notes,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Calculate subtotal before discount
|
||||||
|
double get subtotalBeforeDiscount => quantity * unitPrice;
|
||||||
|
|
||||||
|
/// Calculate discount amount
|
||||||
|
double get discountAmount =>
|
||||||
|
subtotalBeforeDiscount * (discountPercent / 100);
|
||||||
|
|
||||||
|
/// Calculate subtotal after discount (for verification)
|
||||||
|
double get calculatedSubtotal => subtotalBeforeDiscount - discountAmount;
|
||||||
|
|
||||||
|
/// Check if item has discount
|
||||||
|
bool get hasDiscount => discountPercent > 0;
|
||||||
|
|
||||||
|
/// Copy with method for immutability
|
||||||
|
OrderItem copyWith({
|
||||||
|
String? orderItemId,
|
||||||
|
String? orderId,
|
||||||
|
String? productId,
|
||||||
|
double? quantity,
|
||||||
|
double? unitPrice,
|
||||||
|
double? discountPercent,
|
||||||
|
double? subtotal,
|
||||||
|
String? notes,
|
||||||
|
}) {
|
||||||
|
return OrderItem(
|
||||||
|
orderItemId: orderItemId ?? this.orderItemId,
|
||||||
|
orderId: orderId ?? this.orderId,
|
||||||
|
productId: productId ?? this.productId,
|
||||||
|
quantity: quantity ?? this.quantity,
|
||||||
|
unitPrice: unitPrice ?? this.unitPrice,
|
||||||
|
discountPercent: discountPercent ?? this.discountPercent,
|
||||||
|
subtotal: subtotal ?? this.subtotal,
|
||||||
|
notes: notes ?? this.notes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (identical(this, other)) return true;
|
||||||
|
|
||||||
|
return other is OrderItem &&
|
||||||
|
other.orderItemId == orderItemId &&
|
||||||
|
other.orderId == orderId &&
|
||||||
|
other.productId == productId &&
|
||||||
|
other.quantity == quantity &&
|
||||||
|
other.unitPrice == unitPrice &&
|
||||||
|
other.discountPercent == discountPercent &&
|
||||||
|
other.subtotal == subtotal;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode {
|
||||||
|
return Object.hash(
|
||||||
|
orderItemId,
|
||||||
|
orderId,
|
||||||
|
productId,
|
||||||
|
quantity,
|
||||||
|
unitPrice,
|
||||||
|
discountPercent,
|
||||||
|
subtotal,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'OrderItem(orderItemId: $orderItemId, productId: $productId, '
|
||||||
|
'quantity: $quantity, unitPrice: $unitPrice, subtotal: $subtotal)';
|
||||||
|
}
|
||||||
|
}
|
||||||
237
lib/features/orders/domain/entities/payment_line.dart
Normal file
237
lib/features/orders/domain/entities/payment_line.dart
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
/// Domain Entity: Payment Line
|
||||||
|
///
|
||||||
|
/// Represents a payment transaction for an invoice.
|
||||||
|
library;
|
||||||
|
|
||||||
|
/// Payment method enum
|
||||||
|
enum PaymentMethod {
|
||||||
|
/// Cash payment
|
||||||
|
cash,
|
||||||
|
|
||||||
|
/// Bank transfer
|
||||||
|
bankTransfer,
|
||||||
|
|
||||||
|
/// Credit card
|
||||||
|
creditCard,
|
||||||
|
|
||||||
|
/// E-wallet (Momo, ZaloPay, etc.)
|
||||||
|
ewallet,
|
||||||
|
|
||||||
|
/// Check
|
||||||
|
check,
|
||||||
|
|
||||||
|
/// Other method
|
||||||
|
other;
|
||||||
|
|
||||||
|
/// Get display name for payment method
|
||||||
|
String get displayName {
|
||||||
|
switch (this) {
|
||||||
|
case PaymentMethod.cash:
|
||||||
|
return 'Cash';
|
||||||
|
case PaymentMethod.bankTransfer:
|
||||||
|
return 'Bank Transfer';
|
||||||
|
case PaymentMethod.creditCard:
|
||||||
|
return 'Credit Card';
|
||||||
|
case PaymentMethod.ewallet:
|
||||||
|
return 'E-Wallet';
|
||||||
|
case PaymentMethod.check:
|
||||||
|
return 'Check';
|
||||||
|
case PaymentMethod.other:
|
||||||
|
return 'Other';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Payment status enum
|
||||||
|
enum PaymentStatus {
|
||||||
|
/// Payment pending
|
||||||
|
pending,
|
||||||
|
|
||||||
|
/// Payment is being processed
|
||||||
|
processing,
|
||||||
|
|
||||||
|
/// Payment completed successfully
|
||||||
|
completed,
|
||||||
|
|
||||||
|
/// Payment failed
|
||||||
|
failed,
|
||||||
|
|
||||||
|
/// Payment refunded
|
||||||
|
refunded,
|
||||||
|
|
||||||
|
/// Payment cancelled
|
||||||
|
cancelled;
|
||||||
|
|
||||||
|
/// Get display name for status
|
||||||
|
String get displayName {
|
||||||
|
switch (this) {
|
||||||
|
case PaymentStatus.pending:
|
||||||
|
return 'Pending';
|
||||||
|
case PaymentStatus.processing:
|
||||||
|
return 'Processing';
|
||||||
|
case PaymentStatus.completed:
|
||||||
|
return 'Completed';
|
||||||
|
case PaymentStatus.failed:
|
||||||
|
return 'Failed';
|
||||||
|
case PaymentStatus.refunded:
|
||||||
|
return 'Refunded';
|
||||||
|
case PaymentStatus.cancelled:
|
||||||
|
return 'Cancelled';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Payment Line Entity
|
||||||
|
///
|
||||||
|
/// Contains payment transaction information:
|
||||||
|
/// - Payment details
|
||||||
|
/// - Payment method
|
||||||
|
/// - Bank information
|
||||||
|
/// - Status tracking
|
||||||
|
class PaymentLine {
|
||||||
|
/// Unique payment line identifier
|
||||||
|
final String paymentLineId;
|
||||||
|
|
||||||
|
/// Invoice ID this payment is for
|
||||||
|
final String invoiceId;
|
||||||
|
|
||||||
|
/// Payment number (human-readable)
|
||||||
|
final String paymentNumber;
|
||||||
|
|
||||||
|
/// Payment date
|
||||||
|
final DateTime paymentDate;
|
||||||
|
|
||||||
|
/// Payment amount
|
||||||
|
final double amount;
|
||||||
|
|
||||||
|
/// Payment method
|
||||||
|
final PaymentMethod paymentMethod;
|
||||||
|
|
||||||
|
/// Bank name (for bank transfer)
|
||||||
|
final String? bankName;
|
||||||
|
|
||||||
|
/// Bank account number (for bank transfer)
|
||||||
|
final String? bankAccount;
|
||||||
|
|
||||||
|
/// Reference number (transaction ID, check number, etc.)
|
||||||
|
final String? referenceNumber;
|
||||||
|
|
||||||
|
/// Payment notes
|
||||||
|
final String? notes;
|
||||||
|
|
||||||
|
/// Payment status
|
||||||
|
final PaymentStatus status;
|
||||||
|
|
||||||
|
/// Receipt URL
|
||||||
|
final String? receiptUrl;
|
||||||
|
|
||||||
|
/// ERPNext payment entry reference
|
||||||
|
final String? erpnextPaymentEntry;
|
||||||
|
|
||||||
|
/// Creation timestamp
|
||||||
|
final DateTime createdAt;
|
||||||
|
|
||||||
|
/// Processing timestamp
|
||||||
|
final DateTime? processedAt;
|
||||||
|
|
||||||
|
const PaymentLine({
|
||||||
|
required this.paymentLineId,
|
||||||
|
required this.invoiceId,
|
||||||
|
required this.paymentNumber,
|
||||||
|
required this.paymentDate,
|
||||||
|
required this.amount,
|
||||||
|
required this.paymentMethod,
|
||||||
|
this.bankName,
|
||||||
|
this.bankAccount,
|
||||||
|
this.referenceNumber,
|
||||||
|
this.notes,
|
||||||
|
required this.status,
|
||||||
|
this.receiptUrl,
|
||||||
|
this.erpnextPaymentEntry,
|
||||||
|
required this.createdAt,
|
||||||
|
this.processedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Check if payment is completed
|
||||||
|
bool get isCompleted => status == PaymentStatus.completed;
|
||||||
|
|
||||||
|
/// Check if payment is pending
|
||||||
|
bool get isPending => status == PaymentStatus.pending;
|
||||||
|
|
||||||
|
/// Check if payment is being processed
|
||||||
|
bool get isProcessing => status == PaymentStatus.processing;
|
||||||
|
|
||||||
|
/// Check if payment failed
|
||||||
|
bool get isFailed => status == PaymentStatus.failed;
|
||||||
|
|
||||||
|
/// Check if payment has receipt
|
||||||
|
bool get hasReceipt => receiptUrl != null && receiptUrl!.isNotEmpty;
|
||||||
|
|
||||||
|
/// Copy with method for immutability
|
||||||
|
PaymentLine copyWith({
|
||||||
|
String? paymentLineId,
|
||||||
|
String? invoiceId,
|
||||||
|
String? paymentNumber,
|
||||||
|
DateTime? paymentDate,
|
||||||
|
double? amount,
|
||||||
|
PaymentMethod? paymentMethod,
|
||||||
|
String? bankName,
|
||||||
|
String? bankAccount,
|
||||||
|
String? referenceNumber,
|
||||||
|
String? notes,
|
||||||
|
PaymentStatus? status,
|
||||||
|
String? receiptUrl,
|
||||||
|
String? erpnextPaymentEntry,
|
||||||
|
DateTime? createdAt,
|
||||||
|
DateTime? processedAt,
|
||||||
|
}) {
|
||||||
|
return PaymentLine(
|
||||||
|
paymentLineId: paymentLineId ?? this.paymentLineId,
|
||||||
|
invoiceId: invoiceId ?? this.invoiceId,
|
||||||
|
paymentNumber: paymentNumber ?? this.paymentNumber,
|
||||||
|
paymentDate: paymentDate ?? this.paymentDate,
|
||||||
|
amount: amount ?? this.amount,
|
||||||
|
paymentMethod: paymentMethod ?? this.paymentMethod,
|
||||||
|
bankName: bankName ?? this.bankName,
|
||||||
|
bankAccount: bankAccount ?? this.bankAccount,
|
||||||
|
referenceNumber: referenceNumber ?? this.referenceNumber,
|
||||||
|
notes: notes ?? this.notes,
|
||||||
|
status: status ?? this.status,
|
||||||
|
receiptUrl: receiptUrl ?? this.receiptUrl,
|
||||||
|
erpnextPaymentEntry: erpnextPaymentEntry ?? this.erpnextPaymentEntry,
|
||||||
|
createdAt: createdAt ?? this.createdAt,
|
||||||
|
processedAt: processedAt ?? this.processedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (identical(this, other)) return true;
|
||||||
|
|
||||||
|
return other is PaymentLine &&
|
||||||
|
other.paymentLineId == paymentLineId &&
|
||||||
|
other.invoiceId == invoiceId &&
|
||||||
|
other.paymentNumber == paymentNumber &&
|
||||||
|
other.amount == amount &&
|
||||||
|
other.paymentMethod == paymentMethod &&
|
||||||
|
other.status == status;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode {
|
||||||
|
return Object.hash(
|
||||||
|
paymentLineId,
|
||||||
|
invoiceId,
|
||||||
|
paymentNumber,
|
||||||
|
amount,
|
||||||
|
paymentMethod,
|
||||||
|
status,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'PaymentLine(paymentLineId: $paymentLineId, paymentNumber: $paymentNumber, '
|
||||||
|
'amount: $amount, paymentMethod: $paymentMethod, status: $status)';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:hive_ce/hive.dart';
|
import 'package:hive_ce/hive.dart';
|
||||||
|
import 'package:worker/core/constants/storage_constants.dart';
|
||||||
import 'package:worker/features/products/domain/entities/category.dart';
|
import 'package:worker/features/products/domain/entities/category.dart';
|
||||||
|
|
||||||
part 'category_model.g.dart';
|
part 'category_model.g.dart';
|
||||||
@@ -15,8 +16,8 @@ part 'category_model.g.dart';
|
|||||||
/// - Hive local database storage
|
/// - Hive local database storage
|
||||||
/// - Converting to/from domain entity
|
/// - Converting to/from domain entity
|
||||||
///
|
///
|
||||||
/// Hive Type ID: 12
|
/// Hive Type ID: 27 (from HiveTypeIds.categoryModel)
|
||||||
@HiveType(typeId: 12)
|
@HiveType(typeId: HiveTypeIds.categoryModel)
|
||||||
class CategoryModel extends HiveObject {
|
class CategoryModel extends HiveObject {
|
||||||
/// Unique identifier
|
/// Unique identifier
|
||||||
@HiveField(0)
|
@HiveField(0)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ part of 'category_model.dart';
|
|||||||
|
|
||||||
class CategoryModelAdapter extends TypeAdapter<CategoryModel> {
|
class CategoryModelAdapter extends TypeAdapter<CategoryModel> {
|
||||||
@override
|
@override
|
||||||
final typeId = 12;
|
final typeId = 27;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
CategoryModel read(BinaryReader reader) {
|
CategoryModel read(BinaryReader reader) {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user