update database

This commit is contained in:
Phuoc Nguyen
2025-10-24 11:31:48 +07:00
parent f95fa9d0a6
commit c4272f9a21
126 changed files with 23528 additions and 2234 deletions

580
CLAUDE.md
View File

@@ -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) {
state = [
...state,
CartItem(
id: product.id,
productName: product.name,
price: product.price,
quantity: quantity,
imageUrl: product.images.first,
),
];
_persistCart();
}
void updateQuantity(String itemId, int quantity) {
state = state.map((item) {
if (item.id == itemId) {
return item.copyWith(quantity: quantity);
}
return item;
}).toList();
_persistCart();
}
void removeItem(String itemId) {
state = state.where((item) => item.id != itemId).toList();
_persistCart();
}
void clear() {
state = [];
_persistCart();
}
void _persistCart() {
ref.read(cartRepositoryProvider).saveCart(state);
}
}
@riverpod
double cartTotal(CartTotalRef ref) {
final items = ref.watch(cartProvider);
return items.fold(0, (sum, item) => sum + (item.price * item.quantity));
}
```
#### Loyalty Points State
```dart
@riverpod
class LoyaltyPoints extends _$LoyaltyPoints {
@override
Future<LoyaltyPointsData> build() async {
return await ref.read(loyaltyRepositoryProvider).getLoyaltyPoints();
}
Future<void> refresh() async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
return await ref.read(loyaltyRepositoryProvider).getLoyaltyPoints();
});
}
Future<void> redeemReward(Reward reward) async {
final result = await ref.read(loyaltyRepositoryProvider).redeemReward(reward.id);
if (result.success) {
await refresh();
// Show success dialog with gift code
}
}
}
```
---
### Database Schema (Hive)
#### User Model
```dart
@HiveType(typeId: 0)
class UserModel extends HiveObject {
@HiveField(0)
final String id;
@HiveField(1)
final String name;
@HiveField(2)
final String phone;
@HiveField(3)
final String email;
@HiveField(4)
final String? avatar;
@HiveField(5)
final MemberTier memberTier; // diamond, platinum, gold
@HiveField(6)
final int points;
@HiveField(7)
final String referralCode;
@HiveField(8)
final String? company;
@HiveField(9)
final UserType userType; // contractor, architect, distributor, broker
@HiveField(10)
final DateTime createdAt;
}
```
#### Product Model
```dart
@HiveType(typeId: 1)
class ProductModel extends HiveObject {
@HiveField(0)
final String id;
@HiveField(1)
final String name;
@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;
}
```
### Domain Entities & Data Models
The app follows clean architecture with domain entities and Hive data models based on the database schema in `database.md`.
#### **Domain Entities (28 files)**
Pure business logic entities with no external dependencies. All entities use freezed for immutability.
**Auth Feature** (`lib/features/auth/domain/entities/`)
- `user.dart` - User with role, status, loyalty tier, company info
- `user_session.dart` - User session with device tracking
**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)
- `category.dart` - Product categories
**Cart Feature** (`lib/features/cart/domain/entities/`)
- `cart.dart` - Shopping cart with sync status
- `cart_item.dart` - Cart line items
**Orders Feature** (`lib/features/orders/domain/entities/`)
- `order.dart` - Orders with status, addresses, ERPNext integration
- `order_item.dart` - Order line items with discounts
- `invoice.dart` - Invoices with type, status, payment tracking
- `payment_line.dart` - Payment transactions with method and status
**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
- `redeemed_gift.dart` - User's redeemed gifts with voucher codes
- `points_record.dart` - Invoice submissions for manual points
**Projects Feature** (`lib/features/projects/domain/entities/`)
- `project_submission.dart` - Completed project submissions with photos
- `design_request.dart` - Design consultation requests
**Quotes Feature** (`lib/features/quotes/domain/entities/`)
- `quote.dart` - Quotations with status, validity, conversion tracking
- `quote_item.dart` - Quote line items with price negotiation
**Chat Feature** (`lib/features/chat/domain/entities/`)
- `chat_room.dart` - Chat rooms with type, participants
- `message.dart` - Messages with content type, attachments, read status
**Notifications Feature** (`lib/features/notifications/domain/entities/`)
- `notification.dart` - User notifications with type-based data
**Showrooms Feature** (`lib/features/showrooms/domain/entities/`)
- `showroom.dart` - Virtual showrooms with 360 views, gallery
- `showroom_product.dart` - Products used in showrooms
**Account Feature** (`lib/features/account/domain/entities/`)
- `payment_reminder.dart` - Payment reminders with scheduling
- `audit_log.dart` - System audit trail
**Home Feature** (`lib/features/home/domain/entities/`)
- `member_card.dart` - Membership card display
- `promotion.dart` - Promotional campaigns
#### **Hive Data Models (25 files)**
Hive CE models for offline-first local storage. All models extend `HiveObject` with proper type adapters.
**Type ID Allocation:**
- Models: 0-24 (user_model=0, user_session_model=1, product_model=2, etc.)
- Enums: 30-50 (UserRole=30, UserStatus=31, LoyaltyTier=32, etc.)
**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
**All models are located in:** `lib/features/*/data/models/`
- Auth: `user_model.dart`, `user_session_model.dart`
- Products: `product_model.dart`, `stock_level_model.dart`
- Cart: `cart_model.dart`, `cart_item_model.dart`
- Orders: `order_model.dart`, `order_item_model.dart`, `invoice_model.dart`, `payment_line_model.dart`
- Loyalty: `loyalty_point_entry_model.dart`, `gift_catalog_model.dart`, `redeemed_gift_model.dart`, `points_record_model.dart`
- Projects: `project_submission_model.dart`, `design_request_model.dart`
- Quotes: `quote_model.dart`, `quote_item_model.dart`
- Chat: `chat_room_model.dart`, `message_model.dart`
- Notifications: `notification_model.dart`
- Showrooms: `showroom_model.dart`, `showroom_product_model.dart`
- Account: `payment_reminder_model.dart`, `audit_log_model.dart`
#### **Enums (21 types)**
All enums are defined in `lib/core/database/models/enums.dart` with Hive type adapters:
**User & Auth:**
- `UserRole` - admin, customer, contractor, architect, distributor, broker
- `UserStatus` - active, inactive, pending, suspended, banned
- `LoyaltyTier` - diamond, platinum, gold
**Orders & Payments:**
- `OrderStatus` - draft, pending, confirmed, processing, shipped, delivered, completed, cancelled, returned, refunded
- `InvoiceType` - sales, proforma, credit_note, debit_note
- `InvoiceStatus` - draft, sent, viewed, overdue, paid, partially_paid, cancelled
- `PaymentMethod` - cash, bank_transfer, credit_card, debit_card, e_wallet, cod
- `PaymentStatus` - pending, processing, completed, failed, refunded
**Loyalty & Gifts:**
- `EntryType` - earn, spend, adjust, expire, reversal
- `EntrySource` - purchase, referral, promotion, bonus, manual, project_submission, design_request, points_record
- `ComplaintStatus` - pending, reviewing, approved, rejected, resolved
- `GiftCategory` - voucher, product, service, experience, discount
- `GiftStatus` - active, used, expired, cancelled
- `PointsStatus` - pending, approved, rejected
**Projects & Quotes:**
- `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
**Chat:**
- `RoomType` - support, sales, quote, order
- `ContentType` - text, image, file, product, location
- `ReminderType` - payment_due, overdue, final_notice, custom
**Detailed Documentation:**
- See `DOMAIN_ENTITIES_SUMMARY.md` for entity details
- See `HIVE_MODELS_REFERENCE.md` for model templates and usage
- See `lib/core/constants/storage_constants.dart` for Type ID reference
---
@@ -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)
### Setup

235
DOMAIN_ENTITIES_SUMMARY.md Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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

View File

@@ -628,7 +628,7 @@ p {
}
.tab-item.active {
background: var(--primary-blue);
/*background: var(--primary-blue);*/
color: var(--white);
}
@@ -918,6 +918,7 @@ p {
cursor: pointer;
transition: all 0.3s ease;
white-space: nowrap;
height: 40px;
}
.btn-complaint:hover {
@@ -1158,6 +1159,15 @@ p {
gap: 12px;
}
/*.order-card {
background: var(--white);
border-radius: 12px;
box-shadow: var(--shadow-light);
display: flex;
position: relative;
overflow: hidden;
}*/
.order-card {
background: var(--white);
border-radius: 12px;
@@ -1165,6 +1175,13 @@ p {
display: flex;
position: relative;
overflow: hidden;
cursor: pointer;
transition: all 0.3s ease;
}
.order-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-medium);
}
.order-status-indicator {
@@ -2106,3 +2123,130 @@ p {
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);
}

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
</head>

View 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>

View 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
View 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>

View File

@@ -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>

View File

@@ -95,6 +95,12 @@
<div class="card">
<h3 class="card-title">Khách hàng thân thiết</h3>
<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">
<div class="feature-icon">
<i class="fas fa-gift"></i>
@@ -107,17 +113,17 @@
</div>
<div class="feature-title">Lịch sử điểm</div>
</a>
<a href="referral.html" class="feature-item">
<!--<a href="referral.html" 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>
</a>-->
</div>
</div>
<!-- Orders & Payment Section -->
<div class="card">
<!--<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="quotes-list.html" class="feature-item">
@@ -133,12 +139,18 @@
<div class="feature-title">Báo giá</div>
</a>
</div>
</div>
</div>-->
<!-- Orders & Payment Section -->
<div class="card">
<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">
<div class="feature-icon">
<i class="fas fa-box"></i>
@@ -147,7 +159,7 @@
</a>
<a href="payments.html" class="feature-item">
<div class="feature-icon">
<i class="fas fa-credit-card"></i>
<i class="fas fa-file-invoice-dollar"></i>
</div>
<div class="feature-title">Thanh toán</div>
</a>
@@ -182,7 +194,7 @@
<div class="card">
<h3 class="card-title">Nhà mẫu, dự án & tin tức</h3>
<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">
<!--<i class="fas fa-building"></i>-->
<i class="fa-solid fa-house-chimney"></i>
@@ -198,7 +210,7 @@
</a>
<a href="news-list.html" class="feature-item">
<div class="feature-icon">
<i class="fas fa-chart-line"></i>
<i class="fa-solid fa-newspaper"></i>
</div>
<div class="feature-title">Tin tức</div>
</a>

757
html/my-gift-detail.html Normal file
View 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>

View File

@@ -50,7 +50,7 @@
</div>
<div class="gift-actions">
<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>
@@ -74,7 +74,7 @@
</div>
<div class="gift-actions">
<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>
@@ -169,12 +169,13 @@
</div>
<div class="gift-actions">
<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>
<script>
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>
</body>
</html>

View 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
View 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>

View File

@@ -415,13 +415,14 @@
<!-- Header -->
<header class="header">
<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>
</button>
<h1 class="header-title">Quản lý Nhà mẫu</h1>
<button class="add-button" onclick="createNewNhaMau()">
<i class="fas fa-plus"></i>
</button>
<button class="back-button" onclick="window.location.href='nha-mau-create.html'">
<i class="fas fa-plus"></i>
</button>
</div>
</header>
@@ -578,9 +579,9 @@
</div>
<!-- Floating Create Button -->
<button class="floating-create-btn" onclick="createNewNhaMau()">
<!--<button class="floating-create-btn" onclick="createNewNhaMau()">
<i class="fas fa-plus"></i>
</button>
</button>-->
<script>
function goBack() {

481
html/nha-mau.html Normal file
View 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>

View File

@@ -61,7 +61,7 @@
</div>
<!-- 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-content">
<div class="d-flex justify-between align-start mb-2">
@@ -81,7 +81,7 @@
</div>
<!-- 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-content">
<div class="d-flex justify-between align-start mb-2">
@@ -101,7 +101,7 @@
</div>
<!-- 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-content">
<div class="d-flex justify-between align-start mb-2">
@@ -121,7 +121,7 @@
</div>
<!-- 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-content">
<div class="d-flex justify-between align-start mb-2">
@@ -142,35 +142,7 @@
</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>
function viewOrderDetail(orderId) {
window.location.href = `order-detail.html?id=${orderId}`;
}
</script>
</body>
</html>

View File

@@ -29,13 +29,23 @@
</div>
<!-- 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">Chờ xác nhận</button>
<button class="tab-item">Đang xử lý</button>
<button class="tab-item">Đang giao</button>
<button class="tab-item">Hoàn thành</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>
<!-- Orders List -->
@@ -50,18 +60,18 @@
</div>
<div class="order-details">
<p class="order-date">Ngày đặt: 03/08/2023</p>
<p class="order-customer">Khách hàng: Nguyễn Văn A</p>
<p class="order-date">Ngày đặt: 03/08/2025</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">
<span class="status-badge processing">Đang xử lý</span>
</p>
<p class="order-note">Gạch granite 60x60 - Số lượng: 50m²</p>
</div>
</div>
</div>
<!-- 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-content">
<div class="d-flex justify-between align-start mb-2">
@@ -70,18 +80,18 @@
</div>
<div class="order-details">
<p class="order-date">Ngày đặt: 02/08/2023</p>
<p class="order-customer">Khách hàng: Trần Thị B</p>
<p class="order-date">Ngày đặt: 24/06/2025</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">
<span class="status-badge completed">Hoàn thành</span>
</p>
<p class="order-note">Gạch ceramic 30x30 - Số lượng: 80m²</p>
</div>
</div>
</div>
<!-- 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-content">
<div class="d-flex justify-between align-start mb-2">
@@ -90,18 +100,18 @@
</div>
<div class="order-details">
<p class="order-date">Ngày đặt: 01/08/2023</p>
<p class="order-customer">Khách hàng: Lê Văn C</p>
<p class="order-date">Ngày đặt: 01/03/2025</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">
<span class="status-badge shipping">Đang giao</span>
</p>
<p class="order-note">Gạch porcelain 80x80 - Số lượng: 100m²</p>
</div>
</div>
</div>
<!-- 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-content">
<div class="d-flex justify-between align-start mb-2">
@@ -110,18 +120,18 @@
</div>
<div class="order-details">
<p class="order-date">Ngày đặt: 31/07/2023</p>
<p class="order-customer">Khách hàng: Phạm Thị D</p>
<p class="order-date">Ngày đặt: 08/11/2024</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">
<span class="status-badge pending">Chờ xác nhận</span>
</p>
<p class="order-note">Gạch mosaic 25x25 - Số lượng: 40m²</p>
</div>
</div>
</div>
<!-- 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-content">
<div class="d-flex justify-between align-start mb-2">
@@ -130,19 +140,47 @@
</div>
<div class="order-details">
<p class="order-date">Ngày đặt: 30/07/2023</p>
<p class="order-customer">Khách hàng: Hoàng Văn E</p>
<p class="order-date">Ngày đặt: 30/07/2024</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">
<span class="status-badge cancelled">Đã hủy</span>
</p>
<p class="order-note">Gạch terrazzo 40x40 - Số lượng: 20m²</p>
</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>
<script>
function viewOrderDetail(orderId) {
window.location.href = `order-detail.html?id=${orderId}`;
}
</script>
</body>
</html>

733
html/payment-detail.html Normal file
View 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>

View File

@@ -3,10 +3,267 @@
<head>
<meta charset="UTF-8">
<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>
<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>
.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>
<body>
<div class="page-wrapper">
@@ -15,132 +272,362 @@
<a href="index.html" class="back-button">
<i class="fas fa-arrow-left"></i>
</a>
<h1 class="header-title">Danh sách thanh toán</h1>
<button class="back-button">
<i class="fas fa-plus"></i>
</button>
<h1 class="header-title">Thanh toán</h1>
<div style="width: 32px;"></div>
</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ã phiếu thanh toán">
</div>
<!-- Filter Section -->
<div class="card mb-3">
<div class="d-flex justify-between align-center">
<h3 class="card-title">Bộ lọc</h3>
<i class="fas fa-filter" style="color: var(--primary-blue);"></i>
</div>
<div class="payments-container">
<!-- Filter Pills -->
<!--<div class="filter-container">
<button class="filter-pill active" onclick="filterInvoices('all')">>Tất cả</button>
<button class="filter-pill" onclick="filterInvoices('unpaid')">Chưa thanh toán</button>
<button class="filter-pill" onclick="filterInvoices('overdue')">Quá hạn</button>
<button class="filter-pill" onclick="filterInvoices('paid')">Đã thanh toán</button>
</div>-->
<!-- Tab Filters -->
<div class="tab-filters">
<button class="filter-tab active" onclick="filterInvoices('all')">
Tất cả
</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>
<!-- Payments List -->
<div class="payments-list">
<!-- Payment Item 1 - Processing -->
<div class="payment-card processing">
<div class="payment-status-indicator"></div>
<div class="payment-content">
<div class="d-flex justify-between align-start mb-2">
<h4 class="payment-id">#212221</h4>
<span class="payment-amount">12.900.000 VND</span>
<div class="payments-list" id="payments-list">
<!-- Invoice Card 1 - Overdue -->
<div class="invoice-card" data-status="overdue" onclick="viewInvoiceDetail('INV001')">
<div class="invoice-header">
<div class="invoice-codes">
<span class="invoice-id">Mã hóa đơn: #INV001</span>
<span class="order-id">Đơn hàng: #SO001</span>
</div>
<div class="payment-details">
<p class="payment-time">Thời gian: 03/08/2023</p>
<p class="payment-status-text">
<span class="status-badge processing">Đang xử lý</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>
<span class="status-badge status-overdue">Quá hạn</span>
</div>
<div class="invoice-details">
<div class="detail-item">
<span class="detail-label">Ngày đặt:</span>
<span class="detail-value">15/10/2024</span>
</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>
<!-- Payment Item 2 - Completed -->
<div class="payment-card completed">
<div class="payment-status-indicator"></div>
<div class="payment-content">
<div class="d-flex justify-between align-start mb-2">
<h4 class="payment-id">#212221</h4>
<span class="payment-amount">12.900.000 VND</span>
<!-- Invoice Card 2 - Unpaid -->
<div class="invoice-card" data-status="unpaid" onclick="viewInvoiceDetail('INV002')">
<div class="invoice-header">
<div class="invoice-codes">
<span class="invoice-id">Mã hóa đơn: #INV002</span>
<span class="order-id">Đơn hàng: #SO002</span>
</div>
<div class="payment-details">
<p class="payment-time">Thời gian: 03/08/2023</p>
<p class="payment-status-text">
<span class="status-badge completed">Hoàn thành</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>
<span class="status-badge status-unpaid">Chưa thanh toán</span>
</div>
<div class="invoice-details">
<div class="detail-item">
<span class="detail-label">Ngày đặt:</span>
<span class="detail-value">25/10/2024</span>
</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></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>
<!-- Payment Item 3 - Processing -->
<div class="payment-card processing">
<div class="payment-status-indicator"></div>
<div class="payment-content">
<div class="d-flex justify-between align-start mb-2">
<h4 class="payment-id">#212220</h4>
<span class="payment-amount">8.500.000 VND</span>
<!-- Invoice Card 3 - Partial Payment -->
<div class="invoice-card" data-status="unpaid" onclick="viewInvoiceDetail('INV003')">
<div class="invoice-header">
<div class="invoice-codes">
<span class="invoice-id">Mã hóa đơn: #INV003</span>
<span class="order-id">Đơn hàng: #SO003</span>
</div>
<div class="payment-details">
<p class="payment-time">Thời gian: 02/08/2023</p>
<p class="payment-status-text">
<span class="status-badge processing">Đang xử lý</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>
<span class="status-badge status-partial">Thanh toán 1 phần</span>
</div>
<div class="invoice-details">
<div class="detail-item">
<span class="detail-label">Ngày đặt:</span>
<span class="detail-value">20/10/2024</span>
</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>
<!-- Payment Item 4 - Completed -->
<div class="payment-card completed">
<div class="payment-status-indicator"></div>
<div class="payment-content">
<div class="d-flex justify-between align-start mb-2">
<h4 class="payment-id">#212219</h4>
<span class="payment-amount">15.200.000 VND</span>
<!-- Invoice Card 4 - Paid -->
<div class="invoice-card" data-status="paid" onclick="viewInvoiceDetail('INV004')">
<div class="invoice-header">
<div class="invoice-codes">
<span class="invoice-id">Mã hóa đơn: #INV004</span>
<span class="order-id">Đơn hàng: #SO004</span>
</div>
<div class="payment-details">
<p class="payment-time">Thời gian: 01/08/2023</p>
<p class="payment-status-text">
<span class="status-badge completed">Hoàn thành</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>
<span class="status-badge status-paid">Đã hoàn tất</span>
</div>
<div class="invoice-details">
<div class="detail-item">
<span class="detail-label">Ngày đặt:</span>
<span class="detail-value">10/10/2024</span>
</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></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>
<!-- Payment Item 5 - Processing -->
<div class="payment-card processing">
<div class="payment-status-indicator"></div>
<div class="payment-content">
<div class="d-flex justify-between align-start mb-2">
<h4 class="payment-id">#212218</h4>
<span class="payment-amount">6.750.000 VND</span>
<!-- Invoice Card 5 - Overdue -->
<div class="invoice-card" data-status="overdue" onclick="viewInvoiceDetail('INV005')">
<div class="invoice-header">
<div class="invoice-codes">
<span class="invoice-id">Mã hóa đơn: #INV005</span>
<span class="order-id">Đơn hàng: #SO005</span>
</div>
<div class="payment-details">
<p class="payment-time">Thời gian: 31/07/2023</p>
<p class="payment-status-text">
<span class="status-badge processing">Đang xử lý</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>
<span class="status-badge status-overdue">Quá hạn</span>
</div>
<div class="invoice-details">
<div class="detail-item">
<span class="detail-label">Ngày đặt:</span>
<span class="detail-value">05/10/2024</span>
</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></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>
<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>
</html>

632
html/point-complaint.html Normal file
View 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>

View File

@@ -47,7 +47,7 @@
Giao dịch: 100.000.000 VND
</p>
</div>
<button class="btn-complaint">
<button class="btn-complaint" onclick="openComplaint(this)">
Khiếu nại
</button>
</div>
@@ -74,7 +74,7 @@
Giao dịch: 200.000.000 VND
</p>
</div>
<button class="btn-complaint">
<button class="btn-complaint" onclick="openComplaint(this)">
Khiếu nại
</button>
</div>
@@ -98,7 +98,7 @@
Thời gian: 20/09/2023 17:23:18
</p>
</div>
<button class="btn-complaint">
<button class="btn-complaint" onclick="openComplaint(this)">
Khiếu nại
</button>
</div>
@@ -122,7 +122,7 @@
Thời gian: 19/09/2023 17:23:18
</p>
</div>
<button class="btn-complaint">
<button class="btn-complaint" onclick="openComplaint(this)">
Khiếu nại
</button>
</div>
@@ -146,7 +146,7 @@
Thời gian: 10/09/2023 17:23:18
</p>
</div>
<button class="btn-complaint">
<button class="btn-complaint" onclick="openComplaint(this)">
Khiếu nại
</button>
</div>
@@ -170,7 +170,7 @@
Thời gian: 19/09/2023 17:23:18
</p>
</div>
<button class="btn-complaint">
<button class="btn-complaint" onclick="openComplaint(this)">
Khiếu nại
</button>
</div>
@@ -185,5 +185,26 @@
</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>
</html>

View File

@@ -370,7 +370,7 @@
</div>
<!-- Store Location -->
<div class="form-group">
<!--<div class="form-group">
<label class="form-label required">Cửa hàng mua</label>
<select class="form-input form-select" id="storeLocation" required onchange="validateForm()">
<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="other">Cửa hàng khác</option>
</select>
</div>
</div>-->
<!-- Other Store -->
<div class="form-group" id="otherStoreGroup" style="display: none;">

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
View 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>

View 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>

View File

@@ -43,13 +43,21 @@
<div class="product-grid">
<!-- Product 1 -->
<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-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>
<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ỏ
</button>
</button>-->
</div>
</div>
@@ -62,18 +70,25 @@
<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=3FO3H1VE59R&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-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-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>
@@ -86,6 +101,9 @@
<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>
@@ -98,6 +116,9 @@
<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>

View 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
View 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&quote=${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&quote=${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>

View File

@@ -38,8 +38,8 @@
<!-- Quote Requests List -->
<div class="quote-requests-list">
<!-- Quote Request 1 - New -->
<div class="quote-request-card new">
<!-- Quote Request 1 - Đang đàm phán -->
<div class="quote-request-card negotiating" onclick="viewQuoteDetail('YC001234')">
<div class="quote-request-status-indicator"></div>
<div class="quote-request-content">
<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-items">5 sản phẩm - Diện tích: 200m²</p>
<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 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>
<!-- Quote Request 2 - Waiting Response -->
<div class="quote-request-card waiting">
<!-- Quote Request 2 - Đã chốt -->
<div class="quote-request-card finalized" onclick="viewQuoteDetail('YC001233')">
<div class="quote-request-status-indicator"></div>
<div class="quote-request-content">
<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-items">8 sản phẩm - Diện tích: 500m²</p>
<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 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>
<!-- Quote Request 3 - Has Quote -->
<div class="quote-request-card quoted">
<!-- Quote Request 3 - Đã thành đơn hàng -->
<div class="quote-request-card converted" onclick="viewQuoteDetail('YC001232')">
<div class="quote-request-status-indicator"></div>
<div class="quote-request-content">
<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-items">3 sản phẩm - Diện tích: 120m²</p>
<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 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>
<!-- Quote Request 4 - New -->
<div class="quote-request-card new">
<!-- Quote Request 4 - Đã gửi -->
<div class="quote-request-card sent" onclick="viewQuoteDetail('YC001231')">
<div class="quote-request-status-indicator"></div>
<div class="quote-request-content">
<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-items">4 sản phẩm - Diện tích: 300m²</p>
<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 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>
<!-- Quote Request 5 - Waiting Response -->
<div class="quote-request-card waiting">
<!-- Quote Request 5 - Chờ duyệt -->
<div class="quote-request-card pending" onclick="viewQuoteDetail('YC001230')">
<div class="quote-request-status-indicator"></div>
<div class="quote-request-content">
<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-items">12 sản phẩm - Diện tích: 800m²</p>
<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 class="quote-request-note">Yêu cầu báo giá cho khu vực pool và spa</p>
</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>
@@ -167,7 +187,13 @@
<i class="fas fa-user"></i>
<span>Cài đặt</span>
</a>
</div>
</div>-->
</div>-->
</div>
<script>
function viewQuoteDetail(quoteId) {
window.location.href = `quote-detail.html?id=${quoteId}`;
}
</script>
</body>
</html>

View 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>

View 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>

View File

@@ -79,41 +79,77 @@ class HiveTypeIds {
HiveTypeIds._();
// Core Models (0-9)
static const int user = 0;
static const int product = 1;
static const int cartItem = 2;
static const int order = 3;
static const int project = 4;
static const int loyaltyTransaction = 5;
static const int userModel = 0;
static const int userSessionModel = 1;
static const int productModel = 2;
static const int stockLevelModel = 3;
static const int cartModel = 4;
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)
static const int orderItem = 10;
static const int address = 11;
static const int category = 12;
static const int reward = 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;
// Loyalty Models (10-19)
static const int loyaltyPointEntryModel = 10;
static const int giftCatalogModel = 11;
static const int redeemedGiftModel = 12;
static const int pointsRecordModel = 13;
// Enums (20-29)
static const int memberTier = 20;
static const int userType = 21;
static const int orderStatus = 22;
static const int projectStatus = 23;
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;
// Project & Quote Models (14-17)
static const int projectSubmissionModel = 14;
static const int designRequestModel = 15;
static const int quoteModel = 16;
static const int quoteItemModel = 17;
// Cache & Sync Models (30-39)
static const int cachedData = 30;
static const int syncState = 31;
static const int offlineRequest = 32;
// Chat Models (18-19)
static const int chatRoomModel = 18;
static const int messageModel = 19;
// 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

View File

@@ -8,7 +8,7 @@ part of 'cached_data.dart';
class CachedDataAdapter extends TypeAdapter<CachedData> {
@override
final typeId = 30;
final typeId = 60;
@override
CachedData read(BinaryReader reader) {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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(),
};
}

View 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;
}

View 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(),
};
}

View File

@@ -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;
}

View 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)';
}
}

View 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)';
}
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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)';
}
}

View 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)';
}
}

View 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,
);
}

View 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;
}

View 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,
);
}

View 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;
}

View 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)';
}
}

View 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)';
}
}

View 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;
}
}
}

View 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;
}

View 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;
}
}
}

View 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;
}

View 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)';
}
}

View 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)';
}
}

View File

@@ -9,6 +9,7 @@
library;
import 'package:hive_ce/hive.dart';
import 'package:worker/core/constants/storage_constants.dart';
import 'package:worker/features/home/domain/entities/member_card.dart';
part 'member_card_model.g.dart';
@@ -20,8 +21,8 @@ part 'member_card_model.g.dart';
/// - Hive local database storage
/// - Converting to/from domain entity
///
/// Hive Type ID: 10 (ensure this doesn't conflict with other models)
@HiveType(typeId: 10)
/// Hive Type ID: 25 (from HiveTypeIds.memberCardModel)
@HiveType(typeId: HiveTypeIds.memberCardModel)
class MemberCardModel extends HiveObject {
/// Member ID
@HiveField(0)

View File

@@ -8,7 +8,7 @@ part of 'member_card_model.dart';
class MemberCardModelAdapter extends TypeAdapter<MemberCardModel> {
@override
final typeId = 10;
final typeId = 25;
@override
MemberCardModel read(BinaryReader reader) {

View File

@@ -9,6 +9,7 @@
library;
import 'package:hive_ce/hive.dart';
import 'package:worker/core/constants/storage_constants.dart';
import 'package:worker/features/home/domain/entities/promotion.dart';
part 'promotion_model.g.dart';
@@ -20,8 +21,8 @@ part 'promotion_model.g.dart';
/// - Hive local database storage
/// - Converting to/from domain entity
///
/// Hive Type ID: 11 (ensure this doesn't conflict with other models)
@HiveType(typeId: 11)
/// Hive Type ID: 26 (from HiveTypeIds.promotionModel)
@HiveType(typeId: HiveTypeIds.promotionModel)
class PromotionModel extends HiveObject {
/// Promotion ID
@HiveField(0)

View File

@@ -8,7 +8,7 @@ part of 'promotion_model.dart';
class PromotionModelAdapter extends TypeAdapter<PromotionModel> {
@override
final typeId = 11;
final typeId = 26;
@override
PromotionModel read(BinaryReader reader) {

View File

@@ -147,7 +147,7 @@ class HomePage extends ConsumerWidget {
QuickAction(
icon: Icons.grid_view,
label: l10n.products,
onTap: () => context.go(RouteNames.products),
onTap: () => context.pushNamed(RouteNames.products),
),
QuickAction(
icon: Icons.shopping_cart,

View 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;
}
}

View 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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View 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;
}
}
}

View 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;
}

View 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;
}

View 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;
}

View 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)';
}
}

View 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)';
}
}

View 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)';
}
}

View 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)';
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}

View 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)';
}
}

View 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;
}

View 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;
}

View 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,
};
}

View 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;
}

View 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;
}
}
}

View 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;
}

View 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(),
};
}

View 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;
}

View 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)';
}
}

View 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)';
}
}

View 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)';
}
}

View 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)';
}
}

View File

@@ -4,6 +4,7 @@
library;
import 'package:hive_ce/hive.dart';
import 'package:worker/core/constants/storage_constants.dart';
import 'package:worker/features/products/domain/entities/category.dart';
part 'category_model.g.dart';
@@ -15,8 +16,8 @@ part 'category_model.g.dart';
/// - Hive local database storage
/// - Converting to/from domain entity
///
/// Hive Type ID: 12
@HiveType(typeId: 12)
/// Hive Type ID: 27 (from HiveTypeIds.categoryModel)
@HiveType(typeId: HiveTypeIds.categoryModel)
class CategoryModel extends HiveObject {
/// Unique identifier
@HiveField(0)

View File

@@ -8,7 +8,7 @@ part of 'category_model.dart';
class CategoryModelAdapter extends TypeAdapter<CategoryModel> {
@override
final typeId = 12;
final typeId = 27;
@override
CategoryModel read(BinaryReader reader) {

Some files were not shown because too many files have changed in this diff Show More