diff --git a/implementation.md b/implementation.md new file mode 100644 index 0000000..ea88db3 --- /dev/null +++ b/implementation.md @@ -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. diff --git a/kifi-api/fix_controllers.py b/kifi-api/fix_controllers.py new file mode 100644 index 0000000..69e068e --- /dev/null +++ b/kifi-api/fix_controllers.py @@ -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!") diff --git a/kifi-api/kifi.db b/kifi-api/kifi.db new file mode 100644 index 0000000..e69de29 diff --git a/kifi-api/kifi_local.db b/kifi-api/kifi_local.db new file mode 100644 index 0000000..e69de29 diff --git a/kifi-api/scratch.dart b/kifi-api/scratch.dart new file mode 100644 index 0000000..ec1d217 --- /dev/null +++ b/kifi-api/scratch.dart @@ -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 +} diff --git a/kifi-api/scratch.java b/kifi-api/scratch.java new file mode 100644 index 0000000..ab491ab --- /dev/null +++ b/kifi-api/scratch.java @@ -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"); + } +} diff --git a/kifi-api/src/main/java/com/kifi/api/config/WebClientConfig.java b/kifi-api/src/main/java/com/kifi/api/config/WebClientConfig.java index 4cee9ce..4200a13 100644 --- a/kifi-api/src/main/java/com/kifi/api/config/WebClientConfig.java +++ b/kifi-api/src/main/java/com/kifi/api/config/WebClientConfig.java @@ -9,6 +9,10 @@ public class WebClientConfig { @Bean public WebClient.Builder webClientBuilder() { - return WebClient.builder(); + return WebClient + .builder().codecs(configurer -> + configurer.defaultCodecs() + .maxInMemorySize(10 * 1024 * 1024) + ); } } diff --git a/kifi-api/src/main/java/com/kifi/api/controller/WalletController.java b/kifi-api/src/main/java/com/kifi/api/controller/WalletController.java index c527bd1..55a5393 100644 --- a/kifi-api/src/main/java/com/kifi/api/controller/WalletController.java +++ b/kifi-api/src/main/java/com/kifi/api/controller/WalletController.java @@ -28,13 +28,13 @@ public class WalletController { @PostMapping public Mono createWallet(@RequestBody CreateWalletRequest request, Authentication authentication) { 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}") public Mono editWallet(@PathVariable Long walletId, @RequestBody CreateWalletRequest request, Authentication authentication) { 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}") @@ -95,6 +95,11 @@ public class WalletController { private String color; private String currency; 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 diff --git a/kifi-api/src/main/java/com/kifi/api/controller/business/BusinessController.java b/kifi-api/src/main/java/com/kifi/api/controller/business/BusinessController.java index c6b8dab..e54c70f 100644 --- a/kifi-api/src/main/java/com/kifi/api/controller/business/BusinessController.java +++ b/kifi-api/src/main/java/com/kifi/api/controller/business/BusinessController.java @@ -1,6 +1,7 @@ package com.kifi.api.controller.business; import com.kifi.api.entity.business.BusinessProfile; +import com.kifi.api.entity.business.BusinessFeature; import com.kifi.api.service.business.BusinessService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -29,4 +30,18 @@ public class BusinessController { return businessService.saveProfile(userId, profile) .map(ResponseEntity::ok); } + + @GetMapping("/features") + public Mono> getFeatures(Authentication authentication) { + Long userId = Long.valueOf(authentication.getDetails().toString()); + return businessService.getFeaturesByUserId(userId) + .map(ResponseEntity::ok); + } + + @PostMapping("/features") + public Mono> saveFeatures(Authentication authentication, @RequestBody BusinessFeature feature) { + Long userId = Long.valueOf(authentication.getDetails().toString()); + return businessService.saveFeatures(userId, feature) + .map(ResponseEntity::ok); + } } diff --git a/kifi-api/src/main/java/com/kifi/api/controller/business/MasterDataController.java b/kifi-api/src/main/java/com/kifi/api/controller/business/MasterDataController.java new file mode 100644 index 0000000..35e1a75 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/controller/business/MasterDataController.java @@ -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 getIndianStates() { + return indianStateRepository.findAllByOrderByNameAsc(); + } +} diff --git a/kifi-api/src/main/java/com/kifi/api/controller/customer/CustomerController.java b/kifi-api/src/main/java/com/kifi/api/controller/customer/CustomerController.java new file mode 100644 index 0000000..07aebbd --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/controller/customer/CustomerController.java @@ -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 getCustomers( + Authentication authentication, + @RequestParam(required = false) String search) { + Long userId = Long.valueOf(authentication.getDetails().toString()); + return customerService.getCustomers(userId, search); + } + + @GetMapping("/{id}") + public Mono getCustomerById( + @PathVariable Long id, + Authentication authentication) { + Long userId = Long.valueOf(authentication.getDetails().toString()); + return customerService.getCustomerById(id, userId); + } + + @PostMapping + public Mono createCustomer( + Authentication authentication, + @RequestBody Customer customer) { + Long userId = Long.valueOf(authentication.getDetails().toString()); + return customerService.createCustomer(userId, customer); + } + + @PutMapping("/{id}") + public Mono 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 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 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> 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); + }); + } +} diff --git a/kifi-api/src/main/java/com/kifi/api/controller/inventory/ProductBomController.java b/kifi-api/src/main/java/com/kifi/api/controller/inventory/ProductBomController.java new file mode 100644 index 0000000..454afbd --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/controller/inventory/ProductBomController.java @@ -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 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> 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> deleteBomItem( + Authentication authentication, + @PathVariable Long bomId) { + Long userId = Long.valueOf(authentication.getDetails().toString()); + return productService.deleteBomItem(userId, bomId) + .then(Mono.just(ResponseEntity.noContent().build())); + } +} diff --git a/kifi-api/src/main/java/com/kifi/api/controller/inventory/ProductCategoryController.java b/kifi-api/src/main/java/com/kifi/api/controller/inventory/ProductCategoryController.java index 533f55c..596eb95 100644 --- a/kifi-api/src/main/java/com/kifi/api/controller/inventory/ProductCategoryController.java +++ b/kifi-api/src/main/java/com/kifi/api/controller/inventory/ProductCategoryController.java @@ -2,6 +2,9 @@ package com.kifi.api.controller.inventory; import com.kifi.api.entity.inventory.ProductCategory; 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 org.springframework.http.ResponseEntity; 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.Mono; +import java.time.LocalDate; import java.time.LocalDateTime; +import java.math.BigDecimal; +import java.util.Map; @RestController @RequestMapping("/api/kifi-v2/inventory/categories") @RequiredArgsConstructor public class ProductCategoryController { private final ProductCategoryRepository categoryRepository; + private final CategoryRateHistoryRepository rateHistoryRepository; + private final ProductService productService; @GetMapping public Flux getCategories(Authentication authentication) { @@ -36,13 +44,55 @@ public class ProductCategoryController { public Mono> updateCategory(@PathVariable Long id, @RequestBody ProductCategory category) { return categoryRepository.findById(id) .flatMap(existing -> { + boolean rateChanged = category.getDailyRate() != null && !category.getDailyRate().equals(existing.getDailyRate()); existing.setName(category.getName()); existing.setParentCategoryId(category.getParentCategoryId()); existing.setIsCommodity(category.getIsCommodity()); existing.setDailyRate(category.getDailyRate()); - return categoryRepository.save(existing); + + Mono 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) .defaultIfEmpty(ResponseEntity.notFound().build()); } + + @GetMapping("/{id}/rate-history") + public Flux getRateHistory(@PathVariable Long id) { + return rateHistoryRepository.findRecentHistoryByCategoryId(id); + } + + @PostMapping("/{id}/sync-rates") + public Mono>> syncRates(@PathVariable Long id) { + return categoryRepository.findById(id) + .flatMap(category -> + productService.syncCategoryRates(id, category.getDailyRate()) + .count() + .map(count -> { + Map 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()); + } } diff --git a/kifi-api/src/main/java/com/kifi/api/controller/inventory/ProductController.java b/kifi-api/src/main/java/com/kifi/api/controller/inventory/ProductController.java index 972507e..c67839f 100644 --- a/kifi-api/src/main/java/com/kifi/api/controller/inventory/ProductController.java +++ b/kifi-api/src/main/java/com/kifi/api/controller/inventory/ProductController.java @@ -23,11 +23,13 @@ public class ProductController { @GetMapping public Flux getProducts( Authentication authentication, + @RequestParam(required = false) String search, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size ) { 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 @@ -78,4 +80,24 @@ public class ProductController { public Mono deleteImage(@PathVariable Long imageId) { return productService.deleteProductImage(imageId); } + + @PostMapping("/{id}/movements") + public Mono> 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 getStockLedger( + Authentication authentication, + @PathVariable Long id + ) { + Long userId = Long.valueOf(authentication.getDetails().toString()); + return productService.getProductStockLedger(userId, id); + } } diff --git a/kifi-api/src/main/java/com/kifi/api/controller/inventory/UnitOfMeasureController.java b/kifi-api/src/main/java/com/kifi/api/controller/inventory/UnitOfMeasureController.java new file mode 100644 index 0000000..48eca00 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/controller/inventory/UnitOfMeasureController.java @@ -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 getUoms(Authentication authentication) { + Long userId = Long.valueOf(authentication.getDetails().toString()); + return uomService.getUomsByUserId(userId); + } + + @GetMapping("/{id}") + public Mono getUomById(@PathVariable Long id, Authentication authentication) { + Long userId = Long.valueOf(authentication.getDetails().toString()); + return uomService.getUomById(id, userId); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public Mono createUom(@RequestBody UnitOfMeasure uom, Authentication authentication) { + Long userId = Long.valueOf(authentication.getDetails().toString()); + uom.setUserId(userId); + return uomService.createUom(uom); + } + + @PutMapping("/{id}") + public Mono 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 deleteUom(@PathVariable Long id, Authentication authentication) { + Long userId = Long.valueOf(authentication.getDetails().toString()); + return uomService.deleteUom(id, userId); + } +} diff --git a/kifi-api/src/main/java/com/kifi/api/controller/invoice/InvoiceController.java b/kifi-api/src/main/java/com/kifi/api/controller/invoice/InvoiceController.java new file mode 100644 index 0000000..90e96f6 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/controller/invoice/InvoiceController.java @@ -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 getInvoices(Authentication authentication) { + Long userId = Long.valueOf(authentication.getDetails().toString()); + return invoiceService.getInvoices(userId); + } + + @GetMapping("/{id}") + public Mono getInvoiceById( + @PathVariable Long id, + Authentication authentication) { + Long userId = Long.valueOf(authentication.getDetails().toString()); + return invoiceService.getInvoiceById(id, userId); + } + + @PostMapping + public Mono createInvoice( + Authentication authentication, + @RequestBody Invoice invoice) { + Long userId = Long.valueOf(authentication.getDetails().toString()); + return invoiceService.createInvoice(userId, invoice); + } + + @PutMapping("/{id}/finalize") + public Mono finalizeInvoice( + @PathVariable Long id, + Authentication authentication) { + Long userId = Long.valueOf(authentication.getDetails().toString()); + return invoiceService.finalizeInvoice(id, userId); + } + + @PostMapping("/{id}/payments") + public Mono 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 getPayments( + @PathVariable Long id, + Authentication authentication) { + Long userId = Long.valueOf(authentication.getDetails().toString()); + return invoiceService.getPaymentsForInvoice(id, userId); + } +} diff --git a/kifi-api/src/main/java/com/kifi/api/entity/Wallet.java b/kifi-api/src/main/java/com/kifi/api/entity/Wallet.java index 70f0a9e..7798d32 100644 --- a/kifi-api/src/main/java/com/kifi/api/entity/Wallet.java +++ b/kifi-api/src/main/java/com/kifi/api/entity/Wallet.java @@ -18,5 +18,10 @@ public class Wallet { private String currency; private String icon; 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; } diff --git a/kifi-api/src/main/java/com/kifi/api/entity/business/BusinessFeature.java b/kifi-api/src/main/java/com/kifi/api/entity/business/BusinessFeature.java index aee1947..0101d47 100644 --- a/kifi-api/src/main/java/com/kifi/api/entity/business/BusinessFeature.java +++ b/kifi-api/src/main/java/com/kifi/api/entity/business/BusinessFeature.java @@ -5,6 +5,7 @@ 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; @@ -21,5 +22,16 @@ public class BusinessFeature { private Boolean inventoryManagement; private Boolean salesManagement; 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; } diff --git a/kifi-api/src/main/java/com/kifi/api/entity/business/BusinessProfile.java b/kifi-api/src/main/java/com/kifi/api/entity/business/BusinessProfile.java index 78de672..718dee6 100644 --- a/kifi-api/src/main/java/com/kifi/api/entity/business/BusinessProfile.java +++ b/kifi-api/src/main/java/com/kifi/api/entity/business/BusinessProfile.java @@ -23,6 +23,13 @@ public class BusinessProfile { private String taxNumber; private String currency; 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 updatedAt; } diff --git a/kifi-api/src/main/java/com/kifi/api/entity/business/IndianState.java b/kifi-api/src/main/java/com/kifi/api/entity/business/IndianState.java new file mode 100644 index 0000000..c2b6e76 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/entity/business/IndianState.java @@ -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; +} diff --git a/kifi-api/src/main/java/com/kifi/api/entity/customer/Customer.java b/kifi-api/src/main/java/com/kifi/api/entity/customer/Customer.java new file mode 100644 index 0000000..0aba680 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/entity/customer/Customer.java @@ -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; +} diff --git a/kifi-api/src/main/java/com/kifi/api/entity/inventory/CategoryRateHistory.java b/kifi-api/src/main/java/com/kifi/api/entity/inventory/CategoryRateHistory.java new file mode 100644 index 0000000..ab1670b --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/entity/inventory/CategoryRateHistory.java @@ -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; +} diff --git a/kifi-api/src/main/java/com/kifi/api/entity/inventory/InventoryMovement.java b/kifi-api/src/main/java/com/kifi/api/entity/inventory/InventoryMovement.java index 8b5d8af..62da622 100644 --- a/kifi-api/src/main/java/com/kifi/api/entity/inventory/InventoryMovement.java +++ b/kifi-api/src/main/java/com/kifi/api/entity/inventory/InventoryMovement.java @@ -23,4 +23,7 @@ public class InventoryMovement { private Long referenceTransactionId; private String notes; private LocalDateTime createdAt; + + @org.springframework.data.annotation.Transient + private java.util.List items; } diff --git a/kifi-api/src/main/java/com/kifi/api/entity/inventory/Product.java b/kifi-api/src/main/java/com/kifi/api/entity/inventory/Product.java index e75a583..00da6de 100644 --- a/kifi-api/src/main/java/com/kifi/api/entity/inventory/Product.java +++ b/kifi-api/src/main/java/com/kifi/api/entity/inventory/Product.java @@ -49,4 +49,7 @@ public class Product { @Transient private List images; + + @Transient + private BigDecimal currentStock; } diff --git a/kifi-api/src/main/java/com/kifi/api/entity/invoice/Invoice.java b/kifi-api/src/main/java/com/kifi/api/entity/invoice/Invoice.java new file mode 100644 index 0000000..d51c4bf --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/entity/invoice/Invoice.java @@ -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 items; + + @Transient + private String paymentMethod; + + @Transient + private Long paymentWalletId; + + @Transient + private List payments; +} diff --git a/kifi-api/src/main/java/com/kifi/api/entity/invoice/InvoiceItem.java b/kifi-api/src/main/java/com/kifi/api/entity/invoice/InvoiceItem.java new file mode 100644 index 0000000..527f283 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/entity/invoice/InvoiceItem.java @@ -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; +} diff --git a/kifi-api/src/main/java/com/kifi/api/entity/invoice/InvoicePayment.java b/kifi-api/src/main/java/com/kifi/api/entity/invoice/InvoicePayment.java new file mode 100644 index 0000000..eca4459 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/entity/invoice/InvoicePayment.java @@ -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; +} diff --git a/kifi-api/src/main/java/com/kifi/api/repository/TransactionRepository.java b/kifi-api/src/main/java/com/kifi/api/repository/TransactionRepository.java index 344333b..b3973e6 100644 --- a/kifi-api/src/main/java/com/kifi/api/repository/TransactionRepository.java +++ b/kifi-api/src/main/java/com/kifi/api/repository/TransactionRepository.java @@ -8,7 +8,7 @@ import reactor.core.publisher.Mono; @Repository public interface TransactionRepository extends R2dbcRepository, 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 findVisibleTransactionsForUser(Long userId); Mono countByFromWalletIdOrToWalletId(Long fromWalletId, Long toWalletId); } diff --git a/kifi-api/src/main/java/com/kifi/api/repository/TransactionRepositoryImpl.java b/kifi-api/src/main/java/com/kifi/api/repository/TransactionRepositoryImpl.java index 3958416..b077f44 100644 --- a/kifi-api/src/main/java/com/kifi/api/repository/TransactionRepositoryImpl.java +++ b/kifi-api/src/main/java/com/kifi/api/repository/TransactionRepositoryImpl.java @@ -53,7 +53,7 @@ public class TransactionRepositoryImpl implements TransactionRepositoryCustom { 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(); DatabaseClient.GenericExecuteSpec dataSpec = databaseClient.sql(dataQueryStr).bind("userId", userId); diff --git a/kifi-api/src/main/java/com/kifi/api/repository/business/IndianStateRepository.java b/kifi-api/src/main/java/com/kifi/api/repository/business/IndianStateRepository.java new file mode 100644 index 0000000..9f81190 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/repository/business/IndianStateRepository.java @@ -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 { + Flux findAllByOrderByNameAsc(); +} diff --git a/kifi-api/src/main/java/com/kifi/api/repository/customer/CustomerRepository.java b/kifi-api/src/main/java/com/kifi/api/repository/customer/CustomerRepository.java new file mode 100644 index 0000000..6a10a9c --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/repository/customer/CustomerRepository.java @@ -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 { + Flux findByUserId(Long userId); + Flux findByUserIdAndNameContainingIgnoreCase(Long userId, String name); +} diff --git a/kifi-api/src/main/java/com/kifi/api/repository/inventory/CategoryRateHistoryRepository.java b/kifi-api/src/main/java/com/kifi/api/repository/inventory/CategoryRateHistoryRepository.java new file mode 100644 index 0000000..fa95dc4 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/repository/inventory/CategoryRateHistoryRepository.java @@ -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 { + Flux findByCategoryIdOrderByDateDesc(Long categoryId); + Mono findByCategoryIdAndDate(Long categoryId, LocalDate date); + + @Query("SELECT * FROM category_rate_history WHERE category_id = :categoryId ORDER BY date DESC LIMIT 30") + Flux findRecentHistoryByCategoryId(Long categoryId); +} diff --git a/kifi-api/src/main/java/com/kifi/api/repository/inventory/InventoryBalanceRepository.java b/kifi-api/src/main/java/com/kifi/api/repository/inventory/InventoryBalanceRepository.java index 126da30..cf376e6 100644 --- a/kifi-api/src/main/java/com/kifi/api/repository/inventory/InventoryBalanceRepository.java +++ b/kifi-api/src/main/java/com/kifi/api/repository/inventory/InventoryBalanceRepository.java @@ -8,4 +8,5 @@ import reactor.core.publisher.Flux; public interface InventoryBalanceRepository extends ReactiveCrudRepository { Mono findByProductIdAndLocationId(Long productId, Long locationId); Flux findByLocationId(Long locationId); + Flux findByProductId(Long productId); } diff --git a/kifi-api/src/main/java/com/kifi/api/repository/inventory/ProductRepository.java b/kifi-api/src/main/java/com/kifi/api/repository/inventory/ProductRepository.java index bde48ca..52e27c9 100644 --- a/kifi-api/src/main/java/com/kifi/api/repository/inventory/ProductRepository.java +++ b/kifi-api/src/main/java/com/kifi/api/repository/inventory/ProductRepository.java @@ -7,4 +7,9 @@ import reactor.core.publisher.Flux; public interface ProductRepository extends ReactiveCrudRepository { Flux 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 searchProducts(Long userId, String keyword, long offset, int limit); + + Flux findByCategoryId(Long categoryId); } diff --git a/kifi-api/src/main/java/com/kifi/api/repository/invoice/InvoiceItemRepository.java b/kifi-api/src/main/java/com/kifi/api/repository/invoice/InvoiceItemRepository.java new file mode 100644 index 0000000..1a75ff5 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/repository/invoice/InvoiceItemRepository.java @@ -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 { + Flux findByInvoiceId(Long invoiceId); + Mono deleteByInvoiceId(Long invoiceId); +} diff --git a/kifi-api/src/main/java/com/kifi/api/repository/invoice/InvoicePaymentRepository.java b/kifi-api/src/main/java/com/kifi/api/repository/invoice/InvoicePaymentRepository.java new file mode 100644 index 0000000..d9e46d0 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/repository/invoice/InvoicePaymentRepository.java @@ -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 { + Flux findByInvoiceId(Long invoiceId); +} diff --git a/kifi-api/src/main/java/com/kifi/api/repository/invoice/InvoiceRepository.java b/kifi-api/src/main/java/com/kifi/api/repository/invoice/InvoiceRepository.java new file mode 100644 index 0000000..fc12209 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/repository/invoice/InvoiceRepository.java @@ -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 { + Flux findByUserId(Long userId); + Flux findByUserIdAndCustomerId(Long userId, Long customerId); + Mono findByUserIdAndInvoiceNumber(Long userId, String invoiceNumber); +} diff --git a/kifi-api/src/main/java/com/kifi/api/service/MinioServiceClient.java b/kifi-api/src/main/java/com/kifi/api/service/MinioServiceClient.java index c2d23c8..9c9b1e6 100644 --- a/kifi-api/src/main/java/com/kifi/api/service/MinioServiceClient.java +++ b/kifi-api/src/main/java/com/kifi/api/service/MinioServiceClient.java @@ -11,10 +11,10 @@ public class MinioServiceClient { private final WebClient webClient; - @Value("${minio.service.url:http://minio-service:1500}") + @Value("${spring.minio.service.url:http://minio-service:1500}") private String minioServiceUrl; - @Value("${minio.service.bucket:kifi}") + @Value("${spring.minio.service.bucket:kifi}") private String bucketName; public MinioServiceClient(WebClient.Builder webClientBuilder) { diff --git a/kifi-api/src/main/java/com/kifi/api/service/WalletService.java b/kifi-api/src/main/java/com/kifi/api/service/WalletService.java index 5023070..d76df35 100644 --- a/kifi-api/src/main/java/com/kifi/api/service/WalletService.java +++ b/kifi-api/src/main/java/com/kifi/api/service/WalletService.java @@ -29,7 +29,7 @@ public class WalletService { private final EmailService emailService; private final com.kifi.api.repository.UserRepository userRepository; - public Mono createWallet(Long ownerId, String name, String nature, String icon, String color, String currency, java.math.BigDecimal initialBalance) { + public Mono 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.setOwnerId(ownerId); wallet.setName(name); @@ -38,6 +38,11 @@ public class WalletService { wallet.setCurrency(currency != null ? currency : "INR"); wallet.setIcon(icon); wallet.setColor(color); + wallet.setSubNature(subNature); + wallet.setCreditLimit(creditLimit); + wallet.setFixedAmount(fixedAmount); + wallet.setPaymentCycle(paymentCycle); + wallet.setCycleDate(cycleDate); wallet.setCreatedAt(LocalDateTime.now()); return walletRepository.save(wallet) @@ -112,7 +117,7 @@ public class WalletService { }); } - public Mono editWallet(Long walletId, Long ownerId, String name, String nature, String icon, String color, String currency) { + public Mono 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) .filter(w -> w.getOwnerId().equals(ownerId)) .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 (color != null) w.setColor(color); 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); }); } diff --git a/kifi-api/src/main/java/com/kifi/api/service/business/BusinessService.java b/kifi-api/src/main/java/com/kifi/api/service/business/BusinessService.java index 492fe20..e9f3e9e 100644 --- a/kifi-api/src/main/java/com/kifi/api/service/business/BusinessService.java +++ b/kifi-api/src/main/java/com/kifi/api/service/business/BusinessService.java @@ -1,7 +1,9 @@ package com.kifi.api.service.business; 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.BusinessFeatureRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import reactor.core.publisher.Mono; @@ -12,10 +14,44 @@ import java.time.LocalDateTime; @RequiredArgsConstructor public class BusinessService { private final BusinessProfileRepository businessProfileRepository; + private final BusinessFeatureRepository businessFeatureRepository; public Mono getProfileByUserId(Long userId) { return businessProfileRepository.findByUserId(userId); } + + public Mono 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 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 saveProfile(Long userId, BusinessProfile profile) { return businessProfileRepository.findByUserId(userId) @@ -23,6 +59,13 @@ public class BusinessService { existing.setBusinessName(profile.getBusinessName()); existing.setIndustry(profile.getIndustry()); 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.setUpdatedAt(LocalDateTime.now()); return businessProfileRepository.save(existing); diff --git a/kifi-api/src/main/java/com/kifi/api/service/customer/CustomerService.java b/kifi-api/src/main/java/com/kifi/api/service/customer/CustomerService.java new file mode 100644 index 0000000..7a91327 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/service/customer/CustomerService.java @@ -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 getCustomers(Long userId, String search) { + if (search != null && !search.isEmpty()) { + return customerRepository.findByUserIdAndNameContainingIgnoreCase(userId, search); + } + return customerRepository.findByUserId(userId); + } + + public Mono 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 createCustomer(Long userId, Customer customer) { + customer.setUserId(userId); + customer.setCreatedAt(LocalDateTime.now()); + return customerRepository.save(customer); + } + + public Mono 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 deleteCustomer(Long id, Long userId) { + return getCustomerById(id, userId) + .flatMap(customerRepository::delete); + } + + public Mono 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 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); + }); + } +} diff --git a/kifi-api/src/main/java/com/kifi/api/service/inventory/ProductService.java b/kifi-api/src/main/java/com/kifi/api/service/inventory/ProductService.java index 90a45ca..4cb50bc 100644 --- a/kifi-api/src/main/java/com/kifi/api/service/inventory/ProductService.java +++ b/kifi-api/src/main/java/com/kifi/api/service/inventory/ProductService.java @@ -4,6 +4,17 @@ import com.kifi.api.entity.inventory.Product; import com.kifi.api.entity.inventory.ProductImage; import com.kifi.api.repository.inventory.ProductRepository; 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 lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -12,21 +23,42 @@ import reactor.core.publisher.Mono; import org.springframework.data.domain.PageRequest; import java.util.UUID; import java.time.LocalDateTime; +import java.math.BigDecimal; @Service @RequiredArgsConstructor public class ProductService { private final ProductRepository productRepository; 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; - public Flux getProductsByUserId(Long userId, int page, int size) { - return productRepository.findByUserId(userId, PageRequest.of(page, size)) + public Flux getProductsByUserId(Long userId, String search, int page, int size) { + long offset = (long) page * size; + Flux 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() .map(images -> { product.setImages(images); 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.setSku(updatedProduct.getSku()); existingProduct.setCategoryId(updatedProduct.getCategoryId()); + existingProduct.setUomId(updatedProduct.getUomId()); existingProduct.setPurchasePrice(updatedProduct.getPurchasePrice()); existingProduct.setSellingPrice(updatedProduct.getSellingPrice()); existingProduct.setGstRate(updatedProduct.getGstRate()); @@ -106,4 +139,191 @@ public class ProductService { minioServiceClient.downloadFile(image.getContentType(), image.getFilePath()) ); } + + public Flux 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 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 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 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 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> 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 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 getProductBom(Long parentProductId) { + return productBomRepository.findByParentProductId(parentProductId); + } + + public Mono 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 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))); + } } diff --git a/kifi-api/src/main/java/com/kifi/api/service/inventory/UnitOfMeasureService.java b/kifi-api/src/main/java/com/kifi/api/service/inventory/UnitOfMeasureService.java new file mode 100644 index 0000000..debaf11 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/service/inventory/UnitOfMeasureService.java @@ -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 getUomsByUserId(Long userId) { + return uomRepository.findByUserId(userId); + } + + public Mono getUomById(Long id, Long userId) { + return uomRepository.findById(id) + .filter(uom -> uom.getUserId().equals(userId)); + } + + public Mono createUom(UnitOfMeasure uom) { + uom.setCreatedAt(LocalDateTime.now()); + return uomRepository.save(uom); + } + + public Mono 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 deleteUom(Long id, Long userId) { + return uomRepository.findById(id) + .filter(uom -> uom.getUserId().equals(userId)) + .flatMap(uomRepository::delete); + } +} diff --git a/kifi-api/src/main/java/com/kifi/api/service/invoice/InvoiceService.java b/kifi-api/src/main/java/com/kifi/api/service/invoice/InvoiceService.java new file mode 100644 index 0000000..19d2c22 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/service/invoice/InvoiceService.java @@ -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 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 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 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 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 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 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 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 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 getPaymentsForInvoice(Long invoiceId, Long userId) { + return getInvoiceById(invoiceId, userId) + .flatMapMany(invoice -> invoicePaymentRepository.findByInvoiceId(invoiceId)); + } +} diff --git a/kifi-api/src/main/resources/schema.sql b/kifi-api/src/main/resources/schema.sql index 20f6d15..eab9264 100644 --- a/kifi-api/src/main/resources/schema.sql +++ b/kifi-api/src/main/resources/schema.sql @@ -145,17 +145,48 @@ CREATE TABLE IF NOT EXISTS business_profiles ( tax_number VARCHAR(100), currency VARCHAR(10) DEFAULT 'INR', 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, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 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 ( id SERIAL PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, inventory_management BOOLEAN DEFAULT FALSE, sales_management BOOLEAN DEFAULT FALSE, multi_location BOOLEAN DEFAULT FALSE, + bom_reduction_strategy VARCHAR(50) DEFAULT 'COMPONENTS_ONLY', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE(user_id) ); @@ -172,6 +203,16 @@ CREATE TABLE IF NOT EXISTS product_categories ( 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 ( id SERIAL PRIMARY KEY, 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, 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 +); diff --git a/kifi-app/ios/Podfile.lock b/kifi-app/ios/Podfile.lock index b4b5805..2fce58e 100644 --- a/kifi-app/ios/Podfile.lock +++ b/kifi-app/ios/Podfile.lock @@ -77,11 +77,16 @@ PODS: - GTMSessionFetcher/Core (< 4.0, >= 3.3.2) - MLImage (= 1.0.0-beta8) - MLKitCommon (~> 14.0) + - mobile_scanner (7.0.0): + - Flutter + - FlutterMacOS - nanopb (3.30910.0): - nanopb/decode (= 3.30910.0) - nanopb/encode (= 3.30910.0) - nanopb/decode (3.30910.0) - nanopb/encode (3.30910.0) + - printing (1.0.0): + - Flutter - PromisesObjC (2.4.1) - SDWebImage (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`) - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - 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`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) @@ -142,6 +149,10 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/image_picker_ios/ios" local_auth_darwin: :path: ".symlinks/plugins/local_auth_darwin/darwin" + mobile_scanner: + :path: ".symlinks/plugins/mobile_scanner/darwin" + printing: + :path: ".symlinks/plugins/printing/ios" share_plus: :path: ".symlinks/plugins/share_plus/ios" shared_preferences_foundation: @@ -167,7 +178,9 @@ SPEC CHECKSUMS: MLKitTextRecognition: c0ad24510481dfc8893ad15dfcb8a5f05ed9e826 MLKitTextRecognitionCommon: 234ceb1cfdfb5fceb4fd664943046609a2961cc2 MLKitVision: 39a5a812db83c4a0794445088e567f3631c11961 + mobile_scanner: 9157936403f5a0644ca3779a38ff8404c5434a93 nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 + printing: 54ff03f28fe9ba3aa93358afb80a8595a071dd07 PromisesObjC: 752c3227f599e3467650e47ea36f433eeb10c273 SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf SDWebImageWebPCoder: 0e06e365080397465cc73a7a9b472d8a3bd0f377 diff --git a/kifi-app/lib/core/widgets/barcode_scanner_screen.dart b/kifi-app/lib/core/widgets/barcode_scanner_screen.dart new file mode 100644 index 0000000..88f950b --- /dev/null +++ b/kifi-app/lib/core/widgets/barcode_scanner_screen.dart @@ -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 createState() => _BarcodeScannerScreenState(); +} + +class _BarcodeScannerScreenState extends State { + 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 barcodes = capture.barcodes; + if (barcodes.isNotEmpty && barcodes.first.rawValue != null) { + _isScanned = true; + final String code = barcodes.first.rawValue!; + Navigator.pop(context, code); + } + }, + ), + ); + } +} diff --git a/kifi-app/lib/core/widgets/premium_text_field.dart b/kifi-app/lib/core/widgets/premium_text_field.dart new file mode 100644 index 0000000..92b26cf --- /dev/null +++ b/kifi-app/lib/core/widgets/premium_text_field.dart @@ -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, + ); + } +} diff --git a/kifi-app/lib/core/widgets/smart_search_dropdown.dart b/kifi-app/lib/core/widgets/smart_search_dropdown.dart new file mode 100644 index 0000000..8da361b --- /dev/null +++ b/kifi-app/lib/core/widgets/smart_search_dropdown.dart @@ -0,0 +1,220 @@ +import 'package:flutter/material.dart'; + +class SmartSearchDropdown extends StatefulWidget { + final List 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> createState() => _SmartSearchDropdownState(); +} + +class _SmartSearchDropdownState extends State> { + final LayerLink _layerLink = LayerLink(); + final FocusNode _focusNode = FocusNode(); + final TextEditingController _controller = TextEditingController(); + OverlayEntry? _overlayEntry; + bool _showAll = false; + List _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 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] : []); + + 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); + }, + ), + ); + } +} diff --git a/kifi-app/lib/features/budget/presentation/budget_screen.dart b/kifi-app/lib/features/budget/presentation/budget_screen.dart index 7380816..d2b343d 100644 --- a/kifi-app/lib/features/budget/presentation/budget_screen.dart +++ b/kifi-app/lib/features/budget/presentation/budget_screen.dart @@ -237,7 +237,6 @@ class _BudgetScreenState extends ConsumerState { final transState = ref.watch(transactionProvider); return Scaffold( - backgroundColor: Colors.transparent, body: Column( children: [ Padding( diff --git a/kifi-app/lib/features/business/domain/business_feature.dart b/kifi-app/lib/features/business/domain/business_feature.dart new file mode 100644 index 0000000..e68e11c --- /dev/null +++ b/kifi-app/lib/features/business/domain/business_feature.dart @@ -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 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 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, + ); + } +} diff --git a/kifi-app/lib/features/business/domain/business_profile.dart b/kifi-app/lib/features/business/domain/business_profile.dart index 54f4055..a8ceaaf 100644 --- a/kifi-app/lib/features/business/domain/business_profile.dart +++ b/kifi-app/lib/features/business/domain/business_profile.dart @@ -6,6 +6,13 @@ class BusinessProfile { final String? taxNumber; final String? currency; 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({ this.id, @@ -15,6 +22,13 @@ class BusinessProfile { this.taxNumber, this.currency, this.taxIncludedInPrice, + this.address, + this.stateId, + this.contactPerson, + this.contactNumber, + this.emailId, + this.panNumber, + this.gstin, }); factory BusinessProfile.fromJson(Map json) { @@ -26,6 +40,13 @@ class BusinessProfile { taxNumber: json['taxNumber'], currency: json['currency'], 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, 'currency': currency, '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? currency, bool? taxIncludedInPrice, + String? address, + int? stateId, + String? contactPerson, + String? contactNumber, + String? emailId, + String? panNumber, + String? gstin, }) { return BusinessProfile( id: id, @@ -56,6 +91,13 @@ class BusinessProfile { taxNumber: taxNumber ?? this.taxNumber, currency: currency ?? this.currency, 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, ); } } diff --git a/kifi-app/lib/features/business/domain/indian_state.dart b/kifi-app/lib/features/business/domain/indian_state.dart new file mode 100644 index 0000000..0faf936 --- /dev/null +++ b/kifi-app/lib/features/business/domain/indian_state.dart @@ -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 json) { + return IndianState( + id: json['id'], + name: json['name'], + gstCode: json['gstCode'], + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'gstCode': gstCode, + }; + } +} diff --git a/kifi-app/lib/features/business/presentation/hub/business_hub_screen.dart b/kifi-app/lib/features/business/presentation/hub/business_hub_screen.dart index 2a8c730..dabe57c 100644 --- a/kifi-app/lib/features/business/presentation/hub/business_hub_screen.dart +++ b/kifi-app/lib/features/business/presentation/hub/business_hub_screen.dart @@ -3,38 +3,69 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lucide_icons/lucide_icons.dart'; import '../../../../core/theme/nature_colors.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 { const BusinessHubScreen({super.key}); @override 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( padding: const EdgeInsets.all(24.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primary.withOpacity(0.1), - borderRadius: BorderRadius.circular(16), - border: Border.all(color: Theme.of(context).colorScheme.primary.withOpacity(0.2)), - ), - 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('Business Overview', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)), - const Text('Manage your inventory and stock', style: TextStyle(color: Colors.grey)), - ], + InkWell( + onTap: () { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => const BusinessProfileFormSheet(), + ); + }, + borderRadius: BorderRadius.circular(16), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary.withOpacity(0.1), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Theme.of(context).colorScheme.primary.withOpacity(0.2)), + ), + 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), @@ -46,56 +77,68 @@ class BusinessHubScreen extends ConsumerWidget { physics: const NeverScrollableScrollPhysics(), childAspectRatio: 1.2, children: [ - _buildActionCard( - context, - 'Products Catalog', - 'View all products', - LucideIcons.packageSearch, - Colors.blue, - () => Navigator.push(context, MaterialPageRoute(builder: (_) => const ProductListScreen())), - ), - _buildActionCard( - context, - 'Adjust Stock', - 'Add or reduce inventory', - LucideIcons.arrowRightLeft, - Colors.orange, - () { - // TODO: Navigate to Stock Adjustment - }, - ), - _buildActionCard( - context, - 'Sales & Invoices', - 'Coming in Phase 2', - LucideIcons.receipt, - Colors.green, - () {}, - ), - _buildActionCard( - context, - 'Customers', - 'Coming in Phase 2', - LucideIcons.users, - NatureColors.getColor('RECEIVABLES'), - () {}, - ), + if (showInventory) ...[ + _buildActionCard( + context, + 'Products Catalog', + 'View all products', + LucideIcons.packageSearch, + Colors.blue, + () => Navigator.push(context, MaterialPageRoute(builder: (_) => const ProductListScreen())), + ), + _buildActionCard( + context, + 'Adjust Stock', + 'Quick Stock Edit', + LucideIcons.arrowRightLeft, + Colors.orange, + () => Navigator.push(context, MaterialPageRoute(builder: (_) => const QuickAdjustStockScreen())), + ), + _buildActionCard( + context, + 'Units of Measure', + 'Manage measurements', + LucideIcons.ruler, + Colors.purple, + () => Navigator.push(context, MaterialPageRoute(builder: (_) => const UomsListScreen())), + ), + ], + if (showSales) ...[ + _buildActionCard( + context, + 'Sales & Invoices', + 'Create invoices', + 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), - Text('Low Stock Alerts', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)), - const SizedBox(height: 16), - // Placeholder for low stock items - Container( - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - color: Colors.grey.withOpacity(0.05), - borderRadius: BorderRadius.circular(16), + if (showInventory) ...[ + const SizedBox(height: 32), + Text('Low Stock Alerts', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)), + const SizedBox(height: 16), + // Placeholder for low stock items + Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + 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)), - ), - ), + ], ], ), ); diff --git a/kifi-app/lib/features/business/presentation/settings/business_settings_screen.dart b/kifi-app/lib/features/business/presentation/settings/business_settings_screen.dart index f44af27..4f93af6 100644 --- a/kifi-app/lib/features/business/presentation/settings/business_settings_screen.dart +++ b/kifi-app/lib/features/business/presentation/settings/business_settings_screen.dart @@ -25,6 +25,13 @@ class _BusinessSettingsScreenState extends ConsumerState _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 title: const Text('Inventory Management'), subtitle: const Text('Track stock levels, multiple locations, and products'), value: _inventoryEnabled, - onChanged: (val) => setState(() => _inventoryEnabled = val), + onChanged: (val) { + setState(() => _inventoryEnabled = val); + _saveFeatures(); + }, secondary: const Icon(LucideIcons.package), ), SwitchListTile( title: const Text('Sales & POS'), subtitle: const Text('Enable point of sale, invoicing, and receivables'), value: _salesEnabled, - onChanged: (val) => setState(() => _salesEnabled = val), + onChanged: (val) { + setState(() => _salesEnabled = val); + _saveFeatures(); + }, secondary: const Icon(LucideIcons.shoppingCart), ), 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 SizedBox(height: 16), SwitchListTile( @@ -80,4 +146,15 @@ class _BusinessSettingsScreenState extends ConsumerState 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); + } + } } diff --git a/kifi-app/lib/features/business/presentation/widgets/business_profile_form_sheet.dart b/kifi-app/lib/features/business/presentation/widgets/business_profile_form_sheet.dart new file mode 100644 index 0000000..640f348 --- /dev/null +++ b/kifi-app/lib/features/business/presentation/widgets/business_profile_form_sheet.dart @@ -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 createState() => _BusinessProfileFormSheetState(); +} + +class _BusinessProfileFormSheetState extends ConsumerState { + final _formKey = GlobalKey(); + + 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 _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( + 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), + ], + ), + ), + ), + ], + ); + }, + ), + ); + } +} diff --git a/kifi-app/lib/features/business/providers/business_provider.dart b/kifi-app/lib/features/business/providers/business_provider.dart index 7214ba7..67c58ca 100644 --- a/kifi-app/lib/features/business/providers/business_provider.dart +++ b/kifi-app/lib/features/business/providers/business_provider.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/network/dio_client.dart'; import '../domain/business_profile.dart'; +import '../domain/business_feature.dart'; class BusinessProfileNotifier extends AsyncNotifier { @override @@ -37,3 +38,42 @@ class BusinessProfileNotifier extends AsyncNotifier { final businessProfileProvider = AsyncNotifierProvider(() { return BusinessProfileNotifier(); }); + +class BusinessFeatureNotifier extends AsyncNotifier { + @override + FutureOr build() async { + return _fetchFeatures(); + } + + Future _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 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(() { + return BusinessFeatureNotifier(); +}); diff --git a/kifi-app/lib/features/business/providers/indian_states_provider.dart b/kifi-app/lib/features/business/providers/indian_states_provider.dart new file mode 100644 index 0000000..9bbb048 --- /dev/null +++ b/kifi-app/lib/features/business/providers/indian_states_provider.dart @@ -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> { + @override + Future> build() async { + return _fetchStates(); + } + + Future> _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>(() { + return IndianStatesNotifier(); +}); diff --git a/kifi-app/lib/features/dashboard/presentation/accounts_screen.dart b/kifi-app/lib/features/dashboard/presentation/accounts_screen.dart index fe4ff9f..36ac877 100644 --- a/kifi-app/lib/features/dashboard/presentation/accounts_screen.dart +++ b/kifi-app/lib/features/dashboard/presentation/accounts_screen.dart @@ -42,9 +42,18 @@ class _AccountsScreenState extends ConsumerState { void _showCreateWalletDialog() { final ctrl = TextEditingController(); final amtCtrl = TextEditingController(); + final creditLimitCtrl = TextEditingController(); + final fixedAmountCtrl = TextEditingController(); + final cycleDateCtrl = TextEditingController(); DateTime openingDate = DateTime.now(); String selectedNature = 'CASH'; + String? selectedSubNature; + String? selectedPaymentCycle; + 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( context: context, builder: (ctx) => StatefulBuilder( @@ -117,14 +126,121 @@ class _AccountsScreenState extends ConsumerState { ), items: natures.map((n) => DropdownMenuItem(value: n, child: Text(n))).toList(), 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), + if (selectedNature == 'PAYABLES') ...[ + DropdownButtonFormField( + 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( + 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( controller: amtCtrl, - keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: false), + keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: true), textInputAction: TextInputAction.done, style: const TextStyle(fontWeight: FontWeight.w500), decoration: InputDecoration( @@ -189,20 +305,30 @@ class _AccountsScreenState extends ConsumerState { onPressed: () async { if (ctrl.text.isNotEmpty) { 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( name: ctrl.text, nature: selectedNature, 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( id: 0, - type: 'INCOME', - amount: amt, + type: amt > 0 ? 'INCOME' : 'EXPENSE', + amount: amt.abs(), date: openingDate, 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); } @@ -335,7 +461,6 @@ class _AccountsScreenState extends ConsumerState { final walletsState = ref.watch(walletProvider); return Scaffold( - backgroundColor: Colors.transparent, body: SafeArea( bottom: false, child: Column( @@ -472,7 +597,16 @@ class _AccountsScreenState extends ConsumerState { onPressed: () { final editCtrl = TextEditingController(text: w.name); 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 subNatures = ['CREDIT_CARD', 'OD_LIMIT', 'LOAN_EMI', 'POLICY_PREMIUM', 'OTHER']; + final paymentCycles = ['DAILY', 'WEEKLY', 'MONTHLY', 'QUARTERLY', 'YEARLY']; + showDialog( context: context, builder: (ctx) => StatefulBuilder( @@ -480,9 +614,11 @@ class _AccountsScreenState extends ConsumerState { shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)), elevation: 0, backgroundColor: Colors.transparent, - child: Container( - padding: const EdgeInsets.all(24), - decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(28)), + child: GestureDetector( + onTap: () => FocusScope.of(context).unfocus(), + child: Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(28)), child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, @@ -510,10 +646,107 @@ class _AccountsScreenState extends ConsumerState { ), items: natures.map((n) => DropdownMenuItem(value: n, child: Text(n))).toList(), 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( + 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( + 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( mainAxisAlignment: MainAxisAlignment.end, children: [ @@ -525,11 +758,20 @@ class _AccountsScreenState extends ConsumerState { ElevatedButton( onPressed: () async { 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 { await ref.read(walletProvider.notifier).editWallet( w.id, name: editCtrl.text.trim(), nature: editNature, + subNature: editNature == 'PAYABLES' ? editSubNature : null, + creditLimit: cl, + fixedAmount: fa, + paymentCycle: editNature == 'PAYABLES' ? editPaymentCycle : null, + cycleDate: cd, ); if (context.mounted) { Navigator.pop(ctx); @@ -549,6 +791,7 @@ class _AccountsScreenState extends ConsumerState { ), ], ), + ), ), ), ), diff --git a/kifi-app/lib/features/dashboard/presentation/dashboard_screen.dart b/kifi-app/lib/features/dashboard/presentation/dashboard_screen.dart index c5b8a6a..1690434 100644 --- a/kifi-app/lib/features/dashboard/presentation/dashboard_screen.dart +++ b/kifi-app/lib/features/dashboard/presentation/dashboard_screen.dart @@ -12,7 +12,13 @@ import '../../transactions/data/models.dart'; import '../../budget/presentation/budget_screen.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/upcoming_dues_widget.dart'; import 'widgets/statistics_tab.dart'; import '../../../core/widgets/shimmer_loading.dart'; import '../../../core/theme/nature_colors.dart'; @@ -57,6 +63,8 @@ class _DashboardScreenState extends ConsumerState { ref.invalidate(categoryProvider); ref.invalidate(budgetProvider); ref.invalidate(invitationProvider); + ref.invalidate(invoicesProvider); + ref.invalidate(customersProvider); await Future.delayed(const Duration(milliseconds: 500)); } @@ -139,6 +147,8 @@ class _DashboardScreenState extends ConsumerState { final categoriesState = ref.watch(categoryProvider); final insight = ref.watch(insightProvider); final walletsState = ref.watch(walletProvider); + final invoicesState = ref.watch(invoicesProvider); + final customersState = ref.watch(customersProvider); final isBusinessMode = ref.watch(businessModeProvider); final allTransactions = transState.value ?? []; @@ -147,10 +157,18 @@ class _DashboardScreenState extends ConsumerState { final endDate = range.end; String title; - if (_currentIndex == 0) title = 'Dashboard'; - else if (_currentIndex == 1) title = isBusinessMode ? 'Business Hub' : 'Statistics'; - else if (_currentIndex == 2) title = 'My Accounts'; - else title = 'Budgets'; + if (_currentIndex == 0) { + title = 'Dashboard'; + } else if (isBusinessMode) { + 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( appBar: AppBar( @@ -308,6 +326,68 @@ class _DashboardScreenState extends ConsumerState { } } } + + // Process invoices to get total pending + double totalPendingInvoices = 0; + Map 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 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 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( index: _currentIndex, children: [ @@ -352,7 +432,6 @@ class _DashboardScreenState extends ConsumerState { ], const BudgetStatusCard(), const SizedBox(height: 24), - // Summary Cards Grid GridView.count( crossAxisCount: 2, @@ -362,14 +441,34 @@ class _DashboardScreenState extends ConsumerState { physics: const NeverScrollableScrollPhysics(), childAspectRatio: 1.5, children: [ - _buildSummaryCard(context, 'Balance', cashBalance, NatureColors.getColor('BALANCE'), LucideIcons.wallet, 'CASH'), - _buildSummaryCard(context, 'Expense', expenseBalance, NatureColors.getColor('EXPENSE'), LucideIcons.arrowUpRight, 'EXPENSE'), - _buildSummaryCard(context, 'Savings', savingsBalance, NatureColors.getColor('SAVINGS'), LucideIcons.piggyBank, 'SAVINGS'), - _buildSummaryCard(context, 'Investments', investmentsBalance, NatureColors.getColor('INVESTMENTS'), LucideIcons.trendingUp, 'INVESTMENTS'), - _buildSummaryCard(context, 'Payables', payablesBalance, NatureColors.getColor('PAYABLES'), LucideIcons.alertCircle, 'PAYABLES'), - _buildSummaryCard(context, 'Receivables', receivablesBalance, NatureColors.getColor('RECEIVABLES'), LucideIcons.arrowDownLeft, 'RECEIVABLES'), + _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, onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const AccountsScreen(initialFilterNature: 'EXPENSE')))), + _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, onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const AccountsScreen(initialFilterNature: 'INVESTMENTS')))), + _buildSummaryCard(context, 'Total Payables', payablesBalance, NatureColors.getColor('PAYABLES'), LucideIcons.alertCircle, onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const AccountsScreen(initialFilterNature: 'PAYABLES')))), + _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), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -515,8 +614,11 @@ class _DashboardScreenState extends ConsumerState { ), ), - // ---------------- STATS/BUSINESS TAB ---------------- - isBusinessMode ? const BusinessHubScreen() : StatisticsTab( + // ---------------- BUSINESS TAB ---------------- + if (isBusinessMode) const BusinessHubScreen(), + + // ---------------- STATS TAB ---------------- + StatisticsTab( transactions: transactions, wallets: safeWallets, categories: categoriesState.value ?? [], @@ -541,14 +643,17 @@ class _DashboardScreenState extends ConsumerState { }, ), bottomNavigationBar: BottomNavigationBar( - currentIndex: _currentIndex >= 2 ? _currentIndex + 1 : _currentIndex, + currentIndex: isBusinessMode + ? (_currentIndex >= 3 ? _currentIndex + 1 : _currentIndex) + : (_currentIndex >= 2 ? _currentIndex + 1 : _currentIndex), type: BottomNavigationBarType.fixed, onTap: (index) { - if (index == 2) { + final addIndex = isBusinessMode ? 3 : 2; + if (index == addIndex) { Navigator.push(context, MaterialPageRoute(builder: (_) => const AddTransactionScreen())); } else { setState(() { - _currentIndex = index > 2 ? index - 1 : index; + _currentIndex = index > addIndex ? index - 1 : index; }); } }, @@ -558,9 +663,9 @@ class _DashboardScreenState extends ConsumerState { showUnselectedLabels: true, items: [ const BottomNavigationBarItem(icon: Icon(LucideIcons.home), label: 'Home'), - BottomNavigationBarItem( - icon: Icon(isBusinessMode ? LucideIcons.briefcase : LucideIcons.pieChart), - label: isBusinessMode ? 'Business' : 'Stats'), + if (isBusinessMode) + const BottomNavigationBarItem(icon: Icon(LucideIcons.briefcase), label: 'Business'), + const BottomNavigationBarItem(icon: Icon(LucideIcons.pieChart), label: 'Stats'), const BottomNavigationBarItem(icon: Icon(LucideIcons.plusCircle), label: 'Add'), const BottomNavigationBarItem(icon: Icon(LucideIcons.wallet), label: 'Wallets'), const BottomNavigationBarItem(icon: Icon(LucideIcons.target), label: 'Budgets'), @@ -569,16 +674,9 @@ class _DashboardScreenState extends ConsumerState { ); } - 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( - onTap: nature != null ? () { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => AccountsScreen(initialFilterNature: nature), - ), - ); - } : null, + onTap: onTap, child: Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( diff --git a/kifi-app/lib/features/dashboard/presentation/widgets/swipeable_account_card.dart b/kifi-app/lib/features/dashboard/presentation/widgets/swipeable_account_card.dart new file mode 100644 index 0000000..3df23c6 --- /dev/null +++ b/kifi-app/lib/features/dashboard/presentation/widgets/swipeable_account_card.dart @@ -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 items; + + const SwipeableAccountCard({ + super.key, + required this.items, + }); + + @override + State createState() => _SwipeableAccountCardState(); +} + +class _SwipeableAccountCardState extends State { + 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), + ), + ), + ), + ), + ], + ], + ); + } +} diff --git a/kifi-app/lib/features/dashboard/presentation/widgets/upcoming_dues_widget.dart b/kifi-app/lib/features/dashboard/presentation/widgets/upcoming_dues_widget.dart new file mode 100644 index 0000000..59ee698 --- /dev/null +++ b/kifi-app/lib/features/dashboard/presentation/widgets/upcoming_dues_widget.dart @@ -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> 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), + ], + ); + } +} diff --git a/kifi-app/lib/features/inventory/domain/category_rate_history.dart b/kifi-app/lib/features/inventory/domain/category_rate_history.dart new file mode 100644 index 0000000..830f10f --- /dev/null +++ b/kifi-app/lib/features/inventory/domain/category_rate_history.dart @@ -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 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 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(), + }; + } +} diff --git a/kifi-app/lib/features/inventory/domain/product.dart b/kifi-app/lib/features/inventory/domain/product.dart index 47e8af8..5b1889f 100644 --- a/kifi-app/lib/features/inventory/domain/product.dart +++ b/kifi-app/lib/features/inventory/domain/product.dart @@ -25,6 +25,7 @@ class Product { final bool trackInventory; final bool isActive; final List imageIds; + final double? currentStock; Product({ this.id, @@ -53,38 +54,40 @@ class Product { this.trackInventory = true, this.isActive = true, this.imageIds = const [], + this.currentStock = 0.0, }); factory Product.fromJson(Map json) { return Product( id: json['id'], - userId: json['userId'], - categoryId: json['categoryId'], - uomId: json['uomId'], + userId: json['userId'] ?? json['user_id'], + categoryId: json['categoryId'] ?? json['category_id'], + uomId: json['uomId'] ?? json['uom_id'], name: json['name'], sku: json['sku'], barcode: json['barcode'], description: json['description'], - purchasePrice: (json['purchasePrice'] as num?)?.toDouble(), - sellingPrice: (json['sellingPrice'] as num?)?.toDouble(), - minStock: (json['minStock'] as num?)?.toDouble(), - reorderLevel: (json['reorderLevel'] as num?)?.toDouble(), - gstRate: (json['gstRate'] as num?)?.toDouble(), + purchasePrice: (json['purchasePrice'] ?? json['purchase_price'] as num?)?.toDouble(), + sellingPrice: (json['sellingPrice'] ?? json['selling_price'] as num?)?.toDouble(), + minStock: (json['minStock'] ?? json['min_stock'] as num?)?.toDouble(), + reorderLevel: (json['reorderLevel'] ?? json['reorder_level'] as num?)?.toDouble(), + gstRate: (json['gstRate'] ?? json['gst_rate'] as num?)?.toDouble(), dimensions: json['dimensions'], weight: (json['weight'] as num?)?.toDouble(), color: json['color'], size: json['size'], - priceCalcRule: json['priceCalcRule'] ?? 'MANUAL', - autoCalculatePrice: json['autoCalculatePrice'] ?? false, - purityFactor: (json['purityFactor'] as num?)?.toDouble() ?? 1.0, - makingCharges: (json['makingCharges'] as num?)?.toDouble() ?? 0.0, - makingChargesType: json['makingChargesType'] ?? 'FLAT', - wastagePercentage: (json['wastagePercentage'] as num?)?.toDouble() ?? 0.0, - trackInventory: json['trackInventory'] ?? true, - isActive: json['isActive'] ?? true, + priceCalcRule: json['priceCalcRule'] ?? json['price_calc_rule'] ?? 'MANUAL', + autoCalculatePrice: json['autoCalculatePrice'] ?? json['auto_calculate_price'] ?? false, + purityFactor: (json['purityFactor'] ?? json['purity_factor'] as num?)?.toDouble() ?? 1.0, + makingCharges: (json['makingCharges'] ?? json['making_charges'] as num?)?.toDouble() ?? 0.0, + makingChargesType: json['makingChargesType'] ?? json['making_charges_type'] ?? 'FLAT', + wastagePercentage: (json['wastagePercentage'] ?? json['wastage_percentage'] as num?)?.toDouble() ?? 0.0, + trackInventory: json['trackInventory'] ?? json['track_inventory'] ?? true, + isActive: json['isActive'] ?? json['is_active'] ?? true, imageIds: json['images'] != null ? (json['images'] as List).map((i) => i['id'] as int).toList() : [], + currentStock: (json['currentStock'] ?? json['current_stock'] as num?)?.toDouble() ?? 0.0, ); } diff --git a/kifi-app/lib/features/inventory/domain/product_bom.dart b/kifi-app/lib/features/inventory/domain/product_bom.dart new file mode 100644 index 0000000..caddac0 --- /dev/null +++ b/kifi-app/lib/features/inventory/domain/product_bom.dart @@ -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? componentProduct; + + ProductBom({ + this.id, + required this.parentProductId, + required this.componentProductId, + required this.quantity, + this.createdAt, + this.componentProduct, + }); + + factory ProductBom.fromJson(Map 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 toJson() { + return { + if (id != null) 'id': id, + 'parentProductId': parentProductId, + 'componentProductId': componentProductId, + 'quantity': quantity, + }; + } +} diff --git a/kifi-app/lib/features/inventory/domain/stock_movement.dart b/kifi-app/lib/features/inventory/domain/stock_movement.dart new file mode 100644 index 0000000..09dd45b --- /dev/null +++ b/kifi-app/lib/features/inventory/domain/stock_movement.dart @@ -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 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 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? items; + + StockMovement({ + this.id, + this.userId, + this.locationId, + required this.type, + this.referenceTransactionId, + this.notes, + this.createdAt, + this.items, + }); + + factory StockMovement.fromJson(Map 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 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(), + }; + } +} diff --git a/kifi-app/lib/features/inventory/domain/uom.dart b/kifi-app/lib/features/inventory/domain/uom.dart new file mode 100644 index 0000000..848ac22 --- /dev/null +++ b/kifi-app/lib/features/inventory/domain/uom.dart @@ -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 json) { + return UnitOfMeasure( + id: json['id'], + name: json['name'], + abbreviation: json['abbreviation'], + ); + } + + Map 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, + ); + } +} diff --git a/kifi-app/lib/features/inventory/presentation/add_product_screen.dart b/kifi-app/lib/features/inventory/presentation/add_product_screen.dart index 858c77f..2ec24d9 100644 --- a/kifi-app/lib/features/inventory/presentation/add_product_screen.dart +++ b/kifi-app/lib/features/inventory/presentation/add_product_screen.dart @@ -1,14 +1,19 @@ import 'dart:io'; -import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lucide_icons/lucide_icons.dart'; import 'package:image_picker/image_picker.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 '../providers/products_provider.dart'; import '../providers/product_categories_provider.dart'; +import '../providers/uoms_provider.dart'; +import '../domain/uom.dart'; import '../../business/providers/business_provider.dart'; +import '../../transactions/presentation/widgets/attachment_gallery_screen.dart'; class AddProductScreen extends ConsumerStatefulWidget { final Product? product; @@ -28,6 +33,7 @@ class _AddProductScreenState extends ConsumerState { String _name = ''; String _sku = ''; ProductCategory? _selectedCategory; + int? _uomId; // Properties String _color = ''; @@ -50,15 +56,22 @@ class _AddProductScreenState extends ConsumerState { // Media final List _images = []; + final List _existingImageIds = []; bool _isSaving = false; + String? _token; @override void initState() { super.initState(); + import_storage.FlutterSecureStorage().read(key: 'jwt_token').then((val) { + if (mounted) setState(() => _token = val); + }); + if (widget.product != null) { final p = widget.product!; _name = p.name; _sku = p.sku ?? ''; + _uomId = p.uomId; _color = p.color ?? ''; _size = p.size ?? ''; _dimensions = p.dimensions ?? ''; @@ -73,6 +86,10 @@ class _AddProductScreenState extends ConsumerState { _makingChargesType = p.makingChargesType ?? 'FLAT'; _wastagePercentage = p.wastagePercentage ?? 0.0; + if (p.imageIds.isNotEmpty) { + _existingImageIds.addAll(p.imageIds); + } + WidgetsBinding.instance.addPostFrameCallback((_) { final cats = ref.read(productCategoriesProvider).value ?? []; if (cats.isNotEmpty) { @@ -105,7 +122,7 @@ class _AddProductScreenState extends ConsumerState { } Future _pickImages() async { - if (_images.length >= 4) { + if ((_images.length + _existingImageIds.length) >= 4) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Maximum 4 images allowed'))); return; } @@ -113,7 +130,7 @@ class _AddProductScreenState extends ConsumerState { final List picked = await picker.pickMultiImage(); if (picked.isNotEmpty) { 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 { }); } + void _previewImage(int index) { + List 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() { String newCatName = ''; bool newCatCommodity = false; @@ -156,10 +197,12 @@ class _AddProductScreenState extends ConsumerState { onChanged: (val) => setDialogState(() => newCatCommodity = val), ), if (newCatCommodity) ...[ - _buildPremiumDropdown( - label: 'Calculation Method', + // Replace DropdownButtonFormField with SmartSearchDropdown + SmartSearchDropdown( + hintText: 'Calculation Method', value: newCalcMethod, - items: ['UNIT', 'WEIGHT', 'VOLUME'], + items: const ['UNIT', 'WEIGHT', 'VOLUME'], + itemAsString: (val) => val, onChanged: (val) => setDialogState(() { newCalcMethod = val!; if (val == 'WEIGHT') newBaseUnit = 'gm'; @@ -224,15 +267,18 @@ class _AddProductScreenState extends ConsumerState { setState(() => _isSaving = true); try { final product = Product( + id: widget.product?.id, name: _name, 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, sellingPrice: _autoCalculatePrice ? _calculateLivePrice() : _sellingPrice, gstRate: _gstRate, weight: _weight, - color: _color, - size: _size, - dimensions: _dimensions, priceCalcRule: _autoCalculatePrice ? 'COMMODITY' : 'MANUAL', autoCalculatePrice: _autoCalculatePrice, purityFactor: _purityFactor, @@ -258,9 +304,9 @@ class _AddProductScreenState extends ConsumerState { double _calculateLivePrice() { if (_selectedCategory == null || !_selectedCategory!.isCommodity) return _sellingPrice; 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 materialCost = materialWeight * rate * _purityFactor; @@ -431,11 +477,12 @@ class _AddProductScreenState extends ConsumerState { ), ) else - _buildPremiumDropdown( - label: 'Select Category*', + // Replace DropdownButtonFormField with SmartSearchDropdown + SmartSearchDropdown( + hintText: 'Select Category*', value: _selectedCategory, items: leafCategories, - itemLabel: (c) { + itemAsString: (c) { String displayName = c.name; if (c.parentCategoryId != null) { final parent = categories.firstWhere((p) => p.id == c.parentCategoryId, orElse: () => c); @@ -460,6 +507,31 @@ class _AddProductScreenState extends ConsumerState { ); }, ), + 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( + 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 { const SizedBox(height: 32), _buildPremiumTextField( - label: 'Weight', + label: _selectedCategory?.isCommodity == true ? 'Volume / Weight' : 'Weight', initialValue: _weight == 0 ? '' : _weight.toString(), keyboardType: const TextInputType.numberWithOptions(decimal: true), onChanged: (val) => setState(() => _weight = double.tryParse(val) ?? 0), - suffixText: _selectedCategory?.baseUnit ?? 'unit', + suffixText: _getUomAbbreviation() ?? _selectedCategory?.baseUnit ?? 'unit', ), const SizedBox(height: 20), _buildPremiumTextField( @@ -567,7 +639,7 @@ class _AddProductScreenState extends ConsumerState { Row( children: [ Expanded( - flex: 2, + flex: 1, child: _buildPremiumTextField( label: 'Making Charges', initialValue: _makingCharges.toString(), @@ -583,7 +655,7 @@ class _AddProductScreenState extends ConsumerState { label: 'Type', value: _makingChargesType, 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!), darkTheme: true, ), @@ -666,7 +738,7 @@ class _AddProductScreenState extends ConsumerState { const Text('Upload up to 4 high-quality images.', style: TextStyle(color: Colors.grey)), const SizedBox(height: 32), - if (_images.isNotEmpty) + if (_images.isNotEmpty || _existingImageIds.isNotEmpty) GridView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), @@ -676,46 +748,42 @@ class _AddProductScreenState extends ConsumerState { mainAxisSpacing: 16, childAspectRatio: 1, ), - itemCount: _images.length, + itemCount: _images.length + _existingImageIds.length, itemBuilder: (context, index) { - return Stack( - fit: StackFit.expand, - children: [ - 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: Image.file( - File(_images[index].path), - fit: BoxFit.cover, - ), - ), - ), - ), - Positioned( - top: 8, - right: 8, - child: GestureDetector( - onTap: () => _removeImage(index), - 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), - ), - ), - ) - ], - ); + if (index < _existingImageIds.length) { + final imageId = _existingImageIds[index]; + final imageUrl = '${DioClient().dio.options.baseUrl}/inventory/products/${widget.product!.id}/images/$imageId/content'; + return _buildImageThumbnail( + isNetwork: true, + url: imageUrl, + onTap: () => _previewImage(index), + onDelete: () async { + try { + setState(() => _isSaving = true); + await DioClient().dio.delete('/inventory/products/images/$imageId'); + setState(() => _existingImageIds.removeAt(index)); + // Update product list in background + ref.read(productsProvider.notifier).refresh(); + } catch (e) { + if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to delete image: $e'))); + } finally { + if (mounted) setState(() => _isSaving = false); + } + } + ); + } else { + final localIndex = index - _existingImageIds.length; + return _buildImageThumbnail( + isNetwork: false, + file: File(_images[localIndex].path), + onTap: () => _previewImage(index), + onDelete: () => _removeImage(localIndex) + ); + } }, ), const SizedBox(height: 24), - if (_images.length < 4) + if ((_images.length + _existingImageIds.length) < 4) GestureDetector( onTap: _pickImages, child: Container( @@ -731,7 +799,7 @@ class _AddProductScreenState extends ConsumerState { Icon(LucideIcons.uploadCloud, size: 48, color: Theme.of(context).colorScheme.primary), const SizedBox(height: 16), 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 { ); } + 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 _buildPremiumTextField({ @@ -787,26 +904,50 @@ class _AddProductScreenState extends ConsumerState { required void Function(T?) onChanged, bool darkTheme = false, }) { - return DropdownButtonFormField( - decoration: InputDecoration( - labelText: label, - labelStyle: TextStyle(color: darkTheme ? Colors.white70 : Colors.grey[600]), - filled: true, - fillColor: darkTheme ? Colors.black26 : Colors.grey[100], - border: 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) - ), + return Theme( + data: darkTheme ? Theme.of(context).copyWith( + textTheme: Theme.of(context).textTheme.apply(bodyColor: Colors.white, displayColor: Colors.white), + inputDecorationTheme: InputDecorationTheme( + labelStyle: const TextStyle(color: Colors.white70), + filled: true, + fillColor: Colors.black26, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none), + focusedBorder: OutlineInputBorder( + 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( + 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; + } + } } diff --git a/kifi-app/lib/features/inventory/presentation/bom_tab.dart b/kifi-app/lib/features/inventory/presentation/bom_tab.dart new file mode 100644 index 0000000..8a10be4 --- /dev/null +++ b/kifi-app/lib/features/inventory/presentation/bom_tab.dart @@ -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 createState() => _BomTabState(); +} + +class _BomTabState extends ConsumerState { + bool _isLoading = true; + List _bomItems = []; + + @override + void initState() { + super.initState(); + _loadBom(); + } + + Future _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( + 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"), + ), + ), + ], + ); + } +} diff --git a/kifi-app/lib/features/inventory/presentation/daily_rates_screen.dart b/kifi-app/lib/features/inventory/presentation/daily_rates_screen.dart new file mode 100644 index 0000000..6ff074e --- /dev/null +++ b/kifi-app/lib/features/inventory/presentation/daily_rates_screen.dart @@ -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 createState() => _DailyRatesScreenState(); +} + +class _DailyRatesScreenState extends ConsumerState { + final Map _rateControllers = {}; + final Map _isExpanded = {}; + final Map> _historyCache = {}; + final Map _isLoadingHistory = {}; + + @override + void dispose() { + for (var controller in _rateControllers.values) { + controller.dispose(); + } + super.dispose(); + } + + Future _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 _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 _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')), + ), + ); + } +} diff --git a/kifi-app/lib/features/inventory/presentation/product_detail_screen.dart b/kifi-app/lib/features/inventory/presentation/product_detail_screen.dart new file mode 100644 index 0000000..9d06b26 --- /dev/null +++ b/kifi-app/lib/features/inventory/presentation/product_detail_screen.dart @@ -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( + 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)), + ], + ), + ); + } +} diff --git a/kifi-app/lib/features/inventory/presentation/product_list_screen.dart b/kifi-app/lib/features/inventory/presentation/product_list_screen.dart index 66057f1..8ed149f 100644 --- a/kifi-app/lib/features/inventory/presentation/product_list_screen.dart +++ b/kifi-app/lib/features/inventory/presentation/product_list_screen.dart @@ -1,9 +1,12 @@ import 'package:flutter/material.dart'; +import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lucide_icons/lucide_icons.dart'; import '../providers/products_provider.dart'; import '../providers/product_categories_provider.dart'; import 'add_product_screen.dart'; +import 'product_detail_screen.dart'; +import 'daily_rates_screen.dart'; import '../../../core/network/dio_client.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; @@ -17,7 +20,7 @@ class ProductListScreen extends ConsumerStatefulWidget { class _ProductListScreenState extends ConsumerState { final TextEditingController _searchController = TextEditingController(); final ScrollController _scrollController = ScrollController(); - String _searchQuery = ''; + Timer? _debounce; String? _token; @override @@ -36,6 +39,7 @@ class _ProductListScreenState extends ConsumerState { void dispose() { _scrollController.dispose(); _searchController.dispose(); + _debounce?.cancel(); super.dispose(); } @@ -54,7 +58,18 @@ class _ProductListScreenState extends ConsumerState { appBar: AppBar( title: const Text('Products Catalog'), 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( children: [ @@ -73,8 +88,9 @@ class _ProductListScreenState extends ConsumerState { ), ), onChanged: (val) { - setState(() { - _searchQuery = val.toLowerCase(); + if (_debounce?.isActive ?? false) _debounce!.cancel(); + _debounce = Timer(const Duration(milliseconds: 500), () { + ref.read(productsProvider.notifier).search(val); }); }, ), @@ -84,12 +100,11 @@ class _ProductListScreenState extends ConsumerState { loading: () => const Center(child: CircularProgressIndicator()), error: (err, stack) => Center(child: Text('Error: $err')), data: (products) { - final filtered = products.where((p) => p.name.toLowerCase().contains(_searchQuery) || (p.sku != null && p.sku!.toLowerCase().contains(_searchQuery))).toList(); - - if (filtered.isEmpty) { + 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.', style: TextStyle(color: Colors.grey))) @@ -102,17 +117,15 @@ class _ProductListScreenState extends ConsumerState { onRefresh: () => ref.read(productsProvider.notifier).refresh(), child: ListView.builder( 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), itemBuilder: (context, index) { - if (index == filtered.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. + if (index == products.length) { 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'; return Card( @@ -122,7 +135,7 @@ class _ProductListScreenState extends ConsumerState { child: InkWell( borderRadius: BorderRadius.circular(16), onTap: () { - Navigator.push(context, MaterialPageRoute(builder: (_) => AddProductScreen(product: p))); + Navigator.push(context, MaterialPageRoute(builder: (_) => ProductDetailScreen(product: p))); }, child: Padding( padding: const EdgeInsets.all(12.0), @@ -162,7 +175,7 @@ class _ProductListScreenState extends ConsumerState { ), const SizedBox(height: 4), Text( - p.trackInventory ? 'Stock: 0' : 'Untracked', // We'll link stock later + p.trackInventory ? 'Stock: ${p.currentStock ?? 0}' : 'Untracked', style: TextStyle( color: p.trackInventory ? Colors.orange : Colors.grey, fontSize: 12, diff --git a/kifi-app/lib/features/inventory/presentation/quick_adjust_stock_screen.dart b/kifi-app/lib/features/inventory/presentation/quick_adjust_stock_screen.dart new file mode 100644 index 0000000..28c18cc --- /dev/null +++ b/kifi-app/lib/features/inventory/presentation/quick_adjust_stock_screen.dart @@ -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 createState() => _QuickAdjustStockScreenState(); +} + +class _QuickAdjustStockScreenState extends ConsumerState { + 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 _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), + ), + ), + ); + } +} diff --git a/kifi-app/lib/features/inventory/presentation/stock_ledger_tab.dart b/kifi-app/lib/features/inventory/presentation/stock_ledger_tab.dart new file mode 100644 index 0000000..266dc5f --- /dev/null +++ b/kifi-app/lib/features/inventory/presentation/stock_ledger_tab.dart @@ -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 createState() => _StockLedgerTabState(); +} + +class _StockLedgerTabState extends ConsumerState { + List? _ledger; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _fetchLedger(); + } + + Future _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), + ), + ], + ), + ), + ); + }, + ), + ); + } +} diff --git a/kifi-app/lib/features/inventory/presentation/uoms_list_screen.dart b/kifi-app/lib/features/inventory/presentation/uoms_list_screen.dart new file mode 100644 index 0000000..a904e76 --- /dev/null +++ b/kifi-app/lib/features/inventory/presentation/uoms_list_screen.dart @@ -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 createState() => _UomsListScreenState(); +} + +class _UomsListScreenState extends ConsumerState { + 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), + ), + ); + } +} diff --git a/kifi-app/lib/features/inventory/presentation/widgets/add_uom_sheet.dart b/kifi-app/lib/features/inventory/presentation/widgets/add_uom_sheet.dart new file mode 100644 index 0000000..7dfe712 --- /dev/null +++ b/kifi-app/lib/features/inventory/presentation/widgets/add_uom_sheet.dart @@ -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 createState() => _AddUomSheetState(); +} + +class _AddUomSheetState extends ConsumerState { + final _formKey = GlobalKey(); + 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 _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)), + ), + ], + ), + ), + ); + } +} diff --git a/kifi-app/lib/features/inventory/presentation/widgets/adjust_stock_sheet.dart b/kifi-app/lib/features/inventory/presentation/widgets/adjust_stock_sheet.dart new file mode 100644 index 0000000..116930a --- /dev/null +++ b/kifi-app/lib/features/inventory/presentation/widgets/adjust_stock_sheet.dart @@ -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 createState() => _AdjustStockSheetState(); +} + +class _AdjustStockSheetState extends ConsumerState { + String _selectedType = 'ADDITION'; + final _qtyController = TextEditingController(); + final _notesController = TextEditingController(); + bool _isLoading = false; + + final List _types = ['ADDITION', 'REDUCTION', 'DAMAGE', 'ADJUSTMENT']; + + Future _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( + 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)), + ), + ], + ), + ); + } +} diff --git a/kifi-app/lib/features/inventory/providers/product_categories_provider.dart b/kifi-app/lib/features/inventory/providers/product_categories_provider.dart index f97dcd3..cd379f6 100644 --- a/kifi-app/lib/features/inventory/providers/product_categories_provider.dart +++ b/kifi-app/lib/features/inventory/providers/product_categories_provider.dart @@ -76,6 +76,56 @@ class ProductCategoriesNotifier extends AsyncNotifier> { rethrow; } } + + Future 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 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> 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; + } + return []; + } catch (e) { + return []; + } + } } final productCategoriesProvider = AsyncNotifierProvider>(() { diff --git a/kifi-app/lib/features/inventory/providers/products_provider.dart b/kifi-app/lib/features/inventory/providers/products_provider.dart index de44420..48ae485 100644 --- a/kifi-app/lib/features/inventory/providers/products_provider.dart +++ b/kifi-app/lib/features/inventory/providers/products_provider.dart @@ -1,16 +1,21 @@ import 'dart:async'; +import 'dart:convert'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'dart:io'; import '../../../core/network/dio_client.dart'; import '../domain/product.dart'; +import '../domain/stock_movement.dart'; +import '../domain/product_bom.dart'; import 'package:dio/dio.dart'; import 'package:image_picker/image_picker.dart'; +import 'package:flutter_image_compress/flutter_image_compress.dart'; class ProductsNotifier extends AsyncNotifier> { int _currentPage = 0; bool _hasMore = true; bool _isLoadingMore = false; - final int _pageSize = 20; + final int _pageSize = 50; + String _currentSearchQuery = ''; @override FutureOr> build() async { @@ -22,10 +27,20 @@ class ProductsNotifier extends AsyncNotifier> { Future> _fetchProducts({required int page, required int size}) async { final response = await DioClient().dio.get( '/inventory/products', - queryParameters: {'page': page, 'size': size} + queryParameters: { + 'page': page, + 'size': size, + if (_currentSearchQuery.isNotEmpty) 'search': _currentSearchQuery, + } ); if (response.statusCode == 200) { final List 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(); if (products.length < size) { _hasMore = false; @@ -64,6 +79,11 @@ class ProductsNotifier extends AsyncNotifier> { } } + Future search(String query) async { + _currentSearchQuery = query; + await refresh(); + } + Future createProduct(Product product, {List? images}) async { try { final response = await DioClient().dio.post( @@ -74,8 +94,15 @@ class ProductsNotifier extends AsyncNotifier> { if (images != null && images.isNotEmpty && response.data != null) { final productId = response.data['id']; for (var image in images) { + final bytes = await image.readAsBytes(); + final compressedBytes = await FlutterImageCompress.compressWithList( + bytes, + minWidth: 800, + quality: 80, + ); + final formData = FormData.fromMap({ - 'file': await MultipartFile.fromFile(image.path, filename: image.name), + 'file': MultipartFile.fromBytes(compressedBytes, filename: image.name), }); await DioClient().dio.post( '/inventory/products/$productId/images', @@ -100,8 +127,15 @@ class ProductsNotifier extends AsyncNotifier> { if (newImages != null && newImages.isNotEmpty) { for (var image in newImages) { + final bytes = await image.readAsBytes(); + final compressedBytes = await FlutterImageCompress.compressWithList( + bytes, + minWidth: 800, + quality: 80, + ); + final formData = FormData.fromMap({ - 'file': await MultipartFile.fromFile(image.path, filename: image.name), + 'file': MultipartFile.fromBytes(compressedBytes, filename: image.name), }); await DioClient().dio.post( '/inventory/products/$id/images', @@ -116,6 +150,64 @@ class ProductsNotifier extends AsyncNotifier> { rethrow; } } + + Future 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> fetchStockLedger(int productId) async { + try { + final response = await DioClient().dio.get('/inventory/products/$productId/movements'); + if (response.data != null) { + final List data = response.data; + return data.map((e) => StockMovement.fromJson(e)).toList(); + } + return []; + } catch (e) { + throw Exception('Failed to fetch stock ledger: $e'); + } + } + + Future> fetchProductBom(int productId) async { + try { + final response = await DioClient().dio.get('/inventory/products/$productId/bom'); + if (response.data != null) { + final List data = response.data; + return data.map((e) => ProductBom.fromJson(e)).toList(); + } + return []; + } catch (e) { + throw Exception('Failed to fetch product BOM: $e'); + } + } + + Future 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 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>(() { diff --git a/kifi-app/lib/features/inventory/providers/uoms_provider.dart b/kifi-app/lib/features/inventory/providers/uoms_provider.dart new file mode 100644 index 0000000..57e6551 --- /dev/null +++ b/kifi-app/lib/features/inventory/providers/uoms_provider.dart @@ -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 _allUoms = []; + + @override + Future> build() async { + return _fetchUoms(); + } + + Future> _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 fetchUoms() async { + try { + final data = await _fetchUoms(); + state = AsyncValue.data(data); + } catch (e, stack) { + state = AsyncValue.error(e, stack); + } + } + + Future 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 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 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>(() { + return UomsNotifier(); +}); diff --git a/kifi-app/lib/features/sales/domain/customer.dart b/kifi-app/lib/features/sales/domain/customer.dart new file mode 100644 index 0000000..3bef0b8 --- /dev/null +++ b/kifi-app/lib/features/sales/domain/customer.dart @@ -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 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 toJson() { + final Map 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; + } +} diff --git a/kifi-app/lib/features/sales/domain/invoice.dart b/kifi-app/lib/features/sales/domain/invoice.dart new file mode 100644 index 0000000..4c25d7b --- /dev/null +++ b/kifi-app/lib/features/sales/domain/invoice.dart @@ -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 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 toJson() { + final Map 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 items; + final List? 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 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 toJson() { + final Map 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 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 toJson() { + final Map 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; + } +} diff --git a/kifi-app/lib/features/sales/presentation/add_customer_sheet.dart b/kifi-app/lib/features/sales/presentation/add_customer_sheet.dart new file mode 100644 index 0000000..3f3a804 --- /dev/null +++ b/kifi-app/lib/features/sales/presentation/add_customer_sheet.dart @@ -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 createState() => _AddCustomerSheetState(); +} + +class _AddCustomerSheetState extends ConsumerState { + final _formKey = GlobalKey(); + 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 _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 _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( + 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( + 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), + ], + ), + ), + ), + ); + } +} diff --git a/kifi-app/lib/features/sales/presentation/customer_ledger_screen.dart b/kifi-app/lib/features/sales/presentation/customer_ledger_screen.dart new file mode 100644 index 0000000..d8db6c6 --- /dev/null +++ b/kifi-app/lib/features/sales/presentation/customer_ledger_screen.dart @@ -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 createState() => _CustomerLedgerScreenState(); +} + +class _CustomerLedgerScreenState extends ConsumerState { + @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 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, + ), + ), + ), + ), + ], + ); + }, + ), + ); + } +} diff --git a/kifi-app/lib/features/sales/presentation/customers_list_screen.dart b/kifi-app/lib/features/sales/presentation/customers_list_screen.dart new file mode 100644 index 0000000..6d4e502 --- /dev/null +++ b/kifi-app/lib/features/sales/presentation/customers_list_screen.dart @@ -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 createState() => _CustomersListScreenState(); +} + +class _CustomersListScreenState extends ConsumerState { + 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, + ), + ); + } +} diff --git a/kifi-app/lib/features/sales/presentation/invoice_builder_screen.dart b/kifi-app/lib/features/sales/presentation/invoice_builder_screen.dart new file mode 100644 index 0000000..6ce876b --- /dev/null +++ b/kifi-app/lib/features/sales/presentation/invoice_builder_screen.dart @@ -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 createState() => _InvoiceBuilderScreenState(); +} + +class _InvoiceBuilderScreenState extends ConsumerState { + final _formKey = GlobalKey(); + Customer? _selectedCustomer; + final List _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 _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( + 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().firstWhere((p) => p?.barcode == code, orElse: () => null); + } else { + matched = products.cast().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( + 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( + 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( + 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( + 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( + 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), + ], + ), + ), + ), + ), + ); + } +} diff --git a/kifi-app/lib/features/sales/presentation/invoice_details_screen.dart b/kifi-app/lib/features/sales/presentation/invoice_details_screen.dart new file mode 100644 index 0000000..e64fe1e --- /dev/null +++ b/kifi-app/lib/features/sales/presentation/invoice_details_screen.dart @@ -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 createState() => _InvoiceDetailsScreenState(); +} + +class _InvoiceDetailsScreenState extends ConsumerState { + 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>( + 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 _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 _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'))); + } + } +} diff --git a/kifi-app/lib/features/sales/presentation/invoices_list_screen.dart b/kifi-app/lib/features/sales/presentation/invoices_list_screen.dart new file mode 100644 index 0000000..3266bd1 --- /dev/null +++ b/kifi-app/lib/features/sales/presentation/invoices_list_screen.dart @@ -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 createState() => _InvoicesListScreenState(); +} + +class _InvoicesListScreenState extends ConsumerState { + 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>( + 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, + ), + ); + } +} diff --git a/kifi-app/lib/features/sales/presentation/widgets/receive_payment_sheet.dart b/kifi-app/lib/features/sales/presentation/widgets/receive_payment_sheet.dart new file mode 100644 index 0000000..1ab53a3 --- /dev/null +++ b/kifi-app/lib/features/sales/presentation/widgets/receive_payment_sheet.dart @@ -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 createState() => _ReceivePaymentSheetState(); +} + +class _ReceivePaymentSheetState extends ConsumerState { + 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( + 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( + 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), + ], + ), + ); + } +} diff --git a/kifi-app/lib/features/sales/providers/customers_provider.dart b/kifi-app/lib/features/sales/providers/customers_provider.dart new file mode 100644 index 0000000..aa2182f --- /dev/null +++ b/kifi-app/lib/features/sales/providers/customers_provider.dart @@ -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> { + @override + FutureOr> build() async { + return _fetchCustomers(); + } + + Future> _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 data = response.data; + return data.map((e) => Customer.fromJson(e)).toList(); + } + return []; + } catch (e) { + print('Error fetching customers: $e'); + return []; + } + } + + Future 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 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 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 _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 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>(() { + return CustomersNotifier(); +}); diff --git a/kifi-app/lib/features/sales/providers/invoices_provider.dart b/kifi-app/lib/features/sales/providers/invoices_provider.dart new file mode 100644 index 0000000..3216a04 --- /dev/null +++ b/kifi-app/lib/features/sales/providers/invoices_provider.dart @@ -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> { + @override + FutureOr> build() async { + return _fetchInvoices(); + } + + Future> _fetchInvoices() async { + try { + final response = await DioClient().dio.get('/invoices'); + if (response.statusCode == 200) { + final List data = response.data; + return data.map((e) => Invoice.fromJson(e)).toList(); + } + return []; + } catch (e) { + print('Error fetching invoices: $e'); + return []; + } + } + + Future refresh() async { + state = const AsyncValue.loading(); + try { + final invoices = await _fetchInvoices(); + state = AsyncValue.data(invoices); + } catch (e, stack) { + state = AsyncValue.error(e, stack); + } + } + + Future 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 finalizeInvoice(int invoiceId) async { + try { + await DioClient().dio.put('/invoices/$invoiceId/finalize'); + await refresh(); + } catch (e) { + throw Exception('Failed to finalize invoice: $e'); + } + } + + Future 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> fetchPaymentsForInvoice(int invoiceId) async { + try { + final response = await DioClient().dio.get('/invoices/$invoiceId/payments'); + if (response.statusCode == 200) { + final List 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>(() { + return InvoicesNotifier(); +}); diff --git a/kifi-app/lib/features/transactions/data/models.dart b/kifi-app/lib/features/transactions/data/models.dart index c2ad810..6eae067 100644 --- a/kifi-app/lib/features/transactions/data/models.dart +++ b/kifi-app/lib/features/transactions/data/models.dart @@ -256,6 +256,11 @@ class Wallet { final String? currency; final String? icon; final String? color; + final String? subNature; + final double? creditLimit; + final double? fixedAmount; + final String? paymentCycle; + final int? cycleDate; Wallet({ required this.id, @@ -266,6 +271,11 @@ class Wallet { this.currency, this.icon, this.color, + this.subNature, + this.creditLimit, + this.fixedAmount, + this.paymentCycle, + this.cycleDate, }); factory Wallet.fromJson(Map json) { @@ -293,6 +303,11 @@ class Wallet { currency: json['currency'], icon: json['icon'], 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 'icon': icon, 'color': color, + 'subNature': subNature, + 'creditLimit': creditLimit, + 'fixedAmount': fixedAmount, + 'paymentCycle': paymentCycle, + 'cycleDate': cycleDate, }; } class WalletInvitation { diff --git a/kifi-app/lib/features/transactions/data/repository.dart b/kifi-app/lib/features/transactions/data/repository.dart index ef341cb..c7bd721 100644 --- a/kifi-app/lib/features/transactions/data/repository.dart +++ b/kifi-app/lib/features/transactions/data/repository.dart @@ -150,6 +150,11 @@ class ApiRepository { String currency = 'INR', String? icon, String? color, + String? subNature, + double? creditLimit, + double? fixedAmount, + String? paymentCycle, + int? cycleDate, }) async { final response = await dio.post('/wallets', data: { 'name': name, @@ -158,6 +163,11 @@ class ApiRepository { 'currency': currency, 'icon': icon, '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); } @@ -167,12 +177,22 @@ class ApiRepository { String? nature, String? icon, String? color, + String? subNature, + double? creditLimit, + double? fixedAmount, + String? paymentCycle, + int? cycleDate, }) async { final response = await dio.put('/wallets/$id', data: { if (name != null) 'name': name, if (nature != null) 'nature': nature, if (icon != null) 'icon': icon, 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); } diff --git a/kifi-app/lib/features/transactions/providers/providers.dart b/kifi-app/lib/features/transactions/providers/providers.dart index 5c28e79..4921724 100644 --- a/kifi-app/lib/features/transactions/providers/providers.dart +++ b/kifi-app/lib/features/transactions/providers/providers.dart @@ -144,8 +144,10 @@ final recurringTransactionProvider = AsyncNotifierProvider> { @override - FutureOr> build() { - return ref.watch(apiRepositoryProvider).getWallets(); + FutureOr> build() async { + final wallets = await ref.watch(apiRepositoryProvider).getWallets(); + wallets.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); + return wallets; } Future createWallet({ @@ -155,6 +157,11 @@ class WalletNotifier extends AsyncNotifier> { String currency = 'INR', String? icon, String? color, + String? subNature, + double? creditLimit, + double? fixedAmount, + String? paymentCycle, + int? cycleDate, }) async { final newWallet = await ref.read(apiRepositoryProvider).createWallet( name: name, @@ -163,9 +170,16 @@ class WalletNotifier extends AsyncNotifier> { currency: currency, icon: icon, color: color, + subNature: subNature, + creditLimit: creditLimit, + fixedAmount: fixedAmount, + paymentCycle: paymentCycle, + cycleDate: cycleDate, ); 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; } @@ -174,18 +188,23 @@ class WalletNotifier extends AsyncNotifier> { await ref.read(apiRepositoryProvider).inviteUserToWallet(walletId, email); } - Future editWallet(int id, {String? name, String? nature, String? icon, String? color}) async { + Future 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( id: id, name: name, nature: nature, icon: icon, color: color, + subNature: subNature, + creditLimit: creditLimit, + fixedAmount: fixedAmount, + paymentCycle: paymentCycle, + cycleDate: cycleDate, ); if (state.value != null) { - state = AsyncValue.data( - state.value!.map((w) => w.id == id ? updated : w).toList(), - ); + final updatedList = 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); } } diff --git a/kifi-app/linux/flutter/generated_plugin_registrant.cc b/kifi-app/linux/flutter/generated_plugin_registrant.cc index 3ccd551..2b0dbc9 100644 --- a/kifi-app/linux/flutter/generated_plugin_registrant.cc +++ b/kifi-app/linux/flutter/generated_plugin_registrant.cc @@ -8,6 +8,7 @@ #include #include +#include #include void fl_register_plugins(FlPluginRegistry* registry) { @@ -17,6 +18,9 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); 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 = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); diff --git a/kifi-app/linux/flutter/generated_plugins.cmake b/kifi-app/linux/flutter/generated_plugins.cmake index fbedf4a..df2d4d0 100644 --- a/kifi-app/linux/flutter/generated_plugins.cmake +++ b/kifi-app/linux/flutter/generated_plugins.cmake @@ -5,6 +5,7 @@ list(APPEND FLUTTER_PLUGIN_LIST file_selector_linux flutter_secure_storage_linux + printing url_launcher_linux ) diff --git a/kifi-app/macos/Flutter/GeneratedPluginRegistrant.swift b/kifi-app/macos/Flutter/GeneratedPluginRegistrant.swift index a5ddc0a..6a152d1 100644 --- a/kifi-app/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/kifi-app/macos/Flutter/GeneratedPluginRegistrant.swift @@ -10,6 +10,8 @@ import flutter_image_compress_macos import flutter_local_notifications import flutter_secure_storage_darwin import local_auth_darwin +import mobile_scanner +import printing import share_plus import shared_preferences_foundation @@ -19,6 +21,8 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) 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")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) } diff --git a/kifi-app/pubspec.lock b/kifi-app/pubspec.lock index fe3288f..acf8c66 100644 --- a/kifi-app/pubspec.lock +++ b/kifi-app/pubspec.lock @@ -49,6 +49,22 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -776,6 +792,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -808,6 +832,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: "direct main" description: @@ -856,6 +888,22 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -904,6 +952,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -912,6 +968,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + qr: + dependency: transitive + description: + name: qr + sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" + url: "https://pub.dev" + source: hosted + version: "3.0.2" record_use: dependency: transitive description: @@ -928,6 +992,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: "direct main" description: diff --git a/kifi-app/pubspec.yaml b/kifi-app/pubspec.yaml index 550d382..f2032fb 100644 --- a/kifi-app/pubspec.yaml +++ b/kifi-app/pubspec.yaml @@ -53,6 +53,10 @@ dependencies: timezone: ^0.11.1 encrypt: ^5.0.3 pointycastle: ^3.9.1 + mobile_scanner: ^7.4.0 + screenshot: ^3.0.0 + pdf: ^3.12.0 + printing: ^5.14.3 dev_dependencies: flutter_test: diff --git a/kifi-app/windows/flutter/generated_plugin_registrant.cc b/kifi-app/windows/flutter/generated_plugin_registrant.cc index 24a84f1..d3ea9ba 100644 --- a/kifi-app/windows/flutter/generated_plugin_registrant.cc +++ b/kifi-app/windows/flutter/generated_plugin_registrant.cc @@ -9,6 +9,7 @@ #include #include #include +#include #include #include @@ -19,6 +20,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); LocalAuthPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("LocalAuthPlugin")); + PrintingPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("PrintingPlugin")); SharePlusWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); UrlLauncherWindowsRegisterWithRegistrar( diff --git a/kifi-app/windows/flutter/generated_plugins.cmake b/kifi-app/windows/flutter/generated_plugins.cmake index 845f504..01eba97 100644 --- a/kifi-app/windows/flutter/generated_plugins.cmake +++ b/kifi-app/windows/flutter/generated_plugins.cmake @@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST file_selector_windows flutter_secure_storage_windows local_auth_windows + printing share_plus url_launcher_windows )