Inventory Management | Customer Management | Invoice Management Done | Commodity Rate Sync Done
This commit is contained in:
113
implementation.md
Normal file
113
implementation.md
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
# Kifi V2 Phase 1 - Inventory Management Complete Implementation Plan
|
||||||
|
|
||||||
|
This document outlines the complete architecture and task breakdown required to finish Phase 1 (Inventory Management), incorporating all feedback including full UOM CRUD, GST taxation logic, and Bill of Materials (BOM).
|
||||||
|
|
||||||
|
## User Review Required
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> **Stock Movements vs. Direct Edits**
|
||||||
|
> Moving forward, users will *not* directly edit the `quantity` of a product in the "Edit Product" screen. All stock adjustments will go through the new **Inventory Movements** mechanism (Add, Reduce, Adjust) to ensure a historically accurate Stock Ledger.
|
||||||
|
>
|
||||||
|
> **Bill of Materials (BOM) Consumption:**
|
||||||
|
> When a BOM/Bundle product is sold or assembled, the system must automatically deduct the stock of its underlying raw materials. We will implement "Assembly Movements" to handle this cleanly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Product Detail & Ledger (Presentation Layer)
|
||||||
|
A read-only rich product view that replaces the immediate navigation to the Edit screen.
|
||||||
|
|
||||||
|
#### [NEW] `lib/features/inventory/presentation/product_detail_screen.dart`
|
||||||
|
- **Purpose:** Display a rich overview of the product.
|
||||||
|
- **Sections:**
|
||||||
|
- Header: Image carousel, Name, SKU, Status.
|
||||||
|
- Stock Summary: Available stock vs. Reserved stock.
|
||||||
|
- Pricing & Taxes: Purchase vs Selling price, GST configuration.
|
||||||
|
- Action Bar: "Adjust Stock", "Edit Product", "Share".
|
||||||
|
|
||||||
|
#### [NEW] `lib/features/inventory/presentation/stock_ledger_tab.dart`
|
||||||
|
- **Purpose:** A chronological list of all movements in and out of the warehouse for a given product.
|
||||||
|
- **Data:** Display Date, Reference (e.g. "Opening", "Adjustment"), In/Out arrows, and Running Balance.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Inventory Movements (Domain & API Layer)
|
||||||
|
The core backend and frontend logic for tracking stock changes robustly.
|
||||||
|
|
||||||
|
#### [NEW] `lib/features/inventory/domain/stock_movement.dart`
|
||||||
|
- **Purpose:** Data model representing an atomic inventory transaction.
|
||||||
|
- **Fields:** `id`, `productId`, `type` (OPENING, ADDITION, REDUCTION, ADJUSTMENT, DAMAGE, ASSEMBLY), `quantity`, `balanceAfter`, `date`, `notes`.
|
||||||
|
|
||||||
|
#### [MODIFY] `lib/features/inventory/providers/products_provider.dart`
|
||||||
|
- **Changes:** Add methods for `adjustStock(productId, type, quantity, notes)` and `fetchStockLedger(productId)`.
|
||||||
|
|
||||||
|
#### [NEW] `kifi-api/src/main/java/com/kifi/api/entity/inventory/StockMovement.java` (Backend)
|
||||||
|
- **Purpose:** The PostgreSQL entity for tracking historical stock changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Bill of Materials (BOM) / Bundle of Materials
|
||||||
|
Allowing products to be composed of other raw materials or sub-products.
|
||||||
|
|
||||||
|
#### [NEW] `kifi-api/src/main/java/com/kifi/api/entity/inventory/ProductBomItem.java`
|
||||||
|
- **Purpose:** Backend entity mapping a parent product to its component products with required quantities.
|
||||||
|
- **Fields:** `id`, `parentProductId`, `componentProductId`, `quantityRequired`.
|
||||||
|
|
||||||
|
#### [NEW] `lib/features/inventory/domain/bom_item.dart`
|
||||||
|
- **Purpose:** Frontend model for BOM components.
|
||||||
|
|
||||||
|
#### [NEW] `lib/features/inventory/presentation/bom/manage_bom_screen.dart`
|
||||||
|
- **Purpose:** UI for a user to select a product and add component products to it to define its recipe/bundle.
|
||||||
|
- **Functionality:** Search products, specify quantities, and save the BOM structure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Business Profile & GST Configuration
|
||||||
|
The context wrapper for the Business Mode, heavily incorporating Indian GST logic.
|
||||||
|
|
||||||
|
#### [NEW] `lib/features/business/presentation/settings/business_profile_screen.dart`
|
||||||
|
- **Purpose:** UI to capture Business Registration, Industry Type, Tax Numbers (GSTIN), and Business Logo.
|
||||||
|
- **GST Logic Implementation:**
|
||||||
|
- Store the business's Home State.
|
||||||
|
- When calculating taxes on transactions/invoices, compare the customer's state with the business's state.
|
||||||
|
- **Intra-state:** Split total GST equally into SGST (9%) and CGST (9%).
|
||||||
|
- **Inter-state:** Apply full tax to IGST (18%).
|
||||||
|
|
||||||
|
#### [NEW] `lib/features/business/domain/tax_calculator.dart`
|
||||||
|
- **Purpose:** A utility to handle the SGST/CGST vs IGST split logic cleanly based on state codes.
|
||||||
|
|
||||||
|
#### [MODIFY] `lib/features/business/providers/business_provider.dart`
|
||||||
|
- **Changes:** Cache the active `BusinessProfile` object to make the Home State instantly available for tax calculations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Units of Measure (UOM) Management (Full CRUD)
|
||||||
|
Master data UI for inventory sizing, completely managed by the user.
|
||||||
|
|
||||||
|
#### [NEW] `lib/features/inventory/presentation/uom/uom_list_screen.dart`
|
||||||
|
- **Purpose:** View all existing UOMs.
|
||||||
|
|
||||||
|
#### [NEW] `lib/features/inventory/presentation/uom/add_edit_uom_screen.dart`
|
||||||
|
- **Purpose:** Full CRUD capabilities. Users can create custom units (e.g., "Box", "Kg", "Dozen"), update their names, or delete them (if not linked to existing products).
|
||||||
|
|
||||||
|
#### [NEW] `lib/features/inventory/providers/uom_provider.dart`
|
||||||
|
- **Purpose:** Manage the state of UOMs (create, update, delete, fetch).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Inventory Dashboard & Alerts
|
||||||
|
Bringing the data to life on the Business Hub.
|
||||||
|
|
||||||
|
#### [MODIFY] `lib/features/business/presentation/hub/business_hub_screen.dart`
|
||||||
|
- **Changes:** Replace static placeholders with real widgets powered by providers:
|
||||||
|
- **Total Value Card:** Aggregated `quantity * purchasePrice`.
|
||||||
|
- **Low Stock Widget:** List of products where `currentStock <= minStock`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification Plan
|
||||||
|
|
||||||
|
### Manual Verification
|
||||||
|
1. **Product Detail & Movements:** Create a product, perform an "Add Stock" adjustment of 10, then a "Reduce Stock" of 2. Verify the stock ledger shows accurate running balances (10, then 8).
|
||||||
|
2. **UOM CRUD:** Create a new custom UOM ("Pallet"), verify it appears in the dropdown when creating a product, then edit its name.
|
||||||
|
3. **BOM Logic:** Create raw materials A and B. Create Final Product C with a BOM of 1xA + 2xB. Assemble 1 unit of Product C and verify A's stock reduces by 1 and B's stock reduces by 2.
|
||||||
|
4. **GST Logic:** Set Business State to "Maharashtra". Create a mock sale to "Maharashtra" and verify SGST/CGST split. Create a mock sale to "Delhi" and verify IGST mapping.
|
||||||
37
kifi-api/fix_controllers.py
Normal file
37
kifi-api/fix_controllers.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import re
|
||||||
|
import os
|
||||||
|
|
||||||
|
files = [
|
||||||
|
'src/main/java/com/kifi/api/controller/customer/CustomerController.java',
|
||||||
|
'src/main/java/com/kifi/api/controller/invoice/InvoiceController.java'
|
||||||
|
]
|
||||||
|
|
||||||
|
for file in files:
|
||||||
|
with open(file, 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Add import org.springframework.security.core.Authentication; if not present
|
||||||
|
if 'import org.springframework.security.core.Authentication;' not in content:
|
||||||
|
content = content.replace('import org.springframework.web.bind.annotation.*;',
|
||||||
|
'import org.springframework.security.core.Authentication;\nimport org.springframework.web.bind.annotation.*;')
|
||||||
|
|
||||||
|
# Replace @RequestAttribute("userId") Long userId, with Authentication authentication,
|
||||||
|
content = content.replace('@RequestAttribute("userId") Long userId,', 'Authentication authentication,')
|
||||||
|
# Replace @RequestAttribute("userId") Long userId with Authentication authentication
|
||||||
|
content = content.replace('@RequestAttribute("userId") Long userId', 'Authentication authentication')
|
||||||
|
|
||||||
|
# Now, find all method declarations that have (..., Authentication authentication, ...) {
|
||||||
|
# and insert Long userId = Long.valueOf(authentication.getDetails().toString()); right after the {
|
||||||
|
|
||||||
|
# We can use regex to find method bodies
|
||||||
|
pattern = re.compile(r'(public\s+[^\(]+\([^\)]*Authentication authentication[^\)]*\)\s*\{)')
|
||||||
|
|
||||||
|
def replacer(match):
|
||||||
|
return match.group(1) + '\n Long userId = Long.valueOf(authentication.getDetails().toString());'
|
||||||
|
|
||||||
|
content = pattern.sub(replacer, content)
|
||||||
|
|
||||||
|
with open(file, 'w') as f:
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
print("Controllers fixed!")
|
||||||
0
kifi-api/kifi.db
Normal file
0
kifi-api/kifi.db
Normal file
0
kifi-api/kifi_local.db
Normal file
0
kifi-api/kifi_local.db
Normal file
6
kifi-api/scratch.dart
Normal file
6
kifi-api/scratch.dart
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
void main() async {
|
||||||
|
final token = "YOUR_TOKEN"; // I don't have the token
|
||||||
|
}
|
||||||
7
kifi-api/scratch.java
Normal file
7
kifi-api/scratch.java
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
public class scratch {
|
||||||
|
public static void main(String[] args) {
|
||||||
|
System.out.println("Wait");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,10 @@ public class WebClientConfig {
|
|||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public WebClient.Builder webClientBuilder() {
|
public WebClient.Builder webClientBuilder() {
|
||||||
return WebClient.builder();
|
return WebClient
|
||||||
|
.builder().codecs(configurer ->
|
||||||
|
configurer.defaultCodecs()
|
||||||
|
.maxInMemorySize(10 * 1024 * 1024)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,13 +28,13 @@ public class WalletController {
|
|||||||
@PostMapping
|
@PostMapping
|
||||||
public Mono<Wallet> createWallet(@RequestBody CreateWalletRequest request, Authentication authentication) {
|
public Mono<Wallet> createWallet(@RequestBody CreateWalletRequest request, Authentication authentication) {
|
||||||
Long userId = Long.valueOf(authentication.getDetails().toString());
|
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||||
return walletService.createWallet(userId, request.getName(), request.getNature(), request.getIcon(), request.getColor(), request.getCurrency(), request.getInitialBalance());
|
return walletService.createWallet(userId, request.getName(), request.getNature(), request.getIcon(), request.getColor(), request.getCurrency(), request.getInitialBalance(), request.getSubNature(), request.getCreditLimit(), request.getFixedAmount(), request.getPaymentCycle(), request.getCycleDate());
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/{walletId}")
|
@PutMapping("/{walletId}")
|
||||||
public Mono<Wallet> editWallet(@PathVariable Long walletId, @RequestBody CreateWalletRequest request, Authentication authentication) {
|
public Mono<Wallet> editWallet(@PathVariable Long walletId, @RequestBody CreateWalletRequest request, Authentication authentication) {
|
||||||
Long userId = Long.valueOf(authentication.getDetails().toString());
|
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||||
return walletService.editWallet(walletId, userId, request.getName(), request.getNature(), request.getIcon(), request.getColor(), request.getCurrency());
|
return walletService.editWallet(walletId, userId, request.getName(), request.getNature(), request.getIcon(), request.getColor(), request.getCurrency(), request.getSubNature(), request.getCreditLimit(), request.getFixedAmount(), request.getPaymentCycle(), request.getCycleDate());
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/{walletId}")
|
@DeleteMapping("/{walletId}")
|
||||||
@@ -95,6 +95,11 @@ public class WalletController {
|
|||||||
private String color;
|
private String color;
|
||||||
private String currency;
|
private String currency;
|
||||||
private java.math.BigDecimal initialBalance;
|
private java.math.BigDecimal initialBalance;
|
||||||
|
private String subNature;
|
||||||
|
private java.math.BigDecimal creditLimit;
|
||||||
|
private java.math.BigDecimal fixedAmount;
|
||||||
|
private String paymentCycle;
|
||||||
|
private Integer cycleDate;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.kifi.api.controller.business;
|
package com.kifi.api.controller.business;
|
||||||
|
|
||||||
import com.kifi.api.entity.business.BusinessProfile;
|
import com.kifi.api.entity.business.BusinessProfile;
|
||||||
|
import com.kifi.api.entity.business.BusinessFeature;
|
||||||
import com.kifi.api.service.business.BusinessService;
|
import com.kifi.api.service.business.BusinessService;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
@@ -29,4 +30,18 @@ public class BusinessController {
|
|||||||
return businessService.saveProfile(userId, profile)
|
return businessService.saveProfile(userId, profile)
|
||||||
.map(ResponseEntity::ok);
|
.map(ResponseEntity::ok);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/features")
|
||||||
|
public Mono<ResponseEntity<BusinessFeature>> getFeatures(Authentication authentication) {
|
||||||
|
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||||
|
return businessService.getFeaturesByUserId(userId)
|
||||||
|
.map(ResponseEntity::ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/features")
|
||||||
|
public Mono<ResponseEntity<BusinessFeature>> saveFeatures(Authentication authentication, @RequestBody BusinessFeature feature) {
|
||||||
|
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||||
|
return businessService.saveFeatures(userId, feature)
|
||||||
|
.map(ResponseEntity::ok);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.kifi.api.controller.business;
|
||||||
|
|
||||||
|
import com.kifi.api.entity.business.IndianState;
|
||||||
|
import com.kifi.api.repository.business.IndianStateRepository;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/kifi-v2/master")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class MasterDataController {
|
||||||
|
private final IndianStateRepository indianStateRepository;
|
||||||
|
|
||||||
|
@GetMapping("/states")
|
||||||
|
public Flux<IndianState> getIndianStates() {
|
||||||
|
return indianStateRepository.findAllByOrderByNameAsc();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package com.kifi.api.controller.customer;
|
||||||
|
|
||||||
|
import com.kifi.api.entity.customer.Customer;
|
||||||
|
import com.kifi.api.service.customer.CustomerService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.codec.multipart.FilePart;
|
||||||
|
import org.springframework.core.io.buffer.DataBufferUtils;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
import java.util.Base64;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/kifi-v2/customers")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class CustomerController {
|
||||||
|
|
||||||
|
private final CustomerService customerService;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public Flux<Customer> getCustomers(
|
||||||
|
Authentication authentication,
|
||||||
|
@RequestParam(required = false) String search) {
|
||||||
|
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||||
|
return customerService.getCustomers(userId, search);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public Mono<Customer> getCustomerById(
|
||||||
|
@PathVariable Long id,
|
||||||
|
Authentication authentication) {
|
||||||
|
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||||
|
return customerService.getCustomerById(id, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public Mono<Customer> createCustomer(
|
||||||
|
Authentication authentication,
|
||||||
|
@RequestBody Customer customer) {
|
||||||
|
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||||
|
return customerService.createCustomer(userId, customer);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public Mono<Customer> updateCustomer(
|
||||||
|
@PathVariable Long id,
|
||||||
|
Authentication authentication,
|
||||||
|
@RequestBody Customer customer) {
|
||||||
|
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||||
|
return customerService.updateCustomer(id, userId, customer);
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public Mono<Void> deleteCustomer(
|
||||||
|
@PathVariable Long id,
|
||||||
|
Authentication authentication) {
|
||||||
|
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||||
|
return customerService.deleteCustomer(id, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping(value = "/{id}/photo", consumes = org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
|
public Mono<Customer> uploadPhoto(
|
||||||
|
@PathVariable Long id,
|
||||||
|
Authentication authentication,
|
||||||
|
@RequestPart("file") FilePart filePart) {
|
||||||
|
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||||
|
return DataBufferUtils.join(filePart.content())
|
||||||
|
.flatMap(dataBuffer -> {
|
||||||
|
byte[] bytes = new byte[dataBuffer.readableByteCount()];
|
||||||
|
dataBuffer.read(bytes);
|
||||||
|
DataBufferUtils.release(dataBuffer);
|
||||||
|
String base64Content = Base64.getEncoder().encodeToString(bytes);
|
||||||
|
String contentType = filePart.headers().getContentType() != null ? filePart.headers().getContentType().toString() : "image/jpeg";
|
||||||
|
return customerService.uploadPhoto(id, userId, contentType, base64Content);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}/photo/content")
|
||||||
|
public Mono<ResponseEntity<byte[]>> downloadPhoto(
|
||||||
|
@PathVariable Long id,
|
||||||
|
Authentication authentication) {
|
||||||
|
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||||
|
return customerService.downloadPhoto(id, userId)
|
||||||
|
.map(base64Content -> {
|
||||||
|
byte[] decodedBytes = Base64.getDecoder().decode(base64Content);
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.header(HttpHeaders.CONTENT_TYPE, "image/jpeg")
|
||||||
|
.body(decodedBytes);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package com.kifi.api.controller.inventory;
|
||||||
|
|
||||||
|
import com.kifi.api.entity.inventory.ProductBom;
|
||||||
|
import com.kifi.api.service.inventory.ProductService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/kifi-v2/inventory/products")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ProductBomController {
|
||||||
|
|
||||||
|
private final ProductService productService;
|
||||||
|
|
||||||
|
@GetMapping("/{id}/bom")
|
||||||
|
public Flux<ProductBom> getProductBom(Authentication authentication, @PathVariable Long id) {
|
||||||
|
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||||
|
// Validation could be added to ensure the user owns the parent product
|
||||||
|
return productService.getProductBom(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/bom")
|
||||||
|
public Mono<ResponseEntity<ProductBom>> addOrUpdateBomItem(
|
||||||
|
Authentication authentication,
|
||||||
|
@PathVariable Long id,
|
||||||
|
@RequestBody ProductBom bomItem) {
|
||||||
|
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||||
|
bomItem.setParentProductId(id);
|
||||||
|
return productService.addOrUpdateBomItem(userId, bomItem)
|
||||||
|
.map(ResponseEntity::ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/bom/{bomId}")
|
||||||
|
public Mono<ResponseEntity<Void>> deleteBomItem(
|
||||||
|
Authentication authentication,
|
||||||
|
@PathVariable Long bomId) {
|
||||||
|
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||||
|
return productService.deleteBomItem(userId, bomId)
|
||||||
|
.then(Mono.just(ResponseEntity.noContent().<Void>build()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,9 @@ package com.kifi.api.controller.inventory;
|
|||||||
|
|
||||||
import com.kifi.api.entity.inventory.ProductCategory;
|
import com.kifi.api.entity.inventory.ProductCategory;
|
||||||
import com.kifi.api.repository.inventory.ProductCategoryRepository;
|
import com.kifi.api.repository.inventory.ProductCategoryRepository;
|
||||||
|
import com.kifi.api.entity.inventory.CategoryRateHistory;
|
||||||
|
import com.kifi.api.repository.inventory.CategoryRateHistoryRepository;
|
||||||
|
import com.kifi.api.service.inventory.ProductService;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
@@ -9,13 +12,18 @@ import org.springframework.web.bind.annotation.*;
|
|||||||
import reactor.core.publisher.Flux;
|
import reactor.core.publisher.Flux;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/kifi-v2/inventory/categories")
|
@RequestMapping("/api/kifi-v2/inventory/categories")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class ProductCategoryController {
|
public class ProductCategoryController {
|
||||||
private final ProductCategoryRepository categoryRepository;
|
private final ProductCategoryRepository categoryRepository;
|
||||||
|
private final CategoryRateHistoryRepository rateHistoryRepository;
|
||||||
|
private final ProductService productService;
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public Flux<ProductCategory> getCategories(Authentication authentication) {
|
public Flux<ProductCategory> getCategories(Authentication authentication) {
|
||||||
@@ -36,13 +44,55 @@ public class ProductCategoryController {
|
|||||||
public Mono<ResponseEntity<ProductCategory>> updateCategory(@PathVariable Long id, @RequestBody ProductCategory category) {
|
public Mono<ResponseEntity<ProductCategory>> updateCategory(@PathVariable Long id, @RequestBody ProductCategory category) {
|
||||||
return categoryRepository.findById(id)
|
return categoryRepository.findById(id)
|
||||||
.flatMap(existing -> {
|
.flatMap(existing -> {
|
||||||
|
boolean rateChanged = category.getDailyRate() != null && !category.getDailyRate().equals(existing.getDailyRate());
|
||||||
existing.setName(category.getName());
|
existing.setName(category.getName());
|
||||||
existing.setParentCategoryId(category.getParentCategoryId());
|
existing.setParentCategoryId(category.getParentCategoryId());
|
||||||
existing.setIsCommodity(category.getIsCommodity());
|
existing.setIsCommodity(category.getIsCommodity());
|
||||||
existing.setDailyRate(category.getDailyRate());
|
existing.setDailyRate(category.getDailyRate());
|
||||||
return categoryRepository.save(existing);
|
|
||||||
|
Mono<ProductCategory> saveMono = categoryRepository.save(existing);
|
||||||
|
|
||||||
|
if (rateChanged && category.getDailyRate() != null) {
|
||||||
|
return saveMono.flatMap(saved ->
|
||||||
|
rateHistoryRepository.findByCategoryIdAndDate(id, LocalDate.now())
|
||||||
|
.defaultIfEmpty(CategoryRateHistory.builder()
|
||||||
|
.categoryId(id)
|
||||||
|
.date(LocalDate.now())
|
||||||
|
.createdAt(LocalDateTime.now())
|
||||||
|
.build())
|
||||||
|
.flatMap(history -> {
|
||||||
|
history.setRate(BigDecimal.valueOf(category.getDailyRate()));
|
||||||
|
history.setUpdatedAt(LocalDateTime.now());
|
||||||
|
return rateHistoryRepository.save(history);
|
||||||
|
})
|
||||||
|
.thenReturn(saved)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return saveMono;
|
||||||
})
|
})
|
||||||
.map(ResponseEntity::ok)
|
.map(ResponseEntity::ok)
|
||||||
.defaultIfEmpty(ResponseEntity.notFound().build());
|
.defaultIfEmpty(ResponseEntity.notFound().build());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}/rate-history")
|
||||||
|
public Flux<CategoryRateHistory> getRateHistory(@PathVariable Long id) {
|
||||||
|
return rateHistoryRepository.findRecentHistoryByCategoryId(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/sync-rates")
|
||||||
|
public Mono<ResponseEntity<Map<String, Object>>> syncRates(@PathVariable Long id) {
|
||||||
|
return categoryRepository.findById(id)
|
||||||
|
.flatMap(category ->
|
||||||
|
productService.syncCategoryRates(id, category.getDailyRate())
|
||||||
|
.count()
|
||||||
|
.map(count -> {
|
||||||
|
Map<String, Object> response = new java.util.HashMap<>();
|
||||||
|
response.put("success", true);
|
||||||
|
response.put("syncedCount", count);
|
||||||
|
response.put("message", "Successfully synced rates for " + count + " products.");
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.defaultIfEmpty(ResponseEntity.notFound().build());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,11 +23,13 @@ public class ProductController {
|
|||||||
@GetMapping
|
@GetMapping
|
||||||
public Flux<Product> getProducts(
|
public Flux<Product> getProducts(
|
||||||
Authentication authentication,
|
Authentication authentication,
|
||||||
|
@RequestParam(required = false) String search,
|
||||||
@RequestParam(defaultValue = "0") int page,
|
@RequestParam(defaultValue = "0") int page,
|
||||||
@RequestParam(defaultValue = "20") int size
|
@RequestParam(defaultValue = "20") int size
|
||||||
) {
|
) {
|
||||||
Long userId = Long.valueOf(authentication.getDetails().toString());
|
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||||
return productService.getProductsByUserId(userId, page, size);
|
return productService.getProductsByUserId(userId, search, page, size)
|
||||||
|
.doOnNext(p -> System.out.println("Returning product: " + p.getName() + ", currentStock: " + p.getCurrentStock()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
@@ -78,4 +80,24 @@ public class ProductController {
|
|||||||
public Mono<Void> deleteImage(@PathVariable Long imageId) {
|
public Mono<Void> deleteImage(@PathVariable Long imageId) {
|
||||||
return productService.deleteProductImage(imageId);
|
return productService.deleteProductImage(imageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/movements")
|
||||||
|
public Mono<ResponseEntity<com.kifi.api.entity.inventory.InventoryMovement>> adjustStock(
|
||||||
|
Authentication authentication,
|
||||||
|
@PathVariable Long id,
|
||||||
|
@RequestBody com.kifi.api.entity.inventory.InventoryMovement movement
|
||||||
|
) {
|
||||||
|
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||||
|
return productService.adjustStock(userId, id, movement)
|
||||||
|
.map(ResponseEntity::ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}/movements")
|
||||||
|
public Flux<com.kifi.api.entity.inventory.InventoryMovement> getStockLedger(
|
||||||
|
Authentication authentication,
|
||||||
|
@PathVariable Long id
|
||||||
|
) {
|
||||||
|
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||||
|
return productService.getProductStockLedger(userId, id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package com.kifi.api.controller.inventory;
|
||||||
|
|
||||||
|
import com.kifi.api.entity.inventory.UnitOfMeasure;
|
||||||
|
import com.kifi.api.service.inventory.UnitOfMeasureService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/kifi-v2/inventory/uom")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class UnitOfMeasureController {
|
||||||
|
|
||||||
|
private final UnitOfMeasureService uomService;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public Flux<UnitOfMeasure> getUoms(Authentication authentication) {
|
||||||
|
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||||
|
return uomService.getUomsByUserId(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public Mono<UnitOfMeasure> getUomById(@PathVariable Long id, Authentication authentication) {
|
||||||
|
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||||
|
return uomService.getUomById(id, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
|
public Mono<UnitOfMeasure> createUom(@RequestBody UnitOfMeasure uom, Authentication authentication) {
|
||||||
|
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||||
|
uom.setUserId(userId);
|
||||||
|
return uomService.createUom(uom);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public Mono<UnitOfMeasure> updateUom(@PathVariable Long id, @RequestBody UnitOfMeasure uom, Authentication authentication) {
|
||||||
|
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||||
|
return uomService.updateUom(id, uom, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
|
public Mono<Void> deleteUom(@PathVariable Long id, Authentication authentication) {
|
||||||
|
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||||
|
return uomService.deleteUom(id, userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package com.kifi.api.controller.invoice;
|
||||||
|
|
||||||
|
import com.kifi.api.entity.invoice.Invoice;
|
||||||
|
import com.kifi.api.service.invoice.InvoiceService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/kifi-v2/invoices")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class InvoiceController {
|
||||||
|
|
||||||
|
private final InvoiceService invoiceService;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public Flux<Invoice> getInvoices(Authentication authentication) {
|
||||||
|
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||||
|
return invoiceService.getInvoices(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public Mono<Invoice> getInvoiceById(
|
||||||
|
@PathVariable Long id,
|
||||||
|
Authentication authentication) {
|
||||||
|
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||||
|
return invoiceService.getInvoiceById(id, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public Mono<Invoice> createInvoice(
|
||||||
|
Authentication authentication,
|
||||||
|
@RequestBody Invoice invoice) {
|
||||||
|
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||||
|
return invoiceService.createInvoice(userId, invoice);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}/finalize")
|
||||||
|
public Mono<Invoice> finalizeInvoice(
|
||||||
|
@PathVariable Long id,
|
||||||
|
Authentication authentication) {
|
||||||
|
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||||
|
return invoiceService.finalizeInvoice(id, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/payments")
|
||||||
|
public Mono<com.kifi.api.entity.invoice.InvoicePayment> addPayment(
|
||||||
|
@PathVariable Long id,
|
||||||
|
Authentication authentication,
|
||||||
|
@RequestBody com.kifi.api.entity.invoice.InvoicePayment payment) {
|
||||||
|
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||||
|
return invoiceService.addPaymentToInvoice(id, userId, payment);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}/payments")
|
||||||
|
public Flux<com.kifi.api.entity.invoice.InvoicePayment> getPayments(
|
||||||
|
@PathVariable Long id,
|
||||||
|
Authentication authentication) {
|
||||||
|
Long userId = Long.valueOf(authentication.getDetails().toString());
|
||||||
|
return invoiceService.getPaymentsForInvoice(id, userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,5 +18,10 @@ public class Wallet {
|
|||||||
private String currency;
|
private String currency;
|
||||||
private String icon;
|
private String icon;
|
||||||
private String color;
|
private String color;
|
||||||
|
private String subNature;
|
||||||
|
private java.math.BigDecimal creditLimit;
|
||||||
|
private java.math.BigDecimal fixedAmount;
|
||||||
|
private String paymentCycle;
|
||||||
|
private Integer cycleDate;
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import lombok.Builder;
|
|||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
import org.springframework.data.annotation.Id;
|
import org.springframework.data.annotation.Id;
|
||||||
|
import org.springframework.data.relational.core.mapping.Column;
|
||||||
import org.springframework.data.relational.core.mapping.Table;
|
import org.springframework.data.relational.core.mapping.Table;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
@@ -21,5 +22,16 @@ public class BusinessFeature {
|
|||||||
private Boolean inventoryManagement;
|
private Boolean inventoryManagement;
|
||||||
private Boolean salesManagement;
|
private Boolean salesManagement;
|
||||||
private Boolean multiLocation;
|
private Boolean multiLocation;
|
||||||
|
|
||||||
|
@Column("bom_reduction_strategy")
|
||||||
|
private String bomReductionStrategy; // "COMPONENTS_ONLY" or "PARENT_AND_COMPONENTS"
|
||||||
|
|
||||||
|
@Column("stock_deduction_on_invoice")
|
||||||
|
private Boolean stockDeductionOnInvoice;
|
||||||
|
|
||||||
|
@Column("barcode_source")
|
||||||
|
private String barcodeSource;
|
||||||
|
|
||||||
|
@Column("created_at")
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,13 @@ public class BusinessProfile {
|
|||||||
private String taxNumber;
|
private String taxNumber;
|
||||||
private String currency;
|
private String currency;
|
||||||
private Boolean taxIncludedInPrice;
|
private Boolean taxIncludedInPrice;
|
||||||
|
private String address;
|
||||||
|
private Long stateId;
|
||||||
|
private String contactPerson;
|
||||||
|
private String contactNumber;
|
||||||
|
private String emailId;
|
||||||
|
private String panNumber;
|
||||||
|
private String gstin;
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
private LocalDateTime updatedAt;
|
private LocalDateTime updatedAt;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.kifi.api.entity.business;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import org.springframework.data.annotation.Id;
|
||||||
|
import org.springframework.data.relational.core.mapping.Table;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Table("indian_states")
|
||||||
|
public class IndianState {
|
||||||
|
@Id
|
||||||
|
private Long id;
|
||||||
|
private String name;
|
||||||
|
private String gstCode;
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package com.kifi.api.entity.customer;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import org.springframework.data.annotation.Id;
|
||||||
|
import org.springframework.data.relational.core.mapping.Column;
|
||||||
|
import org.springframework.data.relational.core.mapping.Table;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Table("customers")
|
||||||
|
public class Customer {
|
||||||
|
@Id
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column("user_id")
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
private String name;
|
||||||
|
private String email;
|
||||||
|
private String phone;
|
||||||
|
private String address;
|
||||||
|
private String gstin;
|
||||||
|
|
||||||
|
@Column("father_name")
|
||||||
|
private String fatherName;
|
||||||
|
|
||||||
|
private String gender;
|
||||||
|
private Integer age;
|
||||||
|
|
||||||
|
@Column("id_number")
|
||||||
|
private String idNumber;
|
||||||
|
|
||||||
|
@Column("state_id")
|
||||||
|
private Integer stateId;
|
||||||
|
|
||||||
|
@Column("photo_url")
|
||||||
|
private String photoUrl;
|
||||||
|
|
||||||
|
@Column("created_at")
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.kifi.api.entity.inventory;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import org.springframework.data.annotation.Id;
|
||||||
|
import org.springframework.data.relational.core.mapping.Table;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Table("category_rate_history")
|
||||||
|
public class CategoryRateHistory {
|
||||||
|
@Id
|
||||||
|
private Long id;
|
||||||
|
private Long categoryId;
|
||||||
|
private BigDecimal rate;
|
||||||
|
private LocalDate date;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
@@ -23,4 +23,7 @@ public class InventoryMovement {
|
|||||||
private Long referenceTransactionId;
|
private Long referenceTransactionId;
|
||||||
private String notes;
|
private String notes;
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@org.springframework.data.annotation.Transient
|
||||||
|
private java.util.List<InventoryMovementItem> items;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,4 +49,7 @@ public class Product {
|
|||||||
|
|
||||||
@Transient
|
@Transient
|
||||||
private List<ProductImage> images;
|
private List<ProductImage> images;
|
||||||
|
|
||||||
|
@Transient
|
||||||
|
private BigDecimal currentStock;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package com.kifi.api.entity.invoice;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import org.springframework.data.annotation.Id;
|
||||||
|
import org.springframework.data.annotation.Transient;
|
||||||
|
import org.springframework.data.relational.core.mapping.Column;
|
||||||
|
import org.springframework.data.relational.core.mapping.Table;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Table("invoices")
|
||||||
|
public class Invoice {
|
||||||
|
@Id
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column("user_id")
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
@Column("customer_id")
|
||||||
|
private Long customerId;
|
||||||
|
|
||||||
|
@Column("invoice_number")
|
||||||
|
private String invoiceNumber;
|
||||||
|
|
||||||
|
@Column("issue_date")
|
||||||
|
private LocalDate issueDate;
|
||||||
|
|
||||||
|
@Column("due_date")
|
||||||
|
private LocalDate dueDate;
|
||||||
|
|
||||||
|
private BigDecimal subtotal;
|
||||||
|
|
||||||
|
@Column("tax_total")
|
||||||
|
private BigDecimal taxTotal;
|
||||||
|
|
||||||
|
@Column("discount_total")
|
||||||
|
private BigDecimal discountTotal;
|
||||||
|
|
||||||
|
@Column("total_amount")
|
||||||
|
private BigDecimal totalAmount;
|
||||||
|
|
||||||
|
private String status; // DRAFT, SENT, PAID, PARTIAL, OVERDUE, CANCELLED
|
||||||
|
private String notes;
|
||||||
|
|
||||||
|
@Column("amount_paid")
|
||||||
|
private BigDecimal amountPaid;
|
||||||
|
|
||||||
|
@Column("next_payment_date")
|
||||||
|
private LocalDate nextPaymentDate;
|
||||||
|
|
||||||
|
// EMI fields
|
||||||
|
@Column("is_emi")
|
||||||
|
private Boolean isEmi;
|
||||||
|
|
||||||
|
@Column("emi_amount")
|
||||||
|
private BigDecimal emiAmount;
|
||||||
|
|
||||||
|
@Column("emi_cycle")
|
||||||
|
private String emiCycle; // MONTHLY, WEEKLY
|
||||||
|
|
||||||
|
@Column("emi_start_date")
|
||||||
|
private LocalDate emiStartDate;
|
||||||
|
|
||||||
|
@Column("created_at")
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@Column("updated_at")
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
@Transient
|
||||||
|
private List<InvoiceItem> items;
|
||||||
|
|
||||||
|
@Transient
|
||||||
|
private String paymentMethod;
|
||||||
|
|
||||||
|
@Transient
|
||||||
|
private Long paymentWalletId;
|
||||||
|
|
||||||
|
@Transient
|
||||||
|
private List<InvoicePayment> payments;
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package com.kifi.api.entity.invoice;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import org.springframework.data.annotation.Id;
|
||||||
|
import org.springframework.data.relational.core.mapping.Column;
|
||||||
|
import org.springframework.data.relational.core.mapping.Table;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Table("invoice_items")
|
||||||
|
public class InvoiceItem {
|
||||||
|
@Id
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column("invoice_id")
|
||||||
|
private Long invoiceId;
|
||||||
|
|
||||||
|
@Column("product_id")
|
||||||
|
private Long productId;
|
||||||
|
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
private BigDecimal quantity;
|
||||||
|
|
||||||
|
@Column("unit_price")
|
||||||
|
private BigDecimal unitPrice;
|
||||||
|
|
||||||
|
@Column("tax_rate")
|
||||||
|
private BigDecimal taxRate;
|
||||||
|
|
||||||
|
private BigDecimal discount;
|
||||||
|
|
||||||
|
@Column("making_charge")
|
||||||
|
private BigDecimal makingCharge;
|
||||||
|
|
||||||
|
@Column("other_charges")
|
||||||
|
private BigDecimal otherCharges;
|
||||||
|
|
||||||
|
private BigDecimal total;
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.kifi.api.entity.invoice;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import org.springframework.data.annotation.Id;
|
||||||
|
import org.springframework.data.relational.core.mapping.Column;
|
||||||
|
import org.springframework.data.relational.core.mapping.Table;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Table("invoice_payments")
|
||||||
|
public class InvoicePayment {
|
||||||
|
@Id
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column("invoice_id")
|
||||||
|
private Long invoiceId;
|
||||||
|
|
||||||
|
@Column("transaction_id")
|
||||||
|
private Long transactionId;
|
||||||
|
|
||||||
|
@Column("wallet_id")
|
||||||
|
private Long walletId;
|
||||||
|
|
||||||
|
private BigDecimal amount;
|
||||||
|
|
||||||
|
@Column("payment_date")
|
||||||
|
private LocalDate paymentDate;
|
||||||
|
|
||||||
|
@Column("emi_installment_number")
|
||||||
|
private Integer emiInstallmentNumber;
|
||||||
|
|
||||||
|
@Column("payment_method")
|
||||||
|
private String paymentMethod;
|
||||||
|
|
||||||
|
@Column("created_at")
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import reactor.core.publisher.Mono;
|
|||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
public interface TransactionRepository extends R2dbcRepository<Transaction, Long>, TransactionRepositoryCustom {
|
public interface TransactionRepository extends R2dbcRepository<Transaction, Long>, TransactionRepositoryCustom {
|
||||||
@org.springframework.data.r2dbc.repository.Query("SELECT t.* FROM transactions t WHERE t.user_id = :userId OR t.from_wallet_id IN (SELECT uw.wallet_id FROM user_wallets uw WHERE uw.user_id = :userId) OR t.to_wallet_id IN (SELECT uw.wallet_id FROM user_wallets uw WHERE uw.user_id = :userId) ORDER BY t.date DESC")
|
@org.springframework.data.r2dbc.repository.Query("SELECT t.* FROM transactions t WHERE t.user_id = :userId OR t.from_wallet_id IN (SELECT uw.wallet_id FROM user_wallets uw WHERE uw.user_id = :userId) OR t.to_wallet_id IN (SELECT uw.wallet_id FROM user_wallets uw WHERE uw.user_id = :userId) ORDER BY t.date DESC, t.id DESC")
|
||||||
Flux<Transaction> findVisibleTransactionsForUser(Long userId);
|
Flux<Transaction> findVisibleTransactionsForUser(Long userId);
|
||||||
Mono<Long> countByFromWalletIdOrToWalletId(Long fromWalletId, Long toWalletId);
|
Mono<Long> countByFromWalletIdOrToWalletId(Long fromWalletId, Long toWalletId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ public class TransactionRepositoryImpl implements TransactionRepositoryCustom {
|
|||||||
baseQuery.append(" AND t.description ILIKE :search");
|
baseQuery.append(" AND t.description ILIKE :search");
|
||||||
}
|
}
|
||||||
|
|
||||||
String dataQueryStr = "SELECT t.* " + baseQuery.toString() + " ORDER BY t.date DESC LIMIT " + pageable.getPageSize() + " OFFSET " + pageable.getOffset();
|
String dataQueryStr = "SELECT t.* " + baseQuery.toString() + " ORDER BY t.date DESC, t.id DESC LIMIT " + pageable.getPageSize() + " OFFSET " + pageable.getOffset();
|
||||||
String countQueryStr = "SELECT COUNT(t.id) " + baseQuery.toString();
|
String countQueryStr = "SELECT COUNT(t.id) " + baseQuery.toString();
|
||||||
|
|
||||||
DatabaseClient.GenericExecuteSpec dataSpec = databaseClient.sql(dataQueryStr).bind("userId", userId);
|
DatabaseClient.GenericExecuteSpec dataSpec = databaseClient.sql(dataQueryStr).bind("userId", userId);
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.kifi.api.repository.business;
|
||||||
|
|
||||||
|
import com.kifi.api.entity.business.IndianState;
|
||||||
|
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface IndianStateRepository extends ReactiveCrudRepository<IndianState, Long> {
|
||||||
|
Flux<IndianState> findAllByOrderByNameAsc();
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.kifi.api.repository.customer;
|
||||||
|
|
||||||
|
import com.kifi.api.entity.customer.Customer;
|
||||||
|
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
|
||||||
|
public interface CustomerRepository extends ReactiveCrudRepository<Customer, Long> {
|
||||||
|
Flux<Customer> findByUserId(Long userId);
|
||||||
|
Flux<Customer> findByUserIdAndNameContainingIgnoreCase(Long userId, String name);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.kifi.api.repository.inventory;
|
||||||
|
|
||||||
|
import com.kifi.api.entity.inventory.CategoryRateHistory;
|
||||||
|
import org.springframework.data.r2dbc.repository.Query;
|
||||||
|
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
public interface CategoryRateHistoryRepository extends ReactiveCrudRepository<CategoryRateHistory, Long> {
|
||||||
|
Flux<CategoryRateHistory> findByCategoryIdOrderByDateDesc(Long categoryId);
|
||||||
|
Mono<CategoryRateHistory> findByCategoryIdAndDate(Long categoryId, LocalDate date);
|
||||||
|
|
||||||
|
@Query("SELECT * FROM category_rate_history WHERE category_id = :categoryId ORDER BY date DESC LIMIT 30")
|
||||||
|
Flux<CategoryRateHistory> findRecentHistoryByCategoryId(Long categoryId);
|
||||||
|
}
|
||||||
@@ -8,4 +8,5 @@ import reactor.core.publisher.Flux;
|
|||||||
public interface InventoryBalanceRepository extends ReactiveCrudRepository<InventoryBalance, Long> {
|
public interface InventoryBalanceRepository extends ReactiveCrudRepository<InventoryBalance, Long> {
|
||||||
Mono<InventoryBalance> findByProductIdAndLocationId(Long productId, Long locationId);
|
Mono<InventoryBalance> findByProductIdAndLocationId(Long productId, Long locationId);
|
||||||
Flux<InventoryBalance> findByLocationId(Long locationId);
|
Flux<InventoryBalance> findByLocationId(Long locationId);
|
||||||
|
Flux<InventoryBalance> findByProductId(Long productId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,4 +7,9 @@ import reactor.core.publisher.Flux;
|
|||||||
|
|
||||||
public interface ProductRepository extends ReactiveCrudRepository<Product, Long> {
|
public interface ProductRepository extends ReactiveCrudRepository<Product, Long> {
|
||||||
Flux<Product> findByUserId(Long userId, Pageable pageable);
|
Flux<Product> findByUserId(Long userId, Pageable pageable);
|
||||||
|
|
||||||
|
@org.springframework.data.r2dbc.repository.Query("SELECT * FROM products WHERE user_id = :userId AND (:keyword IS NULL OR :keyword = '' OR LOWER(name) LIKE LOWER(CONCAT('%', :keyword, '%')) OR LOWER(sku) LIKE LOWER(CONCAT('%', :keyword, '%'))) OFFSET :offset LIMIT :limit")
|
||||||
|
Flux<Product> searchProducts(Long userId, String keyword, long offset, int limit);
|
||||||
|
|
||||||
|
Flux<Product> findByCategoryId(Long categoryId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.kifi.api.repository.invoice;
|
||||||
|
|
||||||
|
import com.kifi.api.entity.invoice.InvoiceItem;
|
||||||
|
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
public interface InvoiceItemRepository extends ReactiveCrudRepository<InvoiceItem, Long> {
|
||||||
|
Flux<InvoiceItem> findByInvoiceId(Long invoiceId);
|
||||||
|
Mono<Void> deleteByInvoiceId(Long invoiceId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.kifi.api.repository.invoice;
|
||||||
|
|
||||||
|
import com.kifi.api.entity.invoice.InvoicePayment;
|
||||||
|
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
|
||||||
|
public interface InvoicePaymentRepository extends ReactiveCrudRepository<InvoicePayment, Long> {
|
||||||
|
Flux<InvoicePayment> findByInvoiceId(Long invoiceId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.kifi.api.repository.invoice;
|
||||||
|
|
||||||
|
import com.kifi.api.entity.invoice.Invoice;
|
||||||
|
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
public interface InvoiceRepository extends ReactiveCrudRepository<Invoice, Long> {
|
||||||
|
Flux<Invoice> findByUserId(Long userId);
|
||||||
|
Flux<Invoice> findByUserIdAndCustomerId(Long userId, Long customerId);
|
||||||
|
Mono<Invoice> findByUserIdAndInvoiceNumber(Long userId, String invoiceNumber);
|
||||||
|
}
|
||||||
@@ -11,10 +11,10 @@ public class MinioServiceClient {
|
|||||||
|
|
||||||
private final WebClient webClient;
|
private final WebClient webClient;
|
||||||
|
|
||||||
@Value("${minio.service.url:http://minio-service:1500}")
|
@Value("${spring.minio.service.url:http://minio-service:1500}")
|
||||||
private String minioServiceUrl;
|
private String minioServiceUrl;
|
||||||
|
|
||||||
@Value("${minio.service.bucket:kifi}")
|
@Value("${spring.minio.service.bucket:kifi}")
|
||||||
private String bucketName;
|
private String bucketName;
|
||||||
|
|
||||||
public MinioServiceClient(WebClient.Builder webClientBuilder) {
|
public MinioServiceClient(WebClient.Builder webClientBuilder) {
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ public class WalletService {
|
|||||||
private final EmailService emailService;
|
private final EmailService emailService;
|
||||||
private final com.kifi.api.repository.UserRepository userRepository;
|
private final com.kifi.api.repository.UserRepository userRepository;
|
||||||
|
|
||||||
public Mono<Wallet> createWallet(Long ownerId, String name, String nature, String icon, String color, String currency, java.math.BigDecimal initialBalance) {
|
public Mono<Wallet> createWallet(Long ownerId, String name, String nature, String icon, String color, String currency, java.math.BigDecimal initialBalance, String subNature, java.math.BigDecimal creditLimit, java.math.BigDecimal fixedAmount, String paymentCycle, Integer cycleDate) {
|
||||||
Wallet wallet = new Wallet();
|
Wallet wallet = new Wallet();
|
||||||
wallet.setOwnerId(ownerId);
|
wallet.setOwnerId(ownerId);
|
||||||
wallet.setName(name);
|
wallet.setName(name);
|
||||||
@@ -38,6 +38,11 @@ public class WalletService {
|
|||||||
wallet.setCurrency(currency != null ? currency : "INR");
|
wallet.setCurrency(currency != null ? currency : "INR");
|
||||||
wallet.setIcon(icon);
|
wallet.setIcon(icon);
|
||||||
wallet.setColor(color);
|
wallet.setColor(color);
|
||||||
|
wallet.setSubNature(subNature);
|
||||||
|
wallet.setCreditLimit(creditLimit);
|
||||||
|
wallet.setFixedAmount(fixedAmount);
|
||||||
|
wallet.setPaymentCycle(paymentCycle);
|
||||||
|
wallet.setCycleDate(cycleDate);
|
||||||
wallet.setCreatedAt(LocalDateTime.now());
|
wallet.setCreatedAt(LocalDateTime.now());
|
||||||
|
|
||||||
return walletRepository.save(wallet)
|
return walletRepository.save(wallet)
|
||||||
@@ -112,7 +117,7 @@ public class WalletService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public Mono<Wallet> editWallet(Long walletId, Long ownerId, String name, String nature, String icon, String color, String currency) {
|
public Mono<Wallet> editWallet(Long walletId, Long ownerId, String name, String nature, String icon, String color, String currency, String subNature, java.math.BigDecimal creditLimit, java.math.BigDecimal fixedAmount, String paymentCycle, Integer cycleDate) {
|
||||||
return walletRepository.findById(walletId)
|
return walletRepository.findById(walletId)
|
||||||
.filter(w -> w.getOwnerId().equals(ownerId))
|
.filter(w -> w.getOwnerId().equals(ownerId))
|
||||||
.switchIfEmpty(Mono.error(new RuntimeException("Wallet not found or you are not the owner")))
|
.switchIfEmpty(Mono.error(new RuntimeException("Wallet not found or you are not the owner")))
|
||||||
@@ -122,6 +127,11 @@ public class WalletService {
|
|||||||
if (icon != null) w.setIcon(icon);
|
if (icon != null) w.setIcon(icon);
|
||||||
if (color != null) w.setColor(color);
|
if (color != null) w.setColor(color);
|
||||||
if (currency != null) w.setCurrency(currency);
|
if (currency != null) w.setCurrency(currency);
|
||||||
|
if (subNature != null) w.setSubNature(subNature);
|
||||||
|
if (creditLimit != null) w.setCreditLimit(creditLimit);
|
||||||
|
if (fixedAmount != null) w.setFixedAmount(fixedAmount);
|
||||||
|
if (paymentCycle != null) w.setPaymentCycle(paymentCycle);
|
||||||
|
if (cycleDate != null) w.setCycleDate(cycleDate);
|
||||||
return walletRepository.save(w);
|
return walletRepository.save(w);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package com.kifi.api.service.business;
|
package com.kifi.api.service.business;
|
||||||
|
|
||||||
import com.kifi.api.entity.business.BusinessProfile;
|
import com.kifi.api.entity.business.BusinessProfile;
|
||||||
|
import com.kifi.api.entity.business.BusinessFeature;
|
||||||
import com.kifi.api.repository.business.BusinessProfileRepository;
|
import com.kifi.api.repository.business.BusinessProfileRepository;
|
||||||
|
import com.kifi.api.repository.business.BusinessFeatureRepository;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
@@ -12,17 +14,58 @@ import java.time.LocalDateTime;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class BusinessService {
|
public class BusinessService {
|
||||||
private final BusinessProfileRepository businessProfileRepository;
|
private final BusinessProfileRepository businessProfileRepository;
|
||||||
|
private final BusinessFeatureRepository businessFeatureRepository;
|
||||||
|
|
||||||
public Mono<BusinessProfile> getProfileByUserId(Long userId) {
|
public Mono<BusinessProfile> getProfileByUserId(Long userId) {
|
||||||
return businessProfileRepository.findByUserId(userId);
|
return businessProfileRepository.findByUserId(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Mono<BusinessFeature> getFeaturesByUserId(Long userId) {
|
||||||
|
return businessFeatureRepository.findByUserId(userId)
|
||||||
|
.defaultIfEmpty(BusinessFeature.builder()
|
||||||
|
.userId(userId)
|
||||||
|
.inventoryManagement(false)
|
||||||
|
.salesManagement(false)
|
||||||
|
.multiLocation(false)
|
||||||
|
.bomReductionStrategy("COMPONENTS_ONLY")
|
||||||
|
.stockDeductionOnInvoice(true)
|
||||||
|
.createdAt(LocalDateTime.now())
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<BusinessFeature> saveFeatures(Long userId, BusinessFeature feature) {
|
||||||
|
return businessFeatureRepository.findByUserId(userId)
|
||||||
|
.flatMap(existing -> {
|
||||||
|
if (feature.getInventoryManagement() != null) existing.setInventoryManagement(feature.getInventoryManagement());
|
||||||
|
if (feature.getSalesManagement() != null) existing.setSalesManagement(feature.getSalesManagement());
|
||||||
|
if (feature.getMultiLocation() != null) existing.setMultiLocation(feature.getMultiLocation());
|
||||||
|
if (feature.getBomReductionStrategy() != null) existing.setBomReductionStrategy(feature.getBomReductionStrategy());
|
||||||
|
if (feature.getStockDeductionOnInvoice() != null) existing.setStockDeductionOnInvoice(feature.getStockDeductionOnInvoice());
|
||||||
|
if (feature.getBarcodeSource() != null) existing.setBarcodeSource(feature.getBarcodeSource());
|
||||||
|
return businessFeatureRepository.save(existing);
|
||||||
|
})
|
||||||
|
.switchIfEmpty(Mono.defer(() -> {
|
||||||
|
feature.setUserId(userId);
|
||||||
|
feature.setCreatedAt(LocalDateTime.now());
|
||||||
|
if (feature.getBomReductionStrategy() == null) feature.setBomReductionStrategy("COMPONENTS_ONLY");
|
||||||
|
if (feature.getStockDeductionOnInvoice() == null) feature.setStockDeductionOnInvoice(true);
|
||||||
|
return businessFeatureRepository.save(feature);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
public Mono<BusinessProfile> saveProfile(Long userId, BusinessProfile profile) {
|
public Mono<BusinessProfile> saveProfile(Long userId, BusinessProfile profile) {
|
||||||
return businessProfileRepository.findByUserId(userId)
|
return businessProfileRepository.findByUserId(userId)
|
||||||
.flatMap(existing -> {
|
.flatMap(existing -> {
|
||||||
existing.setBusinessName(profile.getBusinessName());
|
existing.setBusinessName(profile.getBusinessName());
|
||||||
existing.setIndustry(profile.getIndustry());
|
existing.setIndustry(profile.getIndustry());
|
||||||
existing.setTaxNumber(profile.getTaxNumber());
|
existing.setTaxNumber(profile.getTaxNumber());
|
||||||
|
existing.setAddress(profile.getAddress());
|
||||||
|
existing.setStateId(profile.getStateId());
|
||||||
|
existing.setContactPerson(profile.getContactPerson());
|
||||||
|
existing.setContactNumber(profile.getContactNumber());
|
||||||
|
existing.setEmailId(profile.getEmailId());
|
||||||
|
existing.setPanNumber(profile.getPanNumber());
|
||||||
|
existing.setGstin(profile.getGstin());
|
||||||
existing.setCurrency(profile.getCurrency() != null ? profile.getCurrency() : existing.getCurrency());
|
existing.setCurrency(profile.getCurrency() != null ? profile.getCurrency() : existing.getCurrency());
|
||||||
existing.setUpdatedAt(LocalDateTime.now());
|
existing.setUpdatedAt(LocalDateTime.now());
|
||||||
return businessProfileRepository.save(existing);
|
return businessProfileRepository.save(existing);
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package com.kifi.api.service.customer;
|
||||||
|
|
||||||
|
import com.kifi.api.entity.customer.Customer;
|
||||||
|
import com.kifi.api.repository.customer.CustomerRepository;
|
||||||
|
import com.kifi.api.service.MinioServiceClient;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class CustomerService {
|
||||||
|
|
||||||
|
private final CustomerRepository customerRepository;
|
||||||
|
private final MinioServiceClient minioServiceClient;
|
||||||
|
|
||||||
|
public Flux<Customer> getCustomers(Long userId, String search) {
|
||||||
|
if (search != null && !search.isEmpty()) {
|
||||||
|
return customerRepository.findByUserIdAndNameContainingIgnoreCase(userId, search);
|
||||||
|
}
|
||||||
|
return customerRepository.findByUserId(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<Customer> getCustomerById(Long id, Long userId) {
|
||||||
|
return customerRepository.findById(id)
|
||||||
|
.filter(customer -> customer.getUserId().equals(userId))
|
||||||
|
.switchIfEmpty(Mono.error(new RuntimeException("Customer not found or unauthorized")));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<Customer> createCustomer(Long userId, Customer customer) {
|
||||||
|
customer.setUserId(userId);
|
||||||
|
customer.setCreatedAt(LocalDateTime.now());
|
||||||
|
return customerRepository.save(customer);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<Customer> updateCustomer(Long id, Long userId, Customer customer) {
|
||||||
|
return getCustomerById(id, userId)
|
||||||
|
.flatMap(existing -> {
|
||||||
|
if (customer.getName() != null) existing.setName(customer.getName());
|
||||||
|
if (customer.getEmail() != null) existing.setEmail(customer.getEmail());
|
||||||
|
if (customer.getPhone() != null) existing.setPhone(customer.getPhone());
|
||||||
|
if (customer.getAddress() != null) existing.setAddress(customer.getAddress());
|
||||||
|
if (customer.getGstin() != null) existing.setGstin(customer.getGstin());
|
||||||
|
if (customer.getIdNumber() != null) existing.setIdNumber(customer.getIdNumber());
|
||||||
|
if (customer.getStateId() != null) existing.setStateId(customer.getStateId());
|
||||||
|
if (customer.getPhotoUrl() != null) existing.setPhotoUrl(customer.getPhotoUrl());
|
||||||
|
return customerRepository.save(existing);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<Void> deleteCustomer(Long id, Long userId) {
|
||||||
|
return getCustomerById(id, userId)
|
||||||
|
.flatMap(customerRepository::delete);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<Customer> uploadPhoto(Long id, Long userId, String contentType, String base64Content) {
|
||||||
|
return getCustomerById(id, userId)
|
||||||
|
.flatMap(customer -> {
|
||||||
|
String directoryPath = "users/" + userId + "/customers/" + id;
|
||||||
|
String fileName = "photo.jpg";
|
||||||
|
return minioServiceClient.uploadFile(directoryPath, contentType, fileName, base64Content)
|
||||||
|
.flatMap(response -> {
|
||||||
|
if (response.isSuccess()) {
|
||||||
|
customer.setPhotoUrl(response.getFilePath());
|
||||||
|
return customerRepository.save(customer);
|
||||||
|
} else {
|
||||||
|
return Mono.error(new RuntimeException("Failed to upload photo to Minio"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<String> downloadPhoto(Long id, Long userId) {
|
||||||
|
return getCustomerById(id, userId)
|
||||||
|
.flatMap(customer -> {
|
||||||
|
if (customer.getPhotoUrl() == null) return Mono.empty();
|
||||||
|
return minioServiceClient.downloadFile("photo", customer.getPhotoUrl())
|
||||||
|
.map(com.kifi.api.service.MinioServiceClient.MinioDownloadResponse::getBase64Content);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,17 @@ import com.kifi.api.entity.inventory.Product;
|
|||||||
import com.kifi.api.entity.inventory.ProductImage;
|
import com.kifi.api.entity.inventory.ProductImage;
|
||||||
import com.kifi.api.repository.inventory.ProductRepository;
|
import com.kifi.api.repository.inventory.ProductRepository;
|
||||||
import com.kifi.api.repository.inventory.ProductImageRepository;
|
import com.kifi.api.repository.inventory.ProductImageRepository;
|
||||||
|
import com.kifi.api.repository.inventory.InventoryBalanceRepository;
|
||||||
|
import com.kifi.api.repository.inventory.InventoryMovementRepository;
|
||||||
|
import com.kifi.api.repository.inventory.InventoryMovementItemRepository;
|
||||||
|
import com.kifi.api.repository.inventory.InventoryLocationRepository;
|
||||||
|
import com.kifi.api.repository.inventory.ProductBomRepository;
|
||||||
|
import com.kifi.api.repository.business.BusinessFeatureRepository;
|
||||||
|
import com.kifi.api.entity.inventory.InventoryBalance;
|
||||||
|
import com.kifi.api.entity.inventory.InventoryLocation;
|
||||||
|
import com.kifi.api.entity.inventory.InventoryMovement;
|
||||||
|
import com.kifi.api.entity.inventory.InventoryMovementItem;
|
||||||
|
import com.kifi.api.entity.inventory.ProductBom;
|
||||||
import com.kifi.api.service.MinioServiceClient;
|
import com.kifi.api.service.MinioServiceClient;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -12,21 +23,42 @@ import reactor.core.publisher.Mono;
|
|||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class ProductService {
|
public class ProductService {
|
||||||
private final ProductRepository productRepository;
|
private final ProductRepository productRepository;
|
||||||
private final ProductImageRepository productImageRepository;
|
private final ProductImageRepository productImageRepository;
|
||||||
|
private final InventoryBalanceRepository inventoryBalanceRepository;
|
||||||
|
private final InventoryMovementRepository inventoryMovementRepository;
|
||||||
|
private final InventoryMovementItemRepository inventoryMovementItemRepository;
|
||||||
|
private final InventoryLocationRepository inventoryLocationRepository;
|
||||||
|
private final ProductBomRepository productBomRepository;
|
||||||
|
private final BusinessFeatureRepository businessFeatureRepository;
|
||||||
private final MinioServiceClient minioServiceClient;
|
private final MinioServiceClient minioServiceClient;
|
||||||
|
|
||||||
public Flux<Product> getProductsByUserId(Long userId, int page, int size) {
|
public Flux<Product> getProductsByUserId(Long userId, String search, int page, int size) {
|
||||||
return productRepository.findByUserId(userId, PageRequest.of(page, size))
|
long offset = (long) page * size;
|
||||||
|
Flux<Product> productSource = (search != null && !search.trim().isEmpty())
|
||||||
|
? productRepository.searchProducts(userId, search, offset, size)
|
||||||
|
: productRepository.findByUserId(userId, PageRequest.of(page, size));
|
||||||
|
|
||||||
|
return productSource
|
||||||
.flatMap(product -> productImageRepository.findByProductId(product.getId()).collectList()
|
.flatMap(product -> productImageRepository.findByProductId(product.getId()).collectList()
|
||||||
.map(images -> {
|
.map(images -> {
|
||||||
product.setImages(images);
|
product.setImages(images);
|
||||||
return product;
|
return product;
|
||||||
})
|
})
|
||||||
|
)
|
||||||
|
.flatMap(product -> inventoryBalanceRepository.findByProductId(product.getId())
|
||||||
|
.map(InventoryBalance::getQuantity)
|
||||||
|
.reduce(BigDecimal.ZERO, BigDecimal::add)
|
||||||
|
.map(totalStock -> {
|
||||||
|
product.setCurrentStock(totalStock);
|
||||||
|
return product;
|
||||||
|
})
|
||||||
|
.defaultIfEmpty(product)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,6 +80,7 @@ public class ProductService {
|
|||||||
existingProduct.setName(updatedProduct.getName());
|
existingProduct.setName(updatedProduct.getName());
|
||||||
existingProduct.setSku(updatedProduct.getSku());
|
existingProduct.setSku(updatedProduct.getSku());
|
||||||
existingProduct.setCategoryId(updatedProduct.getCategoryId());
|
existingProduct.setCategoryId(updatedProduct.getCategoryId());
|
||||||
|
existingProduct.setUomId(updatedProduct.getUomId());
|
||||||
existingProduct.setPurchasePrice(updatedProduct.getPurchasePrice());
|
existingProduct.setPurchasePrice(updatedProduct.getPurchasePrice());
|
||||||
existingProduct.setSellingPrice(updatedProduct.getSellingPrice());
|
existingProduct.setSellingPrice(updatedProduct.getSellingPrice());
|
||||||
existingProduct.setGstRate(updatedProduct.getGstRate());
|
existingProduct.setGstRate(updatedProduct.getGstRate());
|
||||||
@@ -106,4 +139,191 @@ public class ProductService {
|
|||||||
minioServiceClient.downloadFile(image.getContentType(), image.getFilePath())
|
minioServiceClient.downloadFile(image.getContentType(), image.getFilePath())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Flux<Product> syncCategoryRates(Long categoryId, Double dailyRate) {
|
||||||
|
if (dailyRate == null) return Flux.empty();
|
||||||
|
|
||||||
|
return productRepository.findByCategoryId(categoryId)
|
||||||
|
.filter(p -> Boolean.TRUE.equals(p.getAutoCalculatePrice()))
|
||||||
|
.flatMap(product -> {
|
||||||
|
double weight = product.getWeight() != null ? product.getWeight().doubleValue() : 0.0;
|
||||||
|
double baseQuantity = (weight > 0) ? weight : 1.0;
|
||||||
|
|
||||||
|
double wastage = product.getWastagePercentage() != null ? product.getWastagePercentage() : 0.0;
|
||||||
|
double purity = product.getPurityFactor() != null ? product.getPurityFactor() : 1.0;
|
||||||
|
|
||||||
|
double materialQuantity = baseQuantity + (baseQuantity * (wastage / 100.0));
|
||||||
|
double materialCost = materialQuantity * dailyRate * purity;
|
||||||
|
|
||||||
|
double makingCharges = product.getMakingCharges() != null ? product.getMakingCharges() : 0.0;
|
||||||
|
String makingType = product.getMakingChargesType() != null ? product.getMakingChargesType() : "FLAT";
|
||||||
|
|
||||||
|
double making = 0.0;
|
||||||
|
if ("FLAT".equals(makingType)) {
|
||||||
|
making = makingCharges;
|
||||||
|
} else if ("PER_UNIT".equals(makingType)) {
|
||||||
|
making = makingCharges * baseQuantity;
|
||||||
|
} else if ("PERCENTAGE".equals(makingType)) {
|
||||||
|
making = materialCost * (makingCharges / 100.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
double newPrice = materialCost + making;
|
||||||
|
product.setSellingPrice(BigDecimal.valueOf(newPrice));
|
||||||
|
product.setUpdatedAt(LocalDateTime.now());
|
||||||
|
|
||||||
|
return productRepository.save(product);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<InventoryMovement> adjustStock(Long userId, Long productId, InventoryMovement movement) {
|
||||||
|
if (movement.getItems() == null || movement.getItems().isEmpty()) {
|
||||||
|
return Mono.error(new IllegalArgumentException("Movement items are required"));
|
||||||
|
}
|
||||||
|
|
||||||
|
movement.setUserId(userId);
|
||||||
|
movement.setCreatedAt(LocalDateTime.now());
|
||||||
|
|
||||||
|
Mono<Long> locationIdMono = movement.getLocationId() != null
|
||||||
|
? Mono.just(movement.getLocationId())
|
||||||
|
: inventoryLocationRepository.findByUserId(userId)
|
||||||
|
.next()
|
||||||
|
.map(InventoryLocation::getId)
|
||||||
|
.switchIfEmpty(inventoryLocationRepository.save(InventoryLocation.builder()
|
||||||
|
.userId(userId)
|
||||||
|
.name("Main Store")
|
||||||
|
.isPrimary(true)
|
||||||
|
.createdAt(LocalDateTime.now())
|
||||||
|
.build())
|
||||||
|
.map(InventoryLocation::getId));
|
||||||
|
|
||||||
|
return productRepository.findById(productId)
|
||||||
|
.filter(p -> p.getUserId().equals(userId))
|
||||||
|
.switchIfEmpty(Mono.error(new RuntimeException("Product not found or access denied")))
|
||||||
|
.flatMap(product -> locationIdMono.flatMap(locId -> {
|
||||||
|
movement.setLocationId(locId);
|
||||||
|
return inventoryBalanceRepository.findByProductIdAndLocationId(productId, locId)
|
||||||
|
.defaultIfEmpty(InventoryBalance.builder()
|
||||||
|
.productId(productId)
|
||||||
|
.locationId(locId)
|
||||||
|
.quantity(BigDecimal.ZERO)
|
||||||
|
.lastUpdated(LocalDateTime.now())
|
||||||
|
.build());
|
||||||
|
}))
|
||||||
|
.flatMap(balance -> {
|
||||||
|
BigDecimal qtyChange = movement.getItems().get(0).getQuantity();
|
||||||
|
if ("REDUCTION".equals(movement.getType()) || "DAMAGE".equals(movement.getType())) {
|
||||||
|
qtyChange = qtyChange.negate();
|
||||||
|
}
|
||||||
|
|
||||||
|
final BigDecimal finalQtyChange = qtyChange;
|
||||||
|
return businessFeatureRepository.findByUserId(userId)
|
||||||
|
.map(f -> f.getBomReductionStrategy() != null ? f.getBomReductionStrategy() : "COMPONENTS_ONLY")
|
||||||
|
.defaultIfEmpty("COMPONENTS_ONLY")
|
||||||
|
.flatMap(strategy -> {
|
||||||
|
Mono<InventoryBalance> parentProcess = Mono.just(balance);
|
||||||
|
if (!"COMPONENTS_ONLY".equals(strategy) || finalQtyChange.compareTo(BigDecimal.ZERO) >= 0 || !"REDUCTION".equals(movement.getType())) {
|
||||||
|
balance.setQuantity(balance.getQuantity().add(finalQtyChange));
|
||||||
|
balance.setLastUpdated(LocalDateTime.now());
|
||||||
|
parentProcess = inventoryBalanceRepository.save(balance);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parentProcess;
|
||||||
|
})
|
||||||
|
.flatMap(savedBalance -> inventoryMovementRepository.save(movement))
|
||||||
|
.flatMap(savedMovement -> {
|
||||||
|
InventoryMovementItem item = movement.getItems().get(0);
|
||||||
|
item.setMovementId(savedMovement.getId());
|
||||||
|
item.setProductId(productId);
|
||||||
|
item.setCreatedAt(LocalDateTime.now());
|
||||||
|
return inventoryMovementItemRepository.save(item)
|
||||||
|
.flatMap(savedItem -> {
|
||||||
|
java.util.List<InventoryMovementItem> items = new java.util.ArrayList<>();
|
||||||
|
items.add(savedItem);
|
||||||
|
savedMovement.setItems(items);
|
||||||
|
|
||||||
|
if (finalQtyChange.compareTo(BigDecimal.ZERO) < 0 && "REDUCTION".equals(movement.getType())) {
|
||||||
|
return reduceBomComponentsWithItems(userId, productId, savedMovement.getId(), movement.getLocationId(), finalQtyChange)
|
||||||
|
.map(componentItems -> {
|
||||||
|
savedMovement.getItems().addAll(componentItems);
|
||||||
|
return savedMovement;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Mono.just(savedMovement);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<java.util.List<InventoryMovementItem>> reduceBomComponentsWithItems(Long userId, Long parentProductId, Long movementId, Long locationId, BigDecimal parentQtyChange) {
|
||||||
|
return productBomRepository.findByParentProductId(parentProductId)
|
||||||
|
.flatMap(bomItem -> {
|
||||||
|
BigDecimal componentQtyChange = bomItem.getQuantity().multiply(parentQtyChange);
|
||||||
|
return inventoryBalanceRepository.findByProductIdAndLocationId(bomItem.getComponentProductId(), locationId)
|
||||||
|
.defaultIfEmpty(InventoryBalance.builder()
|
||||||
|
.productId(bomItem.getComponentProductId())
|
||||||
|
.locationId(locationId)
|
||||||
|
.quantity(BigDecimal.ZERO)
|
||||||
|
.lastUpdated(LocalDateTime.now())
|
||||||
|
.build())
|
||||||
|
.flatMap(balance -> {
|
||||||
|
balance.setQuantity(balance.getQuantity().add(componentQtyChange));
|
||||||
|
balance.setLastUpdated(LocalDateTime.now());
|
||||||
|
return inventoryBalanceRepository.save(balance);
|
||||||
|
})
|
||||||
|
.flatMap(savedBalance -> {
|
||||||
|
InventoryMovementItem compItem = new InventoryMovementItem();
|
||||||
|
compItem.setMovementId(movementId);
|
||||||
|
compItem.setProductId(bomItem.getComponentProductId());
|
||||||
|
compItem.setQuantity(componentQtyChange.abs());
|
||||||
|
compItem.setCreatedAt(LocalDateTime.now());
|
||||||
|
return inventoryMovementItemRepository.save(compItem);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.collectList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Flux<InventoryMovement> getProductStockLedger(Long userId, Long productId) {
|
||||||
|
// Find all movement items for the given product, then fetch their parent movements
|
||||||
|
return inventoryMovementItemRepository.findAll()
|
||||||
|
.filter(item -> productId.equals(item.getProductId()))
|
||||||
|
.flatMap(item -> inventoryMovementRepository.findById(item.getMovementId())
|
||||||
|
.map(movement -> {
|
||||||
|
movement.setItems(java.util.Collections.singletonList(item));
|
||||||
|
return movement;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.filter(movement -> userId.equals(movement.getUserId()))
|
||||||
|
.sort((m1, m2) -> m2.getCreatedAt().compareTo(m1.getCreatedAt())); // Descending order
|
||||||
|
}
|
||||||
|
|
||||||
|
public Flux<ProductBom> getProductBom(Long parentProductId) {
|
||||||
|
return productBomRepository.findByParentProductId(parentProductId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<ProductBom> addOrUpdateBomItem(Long userId, ProductBom bomItem) {
|
||||||
|
return productRepository.findById(bomItem.getParentProductId())
|
||||||
|
.filter(p -> p.getUserId().equals(userId))
|
||||||
|
.switchIfEmpty(Mono.error(new RuntimeException("Parent product not found or access denied")))
|
||||||
|
.flatMap(p -> {
|
||||||
|
if (bomItem.getId() != null) {
|
||||||
|
return productBomRepository.findById(bomItem.getId())
|
||||||
|
.flatMap(existing -> {
|
||||||
|
existing.setComponentProductId(bomItem.getComponentProductId());
|
||||||
|
existing.setQuantity(bomItem.getQuantity());
|
||||||
|
return productBomRepository.save(existing);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
bomItem.setCreatedAt(LocalDateTime.now());
|
||||||
|
return productBomRepository.save(bomItem);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<Void> deleteBomItem(Long userId, Long bomId) {
|
||||||
|
return productBomRepository.findById(bomId)
|
||||||
|
.flatMap(bom -> productRepository.findById(bom.getParentProductId())
|
||||||
|
.filter(p -> p.getUserId().equals(userId))
|
||||||
|
.switchIfEmpty(Mono.error(new RuntimeException("Access denied")))
|
||||||
|
.flatMap(p -> productBomRepository.delete(bom)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package com.kifi.api.service.inventory;
|
||||||
|
|
||||||
|
import com.kifi.api.entity.inventory.UnitOfMeasure;
|
||||||
|
import com.kifi.api.repository.inventory.UnitOfMeasureRepository;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class UnitOfMeasureService {
|
||||||
|
|
||||||
|
private final UnitOfMeasureRepository uomRepository;
|
||||||
|
|
||||||
|
public Flux<UnitOfMeasure> getUomsByUserId(Long userId) {
|
||||||
|
return uomRepository.findByUserId(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<UnitOfMeasure> getUomById(Long id, Long userId) {
|
||||||
|
return uomRepository.findById(id)
|
||||||
|
.filter(uom -> uom.getUserId().equals(userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<UnitOfMeasure> createUom(UnitOfMeasure uom) {
|
||||||
|
uom.setCreatedAt(LocalDateTime.now());
|
||||||
|
return uomRepository.save(uom);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<UnitOfMeasure> updateUom(Long id, UnitOfMeasure updatedUom, Long userId) {
|
||||||
|
return uomRepository.findById(id)
|
||||||
|
.filter(uom -> uom.getUserId().equals(userId))
|
||||||
|
.flatMap(existingUom -> {
|
||||||
|
existingUom.setName(updatedUom.getName());
|
||||||
|
existingUom.setAbbreviation(updatedUom.getAbbreviation());
|
||||||
|
return uomRepository.save(existingUom);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<Void> deleteUom(Long id, Long userId) {
|
||||||
|
return uomRepository.findById(id)
|
||||||
|
.filter(uom -> uom.getUserId().equals(userId))
|
||||||
|
.flatMap(uomRepository::delete);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
package com.kifi.api.service.invoice;
|
||||||
|
|
||||||
|
import com.kifi.api.entity.invoice.Invoice;
|
||||||
|
import com.kifi.api.entity.invoice.InvoiceItem;
|
||||||
|
import com.kifi.api.entity.invoice.InvoicePayment;
|
||||||
|
import com.kifi.api.repository.business.BusinessFeatureRepository;
|
||||||
|
import com.kifi.api.repository.invoice.InvoiceItemRepository;
|
||||||
|
import com.kifi.api.repository.invoice.InvoicePaymentRepository;
|
||||||
|
import com.kifi.api.repository.invoice.InvoiceRepository;
|
||||||
|
import com.kifi.api.service.inventory.ProductService;
|
||||||
|
import com.kifi.api.entity.inventory.InventoryMovement;
|
||||||
|
import com.kifi.api.entity.inventory.InventoryMovementItem;
|
||||||
|
import com.kifi.api.entity.Transaction;
|
||||||
|
import com.kifi.api.repository.TransactionRepository;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Transactional
|
||||||
|
public class InvoiceService {
|
||||||
|
|
||||||
|
private final InvoiceRepository invoiceRepository;
|
||||||
|
private final InvoiceItemRepository invoiceItemRepository;
|
||||||
|
private final InvoicePaymentRepository invoicePaymentRepository;
|
||||||
|
private final BusinessFeatureRepository businessFeatureRepository;
|
||||||
|
private final ProductService productService;
|
||||||
|
private final com.kifi.api.service.TransactionService transactionService;
|
||||||
|
|
||||||
|
public Flux<Invoice> getInvoices(Long userId) {
|
||||||
|
return invoiceRepository.findByUserId(userId)
|
||||||
|
.flatMap(invoice -> Mono.zip(
|
||||||
|
invoiceItemRepository.findByInvoiceId(invoice.getId()).collectList(),
|
||||||
|
invoicePaymentRepository.findByInvoiceId(invoice.getId()).collectList()
|
||||||
|
).map(tuple -> {
|
||||||
|
invoice.setItems(tuple.getT1());
|
||||||
|
invoice.setPayments(tuple.getT2());
|
||||||
|
return invoice;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<Invoice> getInvoiceById(Long id, Long userId) {
|
||||||
|
return invoiceRepository.findById(id)
|
||||||
|
.filter(inv -> inv.getUserId().equals(userId))
|
||||||
|
.switchIfEmpty(Mono.error(new RuntimeException("Invoice not found or unauthorized")))
|
||||||
|
.flatMap(invoice -> invoiceItemRepository.findByInvoiceId(invoice.getId())
|
||||||
|
.collectList()
|
||||||
|
.map(items -> {
|
||||||
|
invoice.setItems(items);
|
||||||
|
return invoice;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<Invoice> createInvoice(Long userId, Invoice invoice) {
|
||||||
|
invoice.setUserId(userId);
|
||||||
|
invoice.setCreatedAt(LocalDateTime.now());
|
||||||
|
invoice.setUpdatedAt(LocalDateTime.now());
|
||||||
|
if (invoice.getStatus() == null || "DRAFT".equals(invoice.getStatus())) {
|
||||||
|
java.math.BigDecimal amountPaid = invoice.getAmountPaid() != null ? invoice.getAmountPaid() : java.math.BigDecimal.ZERO;
|
||||||
|
java.math.BigDecimal total = invoice.getTotalAmount() != null ? invoice.getTotalAmount() : java.math.BigDecimal.ZERO;
|
||||||
|
|
||||||
|
if (amountPaid.compareTo(total) >= 0 && total.compareTo(java.math.BigDecimal.ZERO) > 0) {
|
||||||
|
invoice.setStatus("PAID");
|
||||||
|
} else if (amountPaid.compareTo(java.math.BigDecimal.ZERO) > 0) {
|
||||||
|
invoice.setStatus("PARTIAL");
|
||||||
|
} else {
|
||||||
|
invoice.setStatus("DRAFT");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return invoiceRepository.save(invoice)
|
||||||
|
.flatMap(savedInvoice -> {
|
||||||
|
Mono<Invoice> itemsMono = Mono.just(savedInvoice);
|
||||||
|
if (invoice.getItems() != null && !invoice.getItems().isEmpty()) {
|
||||||
|
itemsMono = Flux.fromIterable(invoice.getItems())
|
||||||
|
.flatMap(item -> {
|
||||||
|
item.setInvoiceId(savedInvoice.getId());
|
||||||
|
return invoiceItemRepository.save(item);
|
||||||
|
})
|
||||||
|
.collectList()
|
||||||
|
.map(savedItems -> {
|
||||||
|
savedInvoice.setItems(savedItems);
|
||||||
|
return savedInvoice;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return itemsMono;
|
||||||
|
})
|
||||||
|
.flatMap(savedInvoice -> {
|
||||||
|
if (!"DRAFT".equals(savedInvoice.getStatus())) {
|
||||||
|
return processStockDeduction(savedInvoice, userId);
|
||||||
|
}
|
||||||
|
return Mono.just(savedInvoice);
|
||||||
|
})
|
||||||
|
.flatMap(savedInvoice -> {
|
||||||
|
if (invoice.getAmountPaid() != null && invoice.getAmountPaid().compareTo(java.math.BigDecimal.ZERO) > 0) {
|
||||||
|
InvoicePayment payment = InvoicePayment.builder()
|
||||||
|
.invoiceId(savedInvoice.getId())
|
||||||
|
.amount(invoice.getAmountPaid())
|
||||||
|
.paymentDate(java.time.LocalDate.now())
|
||||||
|
.createdAt(LocalDateTime.now())
|
||||||
|
.paymentMethod(invoice.getPaymentMethod() != null ? invoice.getPaymentMethod() : "Cash")
|
||||||
|
.walletId(invoice.getPaymentWalletId())
|
||||||
|
.build();
|
||||||
|
return invoicePaymentRepository.save(payment).flatMap(savedPayment -> {
|
||||||
|
if (savedPayment.getWalletId() != null) {
|
||||||
|
Transaction transaction = Transaction.builder()
|
||||||
|
.userId(userId)
|
||||||
|
.toWalletId(savedPayment.getWalletId())
|
||||||
|
.type("INCOME")
|
||||||
|
.amount(savedPayment.getAmount())
|
||||||
|
.date(savedPayment.getPaymentDate())
|
||||||
|
.description("Initial payment for Invoice #" + savedInvoice.getInvoiceNumber())
|
||||||
|
.notes(savedPayment.getPaymentMethod())
|
||||||
|
.createdAt(LocalDateTime.now())
|
||||||
|
.build();
|
||||||
|
return transactionService.addTransaction(userId, transaction).thenReturn(savedInvoice);
|
||||||
|
}
|
||||||
|
return Mono.just(savedInvoice);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Mono.just(savedInvoice);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<Invoice> processStockDeduction(Invoice invoice, Long userId) {
|
||||||
|
return businessFeatureRepository.findByUserId(userId)
|
||||||
|
.map(feature -> feature.getStockDeductionOnInvoice() != null ? feature.getStockDeductionOnInvoice() : true)
|
||||||
|
.defaultIfEmpty(true)
|
||||||
|
.flatMap(shouldDeduct -> {
|
||||||
|
if (shouldDeduct && invoice.getItems() != null && !invoice.getItems().isEmpty()) {
|
||||||
|
return Flux.fromIterable(invoice.getItems())
|
||||||
|
.concatMap(item -> {
|
||||||
|
if (item.getProductId() != null) {
|
||||||
|
InventoryMovement movement = InventoryMovement.builder()
|
||||||
|
.userId(userId)
|
||||||
|
.type("REDUCTION")
|
||||||
|
.notes("Sales Invoice " + invoice.getInvoiceNumber())
|
||||||
|
.createdAt(LocalDateTime.now())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
InventoryMovementItem movementItem = new InventoryMovementItem();
|
||||||
|
movementItem.setProductId(item.getProductId());
|
||||||
|
movementItem.setQuantity(item.getQuantity());
|
||||||
|
movement.setItems(java.util.Collections.singletonList(movementItem));
|
||||||
|
|
||||||
|
return productService.adjustStock(userId, item.getProductId(), movement);
|
||||||
|
}
|
||||||
|
return Mono.just(item);
|
||||||
|
})
|
||||||
|
.then(Mono.just(invoice));
|
||||||
|
}
|
||||||
|
return Mono.just(invoice);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<Invoice> finalizeInvoice(Long invoiceId, Long userId) {
|
||||||
|
return getInvoiceById(invoiceId, userId)
|
||||||
|
.flatMap(invoice -> {
|
||||||
|
if (!"DRAFT".equals(invoice.getStatus())) {
|
||||||
|
return Mono.error(new RuntimeException("Only DRAFT invoices can be finalized."));
|
||||||
|
}
|
||||||
|
|
||||||
|
return processStockDeduction(invoice, userId)
|
||||||
|
.flatMap(inv -> {
|
||||||
|
inv.setStatus("FINALIZED");
|
||||||
|
inv.setUpdatedAt(LocalDateTime.now());
|
||||||
|
return invoiceRepository.save(inv);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<InvoicePayment> addPaymentToInvoice(Long invoiceId, Long userId, InvoicePayment payment) {
|
||||||
|
return getInvoiceById(invoiceId, userId)
|
||||||
|
.flatMap(invoice -> {
|
||||||
|
String oldStatus = invoice.getStatus();
|
||||||
|
payment.setInvoiceId(invoiceId);
|
||||||
|
if (payment.getPaymentDate() == null) {
|
||||||
|
payment.setPaymentDate(java.time.LocalDate.now());
|
||||||
|
}
|
||||||
|
payment.setCreatedAt(LocalDateTime.now());
|
||||||
|
|
||||||
|
return invoicePaymentRepository.save(payment)
|
||||||
|
.flatMap(savedPayment -> {
|
||||||
|
// Update invoice amount_paid and status
|
||||||
|
java.math.BigDecimal currentPaid = invoice.getAmountPaid() != null ? invoice.getAmountPaid() : java.math.BigDecimal.ZERO;
|
||||||
|
java.math.BigDecimal newPaid = currentPaid.add(savedPayment.getAmount());
|
||||||
|
invoice.setAmountPaid(newPaid);
|
||||||
|
|
||||||
|
java.math.BigDecimal total = invoice.getTotalAmount() != null ? invoice.getTotalAmount() : java.math.BigDecimal.ZERO;
|
||||||
|
if (newPaid.compareTo(total) >= 0) {
|
||||||
|
invoice.setStatus("PAID");
|
||||||
|
} else if (newPaid.compareTo(java.math.BigDecimal.ZERO) > 0) {
|
||||||
|
invoice.setStatus("PARTIAL");
|
||||||
|
}
|
||||||
|
return invoiceRepository.save(invoice)
|
||||||
|
.flatMap(inv -> {
|
||||||
|
Mono<Invoice> processMono = Mono.just(inv);
|
||||||
|
if (("DRAFT".equals(oldStatus) || oldStatus == null) && !"DRAFT".equals(inv.getStatus())) {
|
||||||
|
// Fetch items first if not present
|
||||||
|
if (inv.getItems() == null || inv.getItems().isEmpty()) {
|
||||||
|
processMono = invoiceItemRepository.findByInvoiceId(inv.getId()).collectList().flatMap(items -> {
|
||||||
|
inv.setItems(items);
|
||||||
|
return processStockDeduction(inv, userId);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
processMono = processStockDeduction(inv, userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return processMono.flatMap(processedInv -> {
|
||||||
|
if (savedPayment.getWalletId() != null) {
|
||||||
|
Transaction transaction = Transaction.builder()
|
||||||
|
.userId(userId)
|
||||||
|
.toWalletId(savedPayment.getWalletId())
|
||||||
|
.type("INCOME")
|
||||||
|
.amount(savedPayment.getAmount())
|
||||||
|
.date(savedPayment.getPaymentDate())
|
||||||
|
.description("Payment for Invoice #" + invoice.getInvoiceNumber())
|
||||||
|
.notes(savedPayment.getPaymentMethod())
|
||||||
|
.createdAt(LocalDateTime.now())
|
||||||
|
.build();
|
||||||
|
return transactionService.addTransaction(userId, transaction).thenReturn(savedPayment);
|
||||||
|
}
|
||||||
|
return Mono.just(savedPayment);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public Flux<InvoicePayment> getPaymentsForInvoice(Long invoiceId, Long userId) {
|
||||||
|
return getInvoiceById(invoiceId, userId)
|
||||||
|
.flatMapMany(invoice -> invoicePaymentRepository.findByInvoiceId(invoiceId));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -145,17 +145,48 @@ CREATE TABLE IF NOT EXISTS business_profiles (
|
|||||||
tax_number VARCHAR(100),
|
tax_number VARCHAR(100),
|
||||||
currency VARCHAR(10) DEFAULT 'INR',
|
currency VARCHAR(10) DEFAULT 'INR',
|
||||||
tax_included_in_price BOOLEAN DEFAULT FALSE,
|
tax_included_in_price BOOLEAN DEFAULT FALSE,
|
||||||
|
address TEXT,
|
||||||
|
state_id INTEGER,
|
||||||
|
contact_person VARCHAR(100),
|
||||||
|
contact_number VARCHAR(20),
|
||||||
|
email_id VARCHAR(100),
|
||||||
|
pan_number VARCHAR(20),
|
||||||
|
gstin VARCHAR(20),
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
UNIQUE(user_id)
|
UNIQUE(user_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS indian_states (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
gst_code VARCHAR(2) NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Seed Indian States if empty
|
||||||
|
INSERT INTO indian_states (name, gst_code) VALUES
|
||||||
|
('Jammu & Kashmir', '01'), ('Himachal Pradesh', '02'), ('Punjab', '03'),
|
||||||
|
('Chandigarh', '04'), ('Uttarakhand', '05'), ('Haryana', '06'),
|
||||||
|
('Delhi', '07'), ('Rajasthan', '08'), ('Uttar Pradesh', '09'),
|
||||||
|
('Bihar', '10'), ('Sikkim', '11'), ('Arunachal Pradesh', '12'),
|
||||||
|
('Nagaland', '13'), ('Manipur', '14'), ('Mizoram', '15'),
|
||||||
|
('Tripura', '16'), ('Meghalaya', '17'), ('Assam', '18'),
|
||||||
|
('West Bengal', '19'), ('Jharkhand', '20'), ('Odisha', '21'),
|
||||||
|
('Chhattisgarh', '22'), ('Madhya Pradesh', '23'), ('Gujarat', '24'),
|
||||||
|
('Daman & Diu', '25'), ('Dadra & Nagar Haveli and Daman & Diu', '26'),
|
||||||
|
('Maharashtra', '27'), ('Karnataka', '29'), ('Goa', '30'),
|
||||||
|
('Lakshadweep', '31'), ('Kerala', '32'), ('Tamil Nadu', '33'),
|
||||||
|
('Puducherry', '34'), ('Andaman & Nicobar Islands', '35'),
|
||||||
|
('Telangana', '36'), ('Andhra Pradesh', '37'), ('Ladakh', '38')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS business_features (
|
CREATE TABLE IF NOT EXISTS business_features (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||||
inventory_management BOOLEAN DEFAULT FALSE,
|
inventory_management BOOLEAN DEFAULT FALSE,
|
||||||
sales_management BOOLEAN DEFAULT FALSE,
|
sales_management BOOLEAN DEFAULT FALSE,
|
||||||
multi_location BOOLEAN DEFAULT FALSE,
|
multi_location BOOLEAN DEFAULT FALSE,
|
||||||
|
bom_reduction_strategy VARCHAR(50) DEFAULT 'COMPONENTS_ONLY',
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
UNIQUE(user_id)
|
UNIQUE(user_id)
|
||||||
);
|
);
|
||||||
@@ -172,6 +203,16 @@ CREATE TABLE IF NOT EXISTS product_categories (
|
|||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS category_rate_history (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
category_id INTEGER REFERENCES product_categories(id) ON DELETE CASCADE,
|
||||||
|
rate DECIMAL(15, 2) NOT NULL,
|
||||||
|
date DATE NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(category_id, date)
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS units_of_measure (
|
CREATE TABLE IF NOT EXISTS units_of_measure (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||||
@@ -263,3 +304,63 @@ CREATE TABLE IF NOT EXISTS inventory_balances (
|
|||||||
last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
UNIQUE(product_id, location_id)
|
UNIQUE(product_id, location_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- SALES & INVOICES (Phase 2)
|
||||||
|
ALTER TABLE business_features ADD COLUMN IF NOT EXISTS stock_deduction_on_invoice BOOLEAN DEFAULT TRUE;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS customers (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
email VARCHAR(255),
|
||||||
|
phone VARCHAR(50),
|
||||||
|
address TEXT,
|
||||||
|
gstin VARCHAR(50),
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS invoices (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
customer_id INTEGER REFERENCES customers(id),
|
||||||
|
invoice_number VARCHAR(100) NOT NULL,
|
||||||
|
issue_date DATE NOT NULL,
|
||||||
|
due_date DATE,
|
||||||
|
subtotal DECIMAL(15,2) NOT NULL,
|
||||||
|
tax_total DECIMAL(15,2) DEFAULT 0.0,
|
||||||
|
discount_total DECIMAL(15,2) DEFAULT 0.0,
|
||||||
|
total_amount DECIMAL(15,2) NOT NULL,
|
||||||
|
status VARCHAR(50) DEFAULT 'DRAFT', -- DRAFT, SENT, PAID, PARTIAL, OVERDUE, CANCELLED
|
||||||
|
notes TEXT,
|
||||||
|
|
||||||
|
-- EMI tracking fields
|
||||||
|
is_emi BOOLEAN DEFAULT FALSE,
|
||||||
|
emi_amount DECIMAL(15,2),
|
||||||
|
emi_cycle VARCHAR(20), -- MONTHLY, WEEKLY
|
||||||
|
emi_start_date DATE,
|
||||||
|
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS invoice_items (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
invoice_id INTEGER REFERENCES invoices(id) ON DELETE CASCADE,
|
||||||
|
product_id INTEGER REFERENCES products(id),
|
||||||
|
description VARCHAR(255),
|
||||||
|
quantity DECIMAL(10,3) NOT NULL,
|
||||||
|
unit_price DECIMAL(15,2) NOT NULL,
|
||||||
|
tax_rate DECIMAL(5,2) DEFAULT 0.0,
|
||||||
|
discount DECIMAL(15,2) DEFAULT 0.0,
|
||||||
|
total DECIMAL(15,2) NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS invoice_payments (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
invoice_id INTEGER REFERENCES invoices(id) ON DELETE CASCADE,
|
||||||
|
transaction_id INTEGER REFERENCES transactions(id) ON DELETE CASCADE,
|
||||||
|
amount DECIMAL(15,2) NOT NULL,
|
||||||
|
payment_date DATE NOT NULL,
|
||||||
|
emi_installment_number INTEGER,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|||||||
@@ -77,11 +77,16 @@ PODS:
|
|||||||
- GTMSessionFetcher/Core (< 4.0, >= 3.3.2)
|
- GTMSessionFetcher/Core (< 4.0, >= 3.3.2)
|
||||||
- MLImage (= 1.0.0-beta8)
|
- MLImage (= 1.0.0-beta8)
|
||||||
- MLKitCommon (~> 14.0)
|
- MLKitCommon (~> 14.0)
|
||||||
|
- mobile_scanner (7.0.0):
|
||||||
|
- Flutter
|
||||||
|
- FlutterMacOS
|
||||||
- nanopb (3.30910.0):
|
- nanopb (3.30910.0):
|
||||||
- nanopb/decode (= 3.30910.0)
|
- nanopb/decode (= 3.30910.0)
|
||||||
- nanopb/encode (= 3.30910.0)
|
- nanopb/encode (= 3.30910.0)
|
||||||
- nanopb/decode (3.30910.0)
|
- nanopb/decode (3.30910.0)
|
||||||
- nanopb/encode (3.30910.0)
|
- nanopb/encode (3.30910.0)
|
||||||
|
- printing (1.0.0):
|
||||||
|
- Flutter
|
||||||
- PromisesObjC (2.4.1)
|
- PromisesObjC (2.4.1)
|
||||||
- SDWebImage (5.21.7):
|
- SDWebImage (5.21.7):
|
||||||
- SDWebImage/Core (= 5.21.7)
|
- SDWebImage/Core (= 5.21.7)
|
||||||
@@ -104,6 +109,8 @@ DEPENDENCIES:
|
|||||||
- google_mlkit_text_recognition (from `.symlinks/plugins/google_mlkit_text_recognition/ios`)
|
- google_mlkit_text_recognition (from `.symlinks/plugins/google_mlkit_text_recognition/ios`)
|
||||||
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
||||||
- local_auth_darwin (from `.symlinks/plugins/local_auth_darwin/darwin`)
|
- local_auth_darwin (from `.symlinks/plugins/local_auth_darwin/darwin`)
|
||||||
|
- mobile_scanner (from `.symlinks/plugins/mobile_scanner/darwin`)
|
||||||
|
- printing (from `.symlinks/plugins/printing/ios`)
|
||||||
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
||||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||||
|
|
||||||
@@ -142,6 +149,10 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/image_picker_ios/ios"
|
:path: ".symlinks/plugins/image_picker_ios/ios"
|
||||||
local_auth_darwin:
|
local_auth_darwin:
|
||||||
:path: ".symlinks/plugins/local_auth_darwin/darwin"
|
:path: ".symlinks/plugins/local_auth_darwin/darwin"
|
||||||
|
mobile_scanner:
|
||||||
|
:path: ".symlinks/plugins/mobile_scanner/darwin"
|
||||||
|
printing:
|
||||||
|
:path: ".symlinks/plugins/printing/ios"
|
||||||
share_plus:
|
share_plus:
|
||||||
:path: ".symlinks/plugins/share_plus/ios"
|
:path: ".symlinks/plugins/share_plus/ios"
|
||||||
shared_preferences_foundation:
|
shared_preferences_foundation:
|
||||||
@@ -167,7 +178,9 @@ SPEC CHECKSUMS:
|
|||||||
MLKitTextRecognition: c0ad24510481dfc8893ad15dfcb8a5f05ed9e826
|
MLKitTextRecognition: c0ad24510481dfc8893ad15dfcb8a5f05ed9e826
|
||||||
MLKitTextRecognitionCommon: 234ceb1cfdfb5fceb4fd664943046609a2961cc2
|
MLKitTextRecognitionCommon: 234ceb1cfdfb5fceb4fd664943046609a2961cc2
|
||||||
MLKitVision: 39a5a812db83c4a0794445088e567f3631c11961
|
MLKitVision: 39a5a812db83c4a0794445088e567f3631c11961
|
||||||
|
mobile_scanner: 9157936403f5a0644ca3779a38ff8404c5434a93
|
||||||
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
|
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
|
||||||
|
printing: 54ff03f28fe9ba3aa93358afb80a8595a071dd07
|
||||||
PromisesObjC: 752c3227f599e3467650e47ea36f433eeb10c273
|
PromisesObjC: 752c3227f599e3467650e47ea36f433eeb10c273
|
||||||
SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf
|
SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf
|
||||||
SDWebImageWebPCoder: 0e06e365080397465cc73a7a9b472d8a3bd0f377
|
SDWebImageWebPCoder: 0e06e365080397465cc73a7a9b472d8a3bd0f377
|
||||||
|
|||||||
39
kifi-app/lib/core/widgets/barcode_scanner_screen.dart
Normal file
39
kifi-app/lib/core/widgets/barcode_scanner_screen.dart
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||||
|
|
||||||
|
class BarcodeScannerScreen extends StatefulWidget {
|
||||||
|
const BarcodeScannerScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<BarcodeScannerScreen> createState() => _BarcodeScannerScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BarcodeScannerScreenState extends State<BarcodeScannerScreen> {
|
||||||
|
final MobileScannerController controller = MobileScannerController();
|
||||||
|
bool _isScanned = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Scan Barcode')),
|
||||||
|
body: MobileScanner(
|
||||||
|
controller: controller,
|
||||||
|
onDetect: (capture) {
|
||||||
|
if (_isScanned) return;
|
||||||
|
final List<Barcode> barcodes = capture.barcodes;
|
||||||
|
if (barcodes.isNotEmpty && barcodes.first.rawValue != null) {
|
||||||
|
_isScanned = true;
|
||||||
|
final String code = barcodes.first.rawValue!;
|
||||||
|
Navigator.pop(context, code);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
68
kifi-app/lib/core/widgets/premium_text_field.dart
Normal file
68
kifi-app/lib/core/widgets/premium_text_field.dart
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class PremiumTextField extends StatelessWidget {
|
||||||
|
final String labelText;
|
||||||
|
final TextEditingController? controller;
|
||||||
|
final String? initialValue;
|
||||||
|
final Widget? prefixIcon;
|
||||||
|
final Widget? suffixIcon;
|
||||||
|
final String? suffixText;
|
||||||
|
final TextInputType? keyboardType;
|
||||||
|
final TextCapitalization textCapitalization;
|
||||||
|
final int maxLines;
|
||||||
|
final bool obscureText;
|
||||||
|
final String? Function(String?)? validator;
|
||||||
|
final void Function(String)? onChanged;
|
||||||
|
final void Function(String?)? onSaved;
|
||||||
|
final bool darkTheme;
|
||||||
|
|
||||||
|
const PremiumTextField({
|
||||||
|
super.key,
|
||||||
|
required this.labelText,
|
||||||
|
this.controller,
|
||||||
|
this.initialValue,
|
||||||
|
this.prefixIcon,
|
||||||
|
this.suffixIcon,
|
||||||
|
this.suffixText,
|
||||||
|
this.keyboardType,
|
||||||
|
this.textCapitalization = TextCapitalization.none,
|
||||||
|
this.maxLines = 1,
|
||||||
|
this.obscureText = false,
|
||||||
|
this.validator,
|
||||||
|
this.onChanged,
|
||||||
|
this.onSaved,
|
||||||
|
this.darkTheme = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return TextFormField(
|
||||||
|
controller: controller,
|
||||||
|
initialValue: initialValue,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: labelText,
|
||||||
|
labelStyle: TextStyle(color: darkTheme ? Colors.white70 : Colors.grey[600]),
|
||||||
|
prefixIcon: prefixIcon,
|
||||||
|
suffixIcon: suffixIcon,
|
||||||
|
suffixText: suffixText,
|
||||||
|
suffixStyle: TextStyle(color: darkTheme ? Colors.white70 : Colors.grey),
|
||||||
|
filled: true,
|
||||||
|
fillColor: darkTheme ? Colors.black26 : Colors.grey[100],
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
|
||||||
|
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
borderSide: BorderSide(color: darkTheme ? Colors.white : Theme.of(context).colorScheme.primary, width: 2)
|
||||||
|
),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||||
|
),
|
||||||
|
keyboardType: keyboardType,
|
||||||
|
textCapitalization: textCapitalization,
|
||||||
|
maxLines: maxLines,
|
||||||
|
obscureText: obscureText,
|
||||||
|
onChanged: onChanged,
|
||||||
|
onSaved: onSaved,
|
||||||
|
validator: validator,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
220
kifi-app/lib/core/widgets/smart_search_dropdown.dart
Normal file
220
kifi-app/lib/core/widgets/smart_search_dropdown.dart
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class SmartSearchDropdown<T> extends StatefulWidget {
|
||||||
|
final List<T> items;
|
||||||
|
final String Function(T) itemAsString;
|
||||||
|
final void Function(T?) onChanged;
|
||||||
|
final T? value;
|
||||||
|
final String hintText;
|
||||||
|
final Widget Function(BuildContext, T)? itemBuilder;
|
||||||
|
final bool Function(T, String)? filterFn;
|
||||||
|
|
||||||
|
const SmartSearchDropdown({
|
||||||
|
super.key,
|
||||||
|
required this.items,
|
||||||
|
required this.itemAsString,
|
||||||
|
required this.onChanged,
|
||||||
|
this.value,
|
||||||
|
this.hintText = 'Type to search...',
|
||||||
|
this.itemBuilder,
|
||||||
|
this.filterFn,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SmartSearchDropdown<T>> createState() => _SmartSearchDropdownState<T>();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SmartSearchDropdownState<T> extends State<SmartSearchDropdown<T>> {
|
||||||
|
final LayerLink _layerLink = LayerLink();
|
||||||
|
final FocusNode _focusNode = FocusNode();
|
||||||
|
final TextEditingController _controller = TextEditingController();
|
||||||
|
OverlayEntry? _overlayEntry;
|
||||||
|
bool _showAll = false;
|
||||||
|
List<T> _filteredItems = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_filteredItems = widget.items;
|
||||||
|
if (widget.value != null) {
|
||||||
|
_controller.text = widget.itemAsString(widget.value as T);
|
||||||
|
}
|
||||||
|
_focusNode.addListener(() {
|
||||||
|
if (_focusNode.hasFocus) {
|
||||||
|
_showOverlay();
|
||||||
|
} else {
|
||||||
|
_removeOverlay();
|
||||||
|
// Reset text to selected value if focus lost without selection
|
||||||
|
if (widget.value != null) {
|
||||||
|
_controller.text = widget.itemAsString(widget.value as T);
|
||||||
|
} else {
|
||||||
|
_controller.text = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(SmartSearchDropdown<T> oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (widget.items != oldWidget.items) {
|
||||||
|
_filterItems(_controller.text);
|
||||||
|
}
|
||||||
|
if (widget.value != oldWidget.value) {
|
||||||
|
if (widget.value != null) {
|
||||||
|
_controller.text = widget.itemAsString(widget.value as T);
|
||||||
|
} else {
|
||||||
|
_controller.text = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_focusNode.dispose();
|
||||||
|
_controller.dispose();
|
||||||
|
_removeOverlay();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _filterItems(String query) {
|
||||||
|
setState(() {
|
||||||
|
if (query.isEmpty) {
|
||||||
|
_filteredItems = widget.items;
|
||||||
|
} else {
|
||||||
|
_filteredItems = widget.items.where((item) {
|
||||||
|
if (widget.filterFn != null) {
|
||||||
|
return widget.filterFn!(item, query);
|
||||||
|
}
|
||||||
|
return widget.itemAsString(item).toLowerCase().contains(query.toLowerCase());
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_overlayEntry?.markNeedsBuild();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showOverlay() {
|
||||||
|
_removeOverlay();
|
||||||
|
_showAll = true;
|
||||||
|
_filteredItems = widget.items;
|
||||||
|
_overlayEntry = _createOverlayEntry();
|
||||||
|
Overlay.of(context).insert(_overlayEntry!);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _removeOverlay() {
|
||||||
|
_overlayEntry?.remove();
|
||||||
|
_overlayEntry = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
OverlayEntry _createOverlayEntry() {
|
||||||
|
RenderBox renderBox = context.findRenderObject() as RenderBox;
|
||||||
|
var size = renderBox.size;
|
||||||
|
|
||||||
|
return OverlayEntry(
|
||||||
|
builder: (context) => Positioned(
|
||||||
|
width: size.width,
|
||||||
|
child: CompositedTransformFollower(
|
||||||
|
link: _layerLink,
|
||||||
|
showWhenUnlinked: false,
|
||||||
|
offset: Offset(0.0, size.height + 5.0),
|
||||||
|
child: Material(
|
||||||
|
elevation: 4.0,
|
||||||
|
borderRadius: BorderRadius.circular(8.0),
|
||||||
|
color: Theme.of(context).cardColor,
|
||||||
|
child: StatefulBuilder(
|
||||||
|
builder: (context, setOverlayState) {
|
||||||
|
final displayItems = _showAll ? _filteredItems : (_filteredItems.isNotEmpty ? [_filteredItems.first] : <T>[]);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
constraints: const BoxConstraints(maxHeight: 250),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (displayItems.isEmpty)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.all(16.0),
|
||||||
|
child: Text('No matches found'),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Flexible(
|
||||||
|
child: ListView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
itemCount: displayItems.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final item = displayItems[index];
|
||||||
|
return InkWell(
|
||||||
|
onTap: () {
|
||||||
|
widget.onChanged(item);
|
||||||
|
_controller.text = widget.itemAsString(item);
|
||||||
|
_focusNode.unfocus();
|
||||||
|
},
|
||||||
|
child: widget.itemBuilder != null
|
||||||
|
? widget.itemBuilder!(context, item)
|
||||||
|
: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0),
|
||||||
|
child: Text(widget.itemAsString(item)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!_showAll && _controller.text.isEmpty && widget.items.length > 1)
|
||||||
|
InkWell(
|
||||||
|
onTap: () {
|
||||||
|
setOverlayState(() {
|
||||||
|
_showAll = true;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(12.0),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border(top: BorderSide(color: Colors.grey.withOpacity(0.2))),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'Show all',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(color: Colors.blue, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return CompositedTransformTarget(
|
||||||
|
link: _layerLink,
|
||||||
|
child: TextFormField(
|
||||||
|
controller: _controller,
|
||||||
|
focusNode: _focusNode,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: widget.hintText,
|
||||||
|
suffixIcon: const Icon(Icons.arrow_drop_down, color: Colors.grey),
|
||||||
|
filled: Theme.of(context).inputDecorationTheme.filled ?? true,
|
||||||
|
fillColor: Theme.of(context).inputDecorationTheme.fillColor ?? Colors.grey[100],
|
||||||
|
border: Theme.of(context).inputDecorationTheme.border ?? OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
|
||||||
|
enabledBorder: Theme.of(context).inputDecorationTheme.enabledBorder ?? OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
|
||||||
|
focusedBorder: Theme.of(context).inputDecorationTheme.focusedBorder ?? OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
borderSide: BorderSide(color: Theme.of(context).colorScheme.primary, width: 2)
|
||||||
|
),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||||
|
),
|
||||||
|
onChanged: (val) {
|
||||||
|
_showAll = true; // when user types, we want to show all matching results
|
||||||
|
_filterItems(val);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -237,7 +237,6 @@ class _BudgetScreenState extends ConsumerState<BudgetScreen> {
|
|||||||
final transState = ref.watch(transactionProvider);
|
final transState = ref.watch(transactionProvider);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
|
|||||||
71
kifi-app/lib/features/business/domain/business_feature.dart
Normal file
71
kifi-app/lib/features/business/domain/business_feature.dart
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
class BusinessFeature {
|
||||||
|
final int? id;
|
||||||
|
final int? userId;
|
||||||
|
final bool inventoryManagement;
|
||||||
|
final bool salesManagement;
|
||||||
|
final bool multiLocation;
|
||||||
|
final String bomReductionStrategy;
|
||||||
|
final bool stockDeductionOnInvoice;
|
||||||
|
final String barcodeSource;
|
||||||
|
final DateTime? createdAt;
|
||||||
|
|
||||||
|
BusinessFeature({
|
||||||
|
this.id,
|
||||||
|
this.userId,
|
||||||
|
this.inventoryManagement = false,
|
||||||
|
this.salesManagement = false,
|
||||||
|
this.multiLocation = false,
|
||||||
|
this.bomReductionStrategy = 'COMPONENTS_ONLY',
|
||||||
|
this.stockDeductionOnInvoice = true,
|
||||||
|
this.barcodeSource = 'SKU',
|
||||||
|
this.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory BusinessFeature.fromJson(Map<String, dynamic> json) {
|
||||||
|
return BusinessFeature(
|
||||||
|
id: json['id'],
|
||||||
|
userId: json['userId'],
|
||||||
|
inventoryManagement: json['inventoryManagement'] ?? false,
|
||||||
|
salesManagement: json['salesManagement'] ?? false,
|
||||||
|
multiLocation: json['multiLocation'] ?? false,
|
||||||
|
bomReductionStrategy: json['bomReductionStrategy'] ?? 'COMPONENTS_ONLY',
|
||||||
|
stockDeductionOnInvoice: json['stockDeductionOnInvoice'] ?? true,
|
||||||
|
barcodeSource: json['barcodeSource'] ?? 'SKU',
|
||||||
|
createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt']) : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'userId': userId,
|
||||||
|
'inventoryManagement': inventoryManagement,
|
||||||
|
'salesManagement': salesManagement,
|
||||||
|
'multiLocation': multiLocation,
|
||||||
|
'bomReductionStrategy': bomReductionStrategy,
|
||||||
|
'stockDeductionOnInvoice': stockDeductionOnInvoice,
|
||||||
|
'barcodeSource': barcodeSource,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
BusinessFeature copyWith({
|
||||||
|
bool? inventoryManagement,
|
||||||
|
bool? salesManagement,
|
||||||
|
bool? multiLocation,
|
||||||
|
String? bomReductionStrategy,
|
||||||
|
bool? stockDeductionOnInvoice,
|
||||||
|
String? barcodeSource,
|
||||||
|
}) {
|
||||||
|
return BusinessFeature(
|
||||||
|
id: id,
|
||||||
|
userId: userId,
|
||||||
|
inventoryManagement: inventoryManagement ?? this.inventoryManagement,
|
||||||
|
salesManagement: salesManagement ?? this.salesManagement,
|
||||||
|
multiLocation: multiLocation ?? this.multiLocation,
|
||||||
|
bomReductionStrategy: bomReductionStrategy ?? this.bomReductionStrategy,
|
||||||
|
stockDeductionOnInvoice: stockDeductionOnInvoice ?? this.stockDeductionOnInvoice,
|
||||||
|
barcodeSource: barcodeSource ?? this.barcodeSource,
|
||||||
|
createdAt: createdAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,13 @@ class BusinessProfile {
|
|||||||
final String? taxNumber;
|
final String? taxNumber;
|
||||||
final String? currency;
|
final String? currency;
|
||||||
final bool? taxIncludedInPrice;
|
final bool? taxIncludedInPrice;
|
||||||
|
final String? address;
|
||||||
|
final int? stateId;
|
||||||
|
final String? contactPerson;
|
||||||
|
final String? contactNumber;
|
||||||
|
final String? emailId;
|
||||||
|
final String? panNumber;
|
||||||
|
final String? gstin;
|
||||||
|
|
||||||
BusinessProfile({
|
BusinessProfile({
|
||||||
this.id,
|
this.id,
|
||||||
@@ -15,6 +22,13 @@ class BusinessProfile {
|
|||||||
this.taxNumber,
|
this.taxNumber,
|
||||||
this.currency,
|
this.currency,
|
||||||
this.taxIncludedInPrice,
|
this.taxIncludedInPrice,
|
||||||
|
this.address,
|
||||||
|
this.stateId,
|
||||||
|
this.contactPerson,
|
||||||
|
this.contactNumber,
|
||||||
|
this.emailId,
|
||||||
|
this.panNumber,
|
||||||
|
this.gstin,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory BusinessProfile.fromJson(Map<String, dynamic> json) {
|
factory BusinessProfile.fromJson(Map<String, dynamic> json) {
|
||||||
@@ -26,6 +40,13 @@ class BusinessProfile {
|
|||||||
taxNumber: json['taxNumber'],
|
taxNumber: json['taxNumber'],
|
||||||
currency: json['currency'],
|
currency: json['currency'],
|
||||||
taxIncludedInPrice: json['taxIncludedInPrice'],
|
taxIncludedInPrice: json['taxIncludedInPrice'],
|
||||||
|
address: json['address'],
|
||||||
|
stateId: json['stateId'],
|
||||||
|
contactPerson: json['contactPerson'],
|
||||||
|
contactNumber: json['contactNumber'],
|
||||||
|
emailId: json['emailId'],
|
||||||
|
panNumber: json['panNumber'],
|
||||||
|
gstin: json['gstin'],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +59,13 @@ class BusinessProfile {
|
|||||||
'taxNumber': taxNumber,
|
'taxNumber': taxNumber,
|
||||||
'currency': currency,
|
'currency': currency,
|
||||||
'taxIncludedInPrice': taxIncludedInPrice,
|
'taxIncludedInPrice': taxIncludedInPrice,
|
||||||
|
'address': address,
|
||||||
|
'stateId': stateId,
|
||||||
|
'contactPerson': contactPerson,
|
||||||
|
'contactNumber': contactNumber,
|
||||||
|
'emailId': emailId,
|
||||||
|
'panNumber': panNumber,
|
||||||
|
'gstin': gstin,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +75,13 @@ class BusinessProfile {
|
|||||||
String? taxNumber,
|
String? taxNumber,
|
||||||
String? currency,
|
String? currency,
|
||||||
bool? taxIncludedInPrice,
|
bool? taxIncludedInPrice,
|
||||||
|
String? address,
|
||||||
|
int? stateId,
|
||||||
|
String? contactPerson,
|
||||||
|
String? contactNumber,
|
||||||
|
String? emailId,
|
||||||
|
String? panNumber,
|
||||||
|
String? gstin,
|
||||||
}) {
|
}) {
|
||||||
return BusinessProfile(
|
return BusinessProfile(
|
||||||
id: id,
|
id: id,
|
||||||
@@ -56,6 +91,13 @@ class BusinessProfile {
|
|||||||
taxNumber: taxNumber ?? this.taxNumber,
|
taxNumber: taxNumber ?? this.taxNumber,
|
||||||
currency: currency ?? this.currency,
|
currency: currency ?? this.currency,
|
||||||
taxIncludedInPrice: taxIncludedInPrice ?? this.taxIncludedInPrice,
|
taxIncludedInPrice: taxIncludedInPrice ?? this.taxIncludedInPrice,
|
||||||
|
address: address ?? this.address,
|
||||||
|
stateId: stateId ?? this.stateId,
|
||||||
|
contactPerson: contactPerson ?? this.contactPerson,
|
||||||
|
contactNumber: contactNumber ?? this.contactNumber,
|
||||||
|
emailId: emailId ?? this.emailId,
|
||||||
|
panNumber: panNumber ?? this.panNumber,
|
||||||
|
gstin: gstin ?? this.gstin,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
27
kifi-app/lib/features/business/domain/indian_state.dart
Normal file
27
kifi-app/lib/features/business/domain/indian_state.dart
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
class IndianState {
|
||||||
|
final int id;
|
||||||
|
final String name;
|
||||||
|
final String gstCode;
|
||||||
|
|
||||||
|
IndianState({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.gstCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory IndianState.fromJson(Map<String, dynamic> json) {
|
||||||
|
return IndianState(
|
||||||
|
id: json['id'],
|
||||||
|
name: json['name'],
|
||||||
|
gstCode: json['gstCode'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'name': name,
|
||||||
|
'gstCode': gstCode,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,38 +3,69 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:lucide_icons/lucide_icons.dart';
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
import '../../../../core/theme/nature_colors.dart';
|
import '../../../../core/theme/nature_colors.dart';
|
||||||
import '../../../inventory/presentation/product_list_screen.dart';
|
import '../../../inventory/presentation/product_list_screen.dart';
|
||||||
|
import '../../../inventory/presentation/quick_adjust_stock_screen.dart';
|
||||||
|
import '../../../inventory/presentation/uoms_list_screen.dart';
|
||||||
|
import '../../../sales/presentation/customers_list_screen.dart';
|
||||||
|
import '../../../sales/presentation/invoices_list_screen.dart';
|
||||||
|
import '../../providers/business_provider.dart';
|
||||||
|
import '../widgets/business_profile_form_sheet.dart';
|
||||||
|
|
||||||
class BusinessHubScreen extends ConsumerWidget {
|
class BusinessHubScreen extends ConsumerWidget {
|
||||||
const BusinessHubScreen({super.key});
|
const BusinessHubScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final businessState = ref.watch(businessProfileProvider);
|
||||||
|
final profile = businessState.value;
|
||||||
|
|
||||||
|
final title = (profile?.businessName != null && profile!.businessName.isNotEmpty)
|
||||||
|
? profile.businessName
|
||||||
|
: 'Business Overview';
|
||||||
|
final subtitle = (profile?.address != null && profile!.address!.isNotEmpty)
|
||||||
|
? profile.address!
|
||||||
|
: 'Manage your inventory and stock';
|
||||||
|
|
||||||
|
final featureState = ref.watch(businessFeatureProvider).value;
|
||||||
|
final bool showInventory = featureState?.inventoryManagement ?? false;
|
||||||
|
final bool showSales = featureState?.salesManagement ?? false;
|
||||||
|
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(24.0),
|
padding: const EdgeInsets.all(24.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
InkWell(
|
||||||
padding: const EdgeInsets.all(16),
|
onTap: () {
|
||||||
decoration: BoxDecoration(
|
showModalBottomSheet(
|
||||||
color: Theme.of(context).colorScheme.primary.withOpacity(0.1),
|
context: context,
|
||||||
borderRadius: BorderRadius.circular(16),
|
isScrollControlled: true,
|
||||||
border: Border.all(color: Theme.of(context).colorScheme.primary.withOpacity(0.2)),
|
backgroundColor: Colors.transparent,
|
||||||
),
|
builder: (context) => const BusinessProfileFormSheet(),
|
||||||
child: Row(
|
);
|
||||||
children: [
|
},
|
||||||
Icon(LucideIcons.briefcase, color: Theme.of(context).colorScheme.primary, size: 32),
|
borderRadius: BorderRadius.circular(16),
|
||||||
const SizedBox(width: 16),
|
child: Container(
|
||||||
Expanded(
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
decoration: BoxDecoration(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
color: Theme.of(context).colorScheme.primary.withOpacity(0.1),
|
||||||
children: [
|
borderRadius: BorderRadius.circular(16),
|
||||||
Text('Business Overview', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
|
border: Border.all(color: Theme.of(context).colorScheme.primary.withOpacity(0.2)),
|
||||||
const Text('Manage your inventory and stock', style: TextStyle(color: Colors.grey)),
|
),
|
||||||
],
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(LucideIcons.briefcase, color: Theme.of(context).colorScheme.primary, size: 32),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(title, style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
|
||||||
|
Text(subtitle, style: const TextStyle(color: Colors.grey)),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
@@ -46,56 +77,68 @@ class BusinessHubScreen extends ConsumerWidget {
|
|||||||
physics: const NeverScrollableScrollPhysics(),
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
childAspectRatio: 1.2,
|
childAspectRatio: 1.2,
|
||||||
children: [
|
children: [
|
||||||
_buildActionCard(
|
if (showInventory) ...[
|
||||||
context,
|
_buildActionCard(
|
||||||
'Products Catalog',
|
context,
|
||||||
'View all products',
|
'Products Catalog',
|
||||||
LucideIcons.packageSearch,
|
'View all products',
|
||||||
Colors.blue,
|
LucideIcons.packageSearch,
|
||||||
() => Navigator.push(context, MaterialPageRoute(builder: (_) => const ProductListScreen())),
|
Colors.blue,
|
||||||
),
|
() => Navigator.push(context, MaterialPageRoute(builder: (_) => const ProductListScreen())),
|
||||||
_buildActionCard(
|
),
|
||||||
context,
|
_buildActionCard(
|
||||||
'Adjust Stock',
|
context,
|
||||||
'Add or reduce inventory',
|
'Adjust Stock',
|
||||||
LucideIcons.arrowRightLeft,
|
'Quick Stock Edit',
|
||||||
Colors.orange,
|
LucideIcons.arrowRightLeft,
|
||||||
() {
|
Colors.orange,
|
||||||
// TODO: Navigate to Stock Adjustment
|
() => Navigator.push(context, MaterialPageRoute(builder: (_) => const QuickAdjustStockScreen())),
|
||||||
},
|
),
|
||||||
),
|
_buildActionCard(
|
||||||
_buildActionCard(
|
context,
|
||||||
context,
|
'Units of Measure',
|
||||||
'Sales & Invoices',
|
'Manage measurements',
|
||||||
'Coming in Phase 2',
|
LucideIcons.ruler,
|
||||||
LucideIcons.receipt,
|
Colors.purple,
|
||||||
Colors.green,
|
() => Navigator.push(context, MaterialPageRoute(builder: (_) => const UomsListScreen())),
|
||||||
() {},
|
),
|
||||||
),
|
],
|
||||||
_buildActionCard(
|
if (showSales) ...[
|
||||||
context,
|
_buildActionCard(
|
||||||
'Customers',
|
context,
|
||||||
'Coming in Phase 2',
|
'Sales & Invoices',
|
||||||
LucideIcons.users,
|
'Create invoices',
|
||||||
NatureColors.getColor('RECEIVABLES'),
|
LucideIcons.receipt,
|
||||||
() {},
|
Colors.green,
|
||||||
),
|
() => Navigator.push(context, MaterialPageRoute(builder: (_) => const InvoicesListScreen())),
|
||||||
|
),
|
||||||
|
_buildActionCard(
|
||||||
|
context,
|
||||||
|
'Customers',
|
||||||
|
'Manage clients',
|
||||||
|
LucideIcons.users,
|
||||||
|
NatureColors.getColor('RECEIVABLES'),
|
||||||
|
() => Navigator.push(context, MaterialPageRoute(builder: (_) => const CustomersListScreen())),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
if (showInventory) ...[
|
||||||
Text('Low Stock Alerts', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
|
const SizedBox(height: 32),
|
||||||
const SizedBox(height: 16),
|
Text('Low Stock Alerts', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
|
||||||
// Placeholder for low stock items
|
const SizedBox(height: 16),
|
||||||
Container(
|
// Placeholder for low stock items
|
||||||
padding: const EdgeInsets.all(24),
|
Container(
|
||||||
decoration: BoxDecoration(
|
padding: const EdgeInsets.all(24),
|
||||||
color: Colors.grey.withOpacity(0.05),
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(16),
|
color: Colors.grey.withOpacity(0.05),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: const Center(
|
||||||
|
child: Text('All products are adequately stocked.', style: TextStyle(color: Colors.grey)),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: const Center(
|
],
|
||||||
child: Text('All products are adequately stocked.', style: TextStyle(color: Colors.grey)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -25,6 +25,13 @@ class _BusinessSettingsScreenState extends ConsumerState<BusinessSettingsScreen>
|
|||||||
_taxIncludedInPrice = profile.taxIncludedInPrice ?? false;
|
_taxIncludedInPrice = profile.taxIncludedInPrice ?? false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
final feature = ref.read(businessFeatureProvider).value;
|
||||||
|
if (feature != null) {
|
||||||
|
setState(() {
|
||||||
|
_inventoryEnabled = feature.inventoryManagement;
|
||||||
|
_salesEnabled = feature.salesManagement;
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,17 +52,76 @@ class _BusinessSettingsScreenState extends ConsumerState<BusinessSettingsScreen>
|
|||||||
title: const Text('Inventory Management'),
|
title: const Text('Inventory Management'),
|
||||||
subtitle: const Text('Track stock levels, multiple locations, and products'),
|
subtitle: const Text('Track stock levels, multiple locations, and products'),
|
||||||
value: _inventoryEnabled,
|
value: _inventoryEnabled,
|
||||||
onChanged: (val) => setState(() => _inventoryEnabled = val),
|
onChanged: (val) {
|
||||||
|
setState(() => _inventoryEnabled = val);
|
||||||
|
_saveFeatures();
|
||||||
|
},
|
||||||
secondary: const Icon(LucideIcons.package),
|
secondary: const Icon(LucideIcons.package),
|
||||||
),
|
),
|
||||||
SwitchListTile(
|
SwitchListTile(
|
||||||
title: const Text('Sales & POS'),
|
title: const Text('Sales & POS'),
|
||||||
subtitle: const Text('Enable point of sale, invoicing, and receivables'),
|
subtitle: const Text('Enable point of sale, invoicing, and receivables'),
|
||||||
value: _salesEnabled,
|
value: _salesEnabled,
|
||||||
onChanged: (val) => setState(() => _salesEnabled = val),
|
onChanged: (val) {
|
||||||
|
setState(() => _salesEnabled = val);
|
||||||
|
_saveFeatures();
|
||||||
|
},
|
||||||
secondary: const Icon(LucideIcons.shoppingCart),
|
secondary: const Icon(LucideIcons.shoppingCart),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
|
const Text('Inventory Strategy', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.grey)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Consumer(
|
||||||
|
builder: (context, ref, child) {
|
||||||
|
final featureState = ref.watch(businessFeatureProvider);
|
||||||
|
final currentStrategy = featureState.value?.bomReductionStrategy ?? 'COMPONENTS_ONLY';
|
||||||
|
final deductParentStock = currentStrategy == 'PARENT_AND_COMPONENTS';
|
||||||
|
final stockDeductionOnInvoice = featureState.value?.stockDeductionOnInvoice ?? true;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
SwitchListTile(
|
||||||
|
title: const Text('Deduct Parent Stock on BOM Sale'),
|
||||||
|
subtitle: const Text('If enabled, selling an assembled product reduces both parent and component stock.'),
|
||||||
|
value: deductParentStock,
|
||||||
|
secondary: const Icon(LucideIcons.gitMerge),
|
||||||
|
onChanged: (val) {
|
||||||
|
if (featureState.value != null) {
|
||||||
|
final newStrategy = val ? 'PARENT_AND_COMPONENTS' : 'COMPONENTS_ONLY';
|
||||||
|
final updated = featureState.value!.copyWith(bomReductionStrategy: newStrategy);
|
||||||
|
ref.read(businessFeatureProvider.notifier).updateFeatures(updated);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
SwitchListTile(
|
||||||
|
title: const Text('Stock Deduction on Invoice'),
|
||||||
|
subtitle: const Text('If enabled, finalizing an invoice automatically deducts product stock. Turn off for a two-step fulfillment process.'),
|
||||||
|
value: stockDeductionOnInvoice,
|
||||||
|
secondary: const Icon(LucideIcons.boxes),
|
||||||
|
onChanged: (val) {
|
||||||
|
if (featureState.value != null) {
|
||||||
|
final updated = featureState.value!.copyWith(stockDeductionOnInvoice: val);
|
||||||
|
ref.read(businessFeatureProvider.notifier).updateFeatures(updated);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
SwitchListTile(
|
||||||
|
title: const Text('Use EAN/Barcode for Scanner'),
|
||||||
|
subtitle: const Text('If disabled, the barcode scanner will search using the SKU field instead.'),
|
||||||
|
value: (featureState.value?.barcodeSource ?? 'SKU') == 'BARCODE',
|
||||||
|
secondary: const Icon(LucideIcons.scanLine),
|
||||||
|
onChanged: (val) {
|
||||||
|
if (featureState.value != null) {
|
||||||
|
final updated = featureState.value!.copyWith(barcodeSource: val ? 'BARCODE' : 'SKU');
|
||||||
|
ref.read(businessFeatureProvider.notifier).updateFeatures(updated);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
const Text('Pricing & Taxation', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.grey)),
|
const Text('Pricing & Taxation', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.grey)),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
SwitchListTile(
|
SwitchListTile(
|
||||||
@@ -80,4 +146,15 @@ class _BusinessSettingsScreenState extends ConsumerState<BusinessSettingsScreen>
|
|||||||
ref.read(businessProfileProvider.notifier).updateProfile(updated);
|
ref.read(businessProfileProvider.notifier).updateProfile(updated);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _saveFeatures() {
|
||||||
|
final featureState = ref.read(businessFeatureProvider).value;
|
||||||
|
if (featureState != null) {
|
||||||
|
final updated = featureState.copyWith(
|
||||||
|
inventoryManagement: _inventoryEnabled,
|
||||||
|
salesManagement: _salesEnabled,
|
||||||
|
);
|
||||||
|
ref.read(businessFeatureProvider.notifier).updateFeatures(updated);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,288 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import '../../domain/business_profile.dart';
|
||||||
|
import '../../providers/business_provider.dart';
|
||||||
|
import '../../providers/indian_states_provider.dart';
|
||||||
|
|
||||||
|
class BusinessProfileFormSheet extends ConsumerStatefulWidget {
|
||||||
|
const BusinessProfileFormSheet({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<BusinessProfileFormSheet> createState() => _BusinessProfileFormSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BusinessProfileFormSheetState extends ConsumerState<BusinessProfileFormSheet> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
|
||||||
|
late TextEditingController _businessNameController;
|
||||||
|
late TextEditingController _addressController;
|
||||||
|
late TextEditingController _contactPersonController;
|
||||||
|
late TextEditingController _contactNumberController;
|
||||||
|
late TextEditingController _emailController;
|
||||||
|
late TextEditingController _panController;
|
||||||
|
late TextEditingController _gstinController;
|
||||||
|
int? _selectedStateId;
|
||||||
|
|
||||||
|
bool _isLoading = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final profile = ref.read(businessProfileProvider).value;
|
||||||
|
|
||||||
|
_businessNameController = TextEditingController(text: profile?.businessName ?? '');
|
||||||
|
_addressController = TextEditingController(text: profile?.address ?? '');
|
||||||
|
_contactPersonController = TextEditingController(text: profile?.contactPerson ?? '');
|
||||||
|
_contactNumberController = TextEditingController(text: profile?.contactNumber ?? '');
|
||||||
|
_emailController = TextEditingController(text: profile?.emailId ?? '');
|
||||||
|
_panController = TextEditingController(text: profile?.panNumber ?? '');
|
||||||
|
_gstinController = TextEditingController(text: profile?.gstin ?? '');
|
||||||
|
_selectedStateId = profile?.stateId;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_businessNameController.dispose();
|
||||||
|
_addressController.dispose();
|
||||||
|
_contactPersonController.dispose();
|
||||||
|
_contactNumberController.dispose();
|
||||||
|
_emailController.dispose();
|
||||||
|
_panController.dispose();
|
||||||
|
_gstinController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
|
||||||
|
setState(() => _isLoading = true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final existingProfile = ref.read(businessProfileProvider).value;
|
||||||
|
|
||||||
|
final profile = BusinessProfile(
|
||||||
|
id: existingProfile?.id,
|
||||||
|
userId: existingProfile?.userId,
|
||||||
|
businessName: _businessNameController.text.trim(),
|
||||||
|
address: _addressController.text.trim(),
|
||||||
|
stateId: _selectedStateId,
|
||||||
|
contactPerson: _contactPersonController.text.trim(),
|
||||||
|
contactNumber: _contactNumberController.text.trim(),
|
||||||
|
emailId: _emailController.text.trim(),
|
||||||
|
panNumber: _panController.text.trim().toUpperCase(),
|
||||||
|
gstin: _gstinController.text.trim().toUpperCase(),
|
||||||
|
);
|
||||||
|
|
||||||
|
await ref.read(businessProfileProvider.notifier).updateProfile(profile);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
Navigator.pop(context);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Business profile updated successfully')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Error: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _isLoading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final statesState = ref.watch(indianStatesProvider);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||||
|
),
|
||||||
|
child: DraggableScrollableSheet(
|
||||||
|
initialChildSize: 0.9,
|
||||||
|
minChildSize: 0.5,
|
||||||
|
maxChildSize: 0.95,
|
||||||
|
expand: false,
|
||||||
|
builder: (context, scrollController) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
const Text('Business Profile', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
Expanded(
|
||||||
|
child: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: ListView(
|
||||||
|
controller: scrollController,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
TextFormField(
|
||||||
|
controller: _businessNameController,
|
||||||
|
decoration: InputDecoration(labelText: 'Company Name', border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
|
||||||
|
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
|
||||||
|
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Colors.blue, width: 2)),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.grey[100]),
|
||||||
|
validator: (v) => v == null || v.isEmpty ? 'Company name is required' : null,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
controller: _addressController,
|
||||||
|
decoration: InputDecoration(labelText: 'Address', border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
|
||||||
|
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
|
||||||
|
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Colors.blue, width: 2)),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.grey[100]),
|
||||||
|
maxLines: 2,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
statesState.when(
|
||||||
|
data: (states) {
|
||||||
|
return DropdownButtonFormField<int>(
|
||||||
|
value: _selectedStateId,
|
||||||
|
decoration: InputDecoration(labelText: 'State', border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
|
||||||
|
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
|
||||||
|
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Colors.blue, width: 2)),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.grey[100]),
|
||||||
|
items: states.map((s) {
|
||||||
|
return DropdownMenuItem(
|
||||||
|
value: s.id,
|
||||||
|
child: Text('${s.name} (${s.gstCode})'),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
onChanged: (val) {
|
||||||
|
setState(() {
|
||||||
|
_selectedStateId = val;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
loading: () => const Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(16.0),
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
error: (e, stack) => Text('Failed to load states: $e', style: const TextStyle(color: Colors.red)),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
controller: _contactPersonController,
|
||||||
|
decoration: InputDecoration(labelText: 'Contact Person', border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
|
||||||
|
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
|
||||||
|
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Colors.blue, width: 2)),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.grey[100]),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
controller: _contactNumberController,
|
||||||
|
decoration: InputDecoration(labelText: 'Contact Number', border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
|
||||||
|
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
|
||||||
|
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Colors.blue, width: 2)),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.grey[100]),
|
||||||
|
keyboardType: TextInputType.phone,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
controller: _emailController,
|
||||||
|
decoration: InputDecoration(labelText: 'Email ID', border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
|
||||||
|
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
|
||||||
|
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Colors.blue, width: 2)),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.grey[100]),
|
||||||
|
keyboardType: TextInputType.emailAddress,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
controller: _panController,
|
||||||
|
textCapitalization: TextCapitalization.characters,
|
||||||
|
decoration: InputDecoration(labelText: 'PAN Number', border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
|
||||||
|
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
|
||||||
|
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Colors.blue, width: 2)),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.grey[100]),
|
||||||
|
validator: (v) {
|
||||||
|
if (v != null && v.isNotEmpty) {
|
||||||
|
final regex = RegExp(r'^[A-Z]{5}[0-9]{4}[A-Z]{1}$');
|
||||||
|
if (!regex.hasMatch(v.toUpperCase())) {
|
||||||
|
return 'Invalid PAN format';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
controller: _gstinController,
|
||||||
|
textCapitalization: TextCapitalization.characters,
|
||||||
|
decoration: InputDecoration(labelText: 'GSTIN', border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
|
||||||
|
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
|
||||||
|
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Colors.blue, width: 2)),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.grey[100]),
|
||||||
|
validator: (v) {
|
||||||
|
if (v != null && v.isNotEmpty) {
|
||||||
|
final regex = RegExp(r'^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$');
|
||||||
|
if (!regex.hasMatch(v.toUpperCase())) {
|
||||||
|
return 'Invalid GSTIN format';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: _isLoading ? null : _submit,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.blue,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
),
|
||||||
|
child: _isLoading
|
||||||
|
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
|
||||||
|
: const Text('Save Business Profile', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../../core/network/dio_client.dart';
|
import '../../../core/network/dio_client.dart';
|
||||||
import '../domain/business_profile.dart';
|
import '../domain/business_profile.dart';
|
||||||
|
import '../domain/business_feature.dart';
|
||||||
|
|
||||||
class BusinessProfileNotifier extends AsyncNotifier<BusinessProfile?> {
|
class BusinessProfileNotifier extends AsyncNotifier<BusinessProfile?> {
|
||||||
@override
|
@override
|
||||||
@@ -37,3 +38,42 @@ class BusinessProfileNotifier extends AsyncNotifier<BusinessProfile?> {
|
|||||||
final businessProfileProvider = AsyncNotifierProvider<BusinessProfileNotifier, BusinessProfile?>(() {
|
final businessProfileProvider = AsyncNotifierProvider<BusinessProfileNotifier, BusinessProfile?>(() {
|
||||||
return BusinessProfileNotifier();
|
return BusinessProfileNotifier();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
class BusinessFeatureNotifier extends AsyncNotifier<BusinessFeature?> {
|
||||||
|
@override
|
||||||
|
FutureOr<BusinessFeature?> build() async {
|
||||||
|
return _fetchFeatures();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<BusinessFeature?> _fetchFeatures() async {
|
||||||
|
try {
|
||||||
|
final response = await DioClient().dio.get('/business/features');
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
return BusinessFeature.fromJson(response.data);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Return default if error
|
||||||
|
}
|
||||||
|
return BusinessFeature();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> updateFeatures(BusinessFeature feature) async {
|
||||||
|
state = const AsyncValue.loading();
|
||||||
|
try {
|
||||||
|
final response = await DioClient().dio.post(
|
||||||
|
'/business/features',
|
||||||
|
data: feature.toJson(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
state = AsyncValue.data(BusinessFeature.fromJson(response.data));
|
||||||
|
}
|
||||||
|
} catch (e, stack) {
|
||||||
|
state = AsyncValue.error(e, stack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final businessFeatureProvider = AsyncNotifierProvider<BusinessFeatureNotifier, BusinessFeature?>(() {
|
||||||
|
return BusinessFeatureNotifier();
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import '../../../core/network/dio_client.dart';
|
||||||
|
import '../domain/indian_state.dart';
|
||||||
|
|
||||||
|
class IndianStatesNotifier extends AsyncNotifier<List<IndianState>> {
|
||||||
|
@override
|
||||||
|
Future<List<IndianState>> build() async {
|
||||||
|
return _fetchStates();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<IndianState>> _fetchStates() async {
|
||||||
|
final response = await DioClient().dio.get('/master/states').timeout(
|
||||||
|
const Duration(seconds: 5),
|
||||||
|
onTimeout: () => throw Exception('Connection timed out'),
|
||||||
|
);
|
||||||
|
if (response.data == null || response.data.toString().isEmpty) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return (response.data as List).map((e) => IndianState.fromJson(e)).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final indianStatesProvider = AsyncNotifierProvider<IndianStatesNotifier, List<IndianState>>(() {
|
||||||
|
return IndianStatesNotifier();
|
||||||
|
});
|
||||||
@@ -42,9 +42,18 @@ class _AccountsScreenState extends ConsumerState<AccountsScreen> {
|
|||||||
void _showCreateWalletDialog() {
|
void _showCreateWalletDialog() {
|
||||||
final ctrl = TextEditingController();
|
final ctrl = TextEditingController();
|
||||||
final amtCtrl = TextEditingController();
|
final amtCtrl = TextEditingController();
|
||||||
|
final creditLimitCtrl = TextEditingController();
|
||||||
|
final fixedAmountCtrl = TextEditingController();
|
||||||
|
final cycleDateCtrl = TextEditingController();
|
||||||
DateTime openingDate = DateTime.now();
|
DateTime openingDate = DateTime.now();
|
||||||
String selectedNature = 'CASH';
|
String selectedNature = 'CASH';
|
||||||
|
String? selectedSubNature;
|
||||||
|
String? selectedPaymentCycle;
|
||||||
|
|
||||||
final natures = ['CASH', 'SAVINGS', 'INVESTMENTS', 'LOAN', 'LENDING', 'PAYABLES', 'INCOME', 'EXPENSE', 'RECEIVABLES'];
|
final natures = ['CASH', 'SAVINGS', 'INVESTMENTS', 'LOAN', 'LENDING', 'PAYABLES', 'INCOME', 'EXPENSE', 'RECEIVABLES'];
|
||||||
|
final subNatures = ['CREDIT_CARD', 'OD_LIMIT', 'LOAN_EMI', 'POLICY_PREMIUM', 'OTHER'];
|
||||||
|
final paymentCycles = ['DAILY', 'WEEKLY', 'MONTHLY', 'QUARTERLY', 'YEARLY'];
|
||||||
|
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => StatefulBuilder(
|
builder: (ctx) => StatefulBuilder(
|
||||||
@@ -117,14 +126,121 @@ class _AccountsScreenState extends ConsumerState<AccountsScreen> {
|
|||||||
),
|
),
|
||||||
items: natures.map((n) => DropdownMenuItem(value: n, child: Text(n))).toList(),
|
items: natures.map((n) => DropdownMenuItem(value: n, child: Text(n))).toList(),
|
||||||
onChanged: (val) {
|
onChanged: (val) {
|
||||||
if (val != null) setStateDialog(() => selectedNature = val);
|
if (val != null) setStateDialog(() {
|
||||||
|
selectedNature = val;
|
||||||
|
if (val != 'PAYABLES') {
|
||||||
|
selectedSubNature = null;
|
||||||
|
selectedPaymentCycle = null;
|
||||||
|
creditLimitCtrl.clear();
|
||||||
|
fixedAmountCtrl.clear();
|
||||||
|
cycleDateCtrl.clear();
|
||||||
|
} else {
|
||||||
|
selectedSubNature = 'CREDIT_CARD';
|
||||||
|
selectedPaymentCycle = 'MONTHLY';
|
||||||
|
}
|
||||||
|
});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
if (selectedNature == 'PAYABLES') ...[
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
value: selectedSubNature,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w500, color: Colors.black87),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Sub-Nature',
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.grey.shade100,
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
|
||||||
|
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)),
|
||||||
|
),
|
||||||
|
items: subNatures.map((n) => DropdownMenuItem(value: n, child: Text(n.replaceAll('_', ' ')))).toList(),
|
||||||
|
onChanged: (val) {
|
||||||
|
if (val != null) setStateDialog(() => selectedSubNature = val);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
if (selectedSubNature == 'CREDIT_CARD' || selectedSubNature == 'OD_LIMIT') ...[
|
||||||
|
TextField(
|
||||||
|
controller: creditLimitCtrl,
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: false),
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Credit Limit (Optional)',
|
||||||
|
prefixText: 'Rs. ',
|
||||||
|
prefixStyle: const TextStyle(color: Colors.black87, fontWeight: FontWeight.w500, fontSize: 16),
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.grey.shade100,
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
|
||||||
|
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
|
||||||
|
if (selectedSubNature == 'LOAN_EMI' || selectedSubNature == 'POLICY_PREMIUM') ...[
|
||||||
|
TextField(
|
||||||
|
controller: fixedAmountCtrl,
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: false),
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Fixed Amount Due (Optional)',
|
||||||
|
prefixText: 'Rs. ',
|
||||||
|
prefixStyle: const TextStyle(color: Colors.black87, fontWeight: FontWeight.w500, fontSize: 16),
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.grey.shade100,
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
|
||||||
|
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: DropdownButtonFormField<String>(
|
||||||
|
value: selectedPaymentCycle,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w500, color: Colors.black87),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Cycle',
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.grey.shade100,
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
|
||||||
|
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)),
|
||||||
|
),
|
||||||
|
items: paymentCycles.map((n) => DropdownMenuItem(value: n, child: Text(n))).toList(),
|
||||||
|
onChanged: (val) {
|
||||||
|
if (val != null) setStateDialog(() => selectedPaymentCycle = val);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
flex: 1,
|
||||||
|
child: TextField(
|
||||||
|
controller: cycleDateCtrl,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Date (1-31)',
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.grey.shade100,
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
|
||||||
|
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
|
||||||
TextField(
|
TextField(
|
||||||
controller: amtCtrl,
|
controller: amtCtrl,
|
||||||
keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: false),
|
keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: true),
|
||||||
textInputAction: TextInputAction.done,
|
textInputAction: TextInputAction.done,
|
||||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
@@ -189,20 +305,30 @@ class _AccountsScreenState extends ConsumerState<AccountsScreen> {
|
|||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
if (ctrl.text.isNotEmpty) {
|
if (ctrl.text.isNotEmpty) {
|
||||||
final double amt = double.tryParse(amtCtrl.text) ?? 0.0;
|
final double amt = double.tryParse(amtCtrl.text) ?? 0.0;
|
||||||
|
final double? cl = double.tryParse(creditLimitCtrl.text);
|
||||||
|
final double? fa = double.tryParse(fixedAmountCtrl.text);
|
||||||
|
final int? cd = int.tryParse(cycleDateCtrl.text);
|
||||||
|
|
||||||
final newWallet = await ref.read(walletProvider.notifier).createWallet(
|
final newWallet = await ref.read(walletProvider.notifier).createWallet(
|
||||||
name: ctrl.text,
|
name: ctrl.text,
|
||||||
nature: selectedNature,
|
nature: selectedNature,
|
||||||
initialBalance: 0.0,
|
initialBalance: 0.0,
|
||||||
|
subNature: selectedNature == 'PAYABLES' ? selectedSubNature : null,
|
||||||
|
creditLimit: cl,
|
||||||
|
fixedAmount: fa,
|
||||||
|
paymentCycle: selectedNature == 'PAYABLES' ? selectedPaymentCycle : null,
|
||||||
|
cycleDate: cd,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (amt > 0) {
|
if (amt != 0) {
|
||||||
final tx = Transaction(
|
final tx = Transaction(
|
||||||
id: 0,
|
id: 0,
|
||||||
type: 'INCOME',
|
type: amt > 0 ? 'INCOME' : 'EXPENSE',
|
||||||
amount: amt,
|
amount: amt.abs(),
|
||||||
date: openingDate,
|
date: openingDate,
|
||||||
description: 'Opening Balance',
|
description: 'Opening Balance',
|
||||||
toWalletId: newWallet.id,
|
fromWalletId: amt < 0 ? newWallet.id : null,
|
||||||
|
toWalletId: amt > 0 ? newWallet.id : null,
|
||||||
);
|
);
|
||||||
await ref.read(transactionProvider.notifier).addTransaction(tx);
|
await ref.read(transactionProvider.notifier).addTransaction(tx);
|
||||||
}
|
}
|
||||||
@@ -335,7 +461,6 @@ class _AccountsScreenState extends ConsumerState<AccountsScreen> {
|
|||||||
final walletsState = ref.watch(walletProvider);
|
final walletsState = ref.watch(walletProvider);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
bottom: false,
|
bottom: false,
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -472,7 +597,16 @@ class _AccountsScreenState extends ConsumerState<AccountsScreen> {
|
|||||||
onPressed: () {
|
onPressed: () {
|
||||||
final editCtrl = TextEditingController(text: w.name);
|
final editCtrl = TextEditingController(text: w.name);
|
||||||
String editNature = w.nature ?? 'CASH';
|
String editNature = w.nature ?? 'CASH';
|
||||||
|
String? editSubNature = w.subNature;
|
||||||
|
final editCreditLimitCtrl = TextEditingController(text: w.creditLimit?.toString() ?? '');
|
||||||
|
final editFixedAmountCtrl = TextEditingController(text: w.fixedAmount?.toString() ?? '');
|
||||||
|
final editCycleDateCtrl = TextEditingController(text: w.cycleDate?.toString() ?? '');
|
||||||
|
String? editPaymentCycle = w.paymentCycle;
|
||||||
|
|
||||||
final natures = ['CASH', 'SAVINGS', 'EXPENSE', 'INVESTMENTS', 'LOAN', 'LENDING', 'PAYABLES', 'RECEIVABLES', 'INCOME'];
|
final natures = ['CASH', 'SAVINGS', 'EXPENSE', 'INVESTMENTS', 'LOAN', 'LENDING', 'PAYABLES', 'RECEIVABLES', 'INCOME'];
|
||||||
|
final subNatures = ['CREDIT_CARD', 'OD_LIMIT', 'LOAN_EMI', 'POLICY_PREMIUM', 'OTHER'];
|
||||||
|
final paymentCycles = ['DAILY', 'WEEKLY', 'MONTHLY', 'QUARTERLY', 'YEARLY'];
|
||||||
|
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => StatefulBuilder(
|
builder: (ctx) => StatefulBuilder(
|
||||||
@@ -480,9 +614,11 @@ class _AccountsScreenState extends ConsumerState<AccountsScreen> {
|
|||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
child: Container(
|
child: GestureDetector(
|
||||||
padding: const EdgeInsets.all(24),
|
onTap: () => FocusScope.of(context).unfocus(),
|
||||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(28)),
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(28)),
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@@ -510,10 +646,107 @@ class _AccountsScreenState extends ConsumerState<AccountsScreen> {
|
|||||||
),
|
),
|
||||||
items: natures.map((n) => DropdownMenuItem(value: n, child: Text(n))).toList(),
|
items: natures.map((n) => DropdownMenuItem(value: n, child: Text(n))).toList(),
|
||||||
onChanged: (val) {
|
onChanged: (val) {
|
||||||
if (val != null) setStateDialog(() => editNature = val);
|
if (val != null) setStateDialog(() {
|
||||||
|
editNature = val;
|
||||||
|
if (val != 'PAYABLES') {
|
||||||
|
editSubNature = null;
|
||||||
|
editPaymentCycle = null;
|
||||||
|
editCreditLimitCtrl.clear();
|
||||||
|
editFixedAmountCtrl.clear();
|
||||||
|
editCycleDateCtrl.clear();
|
||||||
|
} else if (editSubNature == null) {
|
||||||
|
editSubNature = 'CREDIT_CARD';
|
||||||
|
editPaymentCycle = 'MONTHLY';
|
||||||
|
}
|
||||||
|
});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
if (editNature == 'PAYABLES') ...[
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
value: editSubNature,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Sub-Nature',
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.grey.shade100,
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
|
||||||
|
),
|
||||||
|
items: subNatures.map((n) => DropdownMenuItem(value: n, child: Text(n.replaceAll('_', ' ')))).toList(),
|
||||||
|
onChanged: (val) {
|
||||||
|
if (val != null) setStateDialog(() => editSubNature = val);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
if (editSubNature == 'CREDIT_CARD' || editSubNature == 'OD_LIMIT') ...[
|
||||||
|
TextField(
|
||||||
|
controller: editCreditLimitCtrl,
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: false),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Credit Limit (Optional)',
|
||||||
|
prefixText: 'Rs. ',
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.grey.shade100,
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
|
||||||
|
if (editSubNature == 'LOAN_EMI' || editSubNature == 'POLICY_PREMIUM') ...[
|
||||||
|
TextField(
|
||||||
|
controller: editFixedAmountCtrl,
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: false),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Fixed Amount Due (Optional)',
|
||||||
|
prefixText: 'Rs. ',
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.grey.shade100,
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: DropdownButtonFormField<String>(
|
||||||
|
value: editPaymentCycle,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Cycle',
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.grey.shade100,
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
|
||||||
|
),
|
||||||
|
items: paymentCycles.map((n) => DropdownMenuItem(value: n, child: Text(n))).toList(),
|
||||||
|
onChanged: (val) {
|
||||||
|
if (val != null) setStateDialog(() => editPaymentCycle = val);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
flex: 1,
|
||||||
|
child: TextField(
|
||||||
|
controller: editCycleDateCtrl,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Date (1-31)',
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.grey.shade100,
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
|
||||||
|
const SizedBox(height: 8),
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
@@ -525,11 +758,20 @@ class _AccountsScreenState extends ConsumerState<AccountsScreen> {
|
|||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
if (editCtrl.text.isNotEmpty) {
|
if (editCtrl.text.isNotEmpty) {
|
||||||
|
final double? cl = double.tryParse(editCreditLimitCtrl.text);
|
||||||
|
final double? fa = double.tryParse(editFixedAmountCtrl.text);
|
||||||
|
final int? cd = int.tryParse(editCycleDateCtrl.text);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await ref.read(walletProvider.notifier).editWallet(
|
await ref.read(walletProvider.notifier).editWallet(
|
||||||
w.id,
|
w.id,
|
||||||
name: editCtrl.text.trim(),
|
name: editCtrl.text.trim(),
|
||||||
nature: editNature,
|
nature: editNature,
|
||||||
|
subNature: editNature == 'PAYABLES' ? editSubNature : null,
|
||||||
|
creditLimit: cl,
|
||||||
|
fixedAmount: fa,
|
||||||
|
paymentCycle: editNature == 'PAYABLES' ? editPaymentCycle : null,
|
||||||
|
cycleDate: cd,
|
||||||
);
|
);
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
Navigator.pop(ctx);
|
Navigator.pop(ctx);
|
||||||
@@ -549,6 +791,7 @@ class _AccountsScreenState extends ConsumerState<AccountsScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -12,7 +12,13 @@ import '../../transactions/data/models.dart';
|
|||||||
import '../../budget/presentation/budget_screen.dart';
|
import '../../budget/presentation/budget_screen.dart';
|
||||||
|
|
||||||
import '../providers/insight_provider.dart';
|
import '../providers/insight_provider.dart';
|
||||||
|
import '../../sales/providers/invoices_provider.dart';
|
||||||
|
import '../../sales/providers/customers_provider.dart';
|
||||||
|
import '../../sales/domain/invoice.dart';
|
||||||
|
import '../../sales/presentation/customers_list_screen.dart';
|
||||||
|
import 'widgets/swipeable_account_card.dart';
|
||||||
import 'widgets/budget_status_card.dart';
|
import 'widgets/budget_status_card.dart';
|
||||||
|
import 'widgets/upcoming_dues_widget.dart';
|
||||||
import 'widgets/statistics_tab.dart';
|
import 'widgets/statistics_tab.dart';
|
||||||
import '../../../core/widgets/shimmer_loading.dart';
|
import '../../../core/widgets/shimmer_loading.dart';
|
||||||
import '../../../core/theme/nature_colors.dart';
|
import '../../../core/theme/nature_colors.dart';
|
||||||
@@ -57,6 +63,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
ref.invalidate(categoryProvider);
|
ref.invalidate(categoryProvider);
|
||||||
ref.invalidate(budgetProvider);
|
ref.invalidate(budgetProvider);
|
||||||
ref.invalidate(invitationProvider);
|
ref.invalidate(invitationProvider);
|
||||||
|
ref.invalidate(invoicesProvider);
|
||||||
|
ref.invalidate(customersProvider);
|
||||||
await Future.delayed(const Duration(milliseconds: 500));
|
await Future.delayed(const Duration(milliseconds: 500));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,6 +147,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
final categoriesState = ref.watch(categoryProvider);
|
final categoriesState = ref.watch(categoryProvider);
|
||||||
final insight = ref.watch(insightProvider);
|
final insight = ref.watch(insightProvider);
|
||||||
final walletsState = ref.watch(walletProvider);
|
final walletsState = ref.watch(walletProvider);
|
||||||
|
final invoicesState = ref.watch(invoicesProvider);
|
||||||
|
final customersState = ref.watch(customersProvider);
|
||||||
final isBusinessMode = ref.watch(businessModeProvider);
|
final isBusinessMode = ref.watch(businessModeProvider);
|
||||||
final allTransactions = transState.value ?? [];
|
final allTransactions = transState.value ?? [];
|
||||||
|
|
||||||
@@ -147,10 +157,18 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
final endDate = range.end;
|
final endDate = range.end;
|
||||||
|
|
||||||
String title;
|
String title;
|
||||||
if (_currentIndex == 0) title = 'Dashboard';
|
if (_currentIndex == 0) {
|
||||||
else if (_currentIndex == 1) title = isBusinessMode ? 'Business Hub' : 'Statistics';
|
title = 'Dashboard';
|
||||||
else if (_currentIndex == 2) title = 'My Accounts';
|
} else if (isBusinessMode) {
|
||||||
else title = 'Budgets';
|
if (_currentIndex == 1) title = 'Business Hub';
|
||||||
|
else if (_currentIndex == 2) title = 'Statistics';
|
||||||
|
else if (_currentIndex == 3) title = 'My Accounts';
|
||||||
|
else title = 'Budgets';
|
||||||
|
} else {
|
||||||
|
if (_currentIndex == 1) title = 'Statistics';
|
||||||
|
else if (_currentIndex == 2) title = 'My Accounts';
|
||||||
|
else title = 'Budgets';
|
||||||
|
}
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
@@ -308,6 +326,68 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Process invoices to get total pending
|
||||||
|
double totalPendingInvoices = 0;
|
||||||
|
Map<int, double> pendingByCustomer = {};
|
||||||
|
|
||||||
|
if (invoicesState.hasValue && customersState.hasValue) {
|
||||||
|
final invoices = invoicesState.value!;
|
||||||
|
|
||||||
|
for (var inv in invoices) {
|
||||||
|
if (inv.status != 'PAID' && inv.status != 'CANCELLED' && inv.customerId != null) {
|
||||||
|
double paidAmount = inv.amountPaid ?? 0;
|
||||||
|
double pendingAmount = inv.totalAmount - paidAmount;
|
||||||
|
if (pendingAmount > 0) {
|
||||||
|
pendingByCustomer[inv.customerId!] = (pendingByCustomer[inv.customerId!] ?? 0) + pendingAmount;
|
||||||
|
totalPendingInvoices += pendingAmount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
receivablesBalance += totalPendingInvoices;
|
||||||
|
|
||||||
|
// Build Payables Carousel Items
|
||||||
|
List<CarouselItemData> payablesItems = [];
|
||||||
|
if (walletsState.hasValue) {
|
||||||
|
final payableWallets = walletsState.value!.where((w) => w.nature == 'PAYABLES' || w.nature == 'LOAN').toList();
|
||||||
|
for (var w in payableWallets) {
|
||||||
|
if (w.balance > 0) {
|
||||||
|
payablesItems.add(CarouselItemData(
|
||||||
|
title: 'To: ${w.name}',
|
||||||
|
amount: w.balance,
|
||||||
|
color: NatureColors.getColor('PAYABLES'),
|
||||||
|
icon: LucideIcons.userMinus,
|
||||||
|
onTap: () {
|
||||||
|
Navigator.push(context, MaterialPageRoute(builder: (_) => WalletLedgerScreen(wallet: w)));
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build Receivables Carousel Items
|
||||||
|
List<CarouselItemData> receivablesItems = [];
|
||||||
|
|
||||||
|
if (invoicesState.hasValue && customersState.hasValue) {
|
||||||
|
final customers = customersState.value!;
|
||||||
|
pendingByCustomer.forEach((custId, amount) {
|
||||||
|
if (amount > 0) {
|
||||||
|
final customerName = customers.where((c) => c.id == custId).firstOrNull?.name ?? 'Unknown Customer';
|
||||||
|
receivablesItems.add(CarouselItemData(
|
||||||
|
title: 'From: $customerName',
|
||||||
|
amount: amount,
|
||||||
|
color: NatureColors.getColor('RECEIVABLES'),
|
||||||
|
icon: LucideIcons.userPlus,
|
||||||
|
onTap: () {
|
||||||
|
// Navigate to customer details or invoices in the future
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return IndexedStack(
|
return IndexedStack(
|
||||||
index: _currentIndex,
|
index: _currentIndex,
|
||||||
children: [
|
children: [
|
||||||
@@ -352,7 +432,6 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
],
|
],
|
||||||
const BudgetStatusCard(),
|
const BudgetStatusCard(),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// Summary Cards Grid
|
// Summary Cards Grid
|
||||||
GridView.count(
|
GridView.count(
|
||||||
crossAxisCount: 2,
|
crossAxisCount: 2,
|
||||||
@@ -362,14 +441,34 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
physics: const NeverScrollableScrollPhysics(),
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
childAspectRatio: 1.5,
|
childAspectRatio: 1.5,
|
||||||
children: [
|
children: [
|
||||||
_buildSummaryCard(context, 'Balance', cashBalance, NatureColors.getColor('BALANCE'), LucideIcons.wallet, 'CASH'),
|
_buildSummaryCard(context, 'Balance', cashBalance, NatureColors.getColor('BALANCE'), LucideIcons.wallet, onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const AccountsScreen(initialFilterNature: 'CASH')))),
|
||||||
_buildSummaryCard(context, 'Expense', expenseBalance, NatureColors.getColor('EXPENSE'), LucideIcons.arrowUpRight, 'EXPENSE'),
|
_buildSummaryCard(context, 'Expense', expenseBalance, NatureColors.getColor('EXPENSE'), LucideIcons.arrowUpRight, onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const AccountsScreen(initialFilterNature: 'EXPENSE')))),
|
||||||
_buildSummaryCard(context, 'Savings', savingsBalance, NatureColors.getColor('SAVINGS'), LucideIcons.piggyBank, 'SAVINGS'),
|
_buildSummaryCard(context, 'Savings', savingsBalance, NatureColors.getColor('SAVINGS'), LucideIcons.piggyBank, onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const AccountsScreen(initialFilterNature: 'SAVINGS')))),
|
||||||
_buildSummaryCard(context, 'Investments', investmentsBalance, NatureColors.getColor('INVESTMENTS'), LucideIcons.trendingUp, 'INVESTMENTS'),
|
_buildSummaryCard(context, 'Investments', investmentsBalance, NatureColors.getColor('INVESTMENTS'), LucideIcons.trendingUp, onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const AccountsScreen(initialFilterNature: 'INVESTMENTS')))),
|
||||||
_buildSummaryCard(context, 'Payables', payablesBalance, NatureColors.getColor('PAYABLES'), LucideIcons.alertCircle, 'PAYABLES'),
|
_buildSummaryCard(context, 'Total Payables', payablesBalance, NatureColors.getColor('PAYABLES'), LucideIcons.alertCircle, onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const AccountsScreen(initialFilterNature: 'PAYABLES')))),
|
||||||
_buildSummaryCard(context, 'Receivables', receivablesBalance, NatureColors.getColor('RECEIVABLES'), LucideIcons.arrowDownLeft, 'RECEIVABLES'),
|
_buildSummaryCard(context, 'Total Receivables', receivablesBalance, NatureColors.getColor('RECEIVABLES'), LucideIcons.arrowDownLeft, onTap: () {
|
||||||
|
Navigator.push(context, MaterialPageRoute(builder: (_) => const CustomersListScreen()));
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
const UpcomingDuesWidget(),
|
||||||
|
|
||||||
|
if (payablesItems.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Text('Upcoming Payables', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
SwipeableAccountCard(items: payablesItems),
|
||||||
|
],
|
||||||
|
|
||||||
|
if (receivablesItems.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Text('Upcoming Receivables', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
SwipeableAccountCard(items: receivablesItems),
|
||||||
|
],
|
||||||
|
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
@@ -515,8 +614,11 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// ---------------- STATS/BUSINESS TAB ----------------
|
// ---------------- BUSINESS TAB ----------------
|
||||||
isBusinessMode ? const BusinessHubScreen() : StatisticsTab(
|
if (isBusinessMode) const BusinessHubScreen(),
|
||||||
|
|
||||||
|
// ---------------- STATS TAB ----------------
|
||||||
|
StatisticsTab(
|
||||||
transactions: transactions,
|
transactions: transactions,
|
||||||
wallets: safeWallets,
|
wallets: safeWallets,
|
||||||
categories: categoriesState.value ?? [],
|
categories: categoriesState.value ?? [],
|
||||||
@@ -541,14 +643,17 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
bottomNavigationBar: BottomNavigationBar(
|
bottomNavigationBar: BottomNavigationBar(
|
||||||
currentIndex: _currentIndex >= 2 ? _currentIndex + 1 : _currentIndex,
|
currentIndex: isBusinessMode
|
||||||
|
? (_currentIndex >= 3 ? _currentIndex + 1 : _currentIndex)
|
||||||
|
: (_currentIndex >= 2 ? _currentIndex + 1 : _currentIndex),
|
||||||
type: BottomNavigationBarType.fixed,
|
type: BottomNavigationBarType.fixed,
|
||||||
onTap: (index) {
|
onTap: (index) {
|
||||||
if (index == 2) {
|
final addIndex = isBusinessMode ? 3 : 2;
|
||||||
|
if (index == addIndex) {
|
||||||
Navigator.push(context, MaterialPageRoute(builder: (_) => const AddTransactionScreen()));
|
Navigator.push(context, MaterialPageRoute(builder: (_) => const AddTransactionScreen()));
|
||||||
} else {
|
} else {
|
||||||
setState(() {
|
setState(() {
|
||||||
_currentIndex = index > 2 ? index - 1 : index;
|
_currentIndex = index > addIndex ? index - 1 : index;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -558,9 +663,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
showUnselectedLabels: true,
|
showUnselectedLabels: true,
|
||||||
items: [
|
items: [
|
||||||
const BottomNavigationBarItem(icon: Icon(LucideIcons.home), label: 'Home'),
|
const BottomNavigationBarItem(icon: Icon(LucideIcons.home), label: 'Home'),
|
||||||
BottomNavigationBarItem(
|
if (isBusinessMode)
|
||||||
icon: Icon(isBusinessMode ? LucideIcons.briefcase : LucideIcons.pieChart),
|
const BottomNavigationBarItem(icon: Icon(LucideIcons.briefcase), label: 'Business'),
|
||||||
label: isBusinessMode ? 'Business' : 'Stats'),
|
const BottomNavigationBarItem(icon: Icon(LucideIcons.pieChart), label: 'Stats'),
|
||||||
const BottomNavigationBarItem(icon: Icon(LucideIcons.plusCircle), label: 'Add'),
|
const BottomNavigationBarItem(icon: Icon(LucideIcons.plusCircle), label: 'Add'),
|
||||||
const BottomNavigationBarItem(icon: Icon(LucideIcons.wallet), label: 'Wallets'),
|
const BottomNavigationBarItem(icon: Icon(LucideIcons.wallet), label: 'Wallets'),
|
||||||
const BottomNavigationBarItem(icon: Icon(LucideIcons.target), label: 'Budgets'),
|
const BottomNavigationBarItem(icon: Icon(LucideIcons.target), label: 'Budgets'),
|
||||||
@@ -569,16 +674,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildSummaryCard(BuildContext context, String title, double amount, Color color, IconData icon, [String? nature]) {
|
Widget _buildSummaryCard(BuildContext context, String title, double amount, Color color, IconData icon, {VoidCallback? onTap}) {
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: nature != null ? () {
|
onTap: onTap,
|
||||||
Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (_) => AccountsScreen(initialFilterNature: nature),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} : null,
|
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
|
|
||||||
|
class CarouselItemData {
|
||||||
|
final String title;
|
||||||
|
final String subtitle;
|
||||||
|
final double amount;
|
||||||
|
final Color color;
|
||||||
|
final IconData icon;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
|
CarouselItemData({
|
||||||
|
required this.title,
|
||||||
|
this.subtitle = '',
|
||||||
|
required this.amount,
|
||||||
|
required this.color,
|
||||||
|
required this.icon,
|
||||||
|
this.onTap,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class SwipeableAccountCard extends StatefulWidget {
|
||||||
|
final List<CarouselItemData> items;
|
||||||
|
|
||||||
|
const SwipeableAccountCard({
|
||||||
|
super.key,
|
||||||
|
required this.items,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SwipeableAccountCard> createState() => _SwipeableAccountCardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SwipeableAccountCardState extends State<SwipeableAccountCard> {
|
||||||
|
int _currentPage = 0;
|
||||||
|
late PageController _pageController;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_pageController = PageController(initialPage: 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_pageController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (widget.items.isEmpty) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
height: 85, // adjusted height for the row-based card
|
||||||
|
child: PageView.builder(
|
||||||
|
controller: _pageController,
|
||||||
|
onPageChanged: (index) {
|
||||||
|
setState(() {
|
||||||
|
_currentPage = index;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
itemCount: widget.items.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final item = widget.items[index];
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: item.onTap,
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 4.0, vertical: 4.0),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: Colors.grey.shade200),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(color: Colors.black.withOpacity(0.02), blurRadius: 8, offset: const Offset(0, 2))
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: item.color.withOpacity(0.1),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
item.icon,
|
||||||
|
color: item.color,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
item.title,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
if (item.subtitle.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
item.subtitle,
|
||||||
|
style: TextStyle(color: Colors.grey.shade600, fontSize: 12),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'Rs. ${item.amount.toStringAsFixed(0)}',
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Colors.black87),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (widget.items.length > 1) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: List.generate(
|
||||||
|
widget.items.length,
|
||||||
|
(index) => Container(
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 4.0),
|
||||||
|
width: 8.0,
|
||||||
|
height: 8.0,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: _currentPage == index
|
||||||
|
? widget.items[index].color
|
||||||
|
: Colors.grey.withOpacity(0.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import '../../../transactions/providers/providers.dart';
|
||||||
|
import '../../../transactions/data/models.dart';
|
||||||
|
import '../../../../core/theme/nature_colors.dart';
|
||||||
|
|
||||||
|
class UpcomingDuesWidget extends ConsumerWidget {
|
||||||
|
const UpcomingDuesWidget({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final walletsState = ref.watch(walletProvider);
|
||||||
|
|
||||||
|
if (!walletsState.hasValue || walletsState.value == null) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
final wallets = walletsState.value!;
|
||||||
|
|
||||||
|
// Filter for payables that have a due date/amount
|
||||||
|
final payables = wallets.where((w) => w.nature == 'PAYABLES').toList();
|
||||||
|
|
||||||
|
if (payables.isEmpty) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
final now = DateTime.now();
|
||||||
|
|
||||||
|
// Calculate dues
|
||||||
|
final List<Map<String, dynamic>> dues = [];
|
||||||
|
|
||||||
|
for (var w in payables) {
|
||||||
|
double dueAmount = 0;
|
||||||
|
DateTime? dueDate;
|
||||||
|
String dueLabel = 'Upcoming Due';
|
||||||
|
|
||||||
|
if (w.subNature == 'CREDIT_CARD' || w.subNature == 'OD_LIMIT') {
|
||||||
|
// Balance is negative if we owe money
|
||||||
|
if (w.balance < 0) {
|
||||||
|
dueAmount = w.balance.abs();
|
||||||
|
}
|
||||||
|
} else if (w.subNature == 'LOAN_EMI' || w.subNature == 'POLICY_PREMIUM') {
|
||||||
|
dueAmount = w.fixedAmount ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dueAmount > 0 && w.cycleDate != null) {
|
||||||
|
int year = now.year;
|
||||||
|
int month = now.month;
|
||||||
|
|
||||||
|
// Find next due date based on cycle
|
||||||
|
if (w.paymentCycle == 'MONTHLY' || w.paymentCycle == null) {
|
||||||
|
if (now.day > w.cycleDate!) {
|
||||||
|
// Due date has passed for this month, next is next month
|
||||||
|
month++;
|
||||||
|
if (month > 12) {
|
||||||
|
month = 1;
|
||||||
|
year++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (w.paymentCycle == 'YEARLY') {
|
||||||
|
// Assume cycleDate is day of current month, this is simplistic
|
||||||
|
if (now.day > w.cycleDate!) {
|
||||||
|
year++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle end of month issues (e.g. Feb 30th)
|
||||||
|
int maxDays = DateTime(year, month + 1, 0).day;
|
||||||
|
int day = w.cycleDate! > maxDays ? maxDays : w.cycleDate!;
|
||||||
|
|
||||||
|
dueDate = DateTime(year, month, day);
|
||||||
|
|
||||||
|
int daysLeft = dueDate.difference(DateTime(now.year, now.month, now.day)).inDays;
|
||||||
|
|
||||||
|
if (daysLeft == 0) {
|
||||||
|
dueLabel = 'Due Today';
|
||||||
|
} else if (daysLeft == 1) {
|
||||||
|
dueLabel = 'Due Tomorrow';
|
||||||
|
} else {
|
||||||
|
dueLabel = 'Due in $daysLeft days';
|
||||||
|
}
|
||||||
|
|
||||||
|
dues.add({
|
||||||
|
'wallet': w,
|
||||||
|
'amount': dueAmount,
|
||||||
|
'dueDate': dueDate,
|
||||||
|
'label': dueLabel,
|
||||||
|
'daysLeft': daysLeft,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dues.isEmpty) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by nearest due date
|
||||||
|
dues.sort((a, b) => (a['daysLeft'] as int).compareTo(b['daysLeft'] as int));
|
||||||
|
|
||||||
|
// Only show top 3 dues
|
||||||
|
final displayDues = dues.take(3).toList();
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(LucideIcons.calendarClock, color: Color(0xFF6C63FF), size: 20),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text('Upcoming Dues', style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.bold)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
...displayDues.map((due) {
|
||||||
|
final w = due['wallet'] as Wallet;
|
||||||
|
final amount = due['amount'] as double;
|
||||||
|
final dueDate = due['dueDate'] as DateTime;
|
||||||
|
final label = due['label'] as String;
|
||||||
|
final daysLeft = due['daysLeft'] as int;
|
||||||
|
|
||||||
|
final isUrgent = daysLeft <= 3;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isUrgent ? Colors.red.shade50 : Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: isUrgent ? Colors.red.shade200 : Colors.grey.shade200),
|
||||||
|
boxShadow: [
|
||||||
|
if (!isUrgent) BoxShadow(color: Colors.black.withOpacity(0.02), blurRadius: 8, offset: const Offset(0, 2))
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: (isUrgent ? Colors.red : NatureColors.getColor('PAYABLES')).withOpacity(0.1),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
w.subNature == 'CREDIT_CARD' ? LucideIcons.creditCard :
|
||||||
|
(w.subNature == 'LOAN_EMI' ? LucideIcons.home : LucideIcons.fileText),
|
||||||
|
color: isUrgent ? Colors.red : NatureColors.getColor('PAYABLES'),
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(w.name, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15)),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'$label • ${DateFormat('MMM dd').format(dueDate)}',
|
||||||
|
style: TextStyle(
|
||||||
|
color: isUrgent ? Colors.red.shade700 : Colors.grey.shade600,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: isUrgent ? FontWeight.bold : FontWeight.normal
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'Rs. ${amount.toStringAsFixed(0)}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 16,
|
||||||
|
color: isUrgent ? Colors.red.shade700 : Colors.black87
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
class CategoryRateHistory {
|
||||||
|
final int id;
|
||||||
|
final int categoryId;
|
||||||
|
final double rate;
|
||||||
|
final DateTime date;
|
||||||
|
final DateTime createdAt;
|
||||||
|
final DateTime updatedAt;
|
||||||
|
|
||||||
|
CategoryRateHistory({
|
||||||
|
required this.id,
|
||||||
|
required this.categoryId,
|
||||||
|
required this.rate,
|
||||||
|
required this.date,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory CategoryRateHistory.fromJson(Map<String, dynamic> json) {
|
||||||
|
return CategoryRateHistory(
|
||||||
|
id: json['id'],
|
||||||
|
categoryId: json['categoryId'] ?? json['category_id'],
|
||||||
|
rate: (json['rate'] as num).toDouble(),
|
||||||
|
date: DateTime.parse(json['date']),
|
||||||
|
createdAt: DateTime.parse(json['createdAt'] ?? json['created_at']),
|
||||||
|
updatedAt: DateTime.parse(json['updatedAt'] ?? json['updated_at']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'categoryId': categoryId,
|
||||||
|
'rate': rate,
|
||||||
|
'date': "${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}",
|
||||||
|
'createdAt': createdAt.toIso8601String(),
|
||||||
|
'updatedAt': updatedAt.toIso8601String(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ class Product {
|
|||||||
final bool trackInventory;
|
final bool trackInventory;
|
||||||
final bool isActive;
|
final bool isActive;
|
||||||
final List<int> imageIds;
|
final List<int> imageIds;
|
||||||
|
final double? currentStock;
|
||||||
|
|
||||||
Product({
|
Product({
|
||||||
this.id,
|
this.id,
|
||||||
@@ -53,38 +54,40 @@ class Product {
|
|||||||
this.trackInventory = true,
|
this.trackInventory = true,
|
||||||
this.isActive = true,
|
this.isActive = true,
|
||||||
this.imageIds = const [],
|
this.imageIds = const [],
|
||||||
|
this.currentStock = 0.0,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory Product.fromJson(Map<String, dynamic> json) {
|
factory Product.fromJson(Map<String, dynamic> json) {
|
||||||
return Product(
|
return Product(
|
||||||
id: json['id'],
|
id: json['id'],
|
||||||
userId: json['userId'],
|
userId: json['userId'] ?? json['user_id'],
|
||||||
categoryId: json['categoryId'],
|
categoryId: json['categoryId'] ?? json['category_id'],
|
||||||
uomId: json['uomId'],
|
uomId: json['uomId'] ?? json['uom_id'],
|
||||||
name: json['name'],
|
name: json['name'],
|
||||||
sku: json['sku'],
|
sku: json['sku'],
|
||||||
barcode: json['barcode'],
|
barcode: json['barcode'],
|
||||||
description: json['description'],
|
description: json['description'],
|
||||||
purchasePrice: (json['purchasePrice'] as num?)?.toDouble(),
|
purchasePrice: (json['purchasePrice'] ?? json['purchase_price'] as num?)?.toDouble(),
|
||||||
sellingPrice: (json['sellingPrice'] as num?)?.toDouble(),
|
sellingPrice: (json['sellingPrice'] ?? json['selling_price'] as num?)?.toDouble(),
|
||||||
minStock: (json['minStock'] as num?)?.toDouble(),
|
minStock: (json['minStock'] ?? json['min_stock'] as num?)?.toDouble(),
|
||||||
reorderLevel: (json['reorderLevel'] as num?)?.toDouble(),
|
reorderLevel: (json['reorderLevel'] ?? json['reorder_level'] as num?)?.toDouble(),
|
||||||
gstRate: (json['gstRate'] as num?)?.toDouble(),
|
gstRate: (json['gstRate'] ?? json['gst_rate'] as num?)?.toDouble(),
|
||||||
dimensions: json['dimensions'],
|
dimensions: json['dimensions'],
|
||||||
weight: (json['weight'] as num?)?.toDouble(),
|
weight: (json['weight'] as num?)?.toDouble(),
|
||||||
color: json['color'],
|
color: json['color'],
|
||||||
size: json['size'],
|
size: json['size'],
|
||||||
priceCalcRule: json['priceCalcRule'] ?? 'MANUAL',
|
priceCalcRule: json['priceCalcRule'] ?? json['price_calc_rule'] ?? 'MANUAL',
|
||||||
autoCalculatePrice: json['autoCalculatePrice'] ?? false,
|
autoCalculatePrice: json['autoCalculatePrice'] ?? json['auto_calculate_price'] ?? false,
|
||||||
purityFactor: (json['purityFactor'] as num?)?.toDouble() ?? 1.0,
|
purityFactor: (json['purityFactor'] ?? json['purity_factor'] as num?)?.toDouble() ?? 1.0,
|
||||||
makingCharges: (json['makingCharges'] as num?)?.toDouble() ?? 0.0,
|
makingCharges: (json['makingCharges'] ?? json['making_charges'] as num?)?.toDouble() ?? 0.0,
|
||||||
makingChargesType: json['makingChargesType'] ?? 'FLAT',
|
makingChargesType: json['makingChargesType'] ?? json['making_charges_type'] ?? 'FLAT',
|
||||||
wastagePercentage: (json['wastagePercentage'] as num?)?.toDouble() ?? 0.0,
|
wastagePercentage: (json['wastagePercentage'] ?? json['wastage_percentage'] as num?)?.toDouble() ?? 0.0,
|
||||||
trackInventory: json['trackInventory'] ?? true,
|
trackInventory: json['trackInventory'] ?? json['track_inventory'] ?? true,
|
||||||
isActive: json['isActive'] ?? true,
|
isActive: json['isActive'] ?? json['is_active'] ?? true,
|
||||||
imageIds: json['images'] != null
|
imageIds: json['images'] != null
|
||||||
? (json['images'] as List).map((i) => i['id'] as int).toList()
|
? (json['images'] as List).map((i) => i['id'] as int).toList()
|
||||||
: [],
|
: [],
|
||||||
|
currentStock: (json['currentStock'] ?? json['current_stock'] as num?)?.toDouble() ?? 0.0,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
39
kifi-app/lib/features/inventory/domain/product_bom.dart
Normal file
39
kifi-app/lib/features/inventory/domain/product_bom.dart
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
class ProductBom {
|
||||||
|
final int? id;
|
||||||
|
final int parentProductId;
|
||||||
|
final int componentProductId;
|
||||||
|
final double quantity;
|
||||||
|
final DateTime? createdAt;
|
||||||
|
|
||||||
|
// Optional field to store the actual component product details fetched from the API
|
||||||
|
final Map<String, dynamic>? componentProduct;
|
||||||
|
|
||||||
|
ProductBom({
|
||||||
|
this.id,
|
||||||
|
required this.parentProductId,
|
||||||
|
required this.componentProductId,
|
||||||
|
required this.quantity,
|
||||||
|
this.createdAt,
|
||||||
|
this.componentProduct,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ProductBom.fromJson(Map<String, dynamic> json) {
|
||||||
|
return ProductBom(
|
||||||
|
id: json['id'],
|
||||||
|
parentProductId: json['parentProductId'],
|
||||||
|
componentProductId: json['componentProductId'],
|
||||||
|
quantity: (json['quantity'] as num).toDouble(),
|
||||||
|
createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt']) : null,
|
||||||
|
componentProduct: json['componentProduct'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
if (id != null) 'id': id,
|
||||||
|
'parentProductId': parentProductId,
|
||||||
|
'componentProductId': componentProductId,
|
||||||
|
'quantity': quantity,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
85
kifi-app/lib/features/inventory/domain/stock_movement.dart
Normal file
85
kifi-app/lib/features/inventory/domain/stock_movement.dart
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
class StockMovementItem {
|
||||||
|
final int? id;
|
||||||
|
final int? movementId;
|
||||||
|
final int? productId;
|
||||||
|
final double quantity;
|
||||||
|
final double? unitPrice;
|
||||||
|
|
||||||
|
StockMovementItem({
|
||||||
|
this.id,
|
||||||
|
this.movementId,
|
||||||
|
this.productId,
|
||||||
|
required this.quantity,
|
||||||
|
this.unitPrice,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory StockMovementItem.fromJson(Map<String, dynamic> json) {
|
||||||
|
return StockMovementItem(
|
||||||
|
id: json['id'],
|
||||||
|
movementId: json['movementId'],
|
||||||
|
productId: json['productId'],
|
||||||
|
quantity: (json['quantity'] as num).toDouble(),
|
||||||
|
unitPrice: (json['unitPrice'] as num?)?.toDouble(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'movementId': movementId,
|
||||||
|
'productId': productId,
|
||||||
|
'quantity': quantity,
|
||||||
|
'unitPrice': unitPrice,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class StockMovement {
|
||||||
|
final int? id;
|
||||||
|
final int? userId;
|
||||||
|
final int? locationId;
|
||||||
|
final String type; // OPENING, ADDITION, REDUCTION, ADJUSTMENT, DAMAGE, SALE, RETURN
|
||||||
|
final int? referenceTransactionId;
|
||||||
|
final String? notes;
|
||||||
|
final DateTime? createdAt;
|
||||||
|
final List<StockMovementItem>? items;
|
||||||
|
|
||||||
|
StockMovement({
|
||||||
|
this.id,
|
||||||
|
this.userId,
|
||||||
|
this.locationId,
|
||||||
|
required this.type,
|
||||||
|
this.referenceTransactionId,
|
||||||
|
this.notes,
|
||||||
|
this.createdAt,
|
||||||
|
this.items,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory StockMovement.fromJson(Map<String, dynamic> json) {
|
||||||
|
return StockMovement(
|
||||||
|
id: json['id'],
|
||||||
|
userId: json['userId'],
|
||||||
|
locationId: json['locationId'],
|
||||||
|
type: json['type'],
|
||||||
|
referenceTransactionId: json['referenceTransactionId'],
|
||||||
|
notes: json['notes'],
|
||||||
|
createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt']) : null,
|
||||||
|
items: json['items'] != null
|
||||||
|
? (json['items'] as List).map((i) => StockMovementItem.fromJson(i)).toList()
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'userId': userId,
|
||||||
|
'locationId': locationId,
|
||||||
|
'type': type,
|
||||||
|
'referenceTransactionId': referenceTransactionId,
|
||||||
|
'notes': notes,
|
||||||
|
if (createdAt != null) 'createdAt': createdAt!.toIso8601String(),
|
||||||
|
if (items != null) 'items': items!.map((i) => i.toJson()).toList(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
39
kifi-app/lib/features/inventory/domain/uom.dart
Normal file
39
kifi-app/lib/features/inventory/domain/uom.dart
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
class UnitOfMeasure {
|
||||||
|
final int? id;
|
||||||
|
final String name;
|
||||||
|
final String? abbreviation;
|
||||||
|
|
||||||
|
UnitOfMeasure({
|
||||||
|
this.id,
|
||||||
|
required this.name,
|
||||||
|
this.abbreviation,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory UnitOfMeasure.fromJson(Map<String, dynamic> json) {
|
||||||
|
return UnitOfMeasure(
|
||||||
|
id: json['id'],
|
||||||
|
name: json['name'],
|
||||||
|
abbreviation: json['abbreviation'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'name': name,
|
||||||
|
'abbreviation': abbreviation,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
UnitOfMeasure copyWith({
|
||||||
|
int? id,
|
||||||
|
String? name,
|
||||||
|
String? abbreviation,
|
||||||
|
}) {
|
||||||
|
return UnitOfMeasure(
|
||||||
|
id: id ?? this.id,
|
||||||
|
name: name ?? this.name,
|
||||||
|
abbreviation: abbreviation ?? this.abbreviation,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,19 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:convert';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:lucide_icons/lucide_icons.dart';
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
||||||
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart' as import_storage;
|
||||||
|
import '../../../core/network/dio_client.dart';
|
||||||
|
import '../../../../core/widgets/smart_search_dropdown.dart';
|
||||||
import '../domain/product.dart';
|
import '../domain/product.dart';
|
||||||
import '../providers/products_provider.dart';
|
import '../providers/products_provider.dart';
|
||||||
import '../providers/product_categories_provider.dart';
|
import '../providers/product_categories_provider.dart';
|
||||||
|
import '../providers/uoms_provider.dart';
|
||||||
|
import '../domain/uom.dart';
|
||||||
import '../../business/providers/business_provider.dart';
|
import '../../business/providers/business_provider.dart';
|
||||||
|
import '../../transactions/presentation/widgets/attachment_gallery_screen.dart';
|
||||||
|
|
||||||
class AddProductScreen extends ConsumerStatefulWidget {
|
class AddProductScreen extends ConsumerStatefulWidget {
|
||||||
final Product? product;
|
final Product? product;
|
||||||
@@ -28,6 +33,7 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
|||||||
String _name = '';
|
String _name = '';
|
||||||
String _sku = '';
|
String _sku = '';
|
||||||
ProductCategory? _selectedCategory;
|
ProductCategory? _selectedCategory;
|
||||||
|
int? _uomId;
|
||||||
|
|
||||||
// Properties
|
// Properties
|
||||||
String _color = '';
|
String _color = '';
|
||||||
@@ -50,15 +56,22 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
|||||||
|
|
||||||
// Media
|
// Media
|
||||||
final List<XFile> _images = [];
|
final List<XFile> _images = [];
|
||||||
|
final List<int> _existingImageIds = [];
|
||||||
bool _isSaving = false;
|
bool _isSaving = false;
|
||||||
|
String? _token;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
import_storage.FlutterSecureStorage().read(key: 'jwt_token').then((val) {
|
||||||
|
if (mounted) setState(() => _token = val);
|
||||||
|
});
|
||||||
|
|
||||||
if (widget.product != null) {
|
if (widget.product != null) {
|
||||||
final p = widget.product!;
|
final p = widget.product!;
|
||||||
_name = p.name;
|
_name = p.name;
|
||||||
_sku = p.sku ?? '';
|
_sku = p.sku ?? '';
|
||||||
|
_uomId = p.uomId;
|
||||||
_color = p.color ?? '';
|
_color = p.color ?? '';
|
||||||
_size = p.size ?? '';
|
_size = p.size ?? '';
|
||||||
_dimensions = p.dimensions ?? '';
|
_dimensions = p.dimensions ?? '';
|
||||||
@@ -73,6 +86,10 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
|||||||
_makingChargesType = p.makingChargesType ?? 'FLAT';
|
_makingChargesType = p.makingChargesType ?? 'FLAT';
|
||||||
_wastagePercentage = p.wastagePercentage ?? 0.0;
|
_wastagePercentage = p.wastagePercentage ?? 0.0;
|
||||||
|
|
||||||
|
if (p.imageIds.isNotEmpty) {
|
||||||
|
_existingImageIds.addAll(p.imageIds);
|
||||||
|
}
|
||||||
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
final cats = ref.read(productCategoriesProvider).value ?? [];
|
final cats = ref.read(productCategoriesProvider).value ?? [];
|
||||||
if (cats.isNotEmpty) {
|
if (cats.isNotEmpty) {
|
||||||
@@ -105,7 +122,7 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _pickImages() async {
|
Future<void> _pickImages() async {
|
||||||
if (_images.length >= 4) {
|
if ((_images.length + _existingImageIds.length) >= 4) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Maximum 4 images allowed')));
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Maximum 4 images allowed')));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -113,7 +130,7 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
|||||||
final List<XFile> picked = await picker.pickMultiImage();
|
final List<XFile> picked = await picker.pickMultiImage();
|
||||||
if (picked.isNotEmpty) {
|
if (picked.isNotEmpty) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_images.addAll(picked.take(4 - _images.length));
|
_images.addAll(picked.take(4 - (_images.length + _existingImageIds.length)));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -124,6 +141,30 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _previewImage(int index) {
|
||||||
|
List<ImageProvider> allImages = [];
|
||||||
|
for (final imageId in _existingImageIds) {
|
||||||
|
if (_token != null) {
|
||||||
|
allImages.add(NetworkImage('${DioClient().dio.options.baseUrl}/inventory/products/${widget.product!.id}/images/$imageId/content', headers: {'Authorization': 'Bearer $_token'}));
|
||||||
|
} else {
|
||||||
|
allImages.add(const AssetImage('assets/images/placeholder.png'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (final file in _images) {
|
||||||
|
allImages.add(FileImage(File(file.path)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => AttachmentGalleryScreen(
|
||||||
|
images: allImages,
|
||||||
|
initialIndex: index,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void _showAddCategoryDialog() {
|
void _showAddCategoryDialog() {
|
||||||
String newCatName = '';
|
String newCatName = '';
|
||||||
bool newCatCommodity = false;
|
bool newCatCommodity = false;
|
||||||
@@ -156,10 +197,12 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
|||||||
onChanged: (val) => setDialogState(() => newCatCommodity = val),
|
onChanged: (val) => setDialogState(() => newCatCommodity = val),
|
||||||
),
|
),
|
||||||
if (newCatCommodity) ...[
|
if (newCatCommodity) ...[
|
||||||
_buildPremiumDropdown(
|
// Replace DropdownButtonFormField with SmartSearchDropdown
|
||||||
label: 'Calculation Method',
|
SmartSearchDropdown<String>(
|
||||||
|
hintText: 'Calculation Method',
|
||||||
value: newCalcMethod,
|
value: newCalcMethod,
|
||||||
items: ['UNIT', 'WEIGHT', 'VOLUME'],
|
items: const ['UNIT', 'WEIGHT', 'VOLUME'],
|
||||||
|
itemAsString: (val) => val,
|
||||||
onChanged: (val) => setDialogState(() {
|
onChanged: (val) => setDialogState(() {
|
||||||
newCalcMethod = val!;
|
newCalcMethod = val!;
|
||||||
if (val == 'WEIGHT') newBaseUnit = 'gm';
|
if (val == 'WEIGHT') newBaseUnit = 'gm';
|
||||||
@@ -224,15 +267,18 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
|||||||
setState(() => _isSaving = true);
|
setState(() => _isSaving = true);
|
||||||
try {
|
try {
|
||||||
final product = Product(
|
final product = Product(
|
||||||
|
id: widget.product?.id,
|
||||||
name: _name,
|
name: _name,
|
||||||
sku: _sku.isNotEmpty ? _sku : null,
|
sku: _sku.isNotEmpty ? _sku : null,
|
||||||
|
categoryId: _selectedCategory?.id,
|
||||||
|
uomId: _uomId,
|
||||||
|
color: _color.isNotEmpty ? _color : null,
|
||||||
|
size: _size.isNotEmpty ? _size : null,
|
||||||
|
dimensions: _dimensions.isNotEmpty ? _dimensions : null,
|
||||||
purchasePrice: _purchasePrice,
|
purchasePrice: _purchasePrice,
|
||||||
sellingPrice: _autoCalculatePrice ? _calculateLivePrice() : _sellingPrice,
|
sellingPrice: _autoCalculatePrice ? _calculateLivePrice() : _sellingPrice,
|
||||||
gstRate: _gstRate,
|
gstRate: _gstRate,
|
||||||
weight: _weight,
|
weight: _weight,
|
||||||
color: _color,
|
|
||||||
size: _size,
|
|
||||||
dimensions: _dimensions,
|
|
||||||
priceCalcRule: _autoCalculatePrice ? 'COMMODITY' : 'MANUAL',
|
priceCalcRule: _autoCalculatePrice ? 'COMMODITY' : 'MANUAL',
|
||||||
autoCalculatePrice: _autoCalculatePrice,
|
autoCalculatePrice: _autoCalculatePrice,
|
||||||
purityFactor: _purityFactor,
|
purityFactor: _purityFactor,
|
||||||
@@ -258,9 +304,9 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
|||||||
double _calculateLivePrice() {
|
double _calculateLivePrice() {
|
||||||
if (_selectedCategory == null || !_selectedCategory!.isCommodity) return _sellingPrice;
|
if (_selectedCategory == null || !_selectedCategory!.isCommodity) return _sellingPrice;
|
||||||
double rate = _selectedCategory!.dailyRate ?? 0;
|
double rate = _selectedCategory!.dailyRate ?? 0;
|
||||||
double baseVal = (_selectedCategory!.calculationMethod == 'WEIGHT') ? _weight : 1.0;
|
double baseVal = (_weight > 0) ? _weight : 1.0;
|
||||||
|
|
||||||
// Base Material Cost = (Weight + Wastage) * Rate * Purity
|
// Base Material Cost = (Base Quantity + Wastage) * Rate * Purity
|
||||||
double materialWeight = baseVal + (baseVal * (_wastagePercentage / 100));
|
double materialWeight = baseVal + (baseVal * (_wastagePercentage / 100));
|
||||||
double materialCost = materialWeight * rate * _purityFactor;
|
double materialCost = materialWeight * rate * _purityFactor;
|
||||||
|
|
||||||
@@ -431,11 +477,12 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
_buildPremiumDropdown<ProductCategory>(
|
// Replace DropdownButtonFormField with SmartSearchDropdown
|
||||||
label: 'Select Category*',
|
SmartSearchDropdown<ProductCategory>(
|
||||||
|
hintText: 'Select Category*',
|
||||||
value: _selectedCategory,
|
value: _selectedCategory,
|
||||||
items: leafCategories,
|
items: leafCategories,
|
||||||
itemLabel: (c) {
|
itemAsString: (c) {
|
||||||
String displayName = c.name;
|
String displayName = c.name;
|
||||||
if (c.parentCategoryId != null) {
|
if (c.parentCategoryId != null) {
|
||||||
final parent = categories.firstWhere((p) => p.id == c.parentCategoryId, orElse: () => c);
|
final parent = categories.firstWhere((p) => p.id == c.parentCategoryId, orElse: () => c);
|
||||||
@@ -460,6 +507,31 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
|
const Text('Unit of Measure', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Consumer(
|
||||||
|
builder: (context, ref, child) {
|
||||||
|
final uomsState = ref.watch(uomsProvider);
|
||||||
|
return uomsState.when(
|
||||||
|
loading: () => const CircularProgressIndicator(),
|
||||||
|
error: (err, stack) => Text('Error loading UOMs: $err'),
|
||||||
|
data: (uoms) {
|
||||||
|
return SmartSearchDropdown<int>(
|
||||||
|
hintText: 'Select Unit of Measure (Optional)',
|
||||||
|
value: _uomId,
|
||||||
|
items: uoms.map((u) => u.id!).toList(),
|
||||||
|
itemAsString: (id) {
|
||||||
|
final uom = uoms.firstWhere((u) => u.id == id);
|
||||||
|
return '${uom.name} (${uom.abbreviation})';
|
||||||
|
},
|
||||||
|
onChanged: (val) => setState(() => _uomId = val),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -477,11 +549,11 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
|||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
_buildPremiumTextField(
|
_buildPremiumTextField(
|
||||||
label: 'Weight',
|
label: _selectedCategory?.isCommodity == true ? 'Volume / Weight' : 'Weight',
|
||||||
initialValue: _weight == 0 ? '' : _weight.toString(),
|
initialValue: _weight == 0 ? '' : _weight.toString(),
|
||||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
onChanged: (val) => setState(() => _weight = double.tryParse(val) ?? 0),
|
onChanged: (val) => setState(() => _weight = double.tryParse(val) ?? 0),
|
||||||
suffixText: _selectedCategory?.baseUnit ?? 'unit',
|
suffixText: _getUomAbbreviation() ?? _selectedCategory?.baseUnit ?? 'unit',
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
_buildPremiumTextField(
|
_buildPremiumTextField(
|
||||||
@@ -567,7 +639,7 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
|||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: 2,
|
flex: 1,
|
||||||
child: _buildPremiumTextField(
|
child: _buildPremiumTextField(
|
||||||
label: 'Making Charges',
|
label: 'Making Charges',
|
||||||
initialValue: _makingCharges.toString(),
|
initialValue: _makingCharges.toString(),
|
||||||
@@ -583,7 +655,7 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
|||||||
label: 'Type',
|
label: 'Type',
|
||||||
value: _makingChargesType,
|
value: _makingChargesType,
|
||||||
items: const ['FLAT', 'PER_UNIT', 'PERCENTAGE'],
|
items: const ['FLAT', 'PER_UNIT', 'PERCENTAGE'],
|
||||||
itemLabel: (t) => t == 'PER_UNIT' ? 'Per ${_selectedCategory!.baseUnit}' : t == 'PERCENTAGE' ? '%' : 'Flat',
|
itemLabel: (t) => t == 'PER_UNIT' ? 'Per ${_selectedCategory?.baseUnit ?? "Unit"}' : t == 'PERCENTAGE' ? 'Percentage' : 'Flat',
|
||||||
onChanged: (val) => setState(() => _makingChargesType = val!),
|
onChanged: (val) => setState(() => _makingChargesType = val!),
|
||||||
darkTheme: true,
|
darkTheme: true,
|
||||||
),
|
),
|
||||||
@@ -666,7 +738,7 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
|||||||
const Text('Upload up to 4 high-quality images.', style: TextStyle(color: Colors.grey)),
|
const Text('Upload up to 4 high-quality images.', style: TextStyle(color: Colors.grey)),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
if (_images.isNotEmpty)
|
if (_images.isNotEmpty || _existingImageIds.isNotEmpty)
|
||||||
GridView.builder(
|
GridView.builder(
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
@@ -676,46 +748,42 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
|||||||
mainAxisSpacing: 16,
|
mainAxisSpacing: 16,
|
||||||
childAspectRatio: 1,
|
childAspectRatio: 1,
|
||||||
),
|
),
|
||||||
itemCount: _images.length,
|
itemCount: _images.length + _existingImageIds.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
return Stack(
|
if (index < _existingImageIds.length) {
|
||||||
fit: StackFit.expand,
|
final imageId = _existingImageIds[index];
|
||||||
children: [
|
final imageUrl = '${DioClient().dio.options.baseUrl}/inventory/products/${widget.product!.id}/images/$imageId/content';
|
||||||
Container(
|
return _buildImageThumbnail(
|
||||||
decoration: BoxDecoration(
|
isNetwork: true,
|
||||||
borderRadius: BorderRadius.circular(20),
|
url: imageUrl,
|
||||||
border: Border.all(color: Colors.grey.withOpacity(0.2)),
|
onTap: () => _previewImage(index),
|
||||||
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.05), blurRadius: 10, offset: const Offset(0, 4))],
|
onDelete: () async {
|
||||||
),
|
try {
|
||||||
child: ClipRRect(
|
setState(() => _isSaving = true);
|
||||||
borderRadius: BorderRadius.circular(20),
|
await DioClient().dio.delete('/inventory/products/images/$imageId');
|
||||||
child: Container(
|
setState(() => _existingImageIds.removeAt(index));
|
||||||
color: Colors.grey[200],
|
// Update product list in background
|
||||||
child: Image.file(
|
ref.read(productsProvider.notifier).refresh();
|
||||||
File(_images[index].path),
|
} catch (e) {
|
||||||
fit: BoxFit.cover,
|
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to delete image: $e')));
|
||||||
),
|
} finally {
|
||||||
),
|
if (mounted) setState(() => _isSaving = false);
|
||||||
),
|
}
|
||||||
),
|
}
|
||||||
Positioned(
|
);
|
||||||
top: 8,
|
} else {
|
||||||
right: 8,
|
final localIndex = index - _existingImageIds.length;
|
||||||
child: GestureDetector(
|
return _buildImageThumbnail(
|
||||||
onTap: () => _removeImage(index),
|
isNetwork: false,
|
||||||
child: Container(
|
file: File(_images[localIndex].path),
|
||||||
decoration: BoxDecoration(color: Colors.black.withOpacity(0.6), shape: BoxShape.circle),
|
onTap: () => _previewImage(index),
|
||||||
padding: const EdgeInsets.all(6),
|
onDelete: () => _removeImage(localIndex)
|
||||||
child: const Icon(LucideIcons.x, color: Colors.white, size: 16),
|
);
|
||||||
),
|
}
|
||||||
),
|
|
||||||
)
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
if (_images.length < 4)
|
if ((_images.length + _existingImageIds.length) < 4)
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: _pickImages,
|
onTap: _pickImages,
|
||||||
child: Container(
|
child: Container(
|
||||||
@@ -731,7 +799,7 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
|||||||
Icon(LucideIcons.uploadCloud, size: 48, color: Theme.of(context).colorScheme.primary),
|
Icon(LucideIcons.uploadCloud, size: 48, color: Theme.of(context).colorScheme.primary),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const Text('Tap to Upload', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
const Text('Tap to Upload', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||||
Text('${4 - _images.length} slots remaining', style: const TextStyle(color: Colors.grey)),
|
Text('${4 - (_images.length + _existingImageIds.length)} slots remaining', style: const TextStyle(color: Colors.grey)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -741,6 +809,55 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildImageThumbnail({required bool isNetwork, String? url, File? file, required VoidCallback onDelete, required VoidCallback onTap}) {
|
||||||
|
return Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(color: Colors.grey.withOpacity(0.2)),
|
||||||
|
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.05), blurRadius: 10, offset: const Offset(0, 4))],
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
child: Container(
|
||||||
|
color: Colors.grey[200],
|
||||||
|
child: isNetwork
|
||||||
|
? (_token == null
|
||||||
|
? const Icon(LucideIcons.image, color: Colors.grey)
|
||||||
|
: Image.network(
|
||||||
|
url!,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
headers: {'Authorization': 'Bearer $_token'},
|
||||||
|
errorBuilder: (ctx, err, stack) => const Icon(LucideIcons.imageOff, color: Colors.grey),
|
||||||
|
))
|
||||||
|
: Image.file(
|
||||||
|
file!,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
top: 8,
|
||||||
|
right: 8,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: onDelete,
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(color: Colors.black.withOpacity(0.6), shape: BoxShape.circle),
|
||||||
|
padding: const EdgeInsets.all(6),
|
||||||
|
child: const Icon(LucideIcons.x, color: Colors.white, size: 16),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ==== WIDGET HELPERS ====
|
// ==== WIDGET HELPERS ====
|
||||||
|
|
||||||
Widget _buildPremiumTextField({
|
Widget _buildPremiumTextField({
|
||||||
@@ -787,26 +904,50 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
|||||||
required void Function(T?) onChanged,
|
required void Function(T?) onChanged,
|
||||||
bool darkTheme = false,
|
bool darkTheme = false,
|
||||||
}) {
|
}) {
|
||||||
return DropdownButtonFormField<T>(
|
return Theme(
|
||||||
decoration: InputDecoration(
|
data: darkTheme ? Theme.of(context).copyWith(
|
||||||
labelText: label,
|
textTheme: Theme.of(context).textTheme.apply(bodyColor: Colors.white, displayColor: Colors.white),
|
||||||
labelStyle: TextStyle(color: darkTheme ? Colors.white70 : Colors.grey[600]),
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
filled: true,
|
labelStyle: const TextStyle(color: Colors.white70),
|
||||||
fillColor: darkTheme ? Colors.black26 : Colors.grey[100],
|
filled: true,
|
||||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
|
fillColor: Colors.black26,
|
||||||
focusedBorder: OutlineInputBorder(
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
|
||||||
borderRadius: BorderRadius.circular(16),
|
focusedBorder: OutlineInputBorder(
|
||||||
borderSide: BorderSide(color: darkTheme ? Colors.white : Theme.of(context).colorScheme.primary, width: 2)
|
borderRadius: BorderRadius.circular(16),
|
||||||
),
|
borderSide: const BorderSide(color: Colors.white, width: 2)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
) : Theme.of(context).copyWith(
|
||||||
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
|
labelStyle: TextStyle(color: Colors.grey[600]),
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.grey[100],
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
borderSide: BorderSide(color: Theme.of(context).colorScheme.primary, width: 2)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
child: SmartSearchDropdown<T>(
|
||||||
|
hintText: label,
|
||||||
|
value: value,
|
||||||
|
items: items,
|
||||||
|
itemAsString: (e) => itemLabel != null ? itemLabel(e) : e.toString(),
|
||||||
|
onChanged: onChanged,
|
||||||
),
|
),
|
||||||
dropdownColor: darkTheme ? Colors.blue.shade900 : null,
|
|
||||||
style: TextStyle(color: darkTheme ? Colors.white : Colors.black87, fontSize: 16),
|
|
||||||
value: value,
|
|
||||||
items: items.map((e) => DropdownMenuItem(
|
|
||||||
value: e,
|
|
||||||
child: Text(itemLabel != null ? itemLabel(e) : e.toString()),
|
|
||||||
)).toList(),
|
|
||||||
onChanged: onChanged,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String? _getUomAbbreviation() {
|
||||||
|
if (_uomId == null) return null;
|
||||||
|
final uomsState = ref.read(uomsProvider).value;
|
||||||
|
if (uomsState == null) return null;
|
||||||
|
try {
|
||||||
|
final uom = uomsState.firstWhere((u) => u.id == _uomId);
|
||||||
|
return uom.abbreviation;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
191
kifi-app/lib/features/inventory/presentation/bom_tab.dart
Normal file
191
kifi-app/lib/features/inventory/presentation/bom_tab.dart
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:kifi_app/core/widgets/smart_search_dropdown.dart';
|
||||||
|
import 'package:kifi_app/features/inventory/providers/products_provider.dart';
|
||||||
|
import 'package:kifi_app/features/inventory/domain/product_bom.dart';
|
||||||
|
import 'package:kifi_app/features/inventory/domain/product.dart';
|
||||||
|
|
||||||
|
class BomTab extends ConsumerStatefulWidget {
|
||||||
|
final int productId;
|
||||||
|
|
||||||
|
const BomTab({super.key, required this.productId});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<BomTab> createState() => _BomTabState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BomTabState extends ConsumerState<BomTab> {
|
||||||
|
bool _isLoading = true;
|
||||||
|
List<ProductBom> _bomItems = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadBom();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadBom() async {
|
||||||
|
setState(() => _isLoading = true);
|
||||||
|
try {
|
||||||
|
final items = await ref.read(productsProvider.notifier).fetchProductBom(widget.productId);
|
||||||
|
setState(() {
|
||||||
|
_bomItems = items;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error loading BOM: $e')));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _isLoading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showAddComponentSheet() {
|
||||||
|
final productsState = ref.read(productsProvider);
|
||||||
|
final availableProducts = (productsState.value ?? []).where((p) => p.id != widget.productId).toList();
|
||||||
|
|
||||||
|
Product? selectedProduct;
|
||||||
|
final qtyCtrl = TextEditingController(text: '1');
|
||||||
|
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (context) => StatefulBuilder(
|
||||||
|
builder: (context, setSheetState) {
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||||
|
left: 24,
|
||||||
|
right: 24,
|
||||||
|
top: 24,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text("Add Component", style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
SmartSearchDropdown<Product>(
|
||||||
|
hintText: 'Select Product',
|
||||||
|
value: selectedProduct,
|
||||||
|
items: availableProducts,
|
||||||
|
itemAsString: (p) => p.name,
|
||||||
|
onChanged: (val) {
|
||||||
|
setSheetState(() {
|
||||||
|
selectedProduct = val;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextField(
|
||||||
|
controller: qtyCtrl,
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Quantity Required',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: () async {
|
||||||
|
if (selectedProduct == null) return;
|
||||||
|
final qty = double.tryParse(qtyCtrl.text) ?? 1.0;
|
||||||
|
|
||||||
|
final bomItem = ProductBom(
|
||||||
|
parentProductId: widget.productId,
|
||||||
|
componentProductId: selectedProduct!.id!,
|
||||||
|
quantity: qty,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ref.read(productsProvider.notifier).addBomItem(widget.productId, bomItem);
|
||||||
|
if (context.mounted) Navigator.pop(context);
|
||||||
|
_loadBom();
|
||||||
|
} catch (e) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error adding component: $e')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
backgroundColor: Colors.blue,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
child: const Text('Add to BOM'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (_isLoading) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
final productsState = ref.watch(productsProvider);
|
||||||
|
final allProducts = productsState.value ?? [];
|
||||||
|
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
if (_bomItems.isEmpty)
|
||||||
|
const Center(child: Text("No components added yet.", style: TextStyle(color: Colors.grey)))
|
||||||
|
else
|
||||||
|
ListView.builder(
|
||||||
|
padding: const EdgeInsets.all(16).copyWith(bottom: 80),
|
||||||
|
itemCount: _bomItems.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final item = _bomItems[index];
|
||||||
|
final component = allProducts.firstWhere(
|
||||||
|
(p) => p.id == item.componentProductId,
|
||||||
|
orElse: () => Product(name: 'Unknown Product', priceCalcRule: 'MANUAL'),
|
||||||
|
);
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
child: ListTile(
|
||||||
|
title: Text(component.name, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
subtitle: Text('Quantity Required: ${item.quantity}'),
|
||||||
|
trailing: IconButton(
|
||||||
|
icon: const Icon(Icons.delete, color: Colors.red),
|
||||||
|
onPressed: () async {
|
||||||
|
try {
|
||||||
|
await ref.read(productsProvider.notifier).deleteBomItem(item.id!);
|
||||||
|
_loadBom();
|
||||||
|
} catch (e) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error deleting component: $e')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
Positioned(
|
||||||
|
bottom: 90,
|
||||||
|
right: 16,
|
||||||
|
child: FloatingActionButton.extended(
|
||||||
|
heroTag: 'add_bom_btn',
|
||||||
|
onPressed: _showAddComponentSheet,
|
||||||
|
backgroundColor: Colors.indigo,
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
label: const Text("Add Component"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import '../providers/product_categories_provider.dart';
|
||||||
|
import '../domain/category_rate_history.dart';
|
||||||
|
|
||||||
|
class DailyRatesScreen extends ConsumerStatefulWidget {
|
||||||
|
const DailyRatesScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<DailyRatesScreen> createState() => _DailyRatesScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DailyRatesScreenState extends ConsumerState<DailyRatesScreen> {
|
||||||
|
final Map<int, TextEditingController> _rateControllers = {};
|
||||||
|
final Map<int, bool> _isExpanded = {};
|
||||||
|
final Map<int, List<CategoryRateHistory>> _historyCache = {};
|
||||||
|
final Map<int, bool> _isLoadingHistory = {};
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
for (var controller in _rateControllers.values) {
|
||||||
|
controller.dispose();
|
||||||
|
}
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchHistory(int categoryId) async {
|
||||||
|
setState(() => _isLoadingHistory[categoryId] = true);
|
||||||
|
final historyData = await ref.read(productCategoriesProvider.notifier).fetchRateHistory(categoryId);
|
||||||
|
setState(() {
|
||||||
|
_historyCache[categoryId] = historyData.map((e) => CategoryRateHistory.fromJson(e)).toList();
|
||||||
|
_isLoadingHistory[categoryId] = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleExpand(int categoryId) {
|
||||||
|
setState(() {
|
||||||
|
_isExpanded[categoryId] = !(_isExpanded[categoryId] ?? false);
|
||||||
|
});
|
||||||
|
if (_isExpanded[categoryId] == true && _historyCache[categoryId] == null) {
|
||||||
|
_fetchHistory(categoryId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveRate(int categoryId) async {
|
||||||
|
final text = _rateControllers[categoryId]?.text;
|
||||||
|
if (text == null || text.isEmpty) return;
|
||||||
|
|
||||||
|
final rate = double.tryParse(text);
|
||||||
|
if (rate == null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (_) => const Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
|
|
||||||
|
await ref.read(productCategoriesProvider.notifier).updateCategoryRate(categoryId, rate);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
Navigator.pop(context); // close loading
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Rate updated successfully!'), backgroundColor: Colors.green),
|
||||||
|
);
|
||||||
|
_fetchHistory(categoryId); // refresh history
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
Navigator.pop(context);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Error updating rate: $e'), backgroundColor: Colors.red),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _syncRates(int categoryId) async {
|
||||||
|
try {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (_) => const Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
|
|
||||||
|
final count = await ref.read(productCategoriesProvider.notifier).syncRates(categoryId);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
Navigator.pop(context); // close loading
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Successfully synced rates for $count products!'), backgroundColor: Colors.green),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
Navigator.pop(context);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Error syncing rates: $e'), backgroundColor: Colors.red),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final categoriesState = ref.watch(productCategoriesProvider);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.grey[50],
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Daily Commodity Rates', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20)),
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
foregroundColor: Colors.black,
|
||||||
|
elevation: 0,
|
||||||
|
centerTitle: true,
|
||||||
|
),
|
||||||
|
body: categoriesState.when(
|
||||||
|
data: (categories) {
|
||||||
|
final commodities = categories.where((c) => c.isCommodity).toList();
|
||||||
|
|
||||||
|
if (commodities.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(LucideIcons.boxes, size: 64, color: Colors.grey.shade300),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text('No Commodity Categories', style: TextStyle(fontSize: 18, color: Colors.grey.shade600)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text('Mark a category as a commodity to manage its daily rates.', style: TextStyle(color: Colors.grey.shade500)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListView.builder(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
itemCount: commodities.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final category = commodities[index];
|
||||||
|
if (!_rateControllers.containsKey(category.id)) {
|
||||||
|
_rateControllers[category.id!] = TextEditingController(text: category.dailyRate?.toString() ?? '');
|
||||||
|
}
|
||||||
|
final controller = _rateControllers[category.id]!;
|
||||||
|
final isExpanded = _isExpanded[category.id] ?? false;
|
||||||
|
final history = _historyCache[category.id];
|
||||||
|
final isLoadingHistory = _isLoadingHistory[category.id] ?? false;
|
||||||
|
|
||||||
|
DateTime? lastSyncDate;
|
||||||
|
if (history != null && history.isNotEmpty) {
|
||||||
|
lastSyncDate = history.first.updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(color: Colors.grey.withOpacity(0.05), blurRadius: 10, offset: const Offset(0, 4)),
|
||||||
|
],
|
||||||
|
border: Border.all(color: Colors.grey.shade200),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// Main Card Header
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.blue.shade50,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Icon(LucideIcons.trendingUp, color: Colors.blue.shade700, size: 24),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
category.name,
|
||||||
|
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
if (lastSyncDate != null)
|
||||||
|
Text(
|
||||||
|
'Last updated: ${DateFormat('MMM dd, yyyy HH:mm').format(lastSyncDate)}',
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.grey.shade500),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Text(
|
||||||
|
'Base Unit: ${category.baseUnit}',
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.grey.shade500),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(isExpanded ? LucideIcons.chevronUp : LucideIcons.history, color: Colors.grey.shade600),
|
||||||
|
onPressed: () => _toggleExpand(category.id!),
|
||||||
|
tooltip: 'View History',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
flex: 3,
|
||||||
|
child: TextFormField(
|
||||||
|
controller: controller,
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Today\'s Rate (per ${category.baseUnit})',
|
||||||
|
prefixText: '₹ ',
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: () => _saveRate(category.id!),
|
||||||
|
icon: const Icon(LucideIcons.save, size: 18),
|
||||||
|
label: const Text('Save'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.blue.shade700,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: () => _syncRates(category.id!),
|
||||||
|
icon: Icon(LucideIcons.refreshCw, size: 18, color: Colors.blue.shade700),
|
||||||
|
label: const Text('Sync Rate to All Products', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: Colors.blue.shade700,
|
||||||
|
side: BorderSide(color: Colors.blue.shade700),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Expandable History Section
|
||||||
|
if (isExpanded)
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade50,
|
||||||
|
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(16)),
|
||||||
|
border: Border(top: BorderSide(color: Colors.grey.shade200)),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text('Rate History', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
if (isLoadingHistory)
|
||||||
|
const Center(child: Padding(padding: EdgeInsets.all(16), child: CircularProgressIndicator()))
|
||||||
|
else if (history == null || history.isEmpty)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.all(16.0),
|
||||||
|
child: Text('No history found.'),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
ListView.separated(
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
itemCount: history.length > 5 ? 5 : history.length, // Show up to 5 recent
|
||||||
|
separatorBuilder: (_, __) => Divider(color: Colors.grey.shade300),
|
||||||
|
itemBuilder: (context, idx) {
|
||||||
|
final item = history[idx];
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(DateFormat('MMM dd, yyyy').format(item.date), style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||||
|
Text('₹ ${item.rate.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (err, stack) => Center(child: Text('Error: $err')),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:kifi_app/features/inventory/providers/products_provider.dart';
|
||||||
|
import 'package:kifi_app/core/network/dio_client.dart';
|
||||||
|
import 'package:kifi_app/features/inventory/domain/product.dart';
|
||||||
|
import 'package:kifi_app/features/inventory/presentation/add_product_screen.dart';
|
||||||
|
import 'package:kifi_app/features/inventory/presentation/stock_ledger_tab.dart';
|
||||||
|
import 'package:kifi_app/features/inventory/presentation/bom_tab.dart';
|
||||||
|
import 'package:kifi_app/features/inventory/presentation/widgets/adjust_stock_sheet.dart';
|
||||||
|
|
||||||
|
class ProductDetailScreen extends ConsumerWidget {
|
||||||
|
final Product product;
|
||||||
|
|
||||||
|
const ProductDetailScreen({super.key, required this.product});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
// Watch products to get latest updates (like stock changes)
|
||||||
|
final productsState = ref.watch(productsProvider);
|
||||||
|
final currentProduct = productsState.value?.firstWhere((p) => p.id == product.id, orElse: () => product) ?? product;
|
||||||
|
|
||||||
|
return DefaultTabController(
|
||||||
|
length: 3,
|
||||||
|
child: Scaffold(
|
||||||
|
backgroundColor: Colors.grey[50],
|
||||||
|
appBar: AppBar(
|
||||||
|
elevation: 0,
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
foregroundColor: Colors.black,
|
||||||
|
title: Text(currentProduct.name, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.edit, color: Colors.black),
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => AddProductScreen(product: currentProduct),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
bottom: const TabBar(
|
||||||
|
labelColor: Colors.blue,
|
||||||
|
unselectedLabelColor: Colors.grey,
|
||||||
|
indicatorColor: Colors.blue,
|
||||||
|
tabs: [
|
||||||
|
Tab(text: "Overview"),
|
||||||
|
Tab(text: "BOM"),
|
||||||
|
Tab(text: "Stock Ledger"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: TabBarView(
|
||||||
|
children: [
|
||||||
|
_buildOverviewTab(context, currentProduct, ref),
|
||||||
|
BomTab(productId: currentProduct.id!),
|
||||||
|
StockLedgerTab(productId: currentProduct.id!),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
floatingActionButton: FloatingActionButton.extended(
|
||||||
|
onPressed: () {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (context) => AdjustStockSheet(productId: currentProduct.id!),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
backgroundColor: Colors.blue,
|
||||||
|
icon: const Icon(Icons.inventory),
|
||||||
|
label: const Text("Adjust Stock"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildOverviewTab(BuildContext context, Product p, WidgetRef ref) {
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Image Header
|
||||||
|
if (p.imageIds.isNotEmpty)
|
||||||
|
Container(
|
||||||
|
height: 200,
|
||||||
|
width: double.infinity,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
color: Colors.grey[200],
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: FutureBuilder<String?>(
|
||||||
|
future: DioClient().storage.read(key: 'jwt_token'),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
final token = snapshot.data;
|
||||||
|
if (token == null) {
|
||||||
|
return const Icon(Icons.image, size: 50, color: Colors.grey);
|
||||||
|
}
|
||||||
|
return Image.network(
|
||||||
|
'${DioClient().dio.options.baseUrl}/inventory/products/${p.id}/images/${p.imageIds.first}/content',
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
headers: {'Authorization': 'Bearer $token'},
|
||||||
|
errorBuilder: (context, error, stackTrace) => const Icon(Icons.image, size: 50, color: Colors.grey),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Stock Summary Card
|
||||||
|
Card(
|
||||||
|
elevation: 0,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.blue.withOpacity(0.1),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.inventory_2, color: Colors.blue),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text("Current Stock", style: TextStyle(color: Colors.grey[600], fontSize: 14)),
|
||||||
|
Text(
|
||||||
|
"${p.currentStock ?? 0}",
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 24),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Details Card
|
||||||
|
Card(
|
||||||
|
elevation: 0,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text("Product Details", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
||||||
|
const Divider(),
|
||||||
|
_buildDetailRow("SKU", p.sku ?? "-"),
|
||||||
|
_buildDetailRow("Purchase Price", "₹${p.purchasePrice?.toStringAsFixed(2) ?? '0.00'}"),
|
||||||
|
_buildDetailRow("Selling Price", "₹${p.sellingPrice?.toStringAsFixed(2) ?? '0.00'}"),
|
||||||
|
_buildDetailRow("GST Rate", "${p.gstRate?.toStringAsFixed(1) ?? '0'}%"),
|
||||||
|
_buildDetailRow("Reorder Level", "${p.reorderLevel ?? 0}"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDetailRow(String label, String value) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(label, style: TextStyle(color: Colors.grey[600])),
|
||||||
|
Text(value, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'dart:async';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:lucide_icons/lucide_icons.dart';
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
import '../providers/products_provider.dart';
|
import '../providers/products_provider.dart';
|
||||||
import '../providers/product_categories_provider.dart';
|
import '../providers/product_categories_provider.dart';
|
||||||
import 'add_product_screen.dart';
|
import 'add_product_screen.dart';
|
||||||
|
import 'product_detail_screen.dart';
|
||||||
|
import 'daily_rates_screen.dart';
|
||||||
import '../../../core/network/dio_client.dart';
|
import '../../../core/network/dio_client.dart';
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
|
||||||
@@ -17,7 +20,7 @@ class ProductListScreen extends ConsumerStatefulWidget {
|
|||||||
class _ProductListScreenState extends ConsumerState<ProductListScreen> {
|
class _ProductListScreenState extends ConsumerState<ProductListScreen> {
|
||||||
final TextEditingController _searchController = TextEditingController();
|
final TextEditingController _searchController = TextEditingController();
|
||||||
final ScrollController _scrollController = ScrollController();
|
final ScrollController _scrollController = ScrollController();
|
||||||
String _searchQuery = '';
|
Timer? _debounce;
|
||||||
String? _token;
|
String? _token;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -36,6 +39,7 @@ class _ProductListScreenState extends ConsumerState<ProductListScreen> {
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
_scrollController.dispose();
|
_scrollController.dispose();
|
||||||
_searchController.dispose();
|
_searchController.dispose();
|
||||||
|
_debounce?.cancel();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +58,18 @@ class _ProductListScreenState extends ConsumerState<ProductListScreen> {
|
|||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Products Catalog'),
|
title: const Text('Products Catalog'),
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
backgroundColor: Colors.transparent,
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(LucideIcons.trendingUp),
|
||||||
|
tooltip: 'Daily Commodity Rates',
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (_) => const DailyRatesScreen()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
@@ -73,8 +88,9 @@ class _ProductListScreenState extends ConsumerState<ProductListScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
onChanged: (val) {
|
onChanged: (val) {
|
||||||
setState(() {
|
if (_debounce?.isActive ?? false) _debounce!.cancel();
|
||||||
_searchQuery = val.toLowerCase();
|
_debounce = Timer(const Duration(milliseconds: 500), () {
|
||||||
|
ref.read(productsProvider.notifier).search(val);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -84,12 +100,11 @@ class _ProductListScreenState extends ConsumerState<ProductListScreen> {
|
|||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
error: (err, stack) => Center(child: Text('Error: $err')),
|
error: (err, stack) => Center(child: Text('Error: $err')),
|
||||||
data: (products) {
|
data: (products) {
|
||||||
final filtered = products.where((p) => p.name.toLowerCase().contains(_searchQuery) || (p.sku != null && p.sku!.toLowerCase().contains(_searchQuery))).toList();
|
if (products.isEmpty) {
|
||||||
|
|
||||||
if (filtered.isEmpty) {
|
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
onRefresh: () => ref.read(productsProvider.notifier).refresh(),
|
onRefresh: () => ref.read(productsProvider.notifier).refresh(),
|
||||||
child: ListView(
|
child: ListView(
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
children: const [
|
children: const [
|
||||||
SizedBox(height: 100),
|
SizedBox(height: 100),
|
||||||
Center(child: Text('No products found.', style: TextStyle(color: Colors.grey)))
|
Center(child: Text('No products found.', style: TextStyle(color: Colors.grey)))
|
||||||
@@ -102,17 +117,15 @@ class _ProductListScreenState extends ConsumerState<ProductListScreen> {
|
|||||||
onRefresh: () => ref.read(productsProvider.notifier).refresh(),
|
onRefresh: () => ref.read(productsProvider.notifier).refresh(),
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
controller: _scrollController,
|
controller: _scrollController,
|
||||||
itemCount: filtered.length + 1, // +1 for loading indicator
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
itemCount: products.length + 1, // +1 for loading indicator
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
if (index == filtered.length) {
|
if (index == products.length) {
|
||||||
// We reached the end of the filtered list, show a loader if we are loading more
|
|
||||||
// The notifier state doesn't expose _isLoadingMore cleanly without another property,
|
|
||||||
// but if we are at the end, we can just return a tiny spacer.
|
|
||||||
return const SizedBox(height: 80);
|
return const SizedBox(height: 80);
|
||||||
}
|
}
|
||||||
|
|
||||||
final p = filtered[index];
|
final p = products[index];
|
||||||
final catName = categoriesState.value?.firstWhere((c) => c.id == p.categoryId, orElse: () => categoriesState.value!.first).name ?? 'No Category';
|
final catName = categoriesState.value?.firstWhere((c) => c.id == p.categoryId, orElse: () => categoriesState.value!.first).name ?? 'No Category';
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
@@ -122,7 +135,7 @@ class _ProductListScreenState extends ConsumerState<ProductListScreen> {
|
|||||||
child: InkWell(
|
child: InkWell(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.push(context, MaterialPageRoute(builder: (_) => AddProductScreen(product: p)));
|
Navigator.push(context, MaterialPageRoute(builder: (_) => ProductDetailScreen(product: p)));
|
||||||
},
|
},
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(12.0),
|
padding: const EdgeInsets.all(12.0),
|
||||||
@@ -162,7 +175,7 @@ class _ProductListScreenState extends ConsumerState<ProductListScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
p.trackInventory ? 'Stock: 0' : 'Untracked', // We'll link stock later
|
p.trackInventory ? 'Stock: ${p.currentStock ?? 0}' : 'Untracked',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: p.trackInventory ? Colors.orange : Colors.grey,
|
color: p.trackInventory ? Colors.orange : Colors.grey,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
|
import 'package:kifi_app/features/inventory/providers/products_provider.dart';
|
||||||
|
import 'package:kifi_app/features/inventory/presentation/widgets/adjust_stock_sheet.dart';
|
||||||
|
import 'package:kifi_app/core/network/dio_client.dart';
|
||||||
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
|
||||||
|
class QuickAdjustStockScreen extends ConsumerStatefulWidget {
|
||||||
|
const QuickAdjustStockScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<QuickAdjustStockScreen> createState() => _QuickAdjustStockScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _QuickAdjustStockScreenState extends ConsumerState<QuickAdjustStockScreen> {
|
||||||
|
final TextEditingController _searchController = TextEditingController();
|
||||||
|
final ScrollController _scrollController = ScrollController();
|
||||||
|
Timer? _debounce;
|
||||||
|
String? _token;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_scrollController.addListener(_onScroll);
|
||||||
|
_loadToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onScroll() {
|
||||||
|
if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) {
|
||||||
|
ref.read(productsProvider.notifier).fetchNextPage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadToken() async {
|
||||||
|
final token = await const FlutterSecureStorage().read(key: 'jwt_token');
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _token = token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_scrollController.dispose();
|
||||||
|
_searchController.dispose();
|
||||||
|
_debounce?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final productsState = ref.watch(productsProvider);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Quick Adjust Stock'),
|
||||||
|
elevation: 0,
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: TextField(
|
||||||
|
controller: _searchController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Search product to adjust...',
|
||||||
|
prefixIcon: const Icon(LucideIcons.search),
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.grey.shade100,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
),
|
||||||
|
onChanged: (val) {
|
||||||
|
if (_debounce?.isActive ?? false) _debounce!.cancel();
|
||||||
|
_debounce = Timer(const Duration(milliseconds: 500), () {
|
||||||
|
ref.read(productsProvider.notifier).search(val);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: productsState.when(
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (error, stack) => Center(child: Text('Error: $error')),
|
||||||
|
data: (products) {
|
||||||
|
if (products.isEmpty) {
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: () => ref.read(productsProvider.notifier).refresh(),
|
||||||
|
child: ListView(
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
children: const [
|
||||||
|
SizedBox(height: 100),
|
||||||
|
Center(child: Text('No products found.')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: () => ref.read(productsProvider.notifier).refresh(),
|
||||||
|
child: ListView.builder(
|
||||||
|
controller: _scrollController,
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
itemCount: products.length + 1,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
if (index == products.length) {
|
||||||
|
return const SizedBox(height: 80);
|
||||||
|
}
|
||||||
|
final p = products[index];
|
||||||
|
return Card(
|
||||||
|
elevation: 0,
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
side: BorderSide(color: Colors.grey.shade200),
|
||||||
|
),
|
||||||
|
child: ListTile(
|
||||||
|
contentPadding: const EdgeInsets.all(12),
|
||||||
|
leading: _buildProductImage(p),
|
||||||
|
title: Text(p.name, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
subtitle: Text(p.sku ?? 'No SKU', style: TextStyle(color: Colors.grey.shade600)),
|
||||||
|
trailing: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'${p.currentStock ?? 0}',
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18, color: Colors.blue),
|
||||||
|
),
|
||||||
|
const Text('in stock', style: TextStyle(fontSize: 10, color: Colors.grey)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (context) => AdjustStockSheet(productId: p.id!),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildProductImage(product) {
|
||||||
|
if (product.imageIds.isEmpty) {
|
||||||
|
return Container(
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: const Icon(LucideIcons.package, color: Colors.grey),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final imageUrl = '${DioClient().dio.options.baseUrl}/inventory/products/${product.id}/images/${product.imageIds.first}/content';
|
||||||
|
|
||||||
|
return ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Container(
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
color: Colors.grey[200],
|
||||||
|
child: _token == null
|
||||||
|
? const Icon(LucideIcons.image, color: Colors.grey)
|
||||||
|
: Image.network(
|
||||||
|
imageUrl,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
headers: {'Authorization': 'Bearer $_token'},
|
||||||
|
errorBuilder: (context, error, stackTrace) => const Icon(LucideIcons.imageOff, color: Colors.grey),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:kifi_app/features/inventory/domain/stock_movement.dart';
|
||||||
|
import 'package:kifi_app/features/inventory/providers/products_provider.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
class StockLedgerTab extends ConsumerStatefulWidget {
|
||||||
|
final int productId;
|
||||||
|
|
||||||
|
const StockLedgerTab({super.key, required this.productId});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<StockLedgerTab> createState() => _StockLedgerTabState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
|
||||||
|
List<StockMovement>? _ledger;
|
||||||
|
bool _isLoading = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_fetchLedger();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchLedger() async {
|
||||||
|
try {
|
||||||
|
final ledger = await ref.read(productsProvider.notifier).fetchStockLedger(widget.productId);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_ledger = ledger;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (_isLoading) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_ledger == null || _ledger!.isEmpty) {
|
||||||
|
return const Center(child: Text("No stock movements recorded."));
|
||||||
|
}
|
||||||
|
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: _fetchLedger,
|
||||||
|
child: ListView.builder(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
itemCount: _ledger!.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final movement = _ledger![index];
|
||||||
|
final isAddition = movement.type == 'ADDITION' || movement.type == 'OPENING';
|
||||||
|
final qty = movement.items?.isNotEmpty == true ? movement.items!.first.quantity : 0.0;
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
elevation: 0,
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
child: ListTile(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
leading: Container(
|
||||||
|
padding: const EdgeInsets.all(10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isAddition ? Colors.green.withOpacity(0.1) : Colors.red.withOpacity(0.1),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
isAddition ? Icons.arrow_downward : Icons.arrow_upward,
|
||||||
|
color: isAddition ? Colors.green : Colors.red,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text(movement.type, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
subtitle: Text(
|
||||||
|
movement.createdAt != null ? DateFormat('dd MMM yyyy, hh:mm a').format(movement.createdAt!) : '',
|
||||||
|
style: TextStyle(color: Colors.grey[600]),
|
||||||
|
),
|
||||||
|
trailing: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'${isAddition ? '+' : '-'}${qty.toStringAsFixed(1)}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 16,
|
||||||
|
color: isAddition ? Colors.green : Colors.red,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (movement.notes != null && movement.notes!.isNotEmpty)
|
||||||
|
Text(
|
||||||
|
movement.notes!,
|
||||||
|
style: TextStyle(color: Colors.grey[500], fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
|
import '../providers/uoms_provider.dart';
|
||||||
|
import 'widgets/add_uom_sheet.dart';
|
||||||
|
|
||||||
|
class UomsListScreen extends ConsumerStatefulWidget {
|
||||||
|
const UomsListScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<UomsListScreen> createState() => _UomsListScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _UomsListScreenState extends ConsumerState<UomsListScreen> {
|
||||||
|
final TextEditingController _searchController = TextEditingController();
|
||||||
|
Timer? _debounce;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_searchController.dispose();
|
||||||
|
_debounce?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final uomsState = ref.watch(uomsProvider);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Units of Measure'),
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
foregroundColor: Colors.black,
|
||||||
|
elevation: 0,
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: TextField(
|
||||||
|
controller: _searchController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Search units of measure...',
|
||||||
|
prefixIcon: const Icon(LucideIcons.search),
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.grey.shade100,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onChanged: (val) {
|
||||||
|
if (_debounce?.isActive ?? false) _debounce!.cancel();
|
||||||
|
_debounce = Timer(const Duration(milliseconds: 500), () {
|
||||||
|
ref.read(uomsProvider.notifier).search(val);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: uomsState.when(
|
||||||
|
data: (uoms) {
|
||||||
|
if (uoms.isEmpty) {
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: () => ref.read(uomsProvider.notifier).fetchUoms(),
|
||||||
|
child: ListView(
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 100),
|
||||||
|
Icon(LucideIcons.ruler, size: 64, color: Colors.grey[300]),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Center(child: Text('No Units of Measure found', style: TextStyle(color: Colors.grey[600], fontSize: 16))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: () => ref.read(uomsProvider.notifier).fetchUoms(),
|
||||||
|
child: ListView.separated(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
itemCount: uoms.length,
|
||||||
|
separatorBuilder: (context, index) => const SizedBox(height: 12),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final uom = uoms[index];
|
||||||
|
return Dismissible(
|
||||||
|
key: ValueKey(uom.id),
|
||||||
|
direction: DismissDirection.endToStart,
|
||||||
|
background: Container(
|
||||||
|
color: Colors.red,
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
padding: const EdgeInsets.only(right: 20),
|
||||||
|
child: const Icon(LucideIcons.trash2, color: Colors.white),
|
||||||
|
),
|
||||||
|
confirmDismiss: (direction) async {
|
||||||
|
return await showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Delete UOM'),
|
||||||
|
content: Text('Are you sure you want to delete ${uom.name}?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
|
||||||
|
TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('Delete', style: TextStyle(color: Colors.red))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onDismissed: (direction) {
|
||||||
|
ref.read(uomsProvider.notifier).deleteUom(uom.id!);
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(color: Colors.black.withOpacity(0.05), blurRadius: 10, offset: const Offset(0, 4))
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: ListTile(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
||||||
|
title: Text(uom.name, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
||||||
|
subtitle: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4.0),
|
||||||
|
child: Text('Abbreviation: ${uom.abbreviation}'),
|
||||||
|
),
|
||||||
|
trailing: Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey[100],
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(LucideIcons.chevronRight, size: 20),
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (context) => AddUomSheet(uom: uom),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (e, stack) => Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Text('Error: $e', style: const TextStyle(color: Colors.red)),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => ref.read(uomsProvider.notifier).fetchUoms(),
|
||||||
|
child: const Text('Retry'),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
floatingActionButton: FloatingActionButton(
|
||||||
|
onPressed: () {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (context) => const AddUomSheet(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Icon(LucideIcons.plus),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import '../../domain/uom.dart';
|
||||||
|
import '../../providers/uoms_provider.dart';
|
||||||
|
|
||||||
|
class AddUomSheet extends ConsumerStatefulWidget {
|
||||||
|
final UnitOfMeasure? uom;
|
||||||
|
|
||||||
|
const AddUomSheet({super.key, this.uom});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<AddUomSheet> createState() => _AddUomSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AddUomSheetState extends ConsumerState<AddUomSheet> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
late TextEditingController _nameController;
|
||||||
|
late TextEditingController _abbrevController;
|
||||||
|
bool _isLoading = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_nameController = TextEditingController(text: widget.uom?.name ?? '');
|
||||||
|
_abbrevController = TextEditingController(text: widget.uom?.abbreviation ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_nameController.dispose();
|
||||||
|
_abbrevController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
|
||||||
|
setState(() => _isLoading = true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final uom = UnitOfMeasure(
|
||||||
|
id: widget.uom?.id,
|
||||||
|
name: _nameController.text.trim(),
|
||||||
|
abbreviation: _abbrevController.text.trim(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (widget.uom == null) {
|
||||||
|
await ref.read(uomsProvider.notifier).createUom(uom);
|
||||||
|
} else {
|
||||||
|
await ref.read(uomsProvider.notifier).updateUom(widget.uom!.id!, uom);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
Navigator.pop(context);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(widget.uom == null ? 'Unit of Measure created' : 'Unit of Measure updated')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString())));
|
||||||
|
setState(() => _isLoading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
bottom: MediaQuery.of(context).viewInsets.bottom + 24,
|
||||||
|
top: 24,
|
||||||
|
left: 24,
|
||||||
|
right: 24,
|
||||||
|
),
|
||||||
|
child: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(widget.uom == null ? "Add Unit of Measure" : "Edit Unit of Measure", style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||||
|
IconButton(icon: const Icon(Icons.close), onPressed: () => Navigator.pop(context)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
TextFormField(
|
||||||
|
controller: _nameController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: "Name (e.g., Kilogram)",
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
|
||||||
|
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
|
||||||
|
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Colors.blue, width: 2)),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.grey[100]
|
||||||
|
),
|
||||||
|
validator: (val) => val == null || val.isEmpty ? 'Please enter a name' : null,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
TextFormField(
|
||||||
|
controller: _abbrevController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: "Abbreviation (e.g., kg)",
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
|
||||||
|
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
|
||||||
|
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Colors.blue, width: 2)),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.grey[100]
|
||||||
|
),
|
||||||
|
validator: (val) => val == null || val.isEmpty ? 'Please enter an abbreviation' : null,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _isLoading ? null : _submit,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.blue,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
),
|
||||||
|
child: _isLoading
|
||||||
|
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
|
||||||
|
: Text(widget.uom == null ? "Save UOM" : "Update UOM", style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:kifi_app/features/inventory/domain/stock_movement.dart';
|
||||||
|
import 'package:kifi_app/features/inventory/providers/products_provider.dart';
|
||||||
|
|
||||||
|
class AdjustStockSheet extends ConsumerStatefulWidget {
|
||||||
|
final int productId;
|
||||||
|
|
||||||
|
const AdjustStockSheet({super.key, required this.productId});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<AdjustStockSheet> createState() => _AdjustStockSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AdjustStockSheetState extends ConsumerState<AdjustStockSheet> {
|
||||||
|
String _selectedType = 'ADDITION';
|
||||||
|
final _qtyController = TextEditingController();
|
||||||
|
final _notesController = TextEditingController();
|
||||||
|
bool _isLoading = false;
|
||||||
|
|
||||||
|
final List<String> _types = ['ADDITION', 'REDUCTION', 'DAMAGE', 'ADJUSTMENT'];
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
final qtyText = _qtyController.text.trim();
|
||||||
|
if (qtyText.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Please enter a quantity')));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final qty = double.tryParse(qtyText);
|
||||||
|
if (qty == null || qty <= 0) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Quantity must be greater than 0')));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() => _isLoading = true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final movement = StockMovement(
|
||||||
|
type: _selectedType,
|
||||||
|
notes: _notesController.text.trim(),
|
||||||
|
items: [
|
||||||
|
StockMovementItem(quantity: qty)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
await ref.read(productsProvider.notifier).adjustStock(widget.productId, movement);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
Navigator.pop(context);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Stock adjusted successfully')));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString())));
|
||||||
|
setState(() => _isLoading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
bottom: MediaQuery.of(context).viewInsets.bottom + 24,
|
||||||
|
top: 24,
|
||||||
|
left: 24,
|
||||||
|
right: 24,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
const Text("Adjust Stock", style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||||
|
IconButton(icon: const Icon(Icons.close), onPressed: () => Navigator.pop(context)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
value: _selectedType,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: "Movement Type",
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
),
|
||||||
|
items: _types.map((t) => DropdownMenuItem(value: t, child: Text(t))).toList(),
|
||||||
|
onChanged: (val) {
|
||||||
|
if (val != null) setState(() => _selectedType = val);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
TextFormField(
|
||||||
|
controller: _qtyController,
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: "Quantity",
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
prefixIcon: const Icon(Icons.inventory_2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
TextFormField(
|
||||||
|
controller: _notesController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: "Notes (Optional)",
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
prefixIcon: const Icon(Icons.note),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _isLoading ? null : _submit,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.blue,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
),
|
||||||
|
child: _isLoading
|
||||||
|
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
|
||||||
|
: const Text("Save Adjustment", style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -76,6 +76,56 @@ class ProductCategoriesNotifier extends AsyncNotifier<List<ProductCategory>> {
|
|||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> updateCategoryRate(int categoryId, double rate) async {
|
||||||
|
try {
|
||||||
|
final category = state.value?.firstWhere((c) => c.id == categoryId);
|
||||||
|
if (category == null) return;
|
||||||
|
|
||||||
|
final updatedCategory = ProductCategory(
|
||||||
|
id: category.id,
|
||||||
|
userId: category.userId,
|
||||||
|
name: category.name,
|
||||||
|
parentCategoryId: category.parentCategoryId,
|
||||||
|
isCommodity: category.isCommodity,
|
||||||
|
calculationMethod: category.calculationMethod,
|
||||||
|
baseUnit: category.baseUnit,
|
||||||
|
dailyRate: rate,
|
||||||
|
);
|
||||||
|
|
||||||
|
await DioClient().dio.put(
|
||||||
|
'/inventory/categories/$categoryId',
|
||||||
|
data: updatedCategory.toJson(),
|
||||||
|
);
|
||||||
|
state = AsyncValue.data(await _fetchCategories());
|
||||||
|
} catch (e) {
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int> syncRates(int categoryId) async {
|
||||||
|
try {
|
||||||
|
final response = await DioClient().dio.post('/inventory/categories/$categoryId/sync-rates');
|
||||||
|
if (response.statusCode == 200 && response.data != null) {
|
||||||
|
return response.data['syncedCount'] ?? 0;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
} catch (e) {
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<dynamic>> fetchRateHistory(int categoryId) async {
|
||||||
|
try {
|
||||||
|
final response = await DioClient().dio.get('/inventory/categories/$categoryId/rate-history');
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
return response.data as List<dynamic>;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
} catch (e) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final productCategoriesProvider = AsyncNotifierProvider<ProductCategoriesNotifier, List<ProductCategory>>(() {
|
final productCategoriesProvider = AsyncNotifierProvider<ProductCategoriesNotifier, List<ProductCategory>>(() {
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import '../../../core/network/dio_client.dart';
|
import '../../../core/network/dio_client.dart';
|
||||||
import '../domain/product.dart';
|
import '../domain/product.dart';
|
||||||
|
import '../domain/stock_movement.dart';
|
||||||
|
import '../domain/product_bom.dart';
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
||||||
|
|
||||||
class ProductsNotifier extends AsyncNotifier<List<Product>> {
|
class ProductsNotifier extends AsyncNotifier<List<Product>> {
|
||||||
int _currentPage = 0;
|
int _currentPage = 0;
|
||||||
bool _hasMore = true;
|
bool _hasMore = true;
|
||||||
bool _isLoadingMore = false;
|
bool _isLoadingMore = false;
|
||||||
final int _pageSize = 20;
|
final int _pageSize = 50;
|
||||||
|
String _currentSearchQuery = '';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
FutureOr<List<Product>> build() async {
|
FutureOr<List<Product>> build() async {
|
||||||
@@ -22,10 +27,20 @@ class ProductsNotifier extends AsyncNotifier<List<Product>> {
|
|||||||
Future<List<Product>> _fetchProducts({required int page, required int size}) async {
|
Future<List<Product>> _fetchProducts({required int page, required int size}) async {
|
||||||
final response = await DioClient().dio.get(
|
final response = await DioClient().dio.get(
|
||||||
'/inventory/products',
|
'/inventory/products',
|
||||||
queryParameters: {'page': page, 'size': size}
|
queryParameters: {
|
||||||
|
'page': page,
|
||||||
|
'size': size,
|
||||||
|
if (_currentSearchQuery.isNotEmpty) 'search': _currentSearchQuery,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
final List<dynamic> data = response.data;
|
final List<dynamic> data = response.data;
|
||||||
|
if (data.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
final file = File('/Users/maddy/Projects/Kifi/kifi-app/debug_product.json');
|
||||||
|
await file.writeAsString(jsonEncode(data.first));
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
final products = data.map((e) => Product.fromJson(e)).toList();
|
final products = data.map((e) => Product.fromJson(e)).toList();
|
||||||
if (products.length < size) {
|
if (products.length < size) {
|
||||||
_hasMore = false;
|
_hasMore = false;
|
||||||
@@ -64,6 +79,11 @@ class ProductsNotifier extends AsyncNotifier<List<Product>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> search(String query) async {
|
||||||
|
_currentSearchQuery = query;
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> createProduct(Product product, {List<XFile>? images}) async {
|
Future<void> createProduct(Product product, {List<XFile>? images}) async {
|
||||||
try {
|
try {
|
||||||
final response = await DioClient().dio.post(
|
final response = await DioClient().dio.post(
|
||||||
@@ -74,8 +94,15 @@ class ProductsNotifier extends AsyncNotifier<List<Product>> {
|
|||||||
if (images != null && images.isNotEmpty && response.data != null) {
|
if (images != null && images.isNotEmpty && response.data != null) {
|
||||||
final productId = response.data['id'];
|
final productId = response.data['id'];
|
||||||
for (var image in images) {
|
for (var image in images) {
|
||||||
|
final bytes = await image.readAsBytes();
|
||||||
|
final compressedBytes = await FlutterImageCompress.compressWithList(
|
||||||
|
bytes,
|
||||||
|
minWidth: 800,
|
||||||
|
quality: 80,
|
||||||
|
);
|
||||||
|
|
||||||
final formData = FormData.fromMap({
|
final formData = FormData.fromMap({
|
||||||
'file': await MultipartFile.fromFile(image.path, filename: image.name),
|
'file': MultipartFile.fromBytes(compressedBytes, filename: image.name),
|
||||||
});
|
});
|
||||||
await DioClient().dio.post(
|
await DioClient().dio.post(
|
||||||
'/inventory/products/$productId/images',
|
'/inventory/products/$productId/images',
|
||||||
@@ -100,8 +127,15 @@ class ProductsNotifier extends AsyncNotifier<List<Product>> {
|
|||||||
|
|
||||||
if (newImages != null && newImages.isNotEmpty) {
|
if (newImages != null && newImages.isNotEmpty) {
|
||||||
for (var image in newImages) {
|
for (var image in newImages) {
|
||||||
|
final bytes = await image.readAsBytes();
|
||||||
|
final compressedBytes = await FlutterImageCompress.compressWithList(
|
||||||
|
bytes,
|
||||||
|
minWidth: 800,
|
||||||
|
quality: 80,
|
||||||
|
);
|
||||||
|
|
||||||
final formData = FormData.fromMap({
|
final formData = FormData.fromMap({
|
||||||
'file': await MultipartFile.fromFile(image.path, filename: image.name),
|
'file': MultipartFile.fromBytes(compressedBytes, filename: image.name),
|
||||||
});
|
});
|
||||||
await DioClient().dio.post(
|
await DioClient().dio.post(
|
||||||
'/inventory/products/$id/images',
|
'/inventory/products/$id/images',
|
||||||
@@ -116,6 +150,64 @@ class ProductsNotifier extends AsyncNotifier<List<Product>> {
|
|||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> adjustStock(int productId, StockMovement movement) async {
|
||||||
|
try {
|
||||||
|
await DioClient().dio.post(
|
||||||
|
'/inventory/products/$productId/movements',
|
||||||
|
data: movement.toJson(),
|
||||||
|
);
|
||||||
|
// Refresh the products list to get the updated currentStock
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Failed to adjust stock: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<StockMovement>> fetchStockLedger(int productId) async {
|
||||||
|
try {
|
||||||
|
final response = await DioClient().dio.get('/inventory/products/$productId/movements');
|
||||||
|
if (response.data != null) {
|
||||||
|
final List<dynamic> data = response.data;
|
||||||
|
return data.map((e) => StockMovement.fromJson(e)).toList();
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Failed to fetch stock ledger: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<ProductBom>> fetchProductBom(int productId) async {
|
||||||
|
try {
|
||||||
|
final response = await DioClient().dio.get('/inventory/products/$productId/bom');
|
||||||
|
if (response.data != null) {
|
||||||
|
final List<dynamic> data = response.data;
|
||||||
|
return data.map((e) => ProductBom.fromJson(e)).toList();
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Failed to fetch product BOM: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> addBomItem(int parentProductId, ProductBom bomItem) async {
|
||||||
|
try {
|
||||||
|
await DioClient().dio.post(
|
||||||
|
'/inventory/products/$parentProductId/bom',
|
||||||
|
data: bomItem.toJson(),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Failed to add BOM item: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteBomItem(int bomId) async {
|
||||||
|
try {
|
||||||
|
await DioClient().dio.delete('/inventory/products/bom/$bomId');
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Failed to delete BOM item: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final productsProvider = AsyncNotifierProvider<ProductsNotifier, List<Product>>(() {
|
final productsProvider = AsyncNotifierProvider<ProductsNotifier, List<Product>>(() {
|
||||||
|
|||||||
96
kifi-app/lib/features/inventory/providers/uoms_provider.dart
Normal file
96
kifi-app/lib/features/inventory/providers/uoms_provider.dart
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import '../../../core/network/dio_client.dart';
|
||||||
|
import '../domain/uom.dart';
|
||||||
|
|
||||||
|
class UomsNotifier extends AsyncNotifier<List<UnitOfMeasure>> {
|
||||||
|
List<UnitOfMeasure> _allUoms = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<UnitOfMeasure>> build() async {
|
||||||
|
return _fetchUoms();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<UnitOfMeasure>> _fetchUoms() async {
|
||||||
|
final response = await DioClient().dio.get('/inventory/uom').timeout(
|
||||||
|
const Duration(seconds: 5),
|
||||||
|
onTimeout: () => throw Exception('Connection timed out'),
|
||||||
|
);
|
||||||
|
if (response.data == null || response.data.toString().isEmpty) {
|
||||||
|
_allUoms = [];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
final list = (response.data as List).map((e) => UnitOfMeasure.fromJson(e)).toList();
|
||||||
|
_allUoms = list;
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
void search(String query) {
|
||||||
|
if (query.isEmpty) {
|
||||||
|
state = AsyncValue.data(_allUoms);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final lowerQuery = query.toLowerCase();
|
||||||
|
final filtered = _allUoms.where((uom) =>
|
||||||
|
uom.name.toLowerCase().contains(lowerQuery) ||
|
||||||
|
(uom.abbreviation?.toLowerCase().contains(lowerQuery) ?? false)
|
||||||
|
).toList();
|
||||||
|
state = AsyncValue.data(filtered);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> fetchUoms() async {
|
||||||
|
try {
|
||||||
|
final data = await _fetchUoms();
|
||||||
|
state = AsyncValue.data(data);
|
||||||
|
} catch (e, stack) {
|
||||||
|
state = AsyncValue.error(e, stack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> createUom(UnitOfMeasure uom) async {
|
||||||
|
try {
|
||||||
|
final response = await DioClient().dio.post('/inventory/uom', data: uom.toJson());
|
||||||
|
final newUom = UnitOfMeasure.fromJson(response.data);
|
||||||
|
if (state is AsyncData) {
|
||||||
|
_allUoms = [..._allUoms, newUom];
|
||||||
|
state = AsyncValue.data([...state.value!, newUom]);
|
||||||
|
} else {
|
||||||
|
await fetchUoms();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Failed to create UOM: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> updateUom(int id, UnitOfMeasure uom) async {
|
||||||
|
try {
|
||||||
|
final response = await DioClient().dio.put('/inventory/uom/$id', data: uom.toJson());
|
||||||
|
final updatedUom = UnitOfMeasure.fromJson(response.data);
|
||||||
|
if (state is AsyncData) {
|
||||||
|
_allUoms = _allUoms.map((e) => e.id == id ? updatedUom : e).toList();
|
||||||
|
state = AsyncValue.data(
|
||||||
|
state.value!.map((e) => e.id == id ? updatedUom : e).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Failed to update UOM: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteUom(int id) async {
|
||||||
|
try {
|
||||||
|
await DioClient().dio.delete('/inventory/uom/$id');
|
||||||
|
if (state is AsyncData) {
|
||||||
|
_allUoms = _allUoms.where((e) => e.id != id).toList();
|
||||||
|
state = AsyncValue.data(
|
||||||
|
state.value!.where((e) => e.id != id).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Failed to delete UOM: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final uomsProvider = AsyncNotifierProvider<UomsNotifier, List<UnitOfMeasure>>(() {
|
||||||
|
return UomsNotifier();
|
||||||
|
});
|
||||||
72
kifi-app/lib/features/sales/domain/customer.dart
Normal file
72
kifi-app/lib/features/sales/domain/customer.dart
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
class Customer {
|
||||||
|
final int? id;
|
||||||
|
final int? userId;
|
||||||
|
final String name;
|
||||||
|
final String? email;
|
||||||
|
final String? phone;
|
||||||
|
final String? address;
|
||||||
|
final String? gstin;
|
||||||
|
final String? idNumber;
|
||||||
|
final int? stateId;
|
||||||
|
final String? photoUrl;
|
||||||
|
final DateTime? createdAt;
|
||||||
|
|
||||||
|
// New fields
|
||||||
|
final String? fatherName;
|
||||||
|
final String? gender;
|
||||||
|
final int? age;
|
||||||
|
|
||||||
|
Customer({
|
||||||
|
this.id,
|
||||||
|
this.userId,
|
||||||
|
required this.name,
|
||||||
|
this.email,
|
||||||
|
this.phone,
|
||||||
|
this.address,
|
||||||
|
this.gstin,
|
||||||
|
this.idNumber,
|
||||||
|
this.stateId,
|
||||||
|
this.photoUrl,
|
||||||
|
this.createdAt,
|
||||||
|
this.fatherName,
|
||||||
|
this.gender,
|
||||||
|
this.age,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory Customer.fromJson(Map<String, dynamic> json) {
|
||||||
|
return Customer(
|
||||||
|
id: json['id'],
|
||||||
|
userId: json['userId'],
|
||||||
|
name: json['name'],
|
||||||
|
email: json['email'],
|
||||||
|
phone: json['phone'],
|
||||||
|
address: json['address'],
|
||||||
|
gstin: json['gstin'],
|
||||||
|
idNumber: json['idNumber'],
|
||||||
|
stateId: json['stateId'],
|
||||||
|
photoUrl: json['photoUrl'],
|
||||||
|
fatherName: json['fatherName'],
|
||||||
|
gender: json['gender'],
|
||||||
|
age: json['age'],
|
||||||
|
createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt']) : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final Map<String, dynamic> data = {};
|
||||||
|
if (id != null) data['id'] = id;
|
||||||
|
if (userId != null) data['userId'] = userId;
|
||||||
|
data['name'] = name;
|
||||||
|
if (email != null) data['email'] = email;
|
||||||
|
if (phone != null) data['phone'] = phone;
|
||||||
|
if (address != null) data['address'] = address;
|
||||||
|
if (gstin != null) data['gstin'] = gstin;
|
||||||
|
if (idNumber != null) data['idNumber'] = idNumber;
|
||||||
|
if (stateId != null) data['stateId'] = stateId;
|
||||||
|
if (photoUrl != null) data['photoUrl'] = photoUrl;
|
||||||
|
if (fatherName != null) data['fatherName'] = fatherName;
|
||||||
|
if (gender != null) data['gender'] = gender;
|
||||||
|
if (age != null) data['age'] = age;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
237
kifi-app/lib/features/sales/domain/invoice.dart
Normal file
237
kifi-app/lib/features/sales/domain/invoice.dart
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
class InvoiceItem {
|
||||||
|
final int? id;
|
||||||
|
final int? invoiceId;
|
||||||
|
final int? productId;
|
||||||
|
final String? sku;
|
||||||
|
final String? description;
|
||||||
|
final double quantity;
|
||||||
|
final double unitPrice;
|
||||||
|
final double taxRate;
|
||||||
|
final double discount;
|
||||||
|
final double makingCharge;
|
||||||
|
final double otherCharges;
|
||||||
|
final double total;
|
||||||
|
|
||||||
|
InvoiceItem({
|
||||||
|
this.id,
|
||||||
|
this.invoiceId,
|
||||||
|
this.productId,
|
||||||
|
this.sku,
|
||||||
|
this.description,
|
||||||
|
required this.quantity,
|
||||||
|
required this.unitPrice,
|
||||||
|
this.taxRate = 0.0,
|
||||||
|
this.discount = 0.0,
|
||||||
|
this.makingCharge = 0.0,
|
||||||
|
this.otherCharges = 0.0,
|
||||||
|
required this.total,
|
||||||
|
});
|
||||||
|
|
||||||
|
InvoiceItem copyWith({
|
||||||
|
int? id,
|
||||||
|
int? invoiceId,
|
||||||
|
int? productId,
|
||||||
|
String? sku,
|
||||||
|
String? description,
|
||||||
|
double? quantity,
|
||||||
|
double? unitPrice,
|
||||||
|
double? taxRate,
|
||||||
|
double? discount,
|
||||||
|
double? makingCharge,
|
||||||
|
double? otherCharges,
|
||||||
|
double? total,
|
||||||
|
}) {
|
||||||
|
return InvoiceItem(
|
||||||
|
id: id ?? this.id,
|
||||||
|
invoiceId: invoiceId ?? this.invoiceId,
|
||||||
|
productId: productId ?? this.productId,
|
||||||
|
sku: sku ?? this.sku,
|
||||||
|
description: description ?? this.description,
|
||||||
|
quantity: quantity ?? this.quantity,
|
||||||
|
unitPrice: unitPrice ?? this.unitPrice,
|
||||||
|
taxRate: taxRate ?? this.taxRate,
|
||||||
|
discount: discount ?? this.discount,
|
||||||
|
makingCharge: makingCharge ?? this.makingCharge,
|
||||||
|
otherCharges: otherCharges ?? this.otherCharges,
|
||||||
|
total: total ?? this.total,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
factory InvoiceItem.fromJson(Map<String, dynamic> json) {
|
||||||
|
return InvoiceItem(
|
||||||
|
id: json['id'],
|
||||||
|
invoiceId: json['invoiceId'],
|
||||||
|
productId: json['productId'],
|
||||||
|
sku: json['sku'],
|
||||||
|
description: json['description'],
|
||||||
|
quantity: json['quantity'].toDouble(),
|
||||||
|
unitPrice: json['unitPrice'].toDouble(),
|
||||||
|
taxRate: json['taxRate']?.toDouble() ?? 0.0,
|
||||||
|
discount: json['discount']?.toDouble() ?? 0.0,
|
||||||
|
makingCharge: json['makingCharge']?.toDouble() ?? 0.0,
|
||||||
|
otherCharges: json['otherCharges']?.toDouble() ?? 0.0,
|
||||||
|
total: json['total'].toDouble(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final Map<String, dynamic> data = {};
|
||||||
|
if (id != null) data['id'] = id;
|
||||||
|
if (invoiceId != null) data['invoiceId'] = invoiceId;
|
||||||
|
if (productId != null) data['productId'] = productId;
|
||||||
|
if (sku != null) data['sku'] = sku;
|
||||||
|
if (description != null) data['description'] = description;
|
||||||
|
data['quantity'] = quantity;
|
||||||
|
data['unitPrice'] = unitPrice;
|
||||||
|
data['taxRate'] = taxRate;
|
||||||
|
data['discount'] = discount;
|
||||||
|
data['makingCharge'] = makingCharge;
|
||||||
|
data['otherCharges'] = otherCharges;
|
||||||
|
data['total'] = total;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Invoice {
|
||||||
|
final int? id;
|
||||||
|
final int? customerId;
|
||||||
|
final String invoiceNumber;
|
||||||
|
final DateTime issueDate;
|
||||||
|
final DateTime? dueDate;
|
||||||
|
final double subtotal;
|
||||||
|
final double taxTotal;
|
||||||
|
final double discountTotal;
|
||||||
|
final double totalAmount;
|
||||||
|
final double amountPaid;
|
||||||
|
final DateTime? nextPaymentDate;
|
||||||
|
final String? paymentMethod;
|
||||||
|
final int? paymentWalletId;
|
||||||
|
final String status;
|
||||||
|
final String? notes;
|
||||||
|
final bool isEmi;
|
||||||
|
final double? emiAmount;
|
||||||
|
final String? emiCycle;
|
||||||
|
final DateTime? emiStartDate;
|
||||||
|
final List<InvoiceItem> items;
|
||||||
|
final List<InvoicePayment>? payments;
|
||||||
|
|
||||||
|
Invoice({
|
||||||
|
this.id,
|
||||||
|
this.customerId,
|
||||||
|
required this.invoiceNumber,
|
||||||
|
required this.issueDate,
|
||||||
|
this.dueDate,
|
||||||
|
required this.subtotal,
|
||||||
|
this.taxTotal = 0.0,
|
||||||
|
this.discountTotal = 0.0,
|
||||||
|
required this.totalAmount,
|
||||||
|
this.amountPaid = 0.0,
|
||||||
|
this.nextPaymentDate,
|
||||||
|
this.paymentMethod,
|
||||||
|
this.paymentWalletId,
|
||||||
|
this.status = 'DRAFT',
|
||||||
|
this.notes,
|
||||||
|
this.isEmi = false,
|
||||||
|
this.emiAmount,
|
||||||
|
this.emiCycle,
|
||||||
|
this.emiStartDate,
|
||||||
|
this.items = const [],
|
||||||
|
this.payments,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory Invoice.fromJson(Map<String, dynamic> json) {
|
||||||
|
return Invoice(
|
||||||
|
id: json['id'],
|
||||||
|
customerId: json['customerId'],
|
||||||
|
invoiceNumber: json['invoiceNumber'],
|
||||||
|
issueDate: DateTime.parse(json['issueDate']),
|
||||||
|
dueDate: json['dueDate'] != null ? DateTime.parse(json['dueDate']) : null,
|
||||||
|
subtotal: json['subtotal'].toDouble(),
|
||||||
|
taxTotal: json['taxTotal']?.toDouble() ?? 0.0,
|
||||||
|
discountTotal: json['discountTotal']?.toDouble() ?? 0.0,
|
||||||
|
totalAmount: json['totalAmount'].toDouble(),
|
||||||
|
amountPaid: json['amountPaid']?.toDouble() ?? 0.0,
|
||||||
|
nextPaymentDate: json['nextPaymentDate'] != null ? DateTime.parse(json['nextPaymentDate']) : null,
|
||||||
|
paymentMethod: json['paymentMethod'],
|
||||||
|
paymentWalletId: json['paymentWalletId'],
|
||||||
|
status: json['status'] ?? 'DRAFT',
|
||||||
|
notes: json['notes'],
|
||||||
|
isEmi: json['isEmi'] ?? false,
|
||||||
|
emiAmount: json['emiAmount']?.toDouble(),
|
||||||
|
emiCycle: json['emiCycle'],
|
||||||
|
emiStartDate: json['emiStartDate'] != null ? DateTime.parse(json['emiStartDate']) : null,
|
||||||
|
items: json['items'] != null ? (json['items'] as List).map((i) => InvoiceItem.fromJson(i)).toList() : [],
|
||||||
|
payments: json['payments'] != null ? (json['payments'] as List).map((i) => InvoicePayment.fromJson(i)).toList() : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final Map<String, dynamic> data = {};
|
||||||
|
if (id != null) data['id'] = id;
|
||||||
|
if (customerId != null) data['customerId'] = customerId;
|
||||||
|
data['invoiceNumber'] = invoiceNumber;
|
||||||
|
data['issueDate'] = issueDate.toIso8601String().split('T')[0];
|
||||||
|
if (dueDate != null) data['dueDate'] = dueDate!.toIso8601String().split('T')[0];
|
||||||
|
data['subtotal'] = subtotal;
|
||||||
|
data['taxTotal'] = taxTotal;
|
||||||
|
data['discountTotal'] = discountTotal;
|
||||||
|
data['totalAmount'] = totalAmount;
|
||||||
|
if (amountPaid > 0) data['amountPaid'] = amountPaid;
|
||||||
|
if (nextPaymentDate != null) data['nextPaymentDate'] = nextPaymentDate!.toIso8601String().split('T')[0];
|
||||||
|
if (paymentMethod != null) data['paymentMethod'] = paymentMethod;
|
||||||
|
if (paymentWalletId != null) data['paymentWalletId'] = paymentWalletId;
|
||||||
|
data['status'] = status;
|
||||||
|
if (notes != null) data['notes'] = notes;
|
||||||
|
data['isEmi'] = isEmi;
|
||||||
|
if (emiAmount != null) data['emiAmount'] = emiAmount;
|
||||||
|
if (emiCycle != null) data['emiCycle'] = emiCycle;
|
||||||
|
if (emiStartDate != null) data['emiStartDate'] = emiStartDate!.toIso8601String().split('T')[0];
|
||||||
|
data['items'] = items.map((i) => i.toJson()).toList();
|
||||||
|
if (payments != null) data['payments'] = payments!.map((i) => i.toJson()).toList();
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class InvoicePayment {
|
||||||
|
final int? id;
|
||||||
|
final int? invoiceId;
|
||||||
|
final double amount;
|
||||||
|
final DateTime? paymentDate;
|
||||||
|
final String paymentMethod;
|
||||||
|
final int? emiInstallmentNumber;
|
||||||
|
final int? walletId;
|
||||||
|
|
||||||
|
InvoicePayment({
|
||||||
|
this.id,
|
||||||
|
this.invoiceId,
|
||||||
|
required this.amount,
|
||||||
|
this.paymentDate,
|
||||||
|
required this.paymentMethod,
|
||||||
|
this.emiInstallmentNumber,
|
||||||
|
this.walletId,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory InvoicePayment.fromJson(Map<String, dynamic> json) {
|
||||||
|
return InvoicePayment(
|
||||||
|
id: json['id'],
|
||||||
|
invoiceId: json['invoiceId'],
|
||||||
|
amount: json['amount'].toDouble(),
|
||||||
|
paymentDate: json['paymentDate'] != null ? DateTime.parse(json['paymentDate']) : null,
|
||||||
|
paymentMethod: json['paymentMethod'] ?? 'Cash',
|
||||||
|
emiInstallmentNumber: json['emiInstallmentNumber'],
|
||||||
|
walletId: json['walletId'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final Map<String, dynamic> data = {};
|
||||||
|
if (id != null) data['id'] = id;
|
||||||
|
if (invoiceId != null) data['invoiceId'] = invoiceId;
|
||||||
|
data['amount'] = amount;
|
||||||
|
if (paymentDate != null) data['paymentDate'] = paymentDate!.toIso8601String().split('T')[0];
|
||||||
|
data['paymentMethod'] = paymentMethod;
|
||||||
|
if (emiInstallmentNumber != null) data['emiInstallmentNumber'] = emiInstallmentNumber;
|
||||||
|
if (walletId != null) data['walletId'] = walletId;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
353
kifi-app/lib/features/sales/presentation/add_customer_sheet.dart
Normal file
353
kifi-app/lib/features/sales/presentation/add_customer_sheet.dart
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
|
import '../providers/customers_provider.dart';
|
||||||
|
import '../domain/customer.dart';
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
import '../../../core/network/dio_client.dart';
|
||||||
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart' as import_storage;
|
||||||
|
import '../../../core/widgets/premium_text_field.dart';
|
||||||
|
import '../../../core/widgets/smart_search_dropdown.dart';
|
||||||
|
import '../../business/providers/indian_states_provider.dart';
|
||||||
|
|
||||||
|
class AddCustomerSheet extends ConsumerStatefulWidget {
|
||||||
|
final Customer? customer;
|
||||||
|
|
||||||
|
const AddCustomerSheet({super.key, this.customer});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<AddCustomerSheet> createState() => _AddCustomerSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AddCustomerSheetState extends ConsumerState<AddCustomerSheet> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
late TextEditingController _nameCtrl;
|
||||||
|
late TextEditingController _phoneCtrl;
|
||||||
|
late TextEditingController _emailCtrl;
|
||||||
|
late TextEditingController _addressCtrl;
|
||||||
|
late TextEditingController _gstinCtrl;
|
||||||
|
late TextEditingController _idNumberCtrl;
|
||||||
|
late TextEditingController _fatherNameCtrl;
|
||||||
|
late TextEditingController _ageCtrl;
|
||||||
|
String? _selectedGender;
|
||||||
|
int? _selectedStateId;
|
||||||
|
XFile? _photo;
|
||||||
|
String? _token;
|
||||||
|
bool _isLoading = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_nameCtrl = TextEditingController(text: widget.customer?.name ?? '');
|
||||||
|
_phoneCtrl = TextEditingController(text: widget.customer?.phone ?? '');
|
||||||
|
_emailCtrl = TextEditingController(text: widget.customer?.email ?? '');
|
||||||
|
_addressCtrl = TextEditingController(text: widget.customer?.address ?? '');
|
||||||
|
_gstinCtrl = TextEditingController(text: widget.customer?.gstin ?? '');
|
||||||
|
_idNumberCtrl = TextEditingController(text: widget.customer?.idNumber ?? '');
|
||||||
|
_fatherNameCtrl = TextEditingController(text: widget.customer?.fatherName ?? '');
|
||||||
|
_ageCtrl = TextEditingController(text: widget.customer?.age?.toString() ?? '');
|
||||||
|
_selectedGender = widget.customer?.gender;
|
||||||
|
_selectedStateId = widget.customer?.stateId;
|
||||||
|
import_storage.FlutterSecureStorage().read(key: 'jwt_token').then((val) {
|
||||||
|
if (mounted) setState(() => _token = val);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_nameCtrl.dispose();
|
||||||
|
_phoneCtrl.dispose();
|
||||||
|
_emailCtrl.dispose();
|
||||||
|
_addressCtrl.dispose();
|
||||||
|
_gstinCtrl.dispose();
|
||||||
|
_idNumberCtrl.dispose();
|
||||||
|
_fatherNameCtrl.dispose();
|
||||||
|
_ageCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickImage(ImageSource source) async {
|
||||||
|
final picker = ImagePicker();
|
||||||
|
final picked = await picker.pickImage(source: source, imageQuality: 80);
|
||||||
|
if (picked != null) {
|
||||||
|
setState(() => _photo = picked);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showImagePickerModal() {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))),
|
||||||
|
builder: (ctx) => SafeArea(
|
||||||
|
child: Wrap(
|
||||||
|
children: [
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(LucideIcons.camera),
|
||||||
|
title: const Text('Take a photo'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
_pickImage(ImageSource.camera);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(LucideIcons.image),
|
||||||
|
title: const Text('Choose from gallery'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
_pickImage(ImageSource.gallery);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _save() async {
|
||||||
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
|
||||||
|
setState(() => _isLoading = true);
|
||||||
|
|
||||||
|
final customer = Customer(
|
||||||
|
id: widget.customer?.id,
|
||||||
|
name: _nameCtrl.text,
|
||||||
|
phone: _phoneCtrl.text.isNotEmpty ? _phoneCtrl.text : null,
|
||||||
|
email: _emailCtrl.text.isNotEmpty ? _emailCtrl.text : null,
|
||||||
|
address: _addressCtrl.text.isNotEmpty ? _addressCtrl.text : null,
|
||||||
|
gstin: _gstinCtrl.text.isNotEmpty ? _gstinCtrl.text : null,
|
||||||
|
idNumber: _idNumberCtrl.text.isNotEmpty ? _idNumberCtrl.text : null,
|
||||||
|
fatherName: _fatherNameCtrl.text.isNotEmpty ? _fatherNameCtrl.text : null,
|
||||||
|
age: int.tryParse(_ageCtrl.text),
|
||||||
|
gender: _selectedGender,
|
||||||
|
stateId: _selectedStateId,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (widget.customer == null) {
|
||||||
|
await ref.read(customersProvider.notifier).addCustomer(customer, photo: _photo);
|
||||||
|
} else {
|
||||||
|
await ref.read(customersProvider.notifier).updateCustomer(widget.customer!.id!, customer, photo: _photo);
|
||||||
|
}
|
||||||
|
if (mounted) {
|
||||||
|
Navigator.pop(context);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(widget.customer == null ? 'Customer added' : 'Customer updated')));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('Error'),
|
||||||
|
content: Text(e.toString()),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('OK'))
|
||||||
|
],
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _isLoading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||||
|
),
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||||
|
left: 24,
|
||||||
|
right: 24,
|
||||||
|
top: 24,
|
||||||
|
),
|
||||||
|
child: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(widget.customer == null ? "New Customer" : "Edit Customer", style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Center(
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: _showImagePickerModal,
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
CircleAvatar(
|
||||||
|
radius: 50,
|
||||||
|
backgroundColor: Colors.grey[200],
|
||||||
|
backgroundImage: _photo != null
|
||||||
|
? FileImage(File(_photo!.path)) as ImageProvider
|
||||||
|
: (widget.customer?.photoUrl != null && _token != null
|
||||||
|
? NetworkImage(
|
||||||
|
'${DioClient().dio.options.baseUrl}/customers/${widget.customer!.id}/photo/content',
|
||||||
|
headers: {'Authorization': 'Bearer $_token'},
|
||||||
|
)
|
||||||
|
: null),
|
||||||
|
child: _photo == null && widget.customer?.photoUrl == null
|
||||||
|
? const Icon(LucideIcons.user, size: 50, color: Colors.grey)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
bottom: 0,
|
||||||
|
right: 0,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Colors.blue,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(LucideIcons.camera, color: Colors.white, size: 16),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
PremiumTextField(
|
||||||
|
controller: _nameCtrl,
|
||||||
|
labelText: 'Customer Name *',
|
||||||
|
prefixIcon: const Icon(LucideIcons.user),
|
||||||
|
validator: (val) => val == null || val.isEmpty ? 'Required' : null,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
PremiumTextField(
|
||||||
|
controller: _phoneCtrl,
|
||||||
|
labelText: 'Phone Number',
|
||||||
|
prefixIcon: const Icon(LucideIcons.phone),
|
||||||
|
keyboardType: TextInputType.phone,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
PremiumTextField(
|
||||||
|
controller: _fatherNameCtrl,
|
||||||
|
labelText: 'Father Name',
|
||||||
|
prefixIcon: const Icon(LucideIcons.users),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: PremiumTextField(
|
||||||
|
controller: _ageCtrl,
|
||||||
|
labelText: 'Age',
|
||||||
|
prefixIcon: const Icon(LucideIcons.calendar),
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey[100],
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: DropdownButtonHideUnderline(
|
||||||
|
child: DropdownButton<String>(
|
||||||
|
value: _selectedGender,
|
||||||
|
isExpanded: true,
|
||||||
|
hint: const Text('Gender'),
|
||||||
|
items: ['Male', 'Female', 'Other']
|
||||||
|
.map((g) => DropdownMenuItem(value: g, child: Text(g)))
|
||||||
|
.toList(),
|
||||||
|
onChanged: (val) => setState(() => _selectedGender = val),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
PremiumTextField(
|
||||||
|
controller: _emailCtrl,
|
||||||
|
labelText: 'Email Address',
|
||||||
|
prefixIcon: const Icon(LucideIcons.mail),
|
||||||
|
keyboardType: TextInputType.emailAddress,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
PremiumTextField(
|
||||||
|
controller: _gstinCtrl,
|
||||||
|
labelText: 'GSTIN (Optional)',
|
||||||
|
prefixIcon: const Icon(LucideIcons.building),
|
||||||
|
textCapitalization: TextCapitalization.characters,
|
||||||
|
validator: (val) {
|
||||||
|
if (val == null || val.isEmpty) return null; // Optional
|
||||||
|
final RegExp gstRegExp = RegExp(r'^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$');
|
||||||
|
if (!gstRegExp.hasMatch(val.toUpperCase())) {
|
||||||
|
return 'Invalid GSTIN format';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
PremiumTextField(
|
||||||
|
controller: _idNumberCtrl,
|
||||||
|
labelText: 'ID No. (Aadhar, License, etc.)',
|
||||||
|
prefixIcon: const Icon(LucideIcons.creditCard),
|
||||||
|
textCapitalization: TextCapitalization.characters,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Consumer(
|
||||||
|
builder: (context, ref, _) {
|
||||||
|
final statesState = ref.watch(indianStatesProvider);
|
||||||
|
return statesState.when(
|
||||||
|
data: (states) {
|
||||||
|
return SmartSearchDropdown<int>(
|
||||||
|
hintText: 'Select State',
|
||||||
|
value: _selectedStateId,
|
||||||
|
items: states.map((s) => s.id).toList(),
|
||||||
|
itemAsString: (id) {
|
||||||
|
final s = states.firstWhere((st) => st.id == id);
|
||||||
|
return '${s.name} (${s.gstCode})';
|
||||||
|
},
|
||||||
|
onChanged: (val) => setState(() => _selectedStateId = val),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
loading: () => const CircularProgressIndicator(),
|
||||||
|
error: (e, stack) => Text('Error loading states: $e'),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
PremiumTextField(
|
||||||
|
controller: _addressCtrl,
|
||||||
|
labelText: 'Billing Address',
|
||||||
|
prefixIcon: const Icon(LucideIcons.mapPin),
|
||||||
|
maxLines: 3,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: _isLoading ? null : _save,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
backgroundColor: Colors.blue,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
),
|
||||||
|
child: _isLoading
|
||||||
|
? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
|
||||||
|
: Text(widget.customer == null ? 'Save Customer' : 'Update Customer'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import '../domain/customer.dart';
|
||||||
|
import '../providers/invoices_provider.dart';
|
||||||
|
import '../../transactions/providers/providers.dart';
|
||||||
|
|
||||||
|
class CustomerLedgerScreen extends ConsumerStatefulWidget {
|
||||||
|
final Customer customer;
|
||||||
|
|
||||||
|
const CustomerLedgerScreen({super.key, required this.customer});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<CustomerLedgerScreen> createState() => _CustomerLedgerScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CustomerLedgerScreenState extends ConsumerState<CustomerLedgerScreen> {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final invoicesState = ref.watch(invoicesProvider);
|
||||||
|
final formatCurrency = NumberFormat.currency(symbol: '₹');
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text('${widget.customer.name} Ledger'),
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
foregroundColor: Colors.black,
|
||||||
|
elevation: 0,
|
||||||
|
),
|
||||||
|
body: invoicesState.when(
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (err, stack) => Center(child: Text('Error: $err')),
|
||||||
|
data: (invoices) {
|
||||||
|
final customerInvoices = invoices.where((i) => i.customerId == widget.customer.id).toList();
|
||||||
|
|
||||||
|
if (customerInvoices.isEmpty) {
|
||||||
|
return const Center(child: Text('No invoices found for this customer.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort chronological
|
||||||
|
customerInvoices.sort((a, b) => a.issueDate.compareTo(b.issueDate));
|
||||||
|
|
||||||
|
double runningBalance = 0.0;
|
||||||
|
List<DataRow> rows = [];
|
||||||
|
final wallets = ref.read(walletProvider).value ?? [];
|
||||||
|
|
||||||
|
int index = 1;
|
||||||
|
for (var inv in customerInvoices) {
|
||||||
|
// Add row for Invoice (Debit)
|
||||||
|
runningBalance += inv.totalAmount;
|
||||||
|
rows.add(DataRow(
|
||||||
|
cells: [
|
||||||
|
DataCell(Text('${index++}')),
|
||||||
|
DataCell(Text(DateFormat('dd MMM yyyy').format(inv.issueDate))),
|
||||||
|
DataCell(Text(inv.invoiceNumber, style: const TextStyle(fontWeight: FontWeight.w600))),
|
||||||
|
const DataCell(Text('Invoice')),
|
||||||
|
const DataCell(Text('-')),
|
||||||
|
const DataCell(Text('-')),
|
||||||
|
DataCell(Text(formatCurrency.format(inv.totalAmount), style: TextStyle(color: Colors.red.shade700, fontWeight: FontWeight.bold))),
|
||||||
|
DataCell(Text(formatCurrency.format(runningBalance), style: const TextStyle(fontWeight: FontWeight.bold))),
|
||||||
|
],
|
||||||
|
));
|
||||||
|
|
||||||
|
// Add row for Payment (Credit)
|
||||||
|
if (inv.payments != null && inv.payments!.isNotEmpty) {
|
||||||
|
for (var p in inv.payments!) {
|
||||||
|
runningBalance -= p.amount;
|
||||||
|
final walletName = wallets.firstWhere((w) => w.id == p.walletId, orElse: () => wallets.first).name;
|
||||||
|
rows.add(DataRow(
|
||||||
|
cells: [
|
||||||
|
DataCell(Text('${index++}')),
|
||||||
|
DataCell(Text(p.paymentDate != null ? DateFormat('dd MMM yyyy').format(p.paymentDate!) : DateFormat('dd MMM yyyy').format(inv.issueDate))),
|
||||||
|
DataCell(Text(inv.invoiceNumber, style: const TextStyle(fontWeight: FontWeight.w600))),
|
||||||
|
const DataCell(Text('Payment')),
|
||||||
|
DataCell(Text(p.paymentMethod)),
|
||||||
|
DataCell(Text(p.walletId != null ? walletName : '-')),
|
||||||
|
DataCell(Text(formatCurrency.format(p.amount), style: TextStyle(color: Colors.green.shade700, fontWeight: FontWeight.bold))),
|
||||||
|
DataCell(Text(formatCurrency.format(runningBalance), style: const TextStyle(fontWeight: FontWeight.bold))),
|
||||||
|
],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} else if (inv.amountPaid > 0) {
|
||||||
|
runningBalance -= inv.amountPaid;
|
||||||
|
rows.add(DataRow(
|
||||||
|
cells: [
|
||||||
|
DataCell(Text('${index++}')),
|
||||||
|
DataCell(Text(DateFormat('dd MMM yyyy').format(inv.issueDate))),
|
||||||
|
DataCell(Text(inv.invoiceNumber, style: const TextStyle(fontWeight: FontWeight.w600))),
|
||||||
|
const DataCell(Text('Payment')),
|
||||||
|
const DataCell(Text('-')),
|
||||||
|
const DataCell(Text('-')),
|
||||||
|
DataCell(Text(formatCurrency.format(inv.amountPaid), style: TextStyle(color: Colors.green.shade700, fontWeight: FontWeight.bold))),
|
||||||
|
DataCell(Text(formatCurrency.format(runningBalance), style: const TextStyle(fontWeight: FontWeight.bold))),
|
||||||
|
],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
color: Colors.blue.withOpacity(0.05),
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
const Text('Outstanding Balance', style: TextStyle(color: Colors.grey, fontSize: 14)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
formatCurrency.format(runningBalance),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: runningBalance > 0 ? Colors.red.shade700 : Colors.green.shade700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.vertical,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
child: DataTable(
|
||||||
|
headingRowColor: WidgetStateProperty.resolveWith((states) => Colors.grey.shade100),
|
||||||
|
columnSpacing: 24,
|
||||||
|
columns: const [
|
||||||
|
DataColumn(label: Text('S.No', style: TextStyle(fontWeight: FontWeight.bold))),
|
||||||
|
DataColumn(label: Text('Date', style: TextStyle(fontWeight: FontWeight.bold))),
|
||||||
|
DataColumn(label: Text('Invoice #', style: TextStyle(fontWeight: FontWeight.bold))),
|
||||||
|
DataColumn(label: Text('Type', style: TextStyle(fontWeight: FontWeight.bold))),
|
||||||
|
DataColumn(label: Text('Mode', style: TextStyle(fontWeight: FontWeight.bold))),
|
||||||
|
DataColumn(label: Text('Wallet', style: TextStyle(fontWeight: FontWeight.bold))),
|
||||||
|
DataColumn(label: Text('Amount', style: TextStyle(fontWeight: FontWeight.bold))),
|
||||||
|
DataColumn(label: Text('Balance', style: TextStyle(fontWeight: FontWeight.bold))),
|
||||||
|
],
|
||||||
|
rows: rows,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
|
import '../providers/customers_provider.dart';
|
||||||
|
import 'dart:async';
|
||||||
|
import '../../../core/widgets/shimmer_loading.dart';
|
||||||
|
import 'add_customer_sheet.dart';
|
||||||
|
import 'customer_ledger_screen.dart';
|
||||||
|
import '../providers/invoices_provider.dart';
|
||||||
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart' as import_storage;
|
||||||
|
import '../../../core/network/dio_client.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
class CustomersListScreen extends ConsumerStatefulWidget {
|
||||||
|
const CustomersListScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<CustomersListScreen> createState() => _CustomersListScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CustomersListScreenState extends ConsumerState<CustomersListScreen> {
|
||||||
|
final TextEditingController _searchCtrl = TextEditingController();
|
||||||
|
Timer? _debounce;
|
||||||
|
String? _token;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
import_storage.FlutterSecureStorage().read(key: 'jwt_token').then((val) {
|
||||||
|
if (mounted) setState(() => _token = val);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_searchCtrl.dispose();
|
||||||
|
_debounce?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onSearchChanged(String val) {
|
||||||
|
if (_debounce?.isActive ?? false) _debounce!.cancel();
|
||||||
|
_debounce = Timer(const Duration(milliseconds: 500), () {
|
||||||
|
ref.read(customersProvider.notifier).refresh(val);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final customersState = ref.watch(customersProvider);
|
||||||
|
final formatCurrency = NumberFormat.currency(symbol: '₹', decimalDigits: 0);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.grey[100],
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Customers', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
elevation: 0,
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
foregroundColor: Colors.black,
|
||||||
|
centerTitle: true,
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
color: Colors.white,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
child: TextField(
|
||||||
|
controller: _searchCtrl,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Search by name or phone...',
|
||||||
|
prefixIcon: const Icon(LucideIcons.search, color: Colors.grey),
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.grey[100],
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
|
),
|
||||||
|
onChanged: _onSearchChanged,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: customersState.when(
|
||||||
|
loading: () => ListView.builder(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
itemCount: 5,
|
||||||
|
itemBuilder: (context, index) => const Padding(
|
||||||
|
padding: EdgeInsets.only(bottom: 12),
|
||||||
|
child: ShimmerCard(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
error: (err, stack) => Center(child: Text('Error: $err')),
|
||||||
|
data: (customers) {
|
||||||
|
if (customers.isEmpty) {
|
||||||
|
return const Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(LucideIcons.users, size: 64, color: Colors.grey),
|
||||||
|
SizedBox(height: 16),
|
||||||
|
Text('No customers found.', style: TextStyle(color: Colors.grey, fontSize: 16)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: () async {
|
||||||
|
await ref.read(customersProvider.notifier).refresh(_searchCtrl.text);
|
||||||
|
},
|
||||||
|
child: ListView.builder(
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
itemCount: customers.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final customer = customers[index];
|
||||||
|
|
||||||
|
return Consumer(
|
||||||
|
builder: (context, ref, _) {
|
||||||
|
final invoicesState = ref.watch(invoicesProvider);
|
||||||
|
double outstandingBalance = 0.0;
|
||||||
|
if (invoicesState.hasValue) {
|
||||||
|
for (var inv in invoicesState.value!) {
|
||||||
|
if (inv.customerId == customer.id && inv.status != 'PAID' && inv.status != 'CANCELLED' && inv.status != 'DRAFT') {
|
||||||
|
outstandingBalance += (inv.totalAmount - inv.amountPaid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(color: Colors.grey.withOpacity(0.08), blurRadius: 10, offset: const Offset(0, 4)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.push(context, MaterialPageRoute(builder: (_) => CustomerLedgerScreen(customer: customer)));
|
||||||
|
},
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Avatar
|
||||||
|
Hero(
|
||||||
|
tag: 'avatar_${customer.id}',
|
||||||
|
child: CircleAvatar(
|
||||||
|
radius: 30,
|
||||||
|
backgroundColor: Colors.blue.shade50,
|
||||||
|
backgroundImage: (customer.photoUrl != null && _token != null)
|
||||||
|
? NetworkImage(
|
||||||
|
'${DioClient().dio.options.baseUrl}/customers/${customer.id}/photo/content',
|
||||||
|
headers: {'Authorization': 'Bearer $_token'},
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
child: (customer.photoUrl == null)
|
||||||
|
? Text(customer.name.isNotEmpty ? customer.name[0].toUpperCase() : '?', style: TextStyle(color: Colors.blue.shade700, fontWeight: FontWeight.bold, fontSize: 24))
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
// Details
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(customer.name, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18, color: Colors.black87)),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
if (customer.phone != null && customer.phone!.isNotEmpty)
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(LucideIcons.phone, size: 14, color: Colors.grey.shade600),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(customer.phone!, style: TextStyle(color: Colors.grey.shade700, fontSize: 14)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (customer.gstin != null && customer.gstin!.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(LucideIcons.building, size: 14, color: Colors.grey.shade600),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(customer.gstin!, style: TextStyle(color: Colors.grey.shade700, fontSize: 13, fontWeight: FontWeight.w600)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: outstandingBalance > 0 ? Colors.red.shade50 : Colors.green.shade50,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
outstandingBalance > 0 ? 'Pending: ${formatCurrency.format(outstandingBalance)}' : 'Settled',
|
||||||
|
style: TextStyle(
|
||||||
|
color: outstandingBalance > 0 ? Colors.red.shade700 : Colors.green.shade700,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(LucideIcons.edit2, size: 20, color: Colors.blue),
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
constraints: const BoxConstraints(),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
onPressed: () {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (context) => AddCustomerSheet(customer: customer),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(LucideIcons.trash2, size: 20, color: Colors.redAccent),
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
constraints: const BoxConstraints(),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
onPressed: () {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('Delete Customer'),
|
||||||
|
content: Text('Are you sure you want to delete ${customer.name}? This action cannot be undone.'),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Cancel')),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
ref.read(customersProvider.notifier).deleteCustomer(customer.id!);
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
},
|
||||||
|
child: const Text('Delete', style: TextStyle(color: Colors.red)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
floatingActionButton: FloatingActionButton.extended(
|
||||||
|
onPressed: () {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (context) => const AddCustomerSheet(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
icon: const Icon(LucideIcons.plus),
|
||||||
|
label: const Text('Add Customer', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
backgroundColor: Colors.blue,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,708 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import '../../business/providers/business_provider.dart';
|
||||||
|
import '../../../core/widgets/barcode_scanner_screen.dart';
|
||||||
|
import '../../../core/widgets/smart_search_dropdown.dart';
|
||||||
|
import '../../../core/widgets/premium_text_field.dart';
|
||||||
|
import '../providers/invoices_provider.dart';
|
||||||
|
import '../domain/invoice.dart';
|
||||||
|
import '../providers/customers_provider.dart';
|
||||||
|
import '../domain/customer.dart';
|
||||||
|
import '../../inventory/providers/products_provider.dart';
|
||||||
|
import '../../inventory/domain/product.dart';
|
||||||
|
import 'add_customer_sheet.dart';
|
||||||
|
import '../../transactions/providers/providers.dart';
|
||||||
|
|
||||||
|
class InvoiceBuilderScreen extends ConsumerStatefulWidget {
|
||||||
|
const InvoiceBuilderScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<InvoiceBuilderScreen> createState() => _InvoiceBuilderScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InvoiceBuilderScreenState extends ConsumerState<InvoiceBuilderScreen> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
Customer? _selectedCustomer;
|
||||||
|
final List<InvoiceItem> _items = [];
|
||||||
|
bool _isEmi = false;
|
||||||
|
String _emiCycle = 'MONTHLY';
|
||||||
|
final TextEditingController _emiAmountCtrl = TextEditingController();
|
||||||
|
final TextEditingController _invoiceNumberCtrl = TextEditingController(text: 'INV-${DateTime.now().millisecondsSinceEpoch.toString().substring(7)}');
|
||||||
|
final TextEditingController _invoiceDiscountCtrl = TextEditingController();
|
||||||
|
bool _invoiceDiscountIsPerc = false;
|
||||||
|
DateTime _invoiceDate = DateTime.now();
|
||||||
|
final TextEditingController _amountPaidCtrl = TextEditingController();
|
||||||
|
bool _isAmountPaidEdited = false;
|
||||||
|
DateTime? _nextPaymentDate;
|
||||||
|
String _paymentMethod = 'Cash';
|
||||||
|
int? _selectedWalletId;
|
||||||
|
|
||||||
|
double get _subtotal => _items.fold(0, (sum, item) => sum + (item.quantity * item.unitPrice));
|
||||||
|
double get _taxTotal => _items.fold(0, (sum, item) => sum + (item.quantity * item.unitPrice * (item.taxRate / 100)));
|
||||||
|
double get _itemDiscountTotal => _items.fold(0, (sum, item) => sum + item.discount);
|
||||||
|
|
||||||
|
double get _invoiceDiscountAmount {
|
||||||
|
final raw = double.tryParse(_invoiceDiscountCtrl.text) ?? 0;
|
||||||
|
if (_invoiceDiscountIsPerc) {
|
||||||
|
final taxableAmount = _subtotal + _makingChargeTotal + _otherChargesTotal;
|
||||||
|
return taxableAmount * (raw / 100);
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
double get _discountTotal => _itemDiscountTotal + _invoiceDiscountAmount;
|
||||||
|
double get _makingChargeTotal => _items.fold(0, (sum, item) => sum + item.makingCharge);
|
||||||
|
double get _otherChargesTotal => _items.fold(0, (sum, item) => sum + item.otherCharges);
|
||||||
|
double get _grandTotal => _subtotal + _taxTotal + _makingChargeTotal + _otherChargesTotal - _discountTotal;
|
||||||
|
|
||||||
|
double get _amountPaid {
|
||||||
|
if (!_isAmountPaidEdited) return _grandTotal;
|
||||||
|
final raw = double.tryParse(_amountPaidCtrl.text) ?? 0;
|
||||||
|
return raw > _grandTotal ? _grandTotal : raw; // Cap at grand total
|
||||||
|
}
|
||||||
|
double get _balanceDue => _grandTotal - _amountPaid;
|
||||||
|
|
||||||
|
Future<void> _saveInvoice() async {
|
||||||
|
if (!_formKey.currentState!.validate() || _items.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Please add items and fill all required fields.')));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final invoice = Invoice(
|
||||||
|
customerId: _selectedCustomer?.id,
|
||||||
|
invoiceNumber: _invoiceNumberCtrl.text,
|
||||||
|
issueDate: _invoiceDate,
|
||||||
|
dueDate: DateTime.now().add(const Duration(days: 30)),
|
||||||
|
subtotal: _subtotal,
|
||||||
|
taxTotal: _taxTotal,
|
||||||
|
discountTotal: _discountTotal,
|
||||||
|
totalAmount: _grandTotal,
|
||||||
|
amountPaid: _amountPaid,
|
||||||
|
paymentMethod: _amountPaid > 0 ? _paymentMethod : null,
|
||||||
|
paymentWalletId: _amountPaid > 0 ? (_selectedWalletId ?? (ref.read(walletProvider).value?.firstOrNull?.id)) : null,
|
||||||
|
nextPaymentDate: _balanceDue > 0 ? (_nextPaymentDate ?? DateTime.now().add(const Duration(days: 30))) : null,
|
||||||
|
isEmi: _isEmi,
|
||||||
|
emiAmount: _isEmi && _emiAmountCtrl.text.isNotEmpty ? double.parse(_emiAmountCtrl.text) : null,
|
||||||
|
emiCycle: _isEmi ? _emiCycle : null,
|
||||||
|
emiStartDate: _isEmi ? DateTime.now().add(const Duration(days: 30)) : null,
|
||||||
|
items: _items,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ref.read(invoicesProvider.notifier).createInvoice(invoice);
|
||||||
|
if (mounted) {
|
||||||
|
Navigator.pop(context);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Invoice Created!')));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showAddItemDialog() {
|
||||||
|
Product? selectedProduct;
|
||||||
|
final TextEditingController descCtrl = TextEditingController();
|
||||||
|
final TextEditingController qtyCtrl = TextEditingController(text: '1');
|
||||||
|
final TextEditingController priceCtrl = TextEditingController();
|
||||||
|
final TextEditingController taxCtrl = TextEditingController(text: '0');
|
||||||
|
final TextEditingController discountCtrl = TextEditingController(text: '0');
|
||||||
|
final TextEditingController makingCtrl = TextEditingController(text: '0');
|
||||||
|
final TextEditingController otherCtrl = TextEditingController(text: '0');
|
||||||
|
String uomStr = '';
|
||||||
|
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) {
|
||||||
|
bool isDiscPerc = false;
|
||||||
|
return StatefulBuilder(
|
||||||
|
builder: (context, setDialogState) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Add Line Item'),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Consumer(
|
||||||
|
builder: (context, dialogRef, _) {
|
||||||
|
final productsState = dialogRef.watch(productsProvider);
|
||||||
|
return productsState.when(
|
||||||
|
data: (products) => Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: SmartSearchDropdown<Product>(
|
||||||
|
hintText: 'Select Product (Optional)',
|
||||||
|
value: selectedProduct,
|
||||||
|
items: products,
|
||||||
|
itemAsString: (p) => '${p.name} ${p.sku != null && p.sku!.isNotEmpty ? "(${p.sku})" : ""} (₹${p.sellingPrice})',
|
||||||
|
onChanged: (p) {
|
||||||
|
setDialogState(() {
|
||||||
|
selectedProduct = p;
|
||||||
|
if (p != null) {
|
||||||
|
descCtrl.text = p.name;
|
||||||
|
priceCtrl.text = p.sellingPrice?.toString() ?? '0';
|
||||||
|
taxCtrl.text = p.gstRate?.toString() ?? '0';
|
||||||
|
makingCtrl.text = p.makingCharges?.toString() ?? '0';
|
||||||
|
uomStr = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
IconButton(
|
||||||
|
onPressed: () async {
|
||||||
|
final String? code = await Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (_) => const BarcodeScannerScreen()),
|
||||||
|
);
|
||||||
|
if (code != null && code.isNotEmpty) {
|
||||||
|
final source = ref.read(businessFeatureProvider).value?.barcodeSource ?? 'SKU';
|
||||||
|
Product? matched;
|
||||||
|
if (source == 'BARCODE') {
|
||||||
|
matched = products.cast<Product?>().firstWhere((p) => p?.barcode == code, orElse: () => null);
|
||||||
|
} else {
|
||||||
|
matched = products.cast<Product?>().firstWhere((p) => p?.sku == code, orElse: () => null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matched != null) {
|
||||||
|
setDialogState(() {
|
||||||
|
selectedProduct = matched;
|
||||||
|
descCtrl.text = matched!.name;
|
||||||
|
priceCtrl.text = matched!.sellingPrice?.toString() ?? '0';
|
||||||
|
taxCtrl.text = matched!.gstRate?.toString() ?? '0';
|
||||||
|
makingCtrl.text = matched!.makingCharges?.toString() ?? '0';
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('No product found for $source: $code')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icon: const Icon(LucideIcons.scanLine),
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
tooltip: 'Scan Barcode',
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (e, stack) => Text('Error loading products: $e'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
PremiumTextField(controller: descCtrl, labelText: 'Description'),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: PremiumTextField(controller: qtyCtrl, labelText: uomStr.isNotEmpty ? 'Qty ($uomStr)' : 'Quantity', keyboardType: TextInputType.number)),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(child: PremiumTextField(controller: priceCtrl, labelText: 'Unit Price', keyboardType: TextInputType.number)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: PremiumTextField(controller: taxCtrl, labelText: 'Tax %', keyboardType: TextInputType.number)),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: PremiumTextField(
|
||||||
|
controller: discountCtrl,
|
||||||
|
labelText: isDiscPerc ? 'Discount %' : 'Discount (₹)',
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
icon: Icon(isDiscPerc ? LucideIcons.percent : LucideIcons.indianRupee, size: 16),
|
||||||
|
onPressed: () => setDialogState(() => isDiscPerc = !isDiscPerc),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: PremiumTextField(controller: makingCtrl, labelText: 'Making Chg', keyboardType: TextInputType.number)),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(child: PremiumTextField(controller: otherCtrl, labelText: 'Other Chg', keyboardType: TextInputType.number)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
final qty = double.tryParse(qtyCtrl.text) ?? 1;
|
||||||
|
final price = double.tryParse(priceCtrl.text) ?? 0;
|
||||||
|
final tax = double.tryParse(taxCtrl.text) ?? 0;
|
||||||
|
final discRaw = double.tryParse(discountCtrl.text) ?? 0;
|
||||||
|
final disc = isDiscPerc ? ((qty * price) * (discRaw / 100)) : discRaw;
|
||||||
|
final making = double.tryParse(makingCtrl.text) ?? 0;
|
||||||
|
final other = double.tryParse(otherCtrl.text) ?? 0;
|
||||||
|
final total = (qty * price) + (qty * price * (tax / 100)) + making + other - disc;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_items.add(InvoiceItem(
|
||||||
|
productId: selectedProduct?.id,
|
||||||
|
sku: selectedProduct?.sku,
|
||||||
|
description: descCtrl.text,
|
||||||
|
quantity: qty,
|
||||||
|
unitPrice: price,
|
||||||
|
taxRate: tax,
|
||||||
|
discount: disc,
|
||||||
|
makingCharge: making,
|
||||||
|
otherCharges: other,
|
||||||
|
total: total,
|
||||||
|
));
|
||||||
|
});
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
child: const Text('Add Item'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _scanAndAddBarcode() async {
|
||||||
|
final barcode = await Navigator.push<String>(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (_) => const BarcodeScannerScreen()),
|
||||||
|
);
|
||||||
|
if (barcode != null && barcode.isNotEmpty) {
|
||||||
|
final products = ref.read(productsProvider).value ?? [];
|
||||||
|
final businessFeature = ref.read(businessFeatureProvider).value;
|
||||||
|
final bool useBarcodeField = businessFeature?.barcodeSource == 'BARCODE';
|
||||||
|
|
||||||
|
final p = products.where((p) {
|
||||||
|
if (useBarcodeField) {
|
||||||
|
return p.barcode?.toLowerCase() == barcode.toLowerCase();
|
||||||
|
} else {
|
||||||
|
return p.sku?.toLowerCase() == barcode.toLowerCase();
|
||||||
|
}
|
||||||
|
}).firstOrNull;
|
||||||
|
|
||||||
|
if (p != null) {
|
||||||
|
setState(() {
|
||||||
|
final existingIndex = _items.indexWhere((item) => item.productId == p.id);
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
// Increment qty
|
||||||
|
final item = _items[existingIndex];
|
||||||
|
final newQty = item.quantity + 1;
|
||||||
|
final newTotal = (newQty * item.unitPrice) + (newQty * item.unitPrice * (item.taxRate / 100)) + item.makingCharge + item.otherCharges - item.discount;
|
||||||
|
_items[existingIndex] = item.copyWith(quantity: newQty, total: newTotal);
|
||||||
|
} else {
|
||||||
|
// Add new
|
||||||
|
final price = p.sellingPrice ?? 0;
|
||||||
|
final tax = p.gstRate ?? 0;
|
||||||
|
final making = p.makingCharges ?? 0;
|
||||||
|
final total = price + (price * (tax / 100)) + making;
|
||||||
|
_items.add(InvoiceItem(
|
||||||
|
productId: p.id,
|
||||||
|
sku: p.sku,
|
||||||
|
description: p.name,
|
||||||
|
quantity: 1,
|
||||||
|
unitPrice: price,
|
||||||
|
taxRate: tax,
|
||||||
|
makingCharge: making,
|
||||||
|
otherCharges: 0,
|
||||||
|
discount: 0,
|
||||||
|
total: total,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${p.name} to invoice')));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Product not found for barcode: $barcode')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final customersState = ref.watch(customersProvider);
|
||||||
|
final formatCurrency = NumberFormat.currency(symbol: '₹');
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.grey[50],
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Create Invoice'),
|
||||||
|
elevation: 0,
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
foregroundColor: Colors.black,
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: _saveInvoice,
|
||||||
|
child: const Text('Save Invoice', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.blue)),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: GestureDetector(
|
||||||
|
onTap: () => FocusScope.of(context).unfocus(),
|
||||||
|
child: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Customer Selection
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16)),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
const Text('Customer', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (_) => const AddCustomerSheet(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
icon: const Icon(LucideIcons.plus, size: 16),
|
||||||
|
label: const Text('Add Customer'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
customersState.when(
|
||||||
|
loading: () => const CircularProgressIndicator(),
|
||||||
|
error: (err, stack) => Text('Error loading customers: $err'),
|
||||||
|
data: (customers) => SmartSearchDropdown<Customer>(
|
||||||
|
hintText: 'Search a Customer...',
|
||||||
|
value: _selectedCustomer,
|
||||||
|
items: customers,
|
||||||
|
itemAsString: (c) => c.name,
|
||||||
|
onChanged: (val) => setState(() => _selectedCustomer = val),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Invoice Details
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16)),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text('Invoice Details', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
PremiumTextField(
|
||||||
|
controller: _invoiceNumberCtrl,
|
||||||
|
labelText: 'Invoice Number',
|
||||||
|
validator: (val) => val == null || val.isEmpty ? 'Required' : null,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: const Text('Invoice Date'),
|
||||||
|
subtitle: Text(DateFormat('yyyy-MM-dd').format(_invoiceDate)),
|
||||||
|
trailing: const Icon(LucideIcons.calendar),
|
||||||
|
onTap: () async {
|
||||||
|
final dt = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: _invoiceDate,
|
||||||
|
firstDate: DateTime(2000),
|
||||||
|
lastDate: DateTime(2100),
|
||||||
|
);
|
||||||
|
if (dt != null) {
|
||||||
|
setState(() => _invoiceDate = dt);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Items
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16)),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
const Text('Line Items', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(LucideIcons.scanLine, color: Colors.blue),
|
||||||
|
onPressed: _scanAndAddBarcode,
|
||||||
|
tooltip: 'Scan to add',
|
||||||
|
),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: _showAddItemDialog,
|
||||||
|
icon: const Icon(LucideIcons.plus),
|
||||||
|
label: const Text('Add'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (_items.isEmpty)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 16.0),
|
||||||
|
child: Center(child: Text('No items added', style: TextStyle(color: Colors.grey))),
|
||||||
|
),
|
||||||
|
for (var i = 0; i < _items.length; i++)
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey[50],
|
||||||
|
border: Border.all(color: Colors.grey[200]!),
|
||||||
|
borderRadius: BorderRadius.circular(12)
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(_items[i].description ?? 'Item ${i+1}', style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 4,
|
||||||
|
children: [
|
||||||
|
if (_items[i].sku != null && _items[i].sku!.isNotEmpty) Text('SKU: ${_items[i].sku}', style: const TextStyle(fontSize: 12, color: Colors.grey)),
|
||||||
|
Text('Qty: ${_items[i].quantity}', style: const TextStyle(fontSize: 12, color: Colors.grey)),
|
||||||
|
Text('Rate: ${formatCurrency.format(_items[i].unitPrice)}', style: const TextStyle(fontSize: 12, color: Colors.grey)),
|
||||||
|
if (_items[i].taxRate > 0) Text('Tax: ${_items[i].taxRate}%', style: const TextStyle(fontSize: 12, color: Colors.grey)),
|
||||||
|
if (_items[i].makingCharge > 0) Text('Making: ${formatCurrency.format(_items[i].makingCharge)}', style: const TextStyle(fontSize: 12, color: Colors.grey)),
|
||||||
|
if (_items[i].otherCharges > 0) Text('Other: ${formatCurrency.format(_items[i].otherCharges)}', style: const TextStyle(fontSize: 12, color: Colors.grey)),
|
||||||
|
if (_items[i].discount > 0) Text('Disc: -${formatCurrency.format(_items[i].discount)}', style: const TextStyle(fontSize: 12, color: Colors.red)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
Text(formatCurrency.format(_items[i].total), style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.blue)),
|
||||||
|
IconButton(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(),
|
||||||
|
icon: const Icon(LucideIcons.trash2, color: Colors.red, size: 18),
|
||||||
|
onPressed: () => setState(() => _items.removeAt(i)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
const Text('Invoice Discount', style: TextStyle(fontWeight: FontWeight.w600)),
|
||||||
|
SizedBox(
|
||||||
|
width: 150,
|
||||||
|
child: TextField(
|
||||||
|
controller: _invoiceDiscountCtrl,
|
||||||
|
textAlign: TextAlign.right,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: '0.00',
|
||||||
|
isDense: true,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
prefixIcon: IconButton(
|
||||||
|
icon: Icon(_invoiceDiscountIsPerc ? LucideIcons.percent : LucideIcons.indianRupee, size: 16),
|
||||||
|
onPressed: () => setState(() => _invoiceDiscountIsPerc = !_invoiceDiscountIsPerc),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
|
onChanged: (val) {
|
||||||
|
setState(() {
|
||||||
|
if (!_isAmountPaidEdited) {
|
||||||
|
_amountPaidCtrl.text = _grandTotal.toStringAsFixed(2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
const Text('Total Amount', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)),
|
||||||
|
Text(formatCurrency.format(_grandTotal), style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 20, color: Colors.blue)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const Divider(height: 32),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
const Text('Amount Paid Now', style: TextStyle(fontWeight: FontWeight.w600)),
|
||||||
|
SizedBox(
|
||||||
|
width: 150,
|
||||||
|
child: TextField(
|
||||||
|
controller: _amountPaidCtrl,
|
||||||
|
textAlign: TextAlign.right,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: _grandTotal.toStringAsFixed(2),
|
||||||
|
isDense: true,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
),
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
|
onChanged: (val) => setState(() {
|
||||||
|
_isAmountPaidEdited = true;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (_amountPaid > 0) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
const Text('Receive Into Wallet', style: TextStyle(fontWeight: FontWeight.w600)),
|
||||||
|
SizedBox(
|
||||||
|
width: 150,
|
||||||
|
child: Consumer(
|
||||||
|
builder: (context, consumerRef, _) {
|
||||||
|
final walletsState = consumerRef.watch(walletProvider);
|
||||||
|
final wallets = walletsState.value ?? [];
|
||||||
|
return DropdownButtonHideUnderline(
|
||||||
|
child: DropdownButton<int>(
|
||||||
|
value: _selectedWalletId ?? wallets.firstOrNull?.id,
|
||||||
|
isDense: true,
|
||||||
|
hint: const Text('Wallet'),
|
||||||
|
items: wallets
|
||||||
|
.map((w) => DropdownMenuItem(value: w.id, child: Text(w.name)))
|
||||||
|
.toList(),
|
||||||
|
onChanged: (val) => setState(() => _selectedWalletId = val),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
const Text('Payment Method', style: TextStyle(fontWeight: FontWeight.w600)),
|
||||||
|
SizedBox(
|
||||||
|
width: 150,
|
||||||
|
child: DropdownButtonHideUnderline(
|
||||||
|
child: DropdownButton<String>(
|
||||||
|
value: _paymentMethod,
|
||||||
|
isDense: true,
|
||||||
|
items: ['Cash', 'UPI', 'Bank Transfer', 'Card']
|
||||||
|
.map((m) => DropdownMenuItem(value: m, child: Text(m)))
|
||||||
|
.toList(),
|
||||||
|
onChanged: (val) => setState(() => _paymentMethod = val!),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
const Text('Balance Due', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.red)),
|
||||||
|
Text(formatCurrency.format(_balanceDue), style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.red)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (_balanceDue > 0) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
SwitchListTile(
|
||||||
|
title: const Text('Enable EMI / Installments'),
|
||||||
|
value: _isEmi,
|
||||||
|
onChanged: (val) => setState(() => _isEmi = val),
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
if (_isEmi) ...[
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: PremiumTextField(
|
||||||
|
controller: _emiAmountCtrl,
|
||||||
|
labelText: 'EMI Amount',
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: SmartSearchDropdown<String>(
|
||||||
|
hintText: 'Cycle',
|
||||||
|
value: _emiCycle,
|
||||||
|
items: const ['MONTHLY', 'WEEKLY'],
|
||||||
|
itemAsString: (val) => val == 'MONTHLY' ? 'Monthly' : 'Weekly',
|
||||||
|
onChanged: (val) => setState(() => _emiCycle = val!),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
] else ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: const Text('Next Payment Date', style: TextStyle(fontWeight: FontWeight.w600)),
|
||||||
|
subtitle: Text(_nextPaymentDate == null
|
||||||
|
? 'Not Selected'
|
||||||
|
: DateFormat('yyyy-MM-dd').format(_nextPaymentDate!)),
|
||||||
|
trailing: const Icon(LucideIcons.calendar),
|
||||||
|
onTap: () async {
|
||||||
|
final dt = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: _nextPaymentDate ?? DateTime.now().add(const Duration(days: 30)),
|
||||||
|
firstDate: DateTime.now(),
|
||||||
|
lastDate: DateTime(2100),
|
||||||
|
);
|
||||||
|
if (dt != null) {
|
||||||
|
setState(() => _nextPaymentDate = dt);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,789 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:screenshot/screenshot.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:share_plus/share_plus.dart';
|
||||||
|
import 'package:pdf/pdf.dart';
|
||||||
|
import 'package:pdf/widgets.dart' as pw;
|
||||||
|
import '../../inventory/providers/products_provider.dart';
|
||||||
|
import '../domain/invoice.dart';
|
||||||
|
import '../providers/invoices_provider.dart';
|
||||||
|
import '../providers/customers_provider.dart';
|
||||||
|
import '../../business/providers/business_provider.dart';
|
||||||
|
import 'widgets/receive_payment_sheet.dart';
|
||||||
|
|
||||||
|
class InvoiceDetailsScreen extends ConsumerStatefulWidget {
|
||||||
|
final Invoice invoice;
|
||||||
|
|
||||||
|
const InvoiceDetailsScreen({super.key, required this.invoice});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<InvoiceDetailsScreen> createState() => _InvoiceDetailsScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
|
||||||
|
final ScreenshotController _screenshotController = ScreenshotController();
|
||||||
|
|
||||||
|
void _showReceivePaymentSheet(BuildContext context, WidgetRef ref, Invoice latestInvoice) async {
|
||||||
|
final result = await showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (context) => ReceivePaymentSheet(invoice: latestInvoice),
|
||||||
|
);
|
||||||
|
if (result == true) {
|
||||||
|
ref.read(invoicesProvider.notifier).refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final invoicesState = ref.watch(invoicesProvider);
|
||||||
|
final latestInvoice = invoicesState.value?.firstWhere(
|
||||||
|
(i) => i.id == widget.invoice.id,
|
||||||
|
orElse: () => widget.invoice,
|
||||||
|
) ?? widget.invoice;
|
||||||
|
|
||||||
|
final customersState = ref.watch(customersProvider);
|
||||||
|
final customer = customersState.value?.firstWhere(
|
||||||
|
(c) => c.id == latestInvoice.customerId,
|
||||||
|
orElse: () => null as dynamic,
|
||||||
|
);
|
||||||
|
|
||||||
|
final businessState = ref.watch(businessProfileProvider);
|
||||||
|
final business = businessState.value;
|
||||||
|
|
||||||
|
// Watch products so the UI rebuilds if products are loaded asynchronously
|
||||||
|
ref.watch(productsProvider);
|
||||||
|
|
||||||
|
final formatCurrency = NumberFormat.currency(symbol: '₹');
|
||||||
|
final formatDate = DateFormat('dd MMM yyyy');
|
||||||
|
|
||||||
|
double remaining = latestInvoice.totalAmount - latestInvoice.amountPaid;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.grey[50],
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text('Invoice #${latestInvoice.invoiceNumber}', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
foregroundColor: Colors.black,
|
||||||
|
elevation: 0,
|
||||||
|
centerTitle: true,
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(LucideIcons.share2, size: 20),
|
||||||
|
onPressed: () => _showShareOptions(context, latestInvoice.invoiceNumber),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: SingleChildScrollView(
|
||||||
|
child: Screenshot(
|
||||||
|
controller: _screenshotController,
|
||||||
|
child: Container(
|
||||||
|
color: Colors.grey[50],
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
// Biller Header Card (Premium Dark)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [Colors.blue.shade900, Colors.blue.shade800],
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(color: Colors.blue.withOpacity(0.2), blurRadius: 15, offset: const Offset(0, 5)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
business?.businessName ?? 'Your Company Name',
|
||||||
|
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withOpacity(0.2),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
latestInvoice.status,
|
||||||
|
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 12, letterSpacing: 1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
if (business?.address != null && business!.address!.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 4),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(LucideIcons.mapPin, size: 14, color: Colors.blue.shade200),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(child: Text(business.address!, style: TextStyle(color: Colors.blue.shade100, fontSize: 13, height: 1.4))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (business?.contactNumber != null && business!.contactNumber!.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(LucideIcons.phone, size: 14, color: Colors.blue.shade200),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(business.contactNumber!, style: TextStyle(color: Colors.blue.shade100, fontSize: 13)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (business?.gstin != null && business!.gstin!.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(LucideIcons.building, size: 14, color: Colors.blue.shade200),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text('GSTIN: ${business.gstin}', style: const TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.bold)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// Invoice Dates & Customer Details Row
|
||||||
|
IntrinsicHeight(
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
// Dates
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: Colors.grey.shade200),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text('INVOICE NO', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.grey, letterSpacing: 1)),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(latestInvoice.invoiceNumber, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Colors.blue)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text('INVOICE DATE', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.grey, letterSpacing: 1)),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(formatDate.format(latestInvoice.issueDate), style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text('DUE DATE', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.grey, letterSpacing: 1)),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(latestInvoice.dueDate != null ? formatDate.format(latestInvoice.dueDate!) : '-', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
// Bill To
|
||||||
|
Expanded(
|
||||||
|
flex: 3,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: Colors.grey.shade200),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text('BILL TO', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.grey, letterSpacing: 1)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
if (customer != null) ...[
|
||||||
|
Text(customer.name, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold, height: 1.2)),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
if (customer.address != null && customer.address!.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 4),
|
||||||
|
child: Text(customer.address!, style: TextStyle(fontSize: 13, color: Colors.grey.shade700, height: 1.3)),
|
||||||
|
),
|
||||||
|
if (customer.phone != null && customer.phone!.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 4),
|
||||||
|
child: Text(customer.phone!, style: TextStyle(fontSize: 13, color: Colors.grey.shade700)),
|
||||||
|
),
|
||||||
|
if (customer.gstin != null && customer.gstin!.isNotEmpty)
|
||||||
|
Text('GSTIN: ${customer.gstin}', style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: Colors.blue)),
|
||||||
|
] else ...[
|
||||||
|
const Text('Walk-in Customer', style: TextStyle(fontSize: 14, fontStyle: FontStyle.italic, color: Colors.grey)),
|
||||||
|
]
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 4),
|
||||||
|
child: Text('Itemized Details', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// Item Cards instead of DataTable
|
||||||
|
...(latestInvoice.items ?? []).map((item) {
|
||||||
|
final product = item.productId != null ? ref.read(productsProvider).value?.where((p) => p.id == item.productId).firstOrNull : null;
|
||||||
|
final skuToDisplay = item.sku ?? product?.sku;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: Colors.grey.shade200),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(color: Colors.grey.withOpacity(0.03), blurRadius: 5, offset: const Offset(0, 2)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
item.description ?? 'Item',
|
||||||
|
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
formatCurrency.format(item.total),
|
||||||
|
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.black),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (skuToDisplay != null && skuToDisplay.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4),
|
||||||
|
child: Text('SKU: $skuToDisplay', style: TextStyle(fontSize: 12, color: Colors.grey.shade600, fontWeight: FontWeight.w600)),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade50,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text('${item.quantity.toStringAsFixed(item.quantity.truncateToDouble() == item.quantity ? 0 : 2)} x ${formatCurrency.format(item.unitPrice)}', style: TextStyle(fontSize: 13, color: Colors.grey.shade800)),
|
||||||
|
Text(formatCurrency.format(item.quantity * item.unitPrice), style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (item.makingCharge > 0)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 6),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text('+ Making Charges', style: TextStyle(fontSize: 12, color: Colors.grey.shade600)),
|
||||||
|
Text(formatCurrency.format(item.makingCharge), style: TextStyle(fontSize: 12, color: Colors.grey.shade700)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (item.otherCharges > 0)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 6),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text('+ Other Charges', style: TextStyle(fontSize: 12, color: Colors.grey.shade600)),
|
||||||
|
Text(formatCurrency.format(item.otherCharges), style: TextStyle(fontSize: 12, color: Colors.grey.shade700)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (item.discount > 0)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 6),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text('- Discount', style: TextStyle(fontSize: 12, color: Colors.green.shade600)),
|
||||||
|
Text('-${formatCurrency.format(item.discount)}', style: TextStyle(fontSize: 12, color: Colors.green.shade600, fontWeight: FontWeight.w600)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (item.taxRate > 0)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 6),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text('+ Tax (${item.taxRate}%)', style: TextStyle(fontSize: 12, color: Colors.grey.shade600)),
|
||||||
|
Text(formatCurrency.format(item.total - (item.quantity * item.unitPrice) - item.makingCharge - item.otherCharges + item.discount), style: TextStyle(fontSize: 12, color: Colors.grey.shade700)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// Summary Section
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: Colors.grey.shade200),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(color: Colors.grey.withOpacity(0.05), blurRadius: 10, offset: const Offset(0, 4)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_buildSummaryRow('Subtotal', formatCurrency.format(latestInvoice.subtotal)),
|
||||||
|
if (latestInvoice.discountTotal > 0)
|
||||||
|
_buildSummaryRow('Discount', '-${formatCurrency.format(latestInvoice.discountTotal)}', color: Colors.green.shade700),
|
||||||
|
if (latestInvoice.taxTotal > 0)
|
||||||
|
_buildSummaryRow('Tax', formatCurrency.format(latestInvoice.taxTotal)),
|
||||||
|
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 12),
|
||||||
|
child: Divider(height: 1, color: Colors.grey),
|
||||||
|
),
|
||||||
|
|
||||||
|
_buildSummaryRow('Grand Total', formatCurrency.format(latestInvoice.totalAmount), isBold: true, fontSize: 18, color: Colors.black),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade50,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_buildSummaryRow('Amount Paid', formatCurrency.format(latestInvoice.amountPaid), color: Colors.green.shade700, isBold: true),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_buildSummaryRow('Balance Due', formatCurrency.format(remaining),
|
||||||
|
color: remaining > 0 ? Colors.red.shade700 : Colors.green.shade700,
|
||||||
|
isBold: true,
|
||||||
|
fontSize: 16
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Payment Info
|
||||||
|
FutureBuilder<List<InvoicePayment>>(
|
||||||
|
future: ref.read(invoicesProvider.notifier).fetchPaymentsForInvoice(latestInvoice.id!),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
final payments = snapshot.data ?? [];
|
||||||
|
final displayMethod = payments.isNotEmpty ? payments.last.paymentMethod : latestInvoice.paymentMethod;
|
||||||
|
|
||||||
|
if (displayMethod == null && !latestInvoice.isEmi) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.blue.shade50.withOpacity(0.5),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (displayMethod != null) ...[
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(LucideIcons.creditCard, size: 16, color: Colors.blue),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text('Payment Mode: $displayMethod', style: const TextStyle(fontWeight: FontWeight.w600, color: Colors.blue)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (latestInvoice.isEmi && latestInvoice.emiAmount != null) ...[
|
||||||
|
if (displayMethod != null) const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(LucideIcons.calendarClock, size: 16, color: Colors.purple),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text('EMI: ${formatCurrency.format(latestInvoice.emiAmount)} / ${latestInvoice.emiCycle}', style: const TextStyle(fontWeight: FontWeight.w600, color: Colors.purple)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (latestInvoice.nextPaymentDate != null && remaining > 0)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 24, top: 4),
|
||||||
|
child: Text('Next Due: ${formatDate.format(latestInvoice.nextPaymentDate!)}', style: TextStyle(fontSize: 13, color: Colors.grey.shade700)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
// Bottom Spacing for FAB
|
||||||
|
const SizedBox(height: 100),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
|
||||||
|
floatingActionButton: (remaining > 0 && latestInvoice.status != 'DRAFT')
|
||||||
|
? Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FloatingActionButton.extended(
|
||||||
|
onPressed: () => _showReceivePaymentSheet(context, ref, latestInvoice),
|
||||||
|
icon: const Icon(LucideIcons.indianRupee),
|
||||||
|
label: const Text('Receive Payment', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||||
|
backgroundColor: Colors.blue,
|
||||||
|
elevation: 4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSummaryRow(String label, String value, {bool isBold = false, Color? color, double fontSize = 14}) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(label, style: TextStyle(fontSize: fontSize, fontWeight: isBold ? FontWeight.bold : FontWeight.w500, color: color ?? Colors.grey.shade600)),
|
||||||
|
Text(value, style: TextStyle(fontSize: fontSize, fontWeight: isBold ? FontWeight.bold : FontWeight.w600, color: color ?? Colors.black87)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showShareOptions(BuildContext context, String invoiceNumber) {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))),
|
||||||
|
builder: (ctx) => SafeArea(
|
||||||
|
child: Wrap(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Text('Share Invoice $invoiceNumber', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(LucideIcons.image, color: Colors.blue),
|
||||||
|
title: const Text('Share as Image'),
|
||||||
|
subtitle: const Text('Best for WhatsApp, precise look'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
_shareAsImage(invoiceNumber);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(LucideIcons.fileText, color: Colors.red),
|
||||||
|
title: const Text('Share as PDF'),
|
||||||
|
subtitle: const Text('Professional document format'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
_shareAsPdf(invoiceNumber);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _shareAsImage(String invoiceNumber) async {
|
||||||
|
try {
|
||||||
|
final Uint8List? image = await _screenshotController.capture(pixelRatio: 3.0);
|
||||||
|
if (image == null) return;
|
||||||
|
|
||||||
|
final directory = await getTemporaryDirectory();
|
||||||
|
final imagePath = await File('${directory.path}/Invoice_$invoiceNumber.png').create();
|
||||||
|
await imagePath.writeAsBytes(image);
|
||||||
|
|
||||||
|
await Share.shareXFiles([XFile(imagePath.path)], text: 'Invoice $invoiceNumber');
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error sharing image: $e')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _shareAsPdf(String invoiceNumber) async {
|
||||||
|
try {
|
||||||
|
final invoicesState = ref.read(invoicesProvider);
|
||||||
|
final latestInvoice = invoicesState.value?.firstWhere(
|
||||||
|
(i) => i.id == widget.invoice.id,
|
||||||
|
orElse: () => widget.invoice,
|
||||||
|
) ?? widget.invoice;
|
||||||
|
|
||||||
|
final customers = ref.read(customersProvider).value ?? [];
|
||||||
|
final customer = customers.where((c) => c.id == latestInvoice.customerId).isEmpty
|
||||||
|
? null
|
||||||
|
: customers.firstWhere((c) => c.id == latestInvoice.customerId);
|
||||||
|
|
||||||
|
final businessState = ref.read(businessProfileProvider);
|
||||||
|
final business = businessState.value;
|
||||||
|
|
||||||
|
final formatCurrency = NumberFormat.currency(symbol: 'Rs. ', decimalDigits: 2);
|
||||||
|
final formatDate = DateFormat('dd MMM yyyy');
|
||||||
|
|
||||||
|
// Fetch payment info
|
||||||
|
final payments = await ref.read(invoicesProvider.notifier).fetchPaymentsForInvoice(latestInvoice.id!);
|
||||||
|
final displayMethod = payments.isNotEmpty ? payments.last.paymentMethod : latestInvoice.paymentMethod;
|
||||||
|
|
||||||
|
final pdf = pw.Document();
|
||||||
|
|
||||||
|
pdf.addPage(
|
||||||
|
pw.MultiPage(
|
||||||
|
pageFormat: PdfPageFormat.a4,
|
||||||
|
margin: const pw.EdgeInsets.all(32),
|
||||||
|
build: (pw.Context context) {
|
||||||
|
return [
|
||||||
|
// Header
|
||||||
|
pw.Row(
|
||||||
|
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
||||||
|
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
pw.Column(
|
||||||
|
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
pw.Text(business?.businessName ?? 'Your Company', style: pw.TextStyle(fontSize: 24, fontWeight: pw.FontWeight.bold)),
|
||||||
|
pw.SizedBox(height: 4),
|
||||||
|
if (business?.address != null) pw.Text(business!.address!, style: const pw.TextStyle(fontSize: 12)),
|
||||||
|
if (business?.contactNumber != null) pw.Text('Phone: ${business!.contactNumber}', style: const pw.TextStyle(fontSize: 12)),
|
||||||
|
if (business?.gstin != null) pw.Text('GSTIN: ${business!.gstin}', style: pw.TextStyle(fontSize: 12, fontWeight: pw.FontWeight.bold)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
pw.Column(
|
||||||
|
crossAxisAlignment: pw.CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
pw.Text('INVOICE', style: pw.TextStyle(fontSize: 28, fontWeight: pw.FontWeight.bold, color: PdfColors.blue800)),
|
||||||
|
pw.SizedBox(height: 8),
|
||||||
|
pw.Text(latestInvoice.invoiceNumber, style: pw.TextStyle(fontSize: 16, fontWeight: pw.FontWeight.bold)),
|
||||||
|
pw.Text('Date: ${formatDate.format(latestInvoice.issueDate)}', style: const pw.TextStyle(fontSize: 12)),
|
||||||
|
if (latestInvoice.dueDate != null)
|
||||||
|
pw.Text('Due Date: ${formatDate.format(latestInvoice.dueDate!)}', style: const pw.TextStyle(fontSize: 12)),
|
||||||
|
pw.SizedBox(height: 4),
|
||||||
|
pw.Container(
|
||||||
|
padding: const pw.EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
decoration: pw.BoxDecoration(color: PdfColors.grey200, borderRadius: const pw.BorderRadius.all(pw.Radius.circular(4))),
|
||||||
|
child: pw.Text(latestInvoice.status, style: pw.TextStyle(fontSize: 12, fontWeight: pw.FontWeight.bold)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
pw.SizedBox(height: 32),
|
||||||
|
|
||||||
|
// Bill To
|
||||||
|
pw.Text('BILL TO:', style: pw.TextStyle(fontSize: 12, fontWeight: pw.FontWeight.bold, color: PdfColors.grey600)),
|
||||||
|
pw.SizedBox(height: 4),
|
||||||
|
if (customer != null) ...[
|
||||||
|
pw.Text(customer.name, style: pw.TextStyle(fontSize: 16, fontWeight: pw.FontWeight.bold)),
|
||||||
|
if (customer.address != null) pw.Text(customer.address!, style: const pw.TextStyle(fontSize: 12)),
|
||||||
|
if (customer.phone != null) pw.Text('Phone: ${customer.phone}', style: const pw.TextStyle(fontSize: 12)),
|
||||||
|
if (customer.gstin != null) pw.Text('GSTIN: ${customer.gstin}', style: pw.TextStyle(fontSize: 12, fontWeight: pw.FontWeight.bold)),
|
||||||
|
] else ...[
|
||||||
|
pw.Text('Walk-in Customer', style: const pw.TextStyle(fontSize: 14)),
|
||||||
|
],
|
||||||
|
|
||||||
|
pw.SizedBox(height: 32),
|
||||||
|
|
||||||
|
// Items Table
|
||||||
|
pw.TableHelper.fromTextArray(
|
||||||
|
context: context,
|
||||||
|
border: const pw.TableBorder(
|
||||||
|
bottom: pw.BorderSide(color: PdfColors.grey300, width: .5),
|
||||||
|
horizontalInside: pw.BorderSide(color: PdfColors.grey300, width: .5),
|
||||||
|
),
|
||||||
|
headerStyle: pw.TextStyle(fontWeight: pw.FontWeight.bold, color: PdfColors.white),
|
||||||
|
headerDecoration: const pw.BoxDecoration(color: PdfColors.blue800),
|
||||||
|
cellAlignments: {
|
||||||
|
0: pw.Alignment.centerLeft,
|
||||||
|
1: pw.Alignment.centerRight,
|
||||||
|
2: pw.Alignment.centerRight,
|
||||||
|
3: pw.Alignment.centerRight,
|
||||||
|
},
|
||||||
|
data: [
|
||||||
|
['Description', 'Qty', 'Unit Price', 'Total'],
|
||||||
|
...(latestInvoice.items ?? []).map((item) {
|
||||||
|
final product = item.productId != null ? ref.read(productsProvider).value?.where((p) => p.id == item.productId).firstOrNull : null;
|
||||||
|
final skuToDisplay = item.sku ?? product?.sku;
|
||||||
|
String itemDesc = item.description ?? 'Item';
|
||||||
|
if (skuToDisplay != null && skuToDisplay.isNotEmpty) itemDesc += '\nSKU: $skuToDisplay';
|
||||||
|
if (item.makingCharge > 0) itemDesc += '\n+ Making Charges: ${formatCurrency.format(item.makingCharge)}';
|
||||||
|
if (item.otherCharges > 0) itemDesc += '\n+ Other Charges: ${formatCurrency.format(item.otherCharges)}';
|
||||||
|
if (item.discount > 0) itemDesc += '\n- Discount: ${formatCurrency.format(item.discount)}';
|
||||||
|
if (item.taxRate > 0) {
|
||||||
|
final taxAmt = item.total - (item.quantity * item.unitPrice) - item.makingCharge - item.otherCharges + item.discount;
|
||||||
|
itemDesc += '\n+ Tax (${item.taxRate}%): ${formatCurrency.format(taxAmt)}';
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
itemDesc,
|
||||||
|
item.quantity.toStringAsFixed(item.quantity.truncateToDouble() == item.quantity ? 0 : 2),
|
||||||
|
formatCurrency.format(item.unitPrice),
|
||||||
|
formatCurrency.format(item.total),
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
pw.SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Totals
|
||||||
|
pw.Row(
|
||||||
|
mainAxisAlignment: pw.MainAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
pw.Container(
|
||||||
|
width: 250,
|
||||||
|
child: pw.Column(
|
||||||
|
crossAxisAlignment: pw.CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
pw.Row(
|
||||||
|
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
pw.Text('Subtotal:'),
|
||||||
|
pw.Text(formatCurrency.format(latestInvoice.subtotal)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
pw.SizedBox(height: 4),
|
||||||
|
if (latestInvoice.discountTotal > 0) ...[
|
||||||
|
pw.Row(
|
||||||
|
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
pw.Text('Discount:'),
|
||||||
|
pw.Text('-${formatCurrency.format(latestInvoice.discountTotal)}', style: const pw.TextStyle(color: PdfColors.green700)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
pw.SizedBox(height: 4),
|
||||||
|
],
|
||||||
|
if (latestInvoice.taxTotal > 0) ...[
|
||||||
|
pw.Row(
|
||||||
|
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
pw.Text('Tax:'),
|
||||||
|
pw.Text(formatCurrency.format(latestInvoice.taxTotal)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
pw.SizedBox(height: 4),
|
||||||
|
],
|
||||||
|
pw.Divider(color: PdfColors.grey400),
|
||||||
|
pw.Row(
|
||||||
|
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
pw.Text('Grand Total:', style: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 16)),
|
||||||
|
pw.Text(formatCurrency.format(latestInvoice.totalAmount), style: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 16)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
pw.SizedBox(height: 12),
|
||||||
|
pw.Row(
|
||||||
|
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
pw.Text('Amount Paid:', style: pw.TextStyle(color: PdfColors.green700)),
|
||||||
|
pw.Text(formatCurrency.format(latestInvoice.amountPaid), style: pw.TextStyle(color: PdfColors.green700)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
pw.Divider(color: PdfColors.grey400),
|
||||||
|
pw.Row(
|
||||||
|
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
pw.Text('Balance Due:', style: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 14)),
|
||||||
|
pw.Text(formatCurrency.format(latestInvoice.totalAmount - latestInvoice.amountPaid), style: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 14)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
pw.SizedBox(height: 24),
|
||||||
|
|
||||||
|
if (displayMethod != null || latestInvoice.isEmi)
|
||||||
|
pw.Container(
|
||||||
|
padding: const pw.EdgeInsets.all(12),
|
||||||
|
decoration: pw.BoxDecoration(color: PdfColors.blue50, borderRadius: const pw.BorderRadius.all(pw.Radius.circular(8))),
|
||||||
|
child: pw.Column(
|
||||||
|
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (displayMethod != null)
|
||||||
|
pw.Text('Payment Mode: $displayMethod', style: pw.TextStyle(fontWeight: pw.FontWeight.bold, color: PdfColors.blue800)),
|
||||||
|
if (latestInvoice.isEmi && latestInvoice.emiAmount != null) ...[
|
||||||
|
if (displayMethod != null) pw.SizedBox(height: 4),
|
||||||
|
pw.Text('EMI: ${formatCurrency.format(latestInvoice.emiAmount)} / ${latestInvoice.emiCycle}', style: pw.TextStyle(fontWeight: pw.FontWeight.bold, color: PdfColors.purple800)),
|
||||||
|
if (latestInvoice.nextPaymentDate != null && (latestInvoice.totalAmount - latestInvoice.amountPaid) > 0)
|
||||||
|
pw.Text('Next Due: ${formatDate.format(latestInvoice.nextPaymentDate!)}', style: const pw.TextStyle(fontSize: 12, color: PdfColors.grey700)),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
pw.SizedBox(height: 40),
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
pw.Divider(color: PdfColors.grey300),
|
||||||
|
pw.SizedBox(height: 8),
|
||||||
|
pw.Center(
|
||||||
|
child: pw.Text('Thank you for your business!', style: pw.TextStyle(color: PdfColors.grey600, fontStyle: pw.FontStyle.italic)),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final directory = await getTemporaryDirectory();
|
||||||
|
final pdfPath = await File('${directory.path}/Invoice_$invoiceNumber.pdf').create();
|
||||||
|
await pdfPath.writeAsBytes(await pdf.save());
|
||||||
|
|
||||||
|
await Share.shareXFiles([XFile(pdfPath.path)], text: 'Invoice $invoiceNumber');
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error sharing PDF: $e')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import '../../../core/widgets/shimmer_loading.dart';
|
||||||
|
import '../providers/invoices_provider.dart';
|
||||||
|
import '../domain/invoice.dart';
|
||||||
|
import 'invoice_builder_screen.dart';
|
||||||
|
import 'invoice_details_screen.dart';
|
||||||
|
import '../../transactions/providers/providers.dart';
|
||||||
|
import 'widgets/receive_payment_sheet.dart';
|
||||||
|
import '../providers/customers_provider.dart';
|
||||||
|
|
||||||
|
class InvoicesListScreen extends ConsumerStatefulWidget {
|
||||||
|
const InvoicesListScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<InvoicesListScreen> createState() => _InvoicesListScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InvoicesListScreenState extends ConsumerState<InvoicesListScreen> {
|
||||||
|
String _searchQuery = '';
|
||||||
|
final TextEditingController _searchController = TextEditingController();
|
||||||
|
|
||||||
|
String _getStatusLabel(Invoice invoice) {
|
||||||
|
if (invoice.isEmi && invoice.status != 'PAID') return 'EMI';
|
||||||
|
if (invoice.status == 'PAID') return 'Fully Paid';
|
||||||
|
if (invoice.status == 'PARTIAL') return 'Partially Paid';
|
||||||
|
return invoice.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _getStatusColor(Invoice invoice) {
|
||||||
|
if (invoice.isEmi && invoice.status != 'PAID') return Colors.purple;
|
||||||
|
switch (invoice.status) {
|
||||||
|
case 'DRAFT': return Colors.grey;
|
||||||
|
case 'FINALIZED': return Colors.orange;
|
||||||
|
case 'PAID': return Colors.green;
|
||||||
|
case 'PARTIAL': return Colors.blue;
|
||||||
|
case 'OVERDUE': return Colors.red;
|
||||||
|
case 'CANCELLED': return Colors.black;
|
||||||
|
default: return Colors.grey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showPaymentHistory(BuildContext context, WidgetRef ref, Invoice invoice) {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))),
|
||||||
|
builder: (ctx) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 16),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text('Payment History: ${invoice.invoiceNumber}', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FutureBuilder<List<InvoicePayment>>(
|
||||||
|
future: ref.read(invoicesProvider.notifier).fetchPaymentsForInvoice(invoice.id!),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
if (snapshot.hasError) {
|
||||||
|
return Center(child: Text('Error: ${snapshot.error}'));
|
||||||
|
}
|
||||||
|
final payments = snapshot.data;
|
||||||
|
if (payments == null || payments.isEmpty) {
|
||||||
|
return const Padding(
|
||||||
|
padding: EdgeInsets.all(24.0),
|
||||||
|
child: Text('No payments recorded yet.'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final wallets = ref.read(walletProvider).value ?? [];
|
||||||
|
|
||||||
|
return SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
child: DataTable(
|
||||||
|
headingRowColor: WidgetStateProperty.resolveWith((states) => Colors.grey.shade100),
|
||||||
|
columnSpacing: 24,
|
||||||
|
columns: const [
|
||||||
|
DataColumn(label: Text('Date', style: TextStyle(fontWeight: FontWeight.bold))),
|
||||||
|
DataColumn(label: Text('Wallet', style: TextStyle(fontWeight: FontWeight.bold))),
|
||||||
|
DataColumn(label: Text('Method', style: TextStyle(fontWeight: FontWeight.bold))),
|
||||||
|
DataColumn(label: Text('Amount', style: TextStyle(fontWeight: FontWeight.bold))),
|
||||||
|
],
|
||||||
|
rows: payments.map((p) {
|
||||||
|
final walletName = wallets.firstWhere((w) => w.id == p.walletId, orElse: () => wallets.first).name;
|
||||||
|
return DataRow(
|
||||||
|
cells: [
|
||||||
|
DataCell(Text(p.paymentDate != null ? DateFormat('dd MMM yyyy').format(p.paymentDate!) : '-')),
|
||||||
|
DataCell(Text(p.walletId != null ? walletName : '-')),
|
||||||
|
DataCell(Text(p.paymentMethod ?? '-')),
|
||||||
|
DataCell(Text('₹${p.amount.toStringAsFixed(2)}', style: TextStyle(color: Colors.green.shade700, fontWeight: FontWeight.bold))),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx),
|
||||||
|
child: const Text('Close'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showReceivePaymentSheet(BuildContext context, Invoice invoice) async {
|
||||||
|
final result = await showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (context) => ReceivePaymentSheet(invoice: invoice),
|
||||||
|
);
|
||||||
|
if (result == true) {
|
||||||
|
ref.read(invoicesProvider.notifier).refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final invoicesState = ref.watch(invoicesProvider);
|
||||||
|
final customersState = ref.watch(customersProvider);
|
||||||
|
final customers = customersState.value ?? [];
|
||||||
|
|
||||||
|
final formatCurrency = NumberFormat.currency(symbol: '₹');
|
||||||
|
final formatDate = DateFormat('MMM dd, yyyy');
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.grey[50],
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Invoices'),
|
||||||
|
elevation: 0,
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
foregroundColor: Colors.black,
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
color: Colors.white,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
child: TextField(
|
||||||
|
controller: _searchController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Search by Invoice # or Customer',
|
||||||
|
prefixIcon: const Icon(LucideIcons.search, color: Colors.grey),
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.grey[100],
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
suffixIcon: _searchQuery.isNotEmpty
|
||||||
|
? IconButton(
|
||||||
|
icon: const Icon(LucideIcons.x, size: 20),
|
||||||
|
onPressed: () {
|
||||||
|
_searchController.clear();
|
||||||
|
setState(() {
|
||||||
|
_searchQuery = '';
|
||||||
|
});
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_searchQuery = value.toLowerCase();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: invoicesState.when(
|
||||||
|
loading: () => ListView.builder(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
itemCount: 5,
|
||||||
|
itemBuilder: (context, index) => const Padding(
|
||||||
|
padding: EdgeInsets.only(bottom: 12),
|
||||||
|
child: ShimmerCard(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
error: (err, stack) => Center(child: Text('Error: $err')),
|
||||||
|
data: (allInvoices) {
|
||||||
|
final invoices = allInvoices.where((invoice) {
|
||||||
|
final matchesInvoice = invoice.invoiceNumber.toLowerCase().contains(_searchQuery);
|
||||||
|
final customer = customers.firstWhere((c) => c.id == invoice.customerId, orElse: () => customers.first);
|
||||||
|
final customerName = invoice.customerId != null ? customer.name.toLowerCase() : '';
|
||||||
|
final matchesCustomer = customerName.contains(_searchQuery);
|
||||||
|
return matchesInvoice || matchesCustomer;
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
if (invoices.isEmpty) {
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: () async {
|
||||||
|
await ref.read(invoicesProvider.notifier).refresh();
|
||||||
|
},
|
||||||
|
child: ListView(
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
children: const [
|
||||||
|
SizedBox(height: 100),
|
||||||
|
Center(child: Text('No invoices found.', style: TextStyle(color: Colors.grey))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: () async {
|
||||||
|
await ref.read(invoicesProvider.notifier).refresh();
|
||||||
|
},
|
||||||
|
child: ListView.builder(
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
itemCount: invoices.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final invoice = invoices[index];
|
||||||
|
double remaining = invoice.totalAmount - invoice.amountPaid;
|
||||||
|
final customer = invoice.customerId != null
|
||||||
|
? customers.firstWhere((c) => c.id == invoice.customerId, orElse: () => customers.first)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
elevation: 0,
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (_) => InvoiceDetailsScreen(invoice: invoice)),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(invoice.invoiceNumber, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
||||||
|
if (customer != null)
|
||||||
|
Text(customer.name, style: TextStyle(color: Colors.grey[700], fontSize: 13, fontWeight: FontWeight.w600)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _getStatusColor(invoice).withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
_getStatusLabel(invoice),
|
||||||
|
style: TextStyle(color: _getStatusColor(invoice), fontSize: 12, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('Issued: ${formatDate.format(invoice.issueDate)}', style: TextStyle(color: Colors.grey[600], fontSize: 13)),
|
||||||
|
if (invoice.dueDate != null)
|
||||||
|
Text('Due: ${formatDate.format(invoice.dueDate!)}', style: TextStyle(color: Colors.grey[600], fontSize: 13)),
|
||||||
|
if (invoice.nextPaymentDate != null && remaining > 0)
|
||||||
|
Text('Next Pmt: ${formatDate.format(invoice.nextPaymentDate!)}', style: TextStyle(color: Colors.orange.shade700, fontSize: 13, fontWeight: FontWeight.bold)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
formatCurrency.format(invoice.totalAmount),
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
|
||||||
|
),
|
||||||
|
if (invoice.amountPaid > 0)
|
||||||
|
Text(
|
||||||
|
'Paid: ${formatCurrency.format(invoice.amountPaid)}',
|
||||||
|
style: TextStyle(color: Colors.green.shade700, fontSize: 12),
|
||||||
|
),
|
||||||
|
if (remaining > 0 && invoice.status != 'DRAFT')
|
||||||
|
Text(
|
||||||
|
'Bal: ${formatCurrency.format(remaining)}',
|
||||||
|
style: TextStyle(color: Colors.red.shade700, fontSize: 12, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (invoice.isEmi && invoice.emiAmount != null) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.purple.withOpacity(0.05),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: Colors.purple.withOpacity(0.2)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(LucideIcons.calendarClock, size: 16, color: Colors.purple),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text('EMI: ${formatCurrency.format(invoice.emiAmount)} / ${invoice.emiCycle}', style: const TextStyle(color: Colors.purple, fontSize: 12)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
const Divider(height: 24),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
if (invoice.amountPaid > 0)
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: () => _showPaymentHistory(context, ref, invoice),
|
||||||
|
icon: const Icon(LucideIcons.history, size: 14),
|
||||||
|
label: const Text('History', style: TextStyle(fontSize: 13)),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: Colors.blue.shade700,
|
||||||
|
side: BorderSide(color: Colors.blue.shade200),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (invoice.amountPaid > 0 && remaining > 0) const SizedBox(width: 8),
|
||||||
|
if (remaining > 0)
|
||||||
|
Expanded(
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: () => _showReceivePaymentSheet(context, invoice),
|
||||||
|
icon: const Icon(LucideIcons.indianRupee, size: 14),
|
||||||
|
label: const Text('Receive', style: TextStyle(fontSize: 13)),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.blue.shade50,
|
||||||
|
foregroundColor: Colors.blue.shade700,
|
||||||
|
elevation: 0,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
floatingActionButton: FloatingActionButton.extended(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.push(context, MaterialPageRoute(builder: (_) => const InvoiceBuilderScreen()));
|
||||||
|
},
|
||||||
|
icon: const Icon(LucideIcons.plus),
|
||||||
|
label: const Text('Create Invoice'),
|
||||||
|
backgroundColor: Colors.blue,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
|
import '../../../../core/widgets/premium_text_field.dart';
|
||||||
|
import '../../../transactions/providers/providers.dart';
|
||||||
|
import '../../providers/invoices_provider.dart';
|
||||||
|
import '../../domain/invoice.dart';
|
||||||
|
|
||||||
|
class ReceivePaymentSheet extends ConsumerStatefulWidget {
|
||||||
|
final Invoice invoice;
|
||||||
|
|
||||||
|
const ReceivePaymentSheet({super.key, required this.invoice});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<ReceivePaymentSheet> createState() => _ReceivePaymentSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ReceivePaymentSheetState extends ConsumerState<ReceivePaymentSheet> {
|
||||||
|
final TextEditingController _amountCtrl = TextEditingController();
|
||||||
|
String _paymentMethod = 'Cash';
|
||||||
|
int? _selectedWalletId;
|
||||||
|
bool _isLoading = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final balance = widget.invoice.totalAmount - widget.invoice.amountPaid;
|
||||||
|
_amountCtrl.text = balance.toStringAsFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final walletsState = ref.watch(walletProvider);
|
||||||
|
final wallets = walletsState.value ?? [];
|
||||||
|
|
||||||
|
if (wallets.isNotEmpty && _selectedWalletId == null) {
|
||||||
|
_selectedWalletId = wallets.first.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||||
|
),
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||||
|
top: 24,
|
||||||
|
left: 24,
|
||||||
|
right: 24,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
const Text('Receive Payment', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||||
|
IconButton(icon: const Icon(LucideIcons.x), onPressed: () => Navigator.pop(context)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
PremiumTextField(
|
||||||
|
controller: _amountCtrl,
|
||||||
|
labelText: 'Amount Paid (₹)',
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text('Receive Into Wallet', style: TextStyle(fontWeight: FontWeight.w600, color: Colors.grey)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey[100],
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: DropdownButtonHideUnderline(
|
||||||
|
child: DropdownButton<int>(
|
||||||
|
value: _selectedWalletId,
|
||||||
|
isExpanded: true,
|
||||||
|
hint: const Text('Select Wallet'),
|
||||||
|
items: wallets
|
||||||
|
.map((w) => DropdownMenuItem(value: w.id, child: Text(w.name)))
|
||||||
|
.toList(),
|
||||||
|
onChanged: (val) => setState(() => _selectedWalletId = val),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text('Payment Method', style: TextStyle(fontWeight: FontWeight.w600, color: Colors.grey)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey[100],
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: DropdownButtonHideUnderline(
|
||||||
|
child: DropdownButton<String>(
|
||||||
|
value: _paymentMethod,
|
||||||
|
isExpanded: true,
|
||||||
|
items: ['Cash', 'UPI', 'Bank Transfer', 'Card']
|
||||||
|
.map((m) => DropdownMenuItem(value: m, child: Text(m)))
|
||||||
|
.toList(),
|
||||||
|
onChanged: (val) => setState(() => _paymentMethod = val!),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.blue,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
|
),
|
||||||
|
onPressed: _isLoading ? null : () async {
|
||||||
|
final amount = double.tryParse(_amountCtrl.text) ?? 0;
|
||||||
|
if (amount <= 0) return;
|
||||||
|
|
||||||
|
setState(() => _isLoading = true);
|
||||||
|
try {
|
||||||
|
await ref.read(invoicesProvider.notifier).addPayment(
|
||||||
|
widget.invoice.id!,
|
||||||
|
InvoicePayment(
|
||||||
|
amount: amount,
|
||||||
|
paymentMethod: _paymentMethod,
|
||||||
|
walletId: _selectedWalletId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (mounted) {
|
||||||
|
Navigator.pop(context, true); // true indicates success
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Payment Received Successfully!')));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString())));
|
||||||
|
setState(() => _isLoading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: _isLoading
|
||||||
|
? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
|
||||||
|
: const Text('Confirm Payment', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
115
kifi-app/lib/features/sales/providers/customers_provider.dart
Normal file
115
kifi-app/lib/features/sales/providers/customers_provider.dart
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
||||||
|
import '../../../core/network/dio_client.dart';
|
||||||
|
import '../domain/customer.dart';
|
||||||
|
|
||||||
|
class CustomersNotifier extends AsyncNotifier<List<Customer>> {
|
||||||
|
@override
|
||||||
|
FutureOr<List<Customer>> build() async {
|
||||||
|
return _fetchCustomers();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Customer>> _fetchCustomers([String? search]) async {
|
||||||
|
try {
|
||||||
|
final response = await DioClient().dio.get(
|
||||||
|
'/customers',
|
||||||
|
queryParameters: search != null && search.isNotEmpty ? {'search': search} : null,
|
||||||
|
);
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final List<dynamic> data = response.data;
|
||||||
|
return data.map((e) => Customer.fromJson(e)).toList();
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
} catch (e) {
|
||||||
|
print('Error fetching customers: $e');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> refresh([String? search]) async {
|
||||||
|
state = const AsyncValue.loading();
|
||||||
|
try {
|
||||||
|
final customers = await _fetchCustomers(search);
|
||||||
|
state = AsyncValue.data(customers);
|
||||||
|
} catch (e, stack) {
|
||||||
|
state = AsyncValue.error(e, stack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> addCustomer(Customer customer, {XFile? photo}) async {
|
||||||
|
try {
|
||||||
|
final response = await DioClient().dio.post(
|
||||||
|
'/customers',
|
||||||
|
data: customer.toJson(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (photo != null && response.data != null) {
|
||||||
|
final customerId = response.data['id'];
|
||||||
|
await _uploadPhoto(customerId, photo);
|
||||||
|
}
|
||||||
|
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
if (e is DioException) {
|
||||||
|
throw Exception('Failed to add customer: ${e.response?.statusCode} - ${e.response?.data}');
|
||||||
|
}
|
||||||
|
throw Exception('Failed to add customer: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> updateCustomer(int id, Customer customer, {XFile? photo}) async {
|
||||||
|
try {
|
||||||
|
await DioClient().dio.put(
|
||||||
|
'/customers/$id',
|
||||||
|
data: customer.toJson(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (photo != null) {
|
||||||
|
await _uploadPhoto(id, photo);
|
||||||
|
}
|
||||||
|
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
if (e is DioException) {
|
||||||
|
throw Exception('Failed to update customer: ${e.response?.statusCode} - ${e.response?.data}');
|
||||||
|
}
|
||||||
|
throw Exception('Failed to update customer: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _uploadPhoto(int customerId, XFile photo) async {
|
||||||
|
final bytes = await photo.readAsBytes();
|
||||||
|
final compressedBytes = await FlutterImageCompress.compressWithList(
|
||||||
|
bytes,
|
||||||
|
minWidth: 413,
|
||||||
|
minHeight: 531,
|
||||||
|
quality: 85,
|
||||||
|
);
|
||||||
|
|
||||||
|
final formData = FormData.fromMap({
|
||||||
|
'file': MultipartFile.fromBytes(compressedBytes, filename: photo.name),
|
||||||
|
});
|
||||||
|
|
||||||
|
await DioClient().dio.post(
|
||||||
|
'/customers/$customerId/photo',
|
||||||
|
data: formData,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteCustomer(int id) async {
|
||||||
|
try {
|
||||||
|
await DioClient().dio.delete('/customers/$id');
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Failed to delete customer: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final customersProvider = AsyncNotifierProvider<CustomersNotifier, List<Customer>>(() {
|
||||||
|
return CustomersNotifier();
|
||||||
|
});
|
||||||
93
kifi-app/lib/features/sales/providers/invoices_provider.dart
Normal file
93
kifi-app/lib/features/sales/providers/invoices_provider.dart
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import '../../../core/network/dio_client.dart';
|
||||||
|
import '../domain/invoice.dart';
|
||||||
|
|
||||||
|
class InvoicesNotifier extends AsyncNotifier<List<Invoice>> {
|
||||||
|
@override
|
||||||
|
FutureOr<List<Invoice>> build() async {
|
||||||
|
return _fetchInvoices();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Invoice>> _fetchInvoices() async {
|
||||||
|
try {
|
||||||
|
final response = await DioClient().dio.get('/invoices');
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final List<dynamic> data = response.data;
|
||||||
|
return data.map((e) => Invoice.fromJson(e)).toList();
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
} catch (e) {
|
||||||
|
print('Error fetching invoices: $e');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> refresh() async {
|
||||||
|
state = const AsyncValue.loading();
|
||||||
|
try {
|
||||||
|
final invoices = await _fetchInvoices();
|
||||||
|
state = AsyncValue.data(invoices);
|
||||||
|
} catch (e, stack) {
|
||||||
|
state = AsyncValue.error(e, stack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> createInvoice(Invoice invoice) async {
|
||||||
|
try {
|
||||||
|
await DioClient().dio.post(
|
||||||
|
'/invoices',
|
||||||
|
data: invoice.toJson(),
|
||||||
|
);
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
if (e is DioException) {
|
||||||
|
throw Exception('Failed to create invoice: ${e.response?.statusCode} - ${e.response?.data}');
|
||||||
|
}
|
||||||
|
throw Exception('Failed to create invoice: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> finalizeInvoice(int invoiceId) async {
|
||||||
|
try {
|
||||||
|
await DioClient().dio.put('/invoices/$invoiceId/finalize');
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Failed to finalize invoice: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> addPayment(int invoiceId, InvoicePayment payment) async {
|
||||||
|
try {
|
||||||
|
await DioClient().dio.post(
|
||||||
|
'/invoices/$invoiceId/payments',
|
||||||
|
data: payment.toJson(),
|
||||||
|
);
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
if (e is DioException) {
|
||||||
|
throw Exception('Failed to add payment: ${e.response?.statusCode} - ${e.response?.data}');
|
||||||
|
}
|
||||||
|
throw Exception('Failed to add payment: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<InvoicePayment>> fetchPaymentsForInvoice(int invoiceId) async {
|
||||||
|
try {
|
||||||
|
final response = await DioClient().dio.get('/invoices/$invoiceId/payments');
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final List<dynamic> data = response.data;
|
||||||
|
return data.map((e) => InvoicePayment.fromJson(e)).toList();
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
} catch (e) {
|
||||||
|
print('Error fetching payments for invoice $invoiceId: $e');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final invoicesProvider = AsyncNotifierProvider<InvoicesNotifier, List<Invoice>>(() {
|
||||||
|
return InvoicesNotifier();
|
||||||
|
});
|
||||||
@@ -256,6 +256,11 @@ class Wallet {
|
|||||||
final String? currency;
|
final String? currency;
|
||||||
final String? icon;
|
final String? icon;
|
||||||
final String? color;
|
final String? color;
|
||||||
|
final String? subNature;
|
||||||
|
final double? creditLimit;
|
||||||
|
final double? fixedAmount;
|
||||||
|
final String? paymentCycle;
|
||||||
|
final int? cycleDate;
|
||||||
|
|
||||||
Wallet({
|
Wallet({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -266,6 +271,11 @@ class Wallet {
|
|||||||
this.currency,
|
this.currency,
|
||||||
this.icon,
|
this.icon,
|
||||||
this.color,
|
this.color,
|
||||||
|
this.subNature,
|
||||||
|
this.creditLimit,
|
||||||
|
this.fixedAmount,
|
||||||
|
this.paymentCycle,
|
||||||
|
this.cycleDate,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory Wallet.fromJson(Map<String, dynamic> json) {
|
factory Wallet.fromJson(Map<String, dynamic> json) {
|
||||||
@@ -293,6 +303,11 @@ class Wallet {
|
|||||||
currency: json['currency'],
|
currency: json['currency'],
|
||||||
icon: json['icon'],
|
icon: json['icon'],
|
||||||
color: json['color'],
|
color: json['color'],
|
||||||
|
subNature: json['subNature'],
|
||||||
|
creditLimit: parseDouble(json['creditLimit']),
|
||||||
|
fixedAmount: parseDouble(json['fixedAmount']),
|
||||||
|
paymentCycle: json['paymentCycle'],
|
||||||
|
cycleDate: json['cycleDate'] != null ? parseId(json['cycleDate']) : null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,6 +318,11 @@ class Wallet {
|
|||||||
'initialBalance': balance, // Note: backend uses initialBalance on creation
|
'initialBalance': balance, // Note: backend uses initialBalance on creation
|
||||||
'icon': icon,
|
'icon': icon,
|
||||||
'color': color,
|
'color': color,
|
||||||
|
'subNature': subNature,
|
||||||
|
'creditLimit': creditLimit,
|
||||||
|
'fixedAmount': fixedAmount,
|
||||||
|
'paymentCycle': paymentCycle,
|
||||||
|
'cycleDate': cycleDate,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
class WalletInvitation {
|
class WalletInvitation {
|
||||||
|
|||||||
@@ -150,6 +150,11 @@ class ApiRepository {
|
|||||||
String currency = 'INR',
|
String currency = 'INR',
|
||||||
String? icon,
|
String? icon,
|
||||||
String? color,
|
String? color,
|
||||||
|
String? subNature,
|
||||||
|
double? creditLimit,
|
||||||
|
double? fixedAmount,
|
||||||
|
String? paymentCycle,
|
||||||
|
int? cycleDate,
|
||||||
}) async {
|
}) async {
|
||||||
final response = await dio.post('/wallets', data: {
|
final response = await dio.post('/wallets', data: {
|
||||||
'name': name,
|
'name': name,
|
||||||
@@ -158,6 +163,11 @@ class ApiRepository {
|
|||||||
'currency': currency,
|
'currency': currency,
|
||||||
'icon': icon,
|
'icon': icon,
|
||||||
'color': color,
|
'color': color,
|
||||||
|
if (subNature != null) 'subNature': subNature,
|
||||||
|
if (creditLimit != null) 'creditLimit': creditLimit,
|
||||||
|
if (fixedAmount != null) 'fixedAmount': fixedAmount,
|
||||||
|
if (paymentCycle != null) 'paymentCycle': paymentCycle,
|
||||||
|
if (cycleDate != null) 'cycleDate': cycleDate,
|
||||||
});
|
});
|
||||||
return Wallet.fromJson(response.data);
|
return Wallet.fromJson(response.data);
|
||||||
}
|
}
|
||||||
@@ -167,12 +177,22 @@ class ApiRepository {
|
|||||||
String? nature,
|
String? nature,
|
||||||
String? icon,
|
String? icon,
|
||||||
String? color,
|
String? color,
|
||||||
|
String? subNature,
|
||||||
|
double? creditLimit,
|
||||||
|
double? fixedAmount,
|
||||||
|
String? paymentCycle,
|
||||||
|
int? cycleDate,
|
||||||
}) async {
|
}) async {
|
||||||
final response = await dio.put('/wallets/$id', data: {
|
final response = await dio.put('/wallets/$id', data: {
|
||||||
if (name != null) 'name': name,
|
if (name != null) 'name': name,
|
||||||
if (nature != null) 'nature': nature,
|
if (nature != null) 'nature': nature,
|
||||||
if (icon != null) 'icon': icon,
|
if (icon != null) 'icon': icon,
|
||||||
if (color != null) 'color': color,
|
if (color != null) 'color': color,
|
||||||
|
if (subNature != null) 'subNature': subNature,
|
||||||
|
if (creditLimit != null) 'creditLimit': creditLimit,
|
||||||
|
if (fixedAmount != null) 'fixedAmount': fixedAmount,
|
||||||
|
if (paymentCycle != null) 'paymentCycle': paymentCycle,
|
||||||
|
if (cycleDate != null) 'cycleDate': cycleDate,
|
||||||
});
|
});
|
||||||
return Wallet.fromJson(response.data);
|
return Wallet.fromJson(response.data);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -144,8 +144,10 @@ final recurringTransactionProvider = AsyncNotifierProvider<RecurringTransactionN
|
|||||||
|
|
||||||
class WalletNotifier extends AsyncNotifier<List<Wallet>> {
|
class WalletNotifier extends AsyncNotifier<List<Wallet>> {
|
||||||
@override
|
@override
|
||||||
FutureOr<List<Wallet>> build() {
|
FutureOr<List<Wallet>> build() async {
|
||||||
return ref.watch(apiRepositoryProvider).getWallets();
|
final wallets = await ref.watch(apiRepositoryProvider).getWallets();
|
||||||
|
wallets.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||||
|
return wallets;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Wallet> createWallet({
|
Future<Wallet> createWallet({
|
||||||
@@ -155,6 +157,11 @@ class WalletNotifier extends AsyncNotifier<List<Wallet>> {
|
|||||||
String currency = 'INR',
|
String currency = 'INR',
|
||||||
String? icon,
|
String? icon,
|
||||||
String? color,
|
String? color,
|
||||||
|
String? subNature,
|
||||||
|
double? creditLimit,
|
||||||
|
double? fixedAmount,
|
||||||
|
String? paymentCycle,
|
||||||
|
int? cycleDate,
|
||||||
}) async {
|
}) async {
|
||||||
final newWallet = await ref.read(apiRepositoryProvider).createWallet(
|
final newWallet = await ref.read(apiRepositoryProvider).createWallet(
|
||||||
name: name,
|
name: name,
|
||||||
@@ -163,9 +170,16 @@ class WalletNotifier extends AsyncNotifier<List<Wallet>> {
|
|||||||
currency: currency,
|
currency: currency,
|
||||||
icon: icon,
|
icon: icon,
|
||||||
color: color,
|
color: color,
|
||||||
|
subNature: subNature,
|
||||||
|
creditLimit: creditLimit,
|
||||||
|
fixedAmount: fixedAmount,
|
||||||
|
paymentCycle: paymentCycle,
|
||||||
|
cycleDate: cycleDate,
|
||||||
);
|
);
|
||||||
if (state.value != null) {
|
if (state.value != null) {
|
||||||
state = AsyncValue.data([...state.value!, newWallet]);
|
final updated = [...state.value!, newWallet];
|
||||||
|
updated.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||||
|
state = AsyncValue.data(updated);
|
||||||
}
|
}
|
||||||
return newWallet;
|
return newWallet;
|
||||||
}
|
}
|
||||||
@@ -174,18 +188,23 @@ class WalletNotifier extends AsyncNotifier<List<Wallet>> {
|
|||||||
await ref.read(apiRepositoryProvider).inviteUserToWallet(walletId, email);
|
await ref.read(apiRepositoryProvider).inviteUserToWallet(walletId, email);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> editWallet(int id, {String? name, String? nature, String? icon, String? color}) async {
|
Future<void> editWallet(int id, {String? name, String? nature, String? icon, String? color, String? subNature, double? creditLimit, double? fixedAmount, String? paymentCycle, int? cycleDate}) async {
|
||||||
final updated = await ref.read(apiRepositoryProvider).editWallet(
|
final updated = await ref.read(apiRepositoryProvider).editWallet(
|
||||||
id: id,
|
id: id,
|
||||||
name: name,
|
name: name,
|
||||||
nature: nature,
|
nature: nature,
|
||||||
icon: icon,
|
icon: icon,
|
||||||
color: color,
|
color: color,
|
||||||
|
subNature: subNature,
|
||||||
|
creditLimit: creditLimit,
|
||||||
|
fixedAmount: fixedAmount,
|
||||||
|
paymentCycle: paymentCycle,
|
||||||
|
cycleDate: cycleDate,
|
||||||
);
|
);
|
||||||
if (state.value != null) {
|
if (state.value != null) {
|
||||||
state = AsyncValue.data(
|
final updatedList = state.value!.map((w) => w.id == id ? updated : w).toList();
|
||||||
state.value!.map((w) => w.id == id ? updated : w).toList(),
|
updatedList.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||||
);
|
state = AsyncValue.data(updatedList);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
#include <file_selector_linux/file_selector_plugin.h>
|
#include <file_selector_linux/file_selector_plugin.h>
|
||||||
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
|
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
|
||||||
|
#include <printing/printing_plugin.h>
|
||||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||||
|
|
||||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||||
@@ -17,6 +18,9 @@ void fl_register_plugins(FlPluginRegistry* registry) {
|
|||||||
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
|
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
|
||||||
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
|
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
|
||||||
|
g_autoptr(FlPluginRegistrar) printing_registrar =
|
||||||
|
fl_plugin_registry_get_registrar_for_plugin(registry, "PrintingPlugin");
|
||||||
|
printing_plugin_register_with_registrar(printing_registrar);
|
||||||
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
|
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
||||||
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
|
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
file_selector_linux
|
file_selector_linux
|
||||||
flutter_secure_storage_linux
|
flutter_secure_storage_linux
|
||||||
|
printing
|
||||||
url_launcher_linux
|
url_launcher_linux
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import flutter_image_compress_macos
|
|||||||
import flutter_local_notifications
|
import flutter_local_notifications
|
||||||
import flutter_secure_storage_darwin
|
import flutter_secure_storage_darwin
|
||||||
import local_auth_darwin
|
import local_auth_darwin
|
||||||
|
import mobile_scanner
|
||||||
|
import printing
|
||||||
import share_plus
|
import share_plus
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
|
|
||||||
@@ -19,6 +21,8 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
|||||||
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
||||||
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
|
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
|
||||||
LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin"))
|
LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin"))
|
||||||
|
MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin"))
|
||||||
|
PrintingPlugin.register(with: registry.registrar(forPlugin: "PrintingPlugin"))
|
||||||
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
|
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.13.1"
|
version: "2.13.1"
|
||||||
|
barcode:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: barcode
|
||||||
|
sha256: "7b6729c37e3b7f34233e2318d866e8c48ddb46c1f7ad01ff7bb2a8de1da2b9f4"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.9"
|
||||||
|
bidi:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: bidi
|
||||||
|
sha256: "77f475165e94b261745cf1032c751e2032b8ed92ccb2bf5716036db79320637d"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.13"
|
||||||
boolean_selector:
|
boolean_selector:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -776,6 +792,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.0"
|
version: "2.0.0"
|
||||||
|
mobile_scanner:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: mobile_scanner
|
||||||
|
sha256: ce3f059ebd6dbfab7292bba0e893e354b46730636820d3c9ef69005ce2d55bce
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "7.4.0"
|
||||||
node_preamble:
|
node_preamble:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -808,6 +832,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.9.1"
|
version: "1.9.1"
|
||||||
|
path_parsing:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_parsing
|
||||||
|
sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.0"
|
||||||
path_provider:
|
path_provider:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -856,6 +888,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.0"
|
version: "2.3.0"
|
||||||
|
pdf:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: pdf
|
||||||
|
sha256: e47a275b267873d5944ad5f5ff0dcc7ac2e36c02b3046a0ffac9b72fd362c44b
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.12.0"
|
||||||
|
pdf_widget_wrapper:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: pdf_widget_wrapper
|
||||||
|
sha256: c930860d987213a3d58c7ec3b7ecf8085c3897f773e8dc23da9cae60a5d6d0f5
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.4"
|
||||||
petitparser:
|
petitparser:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -904,6 +952,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.5.2"
|
version: "6.5.2"
|
||||||
|
printing:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: printing
|
||||||
|
sha256: "689170c9ddb1bda85826466ba80378aa8993486d3c959a71cd7d2d80cb606692"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.14.3"
|
||||||
pub_semver:
|
pub_semver:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -912,6 +968,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
version: "2.2.0"
|
||||||
|
qr:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: qr
|
||||||
|
sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.2"
|
||||||
record_use:
|
record_use:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -928,6 +992,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.3.2"
|
version: "3.3.2"
|
||||||
|
screenshot:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: screenshot
|
||||||
|
sha256: "63817697a7835e6ce82add4228e15d233b74d42975c143ad8cfe07009fab866b"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.0"
|
||||||
share_plus:
|
share_plus:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ dependencies:
|
|||||||
timezone: ^0.11.1
|
timezone: ^0.11.1
|
||||||
encrypt: ^5.0.3
|
encrypt: ^5.0.3
|
||||||
pointycastle: ^3.9.1
|
pointycastle: ^3.9.1
|
||||||
|
mobile_scanner: ^7.4.0
|
||||||
|
screenshot: ^3.0.0
|
||||||
|
pdf: ^3.12.0
|
||||||
|
printing: ^5.14.3
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
#include <file_selector_windows/file_selector_windows.h>
|
#include <file_selector_windows/file_selector_windows.h>
|
||||||
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
||||||
#include <local_auth_windows/local_auth_plugin.h>
|
#include <local_auth_windows/local_auth_plugin.h>
|
||||||
|
#include <printing/printing_plugin.h>
|
||||||
#include <share_plus/share_plus_windows_plugin_c_api.h>
|
#include <share_plus/share_plus_windows_plugin_c_api.h>
|
||||||
#include <url_launcher_windows/url_launcher_windows.h>
|
#include <url_launcher_windows/url_launcher_windows.h>
|
||||||
|
|
||||||
@@ -19,6 +20,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
|
|||||||
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
||||||
LocalAuthPluginRegisterWithRegistrar(
|
LocalAuthPluginRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("LocalAuthPlugin"));
|
registry->GetRegistrarForPlugin("LocalAuthPlugin"));
|
||||||
|
PrintingPluginRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("PrintingPlugin"));
|
||||||
SharePlusWindowsPluginCApiRegisterWithRegistrar(
|
SharePlusWindowsPluginCApiRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi"));
|
registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi"));
|
||||||
UrlLauncherWindowsRegisterWithRegistrar(
|
UrlLauncherWindowsRegisterWithRegistrar(
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user