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 8fed691..bf296b5 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 @@ -34,6 +34,7 @@ public class ProductCategoryController { public Mono> createCategory(Authentication authentication, @RequestBody ProductCategory category) { Long userId = Long.valueOf(authentication.getDetails().toString()); category.setUserId(userId); + category.setPurityFactor(normalizePurity(category.getPurityFactor())); category.setCreatedAt(LocalDateTime.now()); return categoryRepository.save(category) .map(ResponseEntity::ok); @@ -47,7 +48,7 @@ public class ProductCategoryController { existing.setParentCategoryId(category.getParentCategoryId()); existing.setHasChild(category.getHasChild()); existing.setCommodityCode(category.getCommodityCode()); - existing.setPurityFactor(category.getPurityFactor() != null ? category.getPurityFactor() : 1.0); + existing.setPurityFactor(normalizePurity(category.getPurityFactor())); existing.setDefaultHsn(category.getDefaultHsn()); existing.setDefaultGst(category.getDefaultGst()); existing.setHuidRequired(category.getHuidRequired()); @@ -63,4 +64,12 @@ public class ProductCategoryController { .defaultIfEmpty(ResponseEntity.notFound().build()); } + private Double normalizePurity(Double rawPurity) { + if (rawPurity == null || rawPurity <= 0) return 1.0; + double p = rawPurity; + if (p > 1.0) p = p / 100.0; + if (p > 1.0) p = 1.0; + return p; + } + } 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 index d51c4bf..0dd027c 100644 --- 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 @@ -43,6 +43,15 @@ public class Invoice { @Column("tax_total") private BigDecimal taxTotal; + @Column("cgst_total") + private BigDecimal cgstTotal; + + @Column("sgst_total") + private BigDecimal sgstTotal; + + @Column("igst_total") + private BigDecimal igstTotal; + @Column("discount_total") private BigDecimal discountTotal; 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 index 61e9cec..777aa08 100644 --- 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 @@ -46,8 +46,20 @@ public class InvoiceItem { @Column("making_charge") private BigDecimal makingCharge; + @Column("making_charges_type") + private String makingChargesType; + @Column("other_charges") private BigDecimal otherCharges; + private String huid; + private BigDecimal weight; + private BigDecimal cgst; + private BigDecimal sgst; + private BigDecimal igst; + + @Column("photo_url") + private String photoUrl; + private BigDecimal total; } diff --git a/kifi-api/src/main/java/com/kifi/api/service/SetupService.java b/kifi-api/src/main/java/com/kifi/api/service/SetupService.java index f20d073..cd2129e 100644 --- a/kifi-api/src/main/java/com/kifi/api/service/SetupService.java +++ b/kifi-api/src/main/java/com/kifi/api/service/SetupService.java @@ -28,6 +28,8 @@ public class SetupService { public Mono> getSetupStatus(Long userId) { return userRepository.findById(userId) + .switchIfEmpty(Mono.error(new org.springframework.web.server.ResponseStatusException( + org.springframework.http.HttpStatus.UNAUTHORIZED, "User does not exist"))) .map(u -> { java.util.Map map = new java.util.HashMap<>(); map.put("status", u.getSetupStatus() != null ? u.getSetupStatus() : "NOT_STARTED"); @@ -35,8 +37,7 @@ public class SetupService { map.put("name", u.getName() != null ? u.getName() : ""); map.put("email", u.getEmail() != null ? u.getEmail() : ""); return map; - }) - .defaultIfEmpty(java.util.Map.of("status", "NOT_STARTED", "profileType", "INDIVIDUAL")); + }); } public Mono isUsernameAvailable(String username) { diff --git a/kifi-api/src/main/java/com/kifi/api/service/inventory/CommodityRateService.java b/kifi-api/src/main/java/com/kifi/api/service/inventory/CommodityRateService.java index e97063a..dafdcd1 100644 --- a/kifi-api/src/main/java/com/kifi/api/service/inventory/CommodityRateService.java +++ b/kifi-api/src/main/java/com/kifi/api/service/inventory/CommodityRateService.java @@ -54,10 +54,10 @@ public class CommodityRateService { .flatMap(latestRate -> productCategoryRepository.findByUserIdAndCommodityCode(userId, commodityCode) .flatMap(category -> { - double categoryPurity = category.getPurityFactor() != null ? category.getPurityFactor() : 1.0; + final double categoryPurity = normalizePurity(category.getPurityFactor()); return productRepository.findByCategoryId(category.getId()) .flatMap(product -> { - double productPurity = product.getPurityFactor() != null ? product.getPurityFactor() : categoryPurity; + double productPurity = normalizePurity(product.getPurityFactor() != null ? product.getPurityFactor() : categoryPurity); BigDecimal baseRate = latestRate.getRate().multiply(BigDecimal.valueOf(productPurity)); if ("WEIGHT_BASED".equalsIgnoreCase(product.getPriceCalcRule())) { @@ -79,4 +79,12 @@ public class CommodityRateService { ) .defaultIfEmpty(0); } + + private Double normalizePurity(Double rawPurity) { + if (rawPurity == null || rawPurity <= 0) return 1.0; + double p = rawPurity; + if (p > 1.0) p = p / 100.0; + if (p > 1.0) p = 1.0; + return p; + } } 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 f6e94d8..e6f10ee 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 @@ -68,7 +68,9 @@ public class ProductService { product.setUpdatedAt(LocalDateTime.now()); if (product.getIsActive() == null) product.setIsActive(true); if (product.getPriceCalcRule() == null) product.setPriceCalcRule("MANUAL"); - if (product.getPurityFactor() == null) product.setPurityFactor(1.0); + product.setPurityFactor(normalizePurity(product.getPurityFactor())); + if (product.getMakingCharges() == null) product.setMakingCharges(0.0); + if (product.getMakingChargesType() == null) product.setMakingChargesType("PER_GRAM"); return productRepository.save(product); } @@ -87,7 +89,7 @@ public class ProductService { existingProduct.setSize(updatedProduct.getSize()); existingProduct.setDimensions(updatedProduct.getDimensions()); existingProduct.setPriceCalcRule(updatedProduct.getPriceCalcRule()); - existingProduct.setPurityFactor(updatedProduct.getPurityFactor()); + existingProduct.setPurityFactor(normalizePurity(updatedProduct.getPurityFactor())); existingProduct.setMakingCharges(updatedProduct.getMakingCharges()); existingProduct.setMakingChargesType(updatedProduct.getMakingChargesType()); existingProduct.setWastagePercentage(updatedProduct.getWastagePercentage()); @@ -288,4 +290,13 @@ public class ProductService { .switchIfEmpty(Mono.error(new RuntimeException("Access denied"))) .flatMap(p -> productBomRepository.delete(bom))); } + + private Double normalizePurity(Double rawPurity) { + if (rawPurity == null) return null; + if (rawPurity <= 0) return 1.0; + double p = rawPurity; + if (p > 1.0) p = p / 100.0; + if (p > 1.0) p = 1.0; + return p; + } } diff --git a/kifi-api/src/main/resources/schema.sql b/kifi-api/src/main/resources/schema.sql index 0a1b619..c8f30df 100644 --- a/kifi-api/src/main/resources/schema.sql +++ b/kifi-api/src/main/resources/schema.sql @@ -1,169 +1,43 @@ +-- ========================================================== +-- KIFI PLATFORM CONSOLIDATED DATABASE SCHEMA +-- ========================================================== +-- 1. Core Users Table CREATE TABLE IF NOT EXISTS users ( id SERIAL PRIMARY KEY, email VARCHAR(255) UNIQUE NOT NULL, password VARCHAR(255), + username VARCHAR(255) UNIQUE, + name VARCHAR(255), + mobile_number VARCHAR(255), + profile_type VARCHAR(20) DEFAULT 'INDIVIDUAL', + setup_status VARCHAR(50) DEFAULT 'NOT_STARTED', + setup_completed_at TIMESTAMP, enabled BOOLEAN DEFAULT FALSE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -CREATE TABLE IF NOT EXISTS merchants ( - id SERIAL PRIMARY KEY, - user_id INTEGER REFERENCES users(id), - name VARCHAR(255) NOT NULL, - icon_name VARCHAR(255), - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - -CREATE TABLE IF NOT EXISTS categories ( - id SERIAL PRIMARY KEY, - user_id INTEGER REFERENCES users(id), - name VARCHAR(255) NOT NULL, - icon_name VARCHAR(255), - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - -CREATE TABLE IF NOT EXISTS transactions ( - id SERIAL PRIMARY KEY, - user_id INTEGER REFERENCES users(id), - category_id INTEGER REFERENCES categories(id), - type VARCHAR(50) NOT NULL, -- 'INCOME' or 'EXPENSE' or 'INVESTMENT' - amount DECIMAL(10, 2) NOT NULL, - date DATE NOT NULL, - description TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - -CREATE TABLE IF NOT EXISTS budgets ( - id SERIAL PRIMARY KEY, - user_id INTEGER REFERENCES users(id), - category_id INTEGER REFERENCES categories(id), - monthly_limit DECIMAL(10, 2) NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - UNIQUE(user_id, category_id) -); - -CREATE TABLE IF NOT EXISTS recurring_transactions ( - id SERIAL PRIMARY KEY, - user_id INTEGER REFERENCES users(id), - category_id INTEGER REFERENCES categories(id), - type VARCHAR(50) NOT NULL, - amount DECIMAL(10, 2) NOT NULL, - frequency VARCHAR(50) NOT NULL, - next_execution_date DATE NOT NULL, - description TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - -CREATE TABLE IF NOT EXISTS wallets ( - id SERIAL PRIMARY KEY, - name VARCHAR(255) NOT NULL, - owner_id INTEGER REFERENCES users(id), - nature VARCHAR(50) DEFAULT 'CASH', -- 'CASH', 'DEPOSIT', 'EXPENSE', 'INCOME', 'SAVINGS', 'LOAN', etc. - balance DECIMAL(15, 2) DEFAULT 0.00, - currency VARCHAR(10) DEFAULT 'INR', - icon VARCHAR(255), - color VARCHAR(50), - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - -CREATE TABLE IF NOT EXISTS user_wallets ( - id SERIAL PRIMARY KEY, - user_id INTEGER REFERENCES users(id), - wallet_id INTEGER REFERENCES wallets(id), - role VARCHAR(50) DEFAULT 'MEMBER', - joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - UNIQUE (user_id, wallet_id) -); - -ALTER TABLE wallets ADD COLUMN IF NOT EXISTS nature VARCHAR(50) DEFAULT 'CASH'; -ALTER TABLE wallets ADD COLUMN IF NOT EXISTS balance DECIMAL(15, 2) DEFAULT 0.00; -ALTER TABLE wallets ADD COLUMN IF NOT EXISTS currency VARCHAR(10) DEFAULT 'INR'; -ALTER TABLE wallets ADD COLUMN IF NOT EXISTS icon VARCHAR(255); -ALTER TABLE wallets ADD COLUMN IF NOT EXISTS color VARCHAR(50); - -ALTER TABLE transactions ADD COLUMN IF NOT EXISTS wallet_id INTEGER REFERENCES wallets(id); -ALTER TABLE transactions ADD COLUMN IF NOT EXISTS from_wallet_id INTEGER REFERENCES wallets(id); -ALTER TABLE transactions ADD COLUMN IF NOT EXISTS to_wallet_id INTEGER REFERENCES wallets(id); -ALTER TABLE transactions ADD COLUMN IF NOT EXISTS notes TEXT; -ALTER TABLE transactions ADD COLUMN IF NOT EXISTS due_date DATE; -ALTER TABLE transactions ADD COLUMN IF NOT EXISTS alert_schedule VARCHAR(50); -ALTER TABLE transactions ADD COLUMN IF NOT EXISTS alert_time TIME; - -ALTER TABLE transactions ADD COLUMN IF NOT EXISTS investment_status VARCHAR(20) DEFAULT 'OPEN'; -ALTER TABLE transactions ADD COLUMN IF NOT EXISTS maturity_amount DECIMAL(10, 2); -ALTER TABLE transactions ADD COLUMN IF NOT EXISTS profit_loss DECIMAL(10, 2); -ALTER TABLE transactions ADD COLUMN IF NOT EXISTS closing_date DATE; - -CREATE TABLE IF NOT EXISTS transaction_items ( - id SERIAL PRIMARY KEY, - transaction_id INTEGER REFERENCES transactions(id) ON DELETE CASCADE, - name VARCHAR(255) NOT NULL, - amount DECIMAL(10, 2) NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - -CREATE TABLE IF NOT EXISTS transaction_attachments ( - id SERIAL PRIMARY KEY, - transaction_id INTEGER REFERENCES transactions(id) ON DELETE CASCADE, - file_name VARCHAR(255) NOT NULL, - file_path VARCHAR(1024) NOT NULL, - content_type VARCHAR(100), - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - -ALTER TABLE budgets ADD COLUMN IF NOT EXISTS wallet_id INTEGER REFERENCES wallets(id); -ALTER TABLE budgets ADD COLUMN IF NOT EXISTS is_shared BOOLEAN DEFAULT FALSE; --- Ensure budget can be either category-specific or wallet-specific -ALTER TABLE budgets ALTER COLUMN category_id DROP NOT NULL; - -ALTER TABLE recurring_transactions ADD COLUMN IF NOT EXISTS from_wallet_id INTEGER REFERENCES wallets(id); -ALTER TABLE recurring_transactions ADD COLUMN IF NOT EXISTS to_wallet_id INTEGER REFERENCES wallets(id); -ALTER TABLE recurring_transactions ADD COLUMN IF NOT EXISTS status VARCHAR(20) DEFAULT 'ACTIVE'; -ALTER TABLE recurring_transactions ADD COLUMN IF NOT EXISTS end_date DATE; -ALTER TABLE recurring_transactions ALTER COLUMN category_id DROP NOT NULL; - -CREATE TABLE IF NOT EXISTS wallet_invitations ( - id SERIAL PRIMARY KEY, - wallet_id INTEGER REFERENCES wallets(id), - inviter_id INTEGER REFERENCES users(id), - invitee_email VARCHAR(255) NOT NULL, - status VARCHAR(20) DEFAULT 'PENDING', - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - --- KIFI V2 PHASE 1 MIGRATION: INVENTORY MANAGEMENT -- - -ALTER TABLE users ADD COLUMN IF NOT EXISTS profile_type VARCHAR(20) DEFAULT 'INDIVIDUAL'; - -CREATE TABLE IF NOT EXISTS business_profiles ( +-- 2. User Consents +CREATE TABLE IF NOT EXISTS user_consents ( id SERIAL PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, - business_name VARCHAR(255) NOT NULL, - industry VARCHAR(100), - 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(255), - gstin VARCHAR(255), - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - UNIQUE(user_id) + consent_type VARCHAR(100) NOT NULL, + policy_version VARCHAR(50) NOT NULL, + accepted BOOLEAN DEFAULT TRUE, + accepted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + ip_address VARCHAR(100), + user_agent TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); +-- 3. Business Profiles & Features 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'), @@ -180,6 +54,28 @@ INSERT INTO indian_states (name, gst_code) VALUES ('Telangana', '36'), ('Andhra Pradesh', '37'), ('Ladakh', '38') ON CONFLICT DO NOTHING; +CREATE TABLE IF NOT EXISTS business_profiles ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + business_name VARCHAR(255) NOT NULL, + industry VARCHAR(100), + tax_number VARCHAR(100), + currency VARCHAR(10) DEFAULT 'INR', + tax_included_in_price BOOLEAN DEFAULT FALSE, + address TEXT, + state_id INTEGER REFERENCES indian_states(id), + contact_person VARCHAR(100), + contact_number VARCHAR(20), + email_id VARCHAR(100), + pan_number VARCHAR(255), + gstin VARCHAR(255), + nature_of_business VARCHAR(50), + msme_number VARCHAR(255), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id) +); + CREATE TABLE IF NOT EXISTS business_features ( id SERIAL PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, @@ -187,20 +83,128 @@ CREATE TABLE IF NOT EXISTS business_features ( sales_management BOOLEAN DEFAULT FALSE, multi_location BOOLEAN DEFAULT FALSE, bom_reduction_strategy VARCHAR(50) DEFAULT 'COMPONENTS_ONLY', + stock_deduction_on_invoice BOOLEAN DEFAULT TRUE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE(user_id) ); +-- 4. Personal Finance: Wallets, Categories, Transactions & Budgets +CREATE TABLE IF NOT EXISTS wallets ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + owner_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + nature VARCHAR(50) DEFAULT 'CASH', -- 'CASH', 'DEPOSIT', 'EXPENSE', 'INCOME', 'SAVINGS', 'LOAN', etc. + balance DECIMAL(15, 2) DEFAULT 0.00, + currency VARCHAR(10) DEFAULT 'INR', + icon VARCHAR(255), + color VARCHAR(50), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS user_wallets ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + wallet_id INTEGER REFERENCES wallets(id) ON DELETE CASCADE, + role VARCHAR(50) DEFAULT 'MEMBER', + joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE (user_id, wallet_id) +); + +CREATE TABLE IF NOT EXISTS wallet_invitations ( + id SERIAL PRIMARY KEY, + wallet_id INTEGER REFERENCES wallets(id) ON DELETE CASCADE, + inviter_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + invitee_email VARCHAR(255) NOT NULL, + status VARCHAR(20) DEFAULT 'PENDING', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS categories ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + icon_name VARCHAR(255), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS transactions ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL, + wallet_id INTEGER REFERENCES wallets(id) ON DELETE SET NULL, + from_wallet_id INTEGER REFERENCES wallets(id) ON DELETE SET NULL, + to_wallet_id INTEGER REFERENCES wallets(id) ON DELETE SET NULL, + type VARCHAR(50) NOT NULL, -- 'INCOME', 'EXPENSE', 'INVESTMENT', 'TRANSFER' + amount DECIMAL(15, 2) NOT NULL, + date DATE NOT NULL, + description TEXT, + notes TEXT, + due_date DATE, + alert_schedule VARCHAR(50), + alert_time TIME, + investment_status VARCHAR(20) DEFAULT 'OPEN', + maturity_amount DECIMAL(15, 2), + profit_loss DECIMAL(15, 2), + closing_date DATE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS transaction_items ( + id SERIAL PRIMARY KEY, + transaction_id INTEGER REFERENCES transactions(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + amount DECIMAL(15, 2) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS transaction_attachments ( + id SERIAL PRIMARY KEY, + transaction_id INTEGER REFERENCES transactions(id) ON DELETE CASCADE, + file_name VARCHAR(255) NOT NULL, + file_path VARCHAR(1024) NOT NULL, + content_type VARCHAR(100), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS budgets ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL, + wallet_id INTEGER REFERENCES wallets(id) ON DELETE SET NULL, + is_shared BOOLEAN DEFAULT FALSE, + monthly_limit DECIMAL(15, 2) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, category_id) +); + +CREATE TABLE IF NOT EXISTS recurring_transactions ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL, + from_wallet_id INTEGER REFERENCES wallets(id) ON DELETE SET NULL, + to_wallet_id INTEGER REFERENCES wallets(id) ON DELETE SET NULL, + type VARCHAR(50) NOT NULL, + amount DECIMAL(15, 2) NOT NULL, + frequency VARCHAR(50) NOT NULL, + next_execution_date DATE NOT NULL, + end_date DATE, + status VARCHAR(20) DEFAULT 'ACTIVE', + description TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 5. Inventory: Categories, Rates, Products, BOM, Locations CREATE TABLE IF NOT EXISTS product_categories ( id SERIAL PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, name VARCHAR(255) NOT NULL, - parent_category_id INTEGER REFERENCES product_categories(id), + parent_category_id INTEGER REFERENCES product_categories(id) ON DELETE CASCADE, has_child BOOLEAN DEFAULT FALSE, default_hsn VARCHAR(50), default_gst DECIMAL(5, 2), huid_required BOOLEAN DEFAULT FALSE, default_making_charge DECIMAL(15, 2), + making_charge_type VARCHAR(50), commodity_code VARCHAR(10), purity_factor DECIMAL(5, 4) DEFAULT 1.0, base_unit VARCHAR(20) DEFAULT 'pcs', @@ -217,38 +221,10 @@ CREATE TABLE IF NOT EXISTS commodity_rate_history ( rate DECIMAL(15, 2) NOT NULL, effective_at TIMESTAMP NOT NULL, source VARCHAR(100), - created_by INTEGER REFERENCES users(id), + created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -CREATE TABLE IF NOT EXISTS inventory_items ( - id SERIAL PRIMARY KEY, - user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, - product_id INTEGER REFERENCES products(id), - tag_number VARCHAR(100), - sku VARCHAR(100), - huid VARCHAR(50), - purity VARCHAR(50), - gross_weight DECIMAL(10, 3), - net_weight DECIMAL(10, 3), - stone_weight DECIMAL(10, 3), - diamond_weight DECIMAL(10, 3), - fine_weight DECIMAL(10, 3), - making_charges DECIMAL(15, 2), - making_charge_type VARCHAR(50), - purchase_cost DECIMAL(15, 2), - metal_cost DECIMAL(15, 2), - stone_cost DECIMAL(15, 2), - certification_cost DECIMAL(15, 2), - tax DECIMAL(15, 2), - vendor_id INTEGER, -- REFERENCES vendors(id) - branch_id INTEGER, - purchase_ref VARCHAR(100), - status VARCHAR(50) DEFAULT 'AVAILABLE', - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - CREATE TABLE IF NOT EXISTS units_of_measure ( id SERIAL PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, @@ -260,8 +236,8 @@ CREATE TABLE IF NOT EXISTS units_of_measure ( CREATE TABLE IF NOT EXISTS products ( id SERIAL PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, - category_id INTEGER REFERENCES product_categories(id), - uom_id INTEGER REFERENCES units_of_measure(id), + category_id INTEGER REFERENCES product_categories(id) ON DELETE SET NULL, + uom_id INTEGER REFERENCES units_of_measure(id) ON DELETE SET NULL, name VARCHAR(255) NOT NULL, sku VARCHAR(100), barcode VARCHAR(100), @@ -274,9 +250,9 @@ CREATE TABLE IF NOT EXISTS products ( color VARCHAR(50), size VARCHAR(50), price_calc_rule VARCHAR(50) DEFAULT 'MANUAL', - purity_factor DECIMAL(5, 4) DEFAULT 1.0, + purity_factor DECIMAL(5, 4), making_charges DECIMAL(15, 2) DEFAULT 0.0, - making_charges_type VARCHAR(50) DEFAULT 'FLAT', + making_charges_type VARCHAR(50) DEFAULT 'PER_GRAM', wastage_percentage DECIMAL(5, 2) DEFAULT 0.0, min_stock DECIMAL(10, 2) DEFAULT 0, reorder_level DECIMAL(10, 2) DEFAULT 0, @@ -312,12 +288,112 @@ CREATE TABLE IF NOT EXISTS inventory_locations ( created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); +-- 6. Vendors & Purchase Orders +CREATE TABLE IF NOT EXISTS vendors ( + 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), + state_id INTEGER REFERENCES indian_states(id), + id_number VARCHAR(100), + photo_url VARCHAR(1024), + contact_person VARCHAR(100), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS purchase_orders ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + vendor_id INTEGER REFERENCES vendors(id) ON DELETE SET NULL, + po_number VARCHAR(100) NOT NULL, + issue_date DATE NOT NULL, + po_date DATE, + due_date DATE, + subtotal DECIMAL(15, 2) NOT NULL, + tax_total DECIMAL(15, 2) DEFAULT 0.0, + cgst_total DECIMAL(15, 2) DEFAULT 0.0, + sgst_total DECIMAL(15, 2) DEFAULT 0.0, + igst_total DECIMAL(15, 2) DEFAULT 0.0, + discount_total DECIMAL(15, 2) DEFAULT 0.0, + total_amount DECIMAL(15, 2) NOT NULL, + amount_paid DECIMAL(15, 2) DEFAULT 0.0, + next_payment_date DATE, + status VARCHAR(50) DEFAULT 'DRAFT', + notes TEXT, + vendor_invoice_url VARCHAR(1024), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS purchase_order_items ( + id SERIAL PRIMARY KEY, + po_id INTEGER REFERENCES purchase_orders(id) ON DELETE CASCADE, + product_id INTEGER REFERENCES products(id) ON DELETE SET NULL, + quantity DECIMAL(10, 3) NOT NULL, + unit_price DECIMAL(15, 2) NOT NULL, + tax_rate DECIMAL(5, 2) DEFAULT 0.0, + cgst_rate DECIMAL(5, 2) DEFAULT 0.0, + sgst_rate DECIMAL(5, 2) DEFAULT 0.0, + igst_rate DECIMAL(5, 2) DEFAULT 0.0, + discount DECIMAL(15, 2) DEFAULT 0.0, + making_charge DECIMAL(15, 2) DEFAULT 0.0, + other_charges DECIMAL(15, 2) DEFAULT 0.0, + sku VARCHAR(100), + huid VARCHAR(50), + weight DECIMAL(10, 3), + photo_url VARCHAR(1024), + description VARCHAR(255), + total DECIMAL(15, 2) NOT NULL +); + +CREATE TABLE IF NOT EXISTS purchase_payments ( + id SERIAL PRIMARY KEY, + po_id INTEGER REFERENCES purchase_orders(id) ON DELETE CASCADE, + transaction_id INTEGER REFERENCES transactions(id) ON DELETE SET NULL, + amount DECIMAL(15, 2) NOT NULL, + payment_method VARCHAR(50), + payment_date TIMESTAMP NOT NULL, + notes TEXT +); + +-- 7. Individual Stock Inventory Items +CREATE TABLE IF NOT EXISTS inventory_items ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + product_id INTEGER REFERENCES products(id) ON DELETE SET NULL, + tag_number VARCHAR(100), + sku VARCHAR(100), + huid VARCHAR(50), + purity VARCHAR(50), + gross_weight DECIMAL(10, 3), + net_weight DECIMAL(10, 3), + stone_weight DECIMAL(10, 3), + diamond_weight DECIMAL(10, 3), + fine_weight DECIMAL(10, 3), + making_charges DECIMAL(15, 2), + making_charge_type VARCHAR(50), + purchase_cost DECIMAL(15, 2), + metal_cost DECIMAL(15, 2), + stone_cost DECIMAL(15, 2), + certification_cost DECIMAL(15, 2), + tax DECIMAL(15, 2), + vendor_id INTEGER REFERENCES vendors(id) ON DELETE SET NULL, + branch_id INTEGER, + purchase_ref VARCHAR(100), + status VARCHAR(50) DEFAULT 'AVAILABLE', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + CREATE TABLE IF NOT EXISTS inventory_movements ( id SERIAL PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, - location_id INTEGER REFERENCES inventory_locations(id), - type VARCHAR(50) NOT NULL, -- ADDITION, REDUCTION, TRANSFER, ADJUSTMENT - reference_transaction_id INTEGER REFERENCES transactions(id), + location_id INTEGER REFERENCES inventory_locations(id) ON DELETE SET NULL, + type VARCHAR(50) NOT NULL, + reference_transaction_id INTEGER REFERENCES transactions(id) ON DELETE SET NULL, notes TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); @@ -325,7 +401,7 @@ CREATE TABLE IF NOT EXISTS inventory_movements ( CREATE TABLE IF NOT EXISTS inventory_movement_items ( id SERIAL PRIMARY KEY, movement_id INTEGER REFERENCES inventory_movements(id) ON DELETE CASCADE, - product_id INTEGER REFERENCES products(id), + product_id INTEGER REFERENCES products(id) ON DELETE SET NULL, quantity DECIMAL(10, 2) NOT NULL, unit_price DECIMAL(10, 2), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP @@ -340,9 +416,7 @@ CREATE TABLE IF NOT EXISTS inventory_balances ( 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; - +-- 8. Customers, Sales & Invoices CREATE TABLE IF NOT EXISTS customers ( id SERIAL PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, @@ -351,29 +425,27 @@ CREATE TABLE IF NOT EXISTS customers ( phone VARCHAR(50), address TEXT, gstin VARCHAR(50), + photo_url VARCHAR(1024), 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), + customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL, 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 + 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', notes TEXT, - - -- EMI tracking fields is_emi BOOLEAN DEFAULT FALSE, - emi_amount DECIMAL(15,2), - emi_cycle VARCHAR(20), -- MONTHLY, WEEKLY + emi_amount DECIMAL(15, 2), + emi_cycle VARCHAR(20), emi_start_date DATE, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); @@ -381,35 +453,36 @@ CREATE TABLE IF NOT EXISTS invoices ( 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), - inventory_item_id INTEGER REFERENCES inventory_items(id), + product_id INTEGER REFERENCES products(id) ON DELETE SET NULL, + inventory_item_id INTEGER REFERENCES inventory_items(id) ON DELETE SET NULL, hsn_code VARCHAR(50), 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, - making_charge DECIMAL(15,2) DEFAULT 0.0, - other_charges DECIMAL(15,2) DEFAULT 0.0, - total DECIMAL(15,2) NOT NULL + 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, + making_charge DECIMAL(15, 2) DEFAULT 0.0, + other_charges 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, + transaction_id INTEGER REFERENCES transactions(id) ON DELETE SET NULL, + amount DECIMAL(15, 2) NOT NULL, payment_date DATE NOT NULL, emi_installment_number INTEGER, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); +-- 9. Projects & Task Management CREATE TABLE IF NOT EXISTS projects ( id SERIAL PRIMARY KEY, name VARCHAR(255) NOT NULL, description TEXT, - customer_id INTEGER REFERENCES customers(id), - user_id INTEGER REFERENCES users(id), + customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, budget DECIMAL(15, 2) DEFAULT 0.00, status VARCHAR(50) DEFAULT 'ACTIVE', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP @@ -421,9 +494,9 @@ CREATE TABLE IF NOT EXISTS project_tasks ( title VARCHAR(255) NOT NULL, description TEXT, assignee_name VARCHAR(255), - assignee_user_id INTEGER REFERENCES users(id), - status VARCHAR(50) DEFAULT 'TODO', -- TODO, IN_PROGRESS, REVIEW, DONE - is_billable BOOLEAN DEFAULT false, + assignee_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + status VARCHAR(50) DEFAULT 'TODO', + is_billable BOOLEAN DEFAULT FALSE, hourly_rate DECIMAL(15, 2) DEFAULT 0.00, hours_logged DECIMAL(10, 2) DEFAULT 0.00, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP @@ -432,44 +505,8 @@ CREATE TABLE IF NOT EXISTS project_tasks ( CREATE TABLE IF NOT EXISTS project_task_comments ( id SERIAL PRIMARY KEY, task_id INTEGER REFERENCES project_tasks(id) ON DELETE CASCADE, - user_id INTEGER REFERENCES users(id), + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, content TEXT NOT NULL, images TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); - --- ACCOUNT SETUP FLOW MIGRATIONS -- -ALTER TABLE users ADD COLUMN IF NOT EXISTS username VARCHAR(255) UNIQUE; -ALTER TABLE users ADD COLUMN IF NOT EXISTS name VARCHAR(255); -ALTER TABLE users ADD COLUMN IF NOT EXISTS mobile_number VARCHAR(255); -ALTER TABLE users ADD COLUMN IF NOT EXISTS setup_status VARCHAR(50) DEFAULT 'NOT_STARTED'; -ALTER TABLE users ADD COLUMN IF NOT EXISTS setup_completed_at TIMESTAMP; - -ALTER TABLE business_profiles ADD COLUMN IF NOT EXISTS nature_of_business VARCHAR(50); -ALTER TABLE business_profiles ADD COLUMN IF NOT EXISTS msme_number VARCHAR(255); - -ALTER TABLE business_profiles ALTER COLUMN pan_number TYPE VARCHAR(255); -ALTER TABLE business_profiles ALTER COLUMN gstin TYPE VARCHAR(255); - -ALTER TABLE product_categories ADD COLUMN IF NOT EXISTS has_child BOOLEAN DEFAULT FALSE; -ALTER TABLE product_categories ADD COLUMN IF NOT EXISTS default_hsn VARCHAR(50); -ALTER TABLE product_categories ADD COLUMN IF NOT EXISTS default_gst DECIMAL(5, 2); -ALTER TABLE product_categories ADD COLUMN IF NOT EXISTS huid_required BOOLEAN DEFAULT FALSE; -ALTER TABLE product_categories ADD COLUMN IF NOT EXISTS default_making_charge DECIMAL(15, 2); -ALTER TABLE product_categories ADD COLUMN IF NOT EXISTS making_charge_type VARCHAR(50); -ALTER TABLE product_categories ADD COLUMN IF NOT EXISTS is_active BOOLEAN DEFAULT TRUE; -ALTER TABLE product_categories ADD COLUMN IF NOT EXISTS sort_order INTEGER DEFAULT 0; -ALTER TABLE product_categories DROP COLUMN IF EXISTS is_commodity; -ALTER TABLE product_categories DROP COLUMN IF EXISTS daily_rate; - -CREATE TABLE IF NOT EXISTS user_consents ( - id SERIAL PRIMARY KEY, - user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, - consent_type VARCHAR(100) NOT NULL, - policy_version VARCHAR(50) NOT NULL, - accepted BOOLEAN DEFAULT TRUE, - accepted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - ip_address VARCHAR(100), - user_agent TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); diff --git a/kifi-api/src/test/java/RunSchema.java b/kifi-api/src/test/java/RunSchema.java deleted file mode 100644 index d9fcddd..0000000 --- a/kifi-api/src/test/java/RunSchema.java +++ /dev/null @@ -1,26 +0,0 @@ -import java.sql.Connection; -import java.sql.DriverManager; -import java.sql.Statement; - -public class RunSchema { - public static void main(String[] args) { - String url = "jdbc:postgresql://localhost:5432/kifi_db"; - String user = "postgres"; - String password = "postgres"; - - try (Connection conn = DriverManager.getConnection(url, user, password); - Statement stmt = conn.createStatement()) { - - stmt.execute("ALTER TABLE purchase_orders ADD COLUMN IF NOT EXISTS cgst_total NUMERIC(10,2)"); - stmt.execute("ALTER TABLE purchase_orders ADD COLUMN IF NOT EXISTS sgst_total NUMERIC(10,2)"); - stmt.execute("ALTER TABLE purchase_orders ADD COLUMN IF NOT EXISTS igst_total NUMERIC(10,2)"); - stmt.execute("ALTER TABLE purchase_order_items ADD COLUMN IF NOT EXISTS cgst_rate NUMERIC(10,2)"); - stmt.execute("ALTER TABLE purchase_order_items ADD COLUMN IF NOT EXISTS sgst_rate NUMERIC(10,2)"); - stmt.execute("ALTER TABLE purchase_order_items ADD COLUMN IF NOT EXISTS igst_rate NUMERIC(10,2)"); - - System.out.println("Schema updated successfully!"); - } catch (Exception e) { - e.printStackTrace(); - } - } -} diff --git a/kifi-api/src/test/java/com/kifi/api/DBTest.java b/kifi-api/src/test/java/com/kifi/api/DBTest.java index ce70acf..eff3f31 100644 --- a/kifi-api/src/test/java/com/kifi/api/DBTest.java +++ b/kifi-api/src/test/java/com/kifi/api/DBTest.java @@ -1,26 +1,13 @@ package com.kifi.api; + import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import com.kifi.api.repository.business.BusinessProfileRepository; -import com.kifi.api.entity.business.BusinessProfile; -import java.time.LocalDateTime; + @SpringBootTest public class DBTest { - @Autowired - private BusinessProfileRepository repo; + @Test - public void test() { - BusinessProfile bp = BusinessProfile.builder().userId(100L).build(); - bp.setBusinessName("Test"); - bp.setAddress(""); - bp.setEmailId(""); - bp.setPanNumber("ENC123"); - bp.setGstin("ENC123"); - bp.setNatureOfBusiness("JEWELLERY"); - bp.setMsmeNumber("ENC123"); - bp.setCreatedAt(LocalDateTime.now()); - bp.setUpdatedAt(LocalDateTime.now()); - repo.save(bp).block(); + public void contextLoads() { + System.out.println("Spring Boot test context loaded successfully."); } } diff --git a/kifi-app/lib/core/network/dio_client.dart b/kifi-app/lib/core/network/dio_client.dart index c539d91..2f7e576 100644 --- a/kifi-app/lib/core/network/dio_client.dart +++ b/kifi-app/lib/core/network/dio_client.dart @@ -26,9 +26,9 @@ class DioClient { DioClient._internal() : dio = Dio(BaseOptions( - //baseUrl: 'https://app.technobeesolutions.in/api/kifi-v2', + baseUrl: 'https://app.technobeesolutions.in/api/kifi-v2', //baseUrl: 'http://192.168.0.104:8080/api/kifi-v2', // Local Mac IP for physical device testing - baseUrl: 'http://192.168.1.5:8080/api/kifi-v2', // Current Local Mac IP (192.168.1.5) + //baseUrl: 'http://192.168.1.5:8080/api/kifi-v2', // Current Local Mac IP (192.168.1.5) connectTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 10), )), diff --git a/kifi-app/lib/core/utils/purity_utils.dart b/kifi-app/lib/core/utils/purity_utils.dart new file mode 100644 index 0000000..f3a8438 --- /dev/null +++ b/kifi-app/lib/core/utils/purity_utils.dart @@ -0,0 +1,32 @@ +/// Utility functions for gold/silver purity factor calculation and formatting. +/// Standard rule: Purity is represented as a fraction between 0.0 and 1.0 (e.g. 0.916 for 22KT / 91.6%). +/// If a raw value > 1.0 is supplied (such as 91.6 or 75.0), it is normalized by dividing by 100 with a maximum of 1.0. + +double normalizePurity(num? rawPurity) { + if (rawPurity == null || rawPurity <= 0) return 1.0; + double p = rawPurity.toDouble(); + if (p > 1.0) { + p = p / 100.0; + } + if (p > 1.0) { + p = 1.0; + } + return p; +} + +String formatPurity(num? rawPurity) { + final p = normalizePurity(rawPurity); + return p.toStringAsFixed(3); +} + +/// Resolves purity factor prioritizing the category (metal grade e.g. 22KT -> 0.916) +/// then falling back to product-specific purity or 1.0. +double resolvePurity({num? categoryPurity, num? productPurity}) { + if (categoryPurity != null && categoryPurity > 0) { + return normalizePurity(categoryPurity); + } + if (productPurity != null && productPurity > 0) { + return normalizePurity(productPurity); + } + return 1.0; +} diff --git a/kifi-app/lib/features/auth/presentation/otp_screen.dart b/kifi-app/lib/features/auth/presentation/otp_screen.dart index 9f776ca..4c7a3ef 100644 --- a/kifi-app/lib/features/auth/presentation/otp_screen.dart +++ b/kifi-app/lib/features/auth/presentation/otp_screen.dart @@ -3,6 +3,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lucide_icons/lucide_icons.dart'; import '../providers/auth_provider.dart'; import '../../dashboard/presentation/dashboard_screen.dart'; +import 'setup_wizard_screen.dart'; +import '../../../../core/network/dio_client.dart'; class OtpScreen extends ConsumerStatefulWidget { final String email; @@ -23,11 +25,32 @@ class _OtpScreenState extends ConsumerState { .read(authControllerProvider.notifier) .verifyOtp(widget.email, otp); if (success && mounted) { - Navigator.pushAndRemoveUntil( - context, - MaterialPageRoute(builder: (_) => const DashboardScreen()), - (route) => false, - ); + try { + await Future.delayed(const Duration(milliseconds: 300)); + final res = await DioClient().dio.get('/account/setup/status'); + final status = res.data['status']; + if (status == 'COMPLETED' && mounted) { + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute(builder: (_) => const DashboardScreen()), + (route) => false, + ); + } else if (mounted) { + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute(builder: (_) => const SetupWizardScreen()), + (route) => false, + ); + } + } catch (_) { + if (mounted) { + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute(builder: (_) => const SetupWizardScreen()), + (route) => false, + ); + } + } } } diff --git a/kifi-app/lib/features/auth/presentation/profile_screen.dart b/kifi-app/lib/features/auth/presentation/profile_screen.dart index 5aa1b61..8b9e980 100644 --- a/kifi-app/lib/features/auth/presentation/profile_screen.dart +++ b/kifi-app/lib/features/auth/presentation/profile_screen.dart @@ -11,10 +11,23 @@ import 'auth_screen.dart'; import '../../../core/network/dio_client.dart'; import '../providers/auth_provider.dart'; import '../../transactions/providers/providers.dart'; +import '../../transactions/providers/paginated_transaction_provider.dart'; import '../../../core/theme/theme_provider.dart'; import '../../business/providers/business_mode_provider.dart'; +import '../../business/providers/business_provider.dart'; import '../../projects/providers/project_mode_provider.dart'; +import '../../projects/providers/project_provider.dart'; +import '../../inventory/providers/products_provider.dart'; +import '../../inventory/providers/product_categories_provider.dart'; +import '../../inventory/providers/inventory_items_provider.dart'; +import '../../inventory/providers/inventory_valuation_provider.dart'; +import '../../inventory/providers/commodity_rates_provider.dart'; +import '../../vendor/providers/vendors_provider.dart'; +import '../../vendor/providers/purchase_orders_provider.dart'; +import '../../sales/providers/customers_provider.dart'; +import '../../sales/providers/invoices_provider.dart'; import '../../business/presentation/settings/business_settings_screen.dart'; +import 'package:shared_preferences/shared_preferences.dart'; class ProfileScreen extends ConsumerStatefulWidget { const ProfileScreen({super.key}); @@ -86,6 +99,35 @@ class _ProfileScreenState extends ConsumerState Future _logout() async { await DioClient().clearToken(); + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove('is_business_mode'); + } catch (_) {} + + try { + ref.invalidate(categoryProvider); + ref.invalidate(transactionProvider); + ref.invalidate(paginatedTransactionProvider); + ref.invalidate(budgetProvider); + ref.invalidate(recurringTransactionProvider); + ref.invalidate(walletProvider); + ref.invalidate(invitationProvider); + ref.invalidate(businessProfileProvider); + ref.invalidate(businessFeatureProvider); + ref.invalidate(businessModeProvider); + ref.invalidate(productsProvider); + ref.invalidate(productCategoriesProvider); + ref.invalidate(inventoryItemsProvider); + ref.invalidate(inventoryValuationProvider); + ref.invalidate(commodityRatesProvider); + ref.invalidate(vendorsProvider); + ref.invalidate(purchaseOrdersProvider); + ref.invalidate(customersProvider); + ref.invalidate(invoicesProvider); + ref.invalidate(projectsProvider); + ref.invalidate(authControllerProvider); + } catch (_) {} + if (mounted) { Navigator.pushAndRemoveUntil( context, diff --git a/kifi-app/lib/features/auth/providers/auth_provider.dart b/kifi-app/lib/features/auth/providers/auth_provider.dart index 8e42fd4..ea4ba83 100644 --- a/kifi-app/lib/features/auth/providers/auth_provider.dart +++ b/kifi-app/lib/features/auth/providers/auth_provider.dart @@ -78,6 +78,12 @@ class AuthController extends AsyncNotifier { return false; } } + + Future logout() async { + try { + await DioClient().clearToken(); + } catch (_) {} + } } final authControllerProvider = AsyncNotifierProvider(() { diff --git a/kifi-app/lib/features/business/providers/business_mode_provider.dart b/kifi-app/lib/features/business/providers/business_mode_provider.dart index b9ff0d7..da8732f 100644 --- a/kifi-app/lib/features/business/providers/business_mode_provider.dart +++ b/kifi-app/lib/features/business/providers/business_mode_provider.dart @@ -1,19 +1,37 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'business_provider.dart'; class BusinessModeNotifier extends Notifier { @override bool build() { - _loadState(); - return false; // Default until loaded + final businessProfile = ref.watch(businessProfileProvider).asData?.value; + if (businessProfile == null) { + return false; + } + _loadPreference(); + return true; } - Future _loadState() async { + Future _loadPreference() async { + final businessProfile = ref.read(businessProfileProvider).asData?.value; + if (businessProfile == null) { + state = false; + return; + } final prefs = await SharedPreferences.getInstance(); - state = prefs.getBool('is_business_mode') ?? false; + final saved = prefs.getBool('is_business_mode'); + if (saved != null) { + state = saved; + } } Future toggleMode() async { + final businessProfile = ref.read(businessProfileProvider).asData?.value; + if (businessProfile == null) { + state = false; + return; + } final prefs = await SharedPreferences.getInstance(); state = !state; await prefs.setBool('is_business_mode', state); diff --git a/kifi-app/lib/features/inventory/domain/product.dart b/kifi-app/lib/features/inventory/domain/product.dart index a2697b5..adf938b 100644 --- a/kifi-app/lib/features/inventory/domain/product.dart +++ b/kifi-app/lib/features/inventory/domain/product.dart @@ -41,7 +41,7 @@ class Product { this.size, this.priceCalcRule = 'MANUAL', this.autoCalculatePrice = false, - this.purityFactor = 1.0, + this.purityFactor, this.makingCharges = 0.0, this.makingChargesType = 'FLAT', this.wastagePercentage = 0.0, @@ -72,8 +72,7 @@ class Product { autoCalculatePrice: json['autoCalculatePrice'] ?? json['auto_calculate_price'] ?? false, purityFactor: - (json['purityFactor'] ?? json['purity_factor'] as num?)?.toDouble() ?? - 1.0, + (json['purityFactor'] ?? json['purity_factor'] as num?)?.toDouble(), makingCharges: (json['makingCharges'] ?? json['making_charges'] as num?) ?.toDouble() ?? 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 081bc4f..8a92803 100644 --- a/kifi-app/lib/features/inventory/presentation/add_product_screen.dart +++ b/kifi-app/lib/features/inventory/presentation/add_product_screen.dart @@ -24,6 +24,8 @@ class _AddProductScreenState extends ConsumerState { final _formKey = GlobalKey(); final _hsnController = TextEditingController(); final _gstController = TextEditingController(); + final _makingChargesController = TextEditingController(); + String _makingChargesType = 'PER_GRAM'; // Basic String _name = ''; @@ -55,6 +57,8 @@ class _AddProductScreenState extends ConsumerState { _uomId = p.uomId; _color = p.color ?? ''; _gstController.text = p.gstRate != null && p.gstRate! > 0 ? p.gstRate.toString() : ''; + _makingChargesController.text = p.makingCharges != null && p.makingCharges! > 0 ? p.makingCharges!.toString() : ''; + _makingChargesType = p.makingChargesType ?? 'PER_GRAM'; if (p.imageIds.isNotEmpty) { _existingImageIds.addAll(p.imageIds); @@ -75,6 +79,7 @@ class _AddProductScreenState extends ConsumerState { void dispose() { _hsnController.dispose(); _gstController.dispose(); + _makingChargesController.dispose(); super.dispose(); } @@ -142,6 +147,8 @@ class _AddProductScreenState extends ConsumerState { uomId: _uomId, color: _color.isNotEmpty ? _color : null, gstRate: double.tryParse(_gstController.text) ?? 0, + makingCharges: double.tryParse(_makingChargesController.text) ?? 0.0, + makingChargesType: _makingChargesType, priceCalcRule: 'MANUAL', autoCalculatePrice: false, ); @@ -245,6 +252,12 @@ class _AddProductScreenState extends ConsumerState { if (val.defaultGst != null && val.defaultGst! > 0) { _gstController.text = val.defaultGst.toString(); } + if (_makingChargesController.text.isEmpty && val.defaultMakingCharge != null && val.defaultMakingCharge! > 0) { + _makingChargesController.text = val.defaultMakingCharge!.toString(); + } + if (val.makingChargeType != null && val.makingChargeType!.isNotEmpty) { + _makingChargesType = val.makingChargeType!; + } } }); }, @@ -284,6 +297,36 @@ class _AddProductScreenState extends ConsumerState { ), ], ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: TextFormField( + controller: _makingChargesController, + decoration: const InputDecoration( + labelText: 'Making Charges', + prefixIcon: Icon(LucideIcons.hammer), + ), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + ), + ), + const SizedBox(width: 16), + Expanded( + child: DropdownButtonFormField( + value: _makingChargesType, + decoration: const InputDecoration(labelText: 'Charge Type'), + items: const [ + DropdownMenuItem(value: 'PER_GRAM', child: Text('Per Gram')), + DropdownMenuItem(value: 'PER_PIECE', child: Text('Per Piece')), + DropdownMenuItem(value: 'PERCENTAGE', child: Text('Percentage %')), + ], + onChanged: (val) { + if (val != null) setState(() => _makingChargesType = val); + }, + ), + ), + ], + ), const SizedBox(height: 24), const Text('Product Images', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), diff --git a/kifi-app/lib/features/inventory/presentation/category_management_screen.dart b/kifi-app/lib/features/inventory/presentation/category_management_screen.dart index 83f010a..ccd32ef 100644 --- a/kifi-app/lib/features/inventory/presentation/category_management_screen.dart +++ b/kifi-app/lib/features/inventory/presentation/category_management_screen.dart @@ -6,6 +6,7 @@ import '../../business/providers/business_mode_provider.dart'; import '../../../core/theme/app_theme.dart'; import 'package:lucide_icons/lucide_icons.dart'; import 'dart:ui'; +import '../../../core/utils/purity_utils.dart'; class CategoryManagementScreen extends ConsumerStatefulWidget { const CategoryManagementScreen({super.key}); @@ -272,7 +273,7 @@ class _CategoryFormSheetState extends ConsumerState { _hsnController.text = c.defaultHsn ?? ''; _gstController.text = c.defaultGst?.toString() ?? ''; _makingChargeController.text = c.defaultMakingCharge?.toString() ?? ''; - _purityFactorController.text = (c.purityFactor ?? 1.0).toString(); + _purityFactorController.text = formatPurity(c.purityFactor); _huidRequired = c.huidRequired; _commodityCode = c.commodityCode ?? 'XAU'; @@ -307,7 +308,7 @@ class _CategoryFormSheetState extends ConsumerState { parentCategoryId: _selectedParentId, hasChild: !_isLeaf, commodityCode: _isLeaf ? _commodityCode : null, - purityFactor: _isLeaf ? (double.tryParse(_purityFactorController.text) ?? 1.0) : 1.0, + purityFactor: _isLeaf ? normalizePurity(double.tryParse(_purityFactorController.text)) : 1.0, defaultHsn: _isLeaf ? _hsnController.text.trim() : null, defaultGst: _isLeaf ? double.tryParse(_gstController.text) : null, huidRequired: _isLeaf ? _huidRequired : false, @@ -483,7 +484,7 @@ class _CategoryFormSheetState extends ConsumerState { flex: 2, child: _buildTextField( controller: _purityFactorController, - label: 'Purity (e.g. 0.916)', + label: 'Purity (0 to 1, e.g. 0.916)', icon: LucideIcons.gem, keyboardType: const TextInputType.numberWithOptions(decimal: true), ), diff --git a/kifi-app/lib/features/inventory/presentation/daily_rates_screen.dart b/kifi-app/lib/features/inventory/presentation/daily_rates_screen.dart index 9942796..486d014 100644 --- a/kifi-app/lib/features/inventory/presentation/daily_rates_screen.dart +++ b/kifi-app/lib/features/inventory/presentation/daily_rates_screen.dart @@ -7,6 +7,7 @@ import '../providers/commodity_rates_provider.dart'; import '../domain/commodity_rate.dart'; import '../../../core/theme/app_theme.dart'; import '../../../core/utils/snackbar_service.dart'; +import '../../../core/utils/purity_utils.dart'; class DailyRatesScreen extends ConsumerStatefulWidget { const DailyRatesScreen({super.key}); @@ -699,7 +700,7 @@ class _DailyRatesScreenState extends ConsumerState { spacing: 8, runSpacing: 8, children: linkedCategories.map((cat) { - final purity = cat.purityFactor ?? 1.0; + final purity = normalizePurity(cat.purityFactor); final effectiveUnitRate = currentRate * purity; return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), @@ -725,7 +726,7 @@ class _DailyRatesScreenState extends ConsumerState { borderRadius: BorderRadius.circular(4), ), child: Text( - '${(purity * 100).toStringAsFixed(1)}% (₹${effectiveUnitRate.toStringAsFixed(0)}/g)', + '${formatPurity(purity)} (₹${effectiveUnitRate.toStringAsFixed(0)}/g)', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: color), ), ), diff --git a/kifi-app/lib/features/inventory/presentation/product_detail_screen.dart b/kifi-app/lib/features/inventory/presentation/product_detail_screen.dart index 1af223c..fb00622 100644 --- a/kifi-app/lib/features/inventory/presentation/product_detail_screen.dart +++ b/kifi-app/lib/features/inventory/presentation/product_detail_screen.dart @@ -133,6 +133,15 @@ class ProductDetailScreen extends ConsumerWidget { "GST Rate", "${p.gstRate?.toStringAsFixed(1) ?? '0'}%", ), + if (p.makingCharges != null && p.makingCharges! > 0) + _buildDetailRow( + "Making Charges", + p.makingChargesType == 'PERCENTAGE' + ? "${p.makingCharges}%" + : (p.makingChargesType == 'PER_PIECE' + ? "₹${p.makingCharges!.toStringAsFixed(2)} / pc" + : "₹${p.makingCharges!.toStringAsFixed(2)} / g"), + ), ], ), ), diff --git a/kifi-app/lib/features/inventory/presentation/stock_ledger_tab.dart b/kifi-app/lib/features/inventory/presentation/stock_ledger_tab.dart index 8c2585f..f44bc4b 100644 --- a/kifi-app/lib/features/inventory/presentation/stock_ledger_tab.dart +++ b/kifi-app/lib/features/inventory/presentation/stock_ledger_tab.dart @@ -10,6 +10,7 @@ import 'package:intl/intl.dart'; import 'package:lucide_icons/lucide_icons.dart'; import 'package:kifi_app/features/vendor/presentation/purchase_order_details_screen.dart'; import 'package:kifi_app/features/vendor/providers/purchase_orders_provider.dart'; +import 'package:kifi_app/core/utils/purity_utils.dart'; class StockLedgerTab extends ConsumerStatefulWidget { final Product product; @@ -71,7 +72,7 @@ class _StockLedgerTabState extends ConsumerState { ).firstOrNull; final String unit = category?.baseUnit ?? 'g'; - final double purityFactor = category?.purityFactor ?? (widget.product.purityFactor ?? 1.0); + final double purityFactor = resolvePurity(categoryPurity: category?.purityFactor, productPurity: widget.product.purityFactor); double commodityRate = 0.0; if (category?.commodityCode != null && commodityRatesState.value != null) { @@ -221,6 +222,23 @@ class _StockLedgerTabState extends ConsumerState { fontSize: 14, ), ), + const SizedBox(height: 3), + Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1.5), + decoration: BoxDecoration( + color: Colors.amber.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: Colors.amber.withValues(alpha: 0.3)), + ), + child: Text( + 'Purity: ${formatPurity(double.tryParse(item.purity ?? '') ?? purityFactor)}', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: Colors.amber.shade900, + ), + ), + ), ], ), ], diff --git a/kifi-app/lib/features/sales/domain/invoice.dart b/kifi-app/lib/features/sales/domain/invoice.dart index 3723341..828c48e 100644 --- a/kifi-app/lib/features/sales/domain/invoice.dart +++ b/kifi-app/lib/features/sales/domain/invoice.dart @@ -3,16 +3,26 @@ class InvoiceItem { final int? invoiceId; final int? productId; final int? inventoryItemId; + final String? productName; + final String? categoryName; + final String? commodityCode; final String? sku; + final String? huid; final String? hsnCode; - final String? description; final double quantity; - final double unitPrice; - final double taxRate; - final double discount; + final double? weight; + final double unitPrice; // metal rate final double makingCharge; + final String makingChargesType; // PER_GRAM, PER_PIECE, PERCENTAGE + final double taxRate; + final double cgst; + final double sgst; + final double igst; + final double discount; final double otherCharges; + final String? photoUrl; + final String? localPhotoPath; final double total; InvoiceItem({ @@ -20,15 +30,26 @@ class InvoiceItem { this.invoiceId, this.productId, this.inventoryItemId, + this.productName, + this.categoryName, + this.commodityCode, this.hsnCode, this.sku, + this.huid, this.description, required this.quantity, + this.weight, required this.unitPrice, - this.taxRate = 0.0, - this.discount = 0.0, this.makingCharge = 0.0, + this.makingChargesType = 'PER_GRAM', + this.taxRate = 0.0, + this.cgst = 0.0, + this.sgst = 0.0, + this.igst = 0.0, + this.discount = 0.0, this.otherCharges = 0.0, + this.photoUrl, + this.localPhotoPath, required this.total, }); @@ -37,15 +58,26 @@ class InvoiceItem { int? invoiceId, int? productId, int? inventoryItemId, + String? productName, + String? categoryName, + String? commodityCode, String? hsnCode, String? sku, + String? huid, String? description, double? quantity, + double? weight, double? unitPrice, - double? taxRate, - double? discount, double? makingCharge, + String? makingChargesType, + double? taxRate, + double? cgst, + double? sgst, + double? igst, + double? discount, double? otherCharges, + String? photoUrl, + String? localPhotoPath, double? total, }) { return InvoiceItem( @@ -53,15 +85,26 @@ class InvoiceItem { invoiceId: invoiceId ?? this.invoiceId, productId: productId ?? this.productId, inventoryItemId: inventoryItemId ?? this.inventoryItemId, + productName: productName ?? this.productName, + categoryName: categoryName ?? this.categoryName, + commodityCode: commodityCode ?? this.commodityCode, hsnCode: hsnCode ?? this.hsnCode, sku: sku ?? this.sku, + huid: huid ?? this.huid, description: description ?? this.description, quantity: quantity ?? this.quantity, + weight: weight ?? this.weight, unitPrice: unitPrice ?? this.unitPrice, - taxRate: taxRate ?? this.taxRate, - discount: discount ?? this.discount, makingCharge: makingCharge ?? this.makingCharge, + makingChargesType: makingChargesType ?? this.makingChargesType, + taxRate: taxRate ?? this.taxRate, + cgst: cgst ?? this.cgst, + sgst: sgst ?? this.sgst, + igst: igst ?? this.igst, + discount: discount ?? this.discount, otherCharges: otherCharges ?? this.otherCharges, + photoUrl: photoUrl ?? this.photoUrl, + localPhotoPath: localPhotoPath ?? this.localPhotoPath, total: total ?? this.total, ); } @@ -69,19 +112,29 @@ class InvoiceItem { factory InvoiceItem.fromJson(Map json) { return InvoiceItem( id: json['id'], - invoiceId: json['invoiceId'], - productId: json['productId'], - inventoryItemId: json['inventoryItemId'], + invoiceId: json['invoiceId'] ?? json['invoice_id'], + productId: json['productId'] ?? json['product_id'], + inventoryItemId: json['inventoryItemId'] ?? json['inventory_item_id'], + productName: json['productName'] ?? json['product_name'], + categoryName: json['categoryName'] ?? json['category_name'], + commodityCode: json['commodityCode'] ?? json['commodity_code'], hsnCode: json['hsnCode'] ?? json['hsn_code'], sku: json['sku'], + huid: json['huid'], 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(), + quantity: (json['quantity'] as num?)?.toDouble() ?? 1.0, + weight: (json['weight'] as num?)?.toDouble(), + unitPrice: (json['unitPrice'] ?? json['unit_price'] as num?)?.toDouble() ?? 0.0, + makingCharge: (json['makingCharge'] ?? json['making_charge'] as num?)?.toDouble() ?? 0.0, + makingChargesType: json['makingChargesType'] ?? json['making_charges_type'] ?? 'PER_GRAM', + taxRate: (json['taxRate'] ?? json['tax_rate'] as num?)?.toDouble() ?? 0.0, + cgst: (json['cgst'] as num?)?.toDouble() ?? 0.0, + sgst: (json['sgst'] as num?)?.toDouble() ?? 0.0, + igst: (json['igst'] as num?)?.toDouble() ?? 0.0, + discount: (json['discount'] as num?)?.toDouble() ?? 0.0, + otherCharges: (json['otherCharges'] ?? json['other_charges'] as num?)?.toDouble() ?? 0.0, + photoUrl: json['photoUrl'] ?? json['photo_url'], + total: (json['total'] as num?)?.toDouble() ?? 0.0, ); } @@ -91,15 +144,25 @@ class InvoiceItem { if (invoiceId != null) data['invoiceId'] = invoiceId; if (productId != null) data['productId'] = productId; if (inventoryItemId != null) data['inventoryItemId'] = inventoryItemId; + if (productName != null) data['productName'] = productName; + if (categoryName != null) data['categoryName'] = categoryName; + if (commodityCode != null) data['commodityCode'] = commodityCode; if (hsnCode != null) data['hsnCode'] = hsnCode; if (sku != null) data['sku'] = sku; + if (huid != null) data['huid'] = huid; if (description != null) data['description'] = description; data['quantity'] = quantity; + if (weight != null) data['weight'] = weight; data['unitPrice'] = unitPrice; - data['taxRate'] = taxRate; - data['discount'] = discount; data['makingCharge'] = makingCharge; + data['makingChargesType'] = makingChargesType; + data['taxRate'] = taxRate; + data['cgst'] = cgst; + data['sgst'] = sgst; + data['igst'] = igst; + data['discount'] = discount; data['otherCharges'] = otherCharges; + if (photoUrl != null) data['photoUrl'] = photoUrl; data['total'] = total; return data; } @@ -113,6 +176,9 @@ class Invoice { final DateTime? dueDate; final double subtotal; final double taxTotal; + final double cgstTotal; + final double sgstTotal; + final double igstTotal; final double discountTotal; final double totalAmount; final double amountPaid; @@ -121,6 +187,7 @@ class Invoice { final int? paymentWalletId; final String status; final String? notes; + final String? invoiceUrl; final bool isEmi; final double? emiAmount; final String? emiCycle; @@ -136,6 +203,9 @@ class Invoice { this.dueDate, required this.subtotal, this.taxTotal = 0.0, + this.cgstTotal = 0.0, + this.sgstTotal = 0.0, + this.igstTotal = 0.0, this.discountTotal = 0.0, required this.totalAmount, this.amountPaid = 0.0, @@ -144,6 +214,7 @@ class Invoice { this.paymentWalletId, this.status = 'DRAFT', this.notes, + this.invoiceUrl, this.isEmi = false, this.emiAmount, this.emiCycle, @@ -155,28 +226,34 @@ class Invoice { 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, + customerId: json['customerId'] ?? json['customer_id'], + invoiceNumber: json['invoiceNumber'] ?? json['invoice_number'] ?? '', + issueDate: json['issueDate'] != null + ? DateTime.parse(json['issueDate']) + : (json['issue_date'] != null ? DateTime.parse(json['issue_date']) : DateTime.now()), + dueDate: json['dueDate'] != null ? DateTime.parse(json['dueDate']) : (json['due_date'] != null ? DateTime.parse(json['due_date']) : null), + subtotal: (json['subtotal'] as num?)?.toDouble() ?? 0.0, + taxTotal: (json['taxTotal'] ?? json['tax_total'] as num?)?.toDouble() ?? 0.0, + cgstTotal: (json['cgstTotal'] ?? json['cgst_total'] as num?)?.toDouble() ?? 0.0, + sgstTotal: (json['sgstTotal'] ?? json['sgst_total'] as num?)?.toDouble() ?? 0.0, + igstTotal: (json['igstTotal'] ?? json['igst_total'] as num?)?.toDouble() ?? 0.0, + discountTotal: (json['discountTotal'] ?? json['discount_total'] as num?)?.toDouble() ?? 0.0, + totalAmount: (json['totalAmount'] ?? json['total_amount'] as num?)?.toDouble() ?? 0.0, + amountPaid: (json['amountPaid'] ?? json['amount_paid'] as num?)?.toDouble() ?? 0.0, nextPaymentDate: json['nextPaymentDate'] != null ? DateTime.parse(json['nextPaymentDate']) - : null, - paymentMethod: json['paymentMethod'], - paymentWalletId: json['paymentWalletId'], + : (json['next_payment_date'] != null ? DateTime.parse(json['next_payment_date']) : null), + paymentMethod: json['paymentMethod'] ?? json['payment_method'], + paymentWalletId: json['paymentWalletId'] ?? json['payment_wallet_id'], status: json['status'] ?? 'DRAFT', notes: json['notes'], - isEmi: json['isEmi'] ?? false, - emiAmount: json['emiAmount']?.toDouble(), - emiCycle: json['emiCycle'], + invoiceUrl: json['invoiceUrl'] ?? json['invoice_url'], + isEmi: json['isEmi'] ?? json['is_emi'] ?? false, + emiAmount: (json['emiAmount'] ?? json['emi_amount'] as num?)?.toDouble(), + emiCycle: json['emiCycle'] ?? json['emi_cycle'], emiStartDate: json['emiStartDate'] != null ? DateTime.parse(json['emiStartDate']) - : null, + : (json['emi_start_date'] != null ? DateTime.parse(json['emi_start_date']) : null), items: json['items'] != null ? (json['items'] as List).map((i) => InvoiceItem.fromJson(i)).toList() : [], @@ -194,29 +271,35 @@ class Invoice { if (customerId != null) data['customerId'] = customerId; data['invoiceNumber'] = invoiceNumber; data['issueDate'] = issueDate.toIso8601String().split('T')[0]; - if (dueDate != null) + if (dueDate != null) { data['dueDate'] = dueDate!.toIso8601String().split('T')[0]; + } data['subtotal'] = subtotal; data['taxTotal'] = taxTotal; + data['cgstTotal'] = cgstTotal; + data['sgstTotal'] = sgstTotal; + data['igstTotal'] = igstTotal; data['discountTotal'] = discountTotal; data['totalAmount'] = totalAmount; if (amountPaid > 0) data['amountPaid'] = amountPaid; - if (nextPaymentDate != null) - data['nextPaymentDate'] = nextPaymentDate!.toIso8601String().split( - 'T', - )[0]; + 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; + if (invoiceUrl != null) data['invoiceUrl'] = invoiceUrl; data['isEmi'] = isEmi; if (emiAmount != null) data['emiAmount'] = emiAmount; if (emiCycle != null) data['emiCycle'] = emiCycle; - if (emiStartDate != null) + if (emiStartDate != null) { data['emiStartDate'] = emiStartDate!.toIso8601String().split('T')[0]; + } data['items'] = items.map((i) => i.toJson()).toList(); - if (payments != null) + if (payments != null) { data['payments'] = payments!.map((i) => i.toJson()).toList(); + } return data; } } @@ -243,14 +326,14 @@ class InvoicePayment { factory InvoicePayment.fromJson(Map json) { return InvoicePayment( id: json['id'], - invoiceId: json['invoiceId'], - amount: json['amount'].toDouble(), + invoiceId: json['invoiceId'] ?? json['invoice_id'], + amount: (json['amount'] as num?)?.toDouble() ?? 0.0, paymentDate: json['paymentDate'] != null ? DateTime.parse(json['paymentDate']) - : null, - paymentMethod: json['paymentMethod'] ?? 'Cash', - emiInstallmentNumber: json['emiInstallmentNumber'], - walletId: json['walletId'], + : (json['payment_date'] != null ? DateTime.parse(json['payment_date']) : null), + paymentMethod: json['paymentMethod'] ?? json['payment_method'] ?? 'Cash', + emiInstallmentNumber: json['emiInstallmentNumber'] ?? json['emi_installment_number'], + walletId: json['walletId'] ?? json['wallet_id'], ); } @@ -259,11 +342,13 @@ class InvoicePayment { if (id != null) data['id'] = id; if (invoiceId != null) data['invoiceId'] = invoiceId; data['amount'] = amount; - if (paymentDate != null) + if (paymentDate != null) { data['paymentDate'] = paymentDate!.toIso8601String().split('T')[0]; + } data['paymentMethod'] = paymentMethod; - if (emiInstallmentNumber != null) + if (emiInstallmentNumber != null) { data['emiInstallmentNumber'] = emiInstallmentNumber; + } if (walletId != null) data['walletId'] = walletId; return data; } diff --git a/kifi-app/lib/features/sales/presentation/invoice_builder_screen.dart b/kifi-app/lib/features/sales/presentation/invoice_builder_screen.dart index 5f11c30..87d89f2 100644 --- a/kifi-app/lib/features/sales/presentation/invoice_builder_screen.dart +++ b/kifi-app/lib/features/sales/presentation/invoice_builder_screen.dart @@ -1,28 +1,54 @@ 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 'dart:io'; +import 'package:dio/dio.dart'; +import '../../../core/network/dio_client.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 '../../../core/widgets/smart_search_dropdown.dart'; +import '../../../core/widgets/barcode_scanner_screen.dart'; import '../../inventory/providers/products_provider.dart'; +import '../../inventory/providers/product_categories_provider.dart'; +import '../../inventory/providers/commodity_rates_provider.dart'; import '../../inventory/providers/inventory_items_provider.dart'; -import '../../projects/providers/project_mode_provider.dart'; import '../../inventory/domain/product.dart'; import '../../inventory/domain/inventory_item.dart'; -import 'add_customer_sheet.dart'; +import '../providers/customers_provider.dart'; +import '../providers/invoices_provider.dart'; +import '../domain/customer.dart'; +import '../domain/invoice.dart'; +import '../../business/providers/business_provider.dart'; import '../../transactions/providers/providers.dart'; +import '../../transactions/presentation/widgets/attachment_gallery_screen.dart'; +import 'widgets/quick_add_customer_sheet.dart'; +import '../../../core/utils/purity_utils.dart'; +import 'invoice_details_screen.dart'; + +String _resolveImageUrl(String path) { + if (path.startsWith('http://') || path.startsWith('https://')) { + return path; + } + final base = DioClient().dio.options.baseUrl; + final cleanPath = path.startsWith('/') ? path.substring(1) : path; + if (cleanPath.startsWith('api/kifi-v2/upload/view') || cleanPath.startsWith('upload/view')) { + return '$base/${cleanPath.replaceFirst('api/kifi-v2/', '')}'; + } + return '$base/upload/view?path=${Uri.encodeComponent(cleanPath)}'; +} class InvoiceBuilderScreen extends ConsumerStatefulWidget { - final List? initialItems; + final Invoice? existingInvoice; final int? initialCustomerId; + final List? initialItems; - const InvoiceBuilderScreen({super.key, this.initialItems, this.initialCustomerId}); + const InvoiceBuilderScreen({ + super.key, + this.existingInvoice, + this.initialCustomerId, + this.initialItems, + }); @override ConsumerState createState() => _InvoiceBuilderScreenState(); @@ -30,800 +56,2096 @@ class InvoiceBuilderScreen extends ConsumerStatefulWidget { class _InvoiceBuilderScreenState extends ConsumerState { final _formKey = GlobalKey(); - Customer? _selectedCustomer; + final _invoiceNumberCtrl = TextEditingController(); + final _notesCtrl = TextEditingController(); + final _amountPaidCtrl = TextEditingController(); + final _emiAmountCtrl = TextEditingController(); + final _discountCtrl = TextEditingController(text: '0.0'); + + DateTime _invoiceDate = DateTime.now(); + DateTime? _dueDate; + int? _selectedCustomerId; final List _items = []; + bool _isLoading = false; + + XFile? _invoiceFile; + String? _invoiceUrl; + + String _paymentMethod = 'Cash'; + int? _selectedWalletId; + DateTime? _nextPaymentDate; + bool _isAmountPaidEdited = false; + + // EMI State bool _isEmi = false; + String _emiCycle = 'MONTHLY'; + DateTime? _emiStartDate; @override void initState() { super.initState(); - if (widget.initialItems != null) { - _items.addAll(widget.initialItems!); + if (widget.existingInvoice != null) { + final inv = widget.existingInvoice!; + _invoiceNumberCtrl.text = inv.invoiceNumber; + _invoiceDate = inv.issueDate; + _dueDate = inv.dueDate; + _selectedCustomerId = inv.customerId; + _notesCtrl.text = inv.notes ?? ''; + _items.addAll(inv.items); + _invoiceUrl = inv.invoiceUrl; + if (inv.discountTotal > 0) { + _discountCtrl.text = inv.discountTotal.toStringAsFixed(2); + } + _amountPaidCtrl.text = inv.amountPaid > 0 ? inv.amountPaid.toStringAsFixed(2) : ''; + _selectedWalletId = inv.paymentWalletId; + _paymentMethod = inv.paymentMethod ?? 'Cash'; + _nextPaymentDate = inv.nextPaymentDate; + _isAmountPaidEdited = inv.amountPaid > 0; + _isEmi = inv.isEmi; + _emiCycle = inv.emiCycle ?? 'MONTHLY'; + _emiStartDate = inv.emiStartDate; + if (inv.emiAmount != null) { + _emiAmountCtrl.text = inv.emiAmount!.toStringAsFixed(2); + } + } else { + _invoiceNumberCtrl.text = 'INV-${DateTime.now().millisecondsSinceEpoch.toString().substring(7)}'; + _selectedCustomerId = widget.initialCustomerId; + _dueDate = DateTime.now().add(const Duration(days: 30)); + _emiStartDate = DateTime.now().add(const Duration(days: 30)); + if (widget.initialItems != null) { + _items.addAll(widget.initialItems!); + } } } - void _loadInitialCustomer() { - if (widget.initialCustomerId != null && _selectedCustomer == null) { - final customers = ref.read(customersProvider).value; - if (customers != null) { - final cust = customers.where((c) => c.id == widget.initialCustomerId).firstOrNull; - if (cust != null) { - setState(() { - _selectedCustomer = cust; - }); + @override + void dispose() { + _invoiceNumberCtrl.dispose(); + _notesCtrl.dispose(); + _amountPaidCtrl.dispose(); + _emiAmountCtrl.dispose(); + _discountCtrl.dispose(); + super.dispose(); + } + + Future _pickInvoiceFile() async { + final picker = ImagePicker(); + final picked = await picker.pickImage( + source: ImageSource.gallery, + imageQuality: 80, + ); + if (picked != null) { + setState(() => _invoiceFile = picked); + } + } + + void _showQuickAddCustomer() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => const QuickAddCustomerSheet(), + ).then((result) { + if (result != null) { + if (result is Customer && result.id != null) { + setState(() => _selectedCustomerId = result.id); + } else { + ref.refresh(customersProvider); } } - } + }); } - 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() { - final isProjectMode = ref.read(projectModeProvider); - 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( + void _openAddItemSheet({int? editIndex}) { + showModalBottomSheet( 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: [ - if (!isProjectMode) 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), - ), - ) - ), - ], - ), - if (!isProjectMode) ...[ - 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'), - ), - ], - ); + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => _AddSalesItemSheet( + initialItem: editIndex != null ? _items[editIndex] : null, + onItemAdded: (item) { + setState(() { + if (editIndex != null) { + _items[editIndex] = item; + } else { + _items.add(item); + } + }); }, - ); - }, + ), ); } - void _scanAndAddBarcode() async { - final barcode = await Navigator.push( + Future _scanBarcode() async { + final scannedCode = await Navigator.push( context, MaterialPageRoute(builder: (_) => const BarcodeScannerScreen()), ); - if (barcode != null && barcode.isNotEmpty) { - final products = ref.read(productsProvider).value ?? []; + if (scannedCode != null && scannedCode.isNotEmpty) { final inventoryItems = ref.read(inventoryItemsProvider).value ?? []; - final businessFeature = ref.read(businessFeatureProvider).value; - final bool useBarcodeField = businessFeature?.barcodeSource == 'BARCODE'; + final products = ref.read(productsProvider).value ?? []; + final categories = ref.read(productCategoriesProvider).value ?? []; + final commodityRates = ref.read(commodityRatesProvider).value ?? []; - // 1. Try to match by HUID first - final invItem = inventoryItems.where((i) => i.huid?.toLowerCase() == barcode.toLowerCase()).firstOrNull; + // Check inventory item by HUID or SKU or Barcode + final invItem = inventoryItems.where((i) => + (i.huid != null && i.huid!.toUpperCase() == scannedCode.toUpperCase()) || + (i.sku != null && i.sku!.toUpperCase() == scannedCode.toUpperCase()) + ).firstOrNull; + Product? matchedProduct; if (invItem != null) { - final p = products.where((p) => p.id == invItem.productId).firstOrNull; - setState(() { - final existingIndex = _items.indexWhere((item) => item.inventoryItemId == invItem.id); - if (existingIndex >= 0) { - // HUIDs are unique, so this shouldn't normally increment qty, but for safety: - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Item with this HUID is already in the invoice'))); - } - } else { - final price = invItem.purchaseCost ?? p?.sellingPrice ?? 0; // Ideally use selling price logic, but keeping simple - final tax = invItem.tax ?? p?.gstRate ?? 0; - final making = invItem.makingCharges ?? p?.makingCharges ?? 0; - final total = price + (price * (tax / 100)) + making; - _items.add(InvoiceItem( - productId: p?.id, - inventoryItemId: invItem.id, - sku: invItem.huid ?? invItem.sku ?? p?.sku, - description: p?.name ?? 'Inventory Item', - 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 HUID item to invoice'))); - } - } - }); - return; + matchedProduct = products.where((p) => p.id == invItem.productId).firstOrNull; + } else { + matchedProduct = products.where((p) => + (p.barcode != null && p.barcode == scannedCode) || + (p.sku != null && p.sku!.toUpperCase() == scannedCode.toUpperCase()) + ).firstOrNull; } - // 2. Fallback to matching by Product SKU or Barcode - final p = products.where((p) { - if (useBarcodeField) { - return p.barcode?.toLowerCase() == barcode.toLowerCase(); - } else { - return p.sku?.toLowerCase() == barcode.toLowerCase(); + if (matchedProduct != null) { + final cat = categories.where((c) => c.id == matchedProduct!.categoryId).firstOrNull; + double commodityRate = 0.0; + if (cat?.commodityCode != null) { + final match = commodityRates.where((r) => r.commodityCode.toUpperCase() == cat!.commodityCode!.toUpperCase()).firstOrNull; + if (match != null) commodityRate = match.rate; } - }).firstOrNull; + final purity = resolvePurity(categoryPurity: cat?.purityFactor, productPurity: matchedProduct.purityFactor); + final effectiveRate = commodityRate > 0 ? (commodityRate * purity) : (matchedProduct.sellingPrice ?? 0.0); + final weight = invItem?.grossWeight ?? 1.0; + final makingCharge = invItem?.makingCharges ?? matchedProduct.makingCharges ?? cat?.defaultMakingCharge ?? 0.0; + final makingType = invItem?.makingChargeType ?? matchedProduct.makingChargesType ?? cat?.makingChargeType ?? 'PER_GRAM'; + final metalAmount = weight * effectiveRate; - if (p != null) { - setState(() { - final existingIndex = _items.indexWhere((item) => item.productId == p.id && item.inventoryItemId == null); - 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'))); + double makingAmt = 0.0; + if (makingType == 'PERCENTAGE') { + makingAmt = metalAmount * (makingCharge / 100.0); + } else if (makingType == 'PER_PIECE') { + makingAmt = makingCharge; + } else { + makingAmt = weight * makingCharge; + } + + final gstRate = matchedProduct.gstRate ?? (cat?.defaultGst ?? 3.0); + final taxable = metalAmount + makingAmt; + final taxAmount = (taxable * gstRate) / 100.0; + final total = taxable + taxAmount; + + final item = InvoiceItem( + productId: matchedProduct.id, + inventoryItemId: invItem?.id, + productName: matchedProduct.name, + categoryName: cat?.name, + commodityCode: cat?.commodityCode, + hsnCode: cat?.defaultHsn ?? matchedProduct.hsnCode, + sku: invItem?.sku ?? matchedProduct.sku, + huid: invItem?.huid, + description: matchedProduct.name, + quantity: 1.0, + weight: weight, + unitPrice: effectiveRate, + makingCharge: makingCharge, + makingChargesType: makingType, + taxRate: gstRate, + total: total, + ); + + setState(() => _items.add(item)); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Added ${matchedProduct.name} via barcode scan!'), backgroundColor: Colors.green), + ); } } else { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Product/Item not found for barcode: $barcode'))); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('No item found with barcode/HUID: $scannedCode'), backgroundColor: Colors.orange), + ); } } } } + Future _saveInvoice() async { + if (!_formKey.currentState!.validate()) return; + if (_items.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please add at least one line item')), + ); + return; + } + + setState(() => _isLoading = true); + try { + String? finalInvoiceUrl = _invoiceUrl; + + if (_invoiceFile != null) { + final formData = FormData.fromMap({ + 'file': await MultipartFile.fromFile( + _invoiceFile!.path, + filename: _invoiceFile!.name, + ), + 'type': 'SALES_INVOICE', + }); + final uploadResp = await DioClient().dio.post( + '/upload', + data: formData, + ); + if (uploadResp.statusCode == 200) { + finalInvoiceUrl = uploadResp.data['url']; + } + } + + final products = ref.read(productsProvider).value ?? []; + final businessStateId = ref.read(businessProfileProvider).value?.stateId; + final customers = ref.read(customersProvider).value ?? []; + final customer = _selectedCustomerId == null + ? null + : customers.where((c) => c.id == _selectedCustomerId).firstOrNull; + + final isSameState = businessStateId != null && customer?.stateId != null && businessStateId == customer!.stateId; + + double subtotal = 0; + double totalCgst = 0; + double totalSgst = 0; + double totalIgst = 0; + double totalTax = 0; + double totalMaking = 0; + double totalDiscount = 0; + double grandTotal = 0; + + final List processedItems = []; + + for (var item in _items) { + final product = products.where((p) => p.id == item.productId).firstOrNull; + final gstRate = item.taxRate > 0 ? item.taxRate : (product?.gstRate ?? 0.0); + + final weight = item.weight ?? item.quantity; + final metalAmount = weight * item.unitPrice; + + double makingChargeAmt = 0.0; + if (item.makingChargesType == 'PERCENTAGE') { + makingChargeAmt = metalAmount * (item.makingCharge / 100.0); + } else if (item.makingChargesType == 'PER_PIECE') { + makingChargeAmt = item.makingCharge; + } else { + makingChargeAmt = weight * item.makingCharge; + } + + final taxableAmount = metalAmount + makingChargeAmt + item.otherCharges - item.discount; + final taxAmount = (taxableAmount * gstRate) / 100.0; + + double cgst = 0; + double sgst = 0; + double igst = 0; + + if (isSameState) { + cgst = taxAmount / 2.0; + sgst = taxAmount / 2.0; + totalCgst += cgst; + totalSgst += sgst; + } else { + igst = taxAmount; + totalIgst += igst; + } + + final lineTotal = taxableAmount + taxAmount; + + subtotal += metalAmount; + totalMaking += makingChargeAmt; + totalTax += taxAmount; + + processedItems.add(item.copyWith( + taxRate: gstRate, + cgst: cgst, + sgst: sgst, + igst: igst, + total: lineTotal, + )); + } + + final invoiceDiscount = double.tryParse(_discountCtrl.text) ?? 0.0; + totalDiscount = invoiceDiscount; + grandTotal = (subtotal + totalMaking - invoiceDiscount).clamp(0.0, double.infinity) + totalTax; + + final amountPaid = double.tryParse(_amountPaidCtrl.text) ?? (_isAmountPaidEdited ? 0.0 : grandTotal); + final validAmountPaid = amountPaid > grandTotal ? grandTotal : amountPaid; + final balanceDue = grandTotal - validAmountPaid; + + final invoice = Invoice( + id: widget.existingInvoice?.id, + customerId: _selectedCustomerId, + invoiceNumber: _invoiceNumberCtrl.text.trim(), + issueDate: _invoiceDate, + dueDate: _dueDate, + subtotal: subtotal, + taxTotal: totalTax, + cgstTotal: totalCgst, + sgstTotal: totalSgst, + igstTotal: totalIgst, + discountTotal: totalDiscount, + totalAmount: grandTotal, + amountPaid: validAmountPaid, + paymentMethod: validAmountPaid > 0 ? _paymentMethod : null, + paymentWalletId: validAmountPaid > 0 ? (_selectedWalletId ?? ref.read(walletProvider).value?.firstOrNull?.id) : null, + nextPaymentDate: balanceDue > 0 ? (_nextPaymentDate ?? DateTime.now().add(const Duration(days: 30))) : null, + status: widget.existingInvoice?.status ?? (validAmountPaid >= grandTotal ? 'PAID' : (validAmountPaid > 0 ? 'PARTIAL' : 'DRAFT')), + notes: _notesCtrl.text.trim().isEmpty ? null : _notesCtrl.text.trim(), + invoiceUrl: finalInvoiceUrl, + isEmi: _isEmi, + emiAmount: _isEmi && _emiAmountCtrl.text.isNotEmpty ? double.tryParse(_emiAmountCtrl.text) : null, + emiCycle: _isEmi ? _emiCycle : null, + emiStartDate: _isEmi ? (_emiStartDate ?? DateTime.now().add(const Duration(days: 30))) : null, + items: processedItems, + ); + + Invoice? savedInvoice; + if (widget.existingInvoice == null) { + savedInvoice = await ref.read(invoicesProvider.notifier).createInvoice(invoice); + } else { + final resp = await DioClient().dio.put( + '/invoices/${widget.existingInvoice!.id}', + data: invoice.toJson(), + ); + if (resp.statusCode == 200) { + savedInvoice = Invoice.fromJson(resp.data); + ref.refresh(invoicesProvider); + } + } + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Sales Invoice saved successfully!'), backgroundColor: Colors.green), + ); + if (savedInvoice != null) { + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (_) => InvoiceDetailsScreen(invoice: savedInvoice!), + ), + ); + } else { + Navigator.pop(context); + } + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error saving invoice: $e'), backgroundColor: Colors.red), + ); + } + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + @override Widget build(BuildContext context) { - _loadInitialCustomer(); final customersState = ref.watch(customersProvider); - final formatCurrency = NumberFormat.currency(symbol: '₹'); + final isDark = Theme.of(context).brightness == Brightness.dark; return Scaffold( - backgroundColor: Colors.grey[50], appBar: AppBar( - title: const Text('Create Invoice'), - elevation: 0, - backgroundColor: Colors.white, - foregroundColor: Colors.black, + title: Text( + widget.existingInvoice == null + ? 'New Sales Invoice' + : 'Edit Invoice ${_invoiceNumberCtrl.text}', + style: const TextStyle(fontWeight: FontWeight.bold), + ), actions: [ - TextButton( - onPressed: _saveInvoice, - child: const Text('Save Invoice', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.blue)), - ) + IconButton( + icon: const Icon(LucideIcons.qrCode), + tooltip: 'Scan Barcode/HUID', + onPressed: _scanBarcode, + ), + if (_isLoading) + const Padding( + padding: EdgeInsets.all(16.0), + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + else + TextButton.icon( + onPressed: _saveInvoice, + icon: const Icon(LucideIcons.check, size: 18), + label: const Text('Save', style: TextStyle(fontWeight: FontWeight.bold)), + ), ], ), body: GestureDetector( onTap: () => FocusScope.of(context).unfocus(), + behavior: HitTestBehavior.translucent, child: Form( key: _formKey, - child: SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: ListView( + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + padding: const EdgeInsets.all(16.0), 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); - } + // Customer Selection Row + Quick Add + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: customersState.when( + data: (customers) => SmartSearchDropdown( + labelText: 'Customer *', + hintText: 'Search Customer by Name / Phone / GSTIN / City', + value: customers.where((c) => c.id == _selectedCustomerId).firstOrNull, + items: customers, + itemAsString: (c) => c.name, + filterFn: (c, query) { + final q = query.toLowerCase(); + return c.name.toLowerCase().contains(q) || + (c.phone != null && c.phone!.contains(q)) || + (c.email != null && c.email!.toLowerCase().contains(q)) || + (c.gstin != null && c.gstin!.toLowerCase().contains(q)) || + (c.address != null && c.address!.toLowerCase().contains(q)); }, - ), - 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( + itemBuilder: (context, c) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 14.0, vertical: 10.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Consumer( - builder: (context, ref, child) { - final feature = ref.watch(businessFeatureProvider).value; - final isBarcode = feature?.barcodeSource == 'BARCODE'; - return Row( - children: [ - Text('Scan by:', style: TextStyle(fontSize: 12, color: Colors.grey.shade600)), - const SizedBox(width: 4), - Text('SKU', style: TextStyle(fontSize: 10, fontWeight: !isBarcode ? FontWeight.bold : FontWeight.normal, color: !isBarcode ? Colors.blue : Colors.grey)), - Transform.scale( - scale: 0.7, - child: Switch( - value: isBarcode, - activeColor: Colors.blue, - onChanged: (val) { - if (feature != null) { - ref.read(businessFeatureProvider.notifier).updateFeatures( - feature.copyWith(barcodeSource: val ? 'BARCODE' : 'SKU') - ); - } - }, - ), - ), - Text('EAN', style: TextStyle(fontSize: 10, fontWeight: isBarcode ? FontWeight.bold : FontWeight.normal, color: isBarcode ? Colors.blue : Colors.grey)), - ], - ); - }, + CircleAvatar( + radius: 18, + backgroundColor: Colors.blue.withValues(alpha: 0.15), + child: Text( + c.name.isNotEmpty ? c.name[0].toUpperCase() : 'C', + style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.blue, fontSize: 14), + ), ), - 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'), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + c.name, + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14), + ), + const SizedBox(height: 3), + Wrap( + spacing: 6, + runSpacing: 3, + children: [ + if (c.phone != null && c.phone!.isNotEmpty) + _buildSmallBadge(c.phone!, Colors.teal), + if (c.address != null && c.address!.isNotEmpty) + _buildSmallBadge(c.address!, Colors.purple), + if (c.gstin != null && c.gstin!.isNotEmpty) + _buildSmallBadge('GST: ${c.gstin}', Colors.indigo), + ], + ), + ], + ), ), ], - ) - ], - ), - 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); - } - }); - }, ), - ), - ], + ); + }, + onChanged: (val) { + setState(() { + _selectedCustomerId = val?.id; + }); + }, ), - 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 allWallets = walletsState.value ?? []; - - final wallets = allWallets.where((w) { - if (_paymentMethod == 'Cash') { - return w.nature == 'CASH'; - } else { - return w.nature == 'INCOME' || w.nature == 'SAVINGS'; - } - }).toList(); + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Text('Error loading customers: $e'), + ), + ), + const SizedBox(width: 8), + Padding( + padding: const EdgeInsets.only(top: 4.0), + child: IconButton.filledTonal( + onPressed: _showQuickAddCustomer, + icon: const Icon(LucideIcons.userPlus, size: 20), + tooltip: 'Quick Add Customer', + ), + ), + ], + ), + const SizedBox(height: 16), - if (wallets.isNotEmpty && (_selectedWalletId == null || !wallets.any((w) => w.id == _selectedWalletId))) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) setState(() => _selectedWalletId = wallets.first.id); - }); - } + PremiumTextField( + controller: _invoiceNumberCtrl, + labelText: 'Invoice Number *', + prefixIcon: const Icon(LucideIcons.fileText), + validator: (val) => val == null || val.isEmpty ? 'Required' : null, + ), + const SizedBox(height: 16), + InkWell( + onTap: () async { + final picked = await showDatePicker( + context: context, + initialDate: _invoiceDate, + firstDate: DateTime(2000), + lastDate: DateTime(2100), + ); + if (picked != null) { + setState(() => _invoiceDate = picked); + } + }, + child: InputDecorator( + decoration: InputDecoration( + labelText: 'Invoice Date', + prefixIcon: const Icon(LucideIcons.calendar), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + ), + child: Text( + DateFormat('dd MMM yyyy').format(_invoiceDate), + style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14), + ), + ), + ), + const SizedBox(height: 24), - return DropdownButtonHideUnderline( - child: DropdownButton( - value: _selectedWalletId, - 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) { - if (val != null) { - setState(() { - _paymentMethod = val; - _selectedWalletId = null; - }); - } - }, - ), - ), - ), - ], - ), - ], + // Items Section Header with Barcode + Add Item + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Invoice Items (${_items.length})', + style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + ), + Row( + children: [ + IconButton.filledTonal( + onPressed: _scanBarcode, + icon: const Icon(LucideIcons.qrCode, size: 18), + tooltip: 'Scan Barcode', + ), + const SizedBox(width: 8), + FilledButton.tonalIcon( + onPressed: () => _openAddItemSheet(), + icon: const Icon(LucideIcons.plus, size: 16), + label: const Text('Add Item'), + ), + ], + ), + ], + ), + const SizedBox(height: 12), + + // Items List + if (_items.isEmpty) + Container( + padding: const EdgeInsets.all(32), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1E293B) : Colors.grey.shade100, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.grey.withValues(alpha: 0.2)), + ), + child: Column( + children: [ + Icon(LucideIcons.shoppingBag, size: 48, color: Colors.grey.shade400), const SizedBox(height: 12), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + Text( + 'No items added yet', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Colors.grey.shade600), + ), + const SizedBox(height: 4), + Text( + 'Tap "Add Item" or scan a barcode/HUID to add jewellery', + style: TextStyle(fontSize: 12, color: Colors.grey.shade500), + ), + ], + ), + ) + else + ..._items.asMap().entries.map((entry) { + final idx = entry.key; + final item = entry.value; + return _buildSalesItemCard(item, idx, isDark); + }), + + const SizedBox(height: 20), + + // Invoice Discount Input Field before Summary + PremiumTextField( + controller: _discountCtrl, + labelText: 'Invoice Discount (₹)', + prefixIcon: const Icon(LucideIcons.tag, color: Colors.red), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + onChanged: (_) => setState(() {}), + ), + + const SizedBox(height: 20), + + // Grand Total & Tax Summary Box + _buildTotalsSummaryCard(isDark), + + const SizedBox(height: 24), + + // Payment / Settlement Details (Full Partial Payment & EMI Support) + _buildPaymentSection(isDark), + + const SizedBox(height: 24), + + // Notes & Remarks + PremiumTextField( + controller: _notesCtrl, + labelText: 'Notes & Terms', + maxLines: 3, + prefixIcon: const Icon(LucideIcons.alignLeft), + ), + const SizedBox(height: 24), + + // Attachments + Text( + 'Attached Invoice / Documents', + style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + if (_invoiceFile != null) + ListTile( + leading: const Icon(LucideIcons.image, color: Colors.blue), + title: Text(_invoiceFile!.name), + subtitle: const Text('Tap to view image', style: TextStyle(fontSize: 12, color: Colors.blue)), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => AttachmentGalleryScreen( + images: [FileImage(File(_invoiceFile!.path))], + initialIndex: 0, + ), + ), + ); + }, + trailing: IconButton( + icon: const Icon(LucideIcons.x), + onPressed: () => setState(() => _invoiceFile = null), + ), + ) + else if (_invoiceUrl != null && _invoiceUrl!.isNotEmpty) + ListTile( + leading: const Icon(LucideIcons.image, color: Colors.blue), + title: const Text('View Attached Invoice'), + subtitle: const Text('Tap to view full image', style: TextStyle(fontSize: 12, color: Colors.blue)), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => AttachmentGalleryScreen( + images: [NetworkImage(_resolveImageUrl(_invoiceUrl!))], + initialIndex: 0, + ), + ), + ); + }, + trailing: IconButton( + icon: const Icon(LucideIcons.x), + onPressed: () => setState(() => _invoiceUrl = null), + ), + ) + else + OutlinedButton.icon( + onPressed: _pickInvoiceFile, + icon: const Icon(LucideIcons.paperclip), + label: const Text('Attach Sales Bill / Slip'), + style: OutlinedButton.styleFrom( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + padding: const EdgeInsets.symmetric(vertical: 14), + ), + ), + const SizedBox(height: 40), + ], + ), + ), + ), + ); + } + + Widget _buildSalesItemCard(InvoiceItem item, int index, bool isDark) { + final weight = item.weight ?? item.quantity; + final metalAmount = weight * item.unitPrice; + + double makingChargeAmt = 0.0; + if (item.makingChargesType == 'PERCENTAGE') { + makingChargeAmt = metalAmount * (item.makingCharge / 100.0); + } else if (item.makingChargesType == 'PER_PIECE') { + makingChargeAmt = item.makingCharge; + } else { + makingChargeAmt = weight * item.makingCharge; + } + + final calculatedTotal = item.total > 0 ? item.total : (metalAmount + makingChargeAmt + item.otherCharges - item.discount); + + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1E293B) : Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: isDark ? Colors.white12 : Colors.grey.shade200), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: isDark ? 0.2 : 0.04), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (item.photoUrl != null && item.photoUrl!.isNotEmpty) + Container( + width: 48, + height: 48, + margin: const EdgeInsets.only(right: 12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + image: DecorationImage( + image: NetworkImage(_resolveImageUrl(item.photoUrl!)), + fit: BoxFit.cover, + ), + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.productName ?? item.description ?? 'Item #${index + 1}', + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + ), + const SizedBox(height: 4), + Wrap( + spacing: 6, + runSpacing: 4, 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 (item.categoryName != null && item.categoryName!.isNotEmpty) + _buildSmallBadge(item.categoryName!, const Color(0xFFD4AF37)), + if (item.sku != null && item.sku!.isNotEmpty) + _buildSmallBadge('SKU: ${item.sku}', Colors.blue), + if (item.huid != null && item.huid!.isNotEmpty) + _buildSmallBadge('HUID: ${item.huid}', Colors.purple), + if (item.hsnCode != null && item.hsnCode!.isNotEmpty) + _buildSmallBadge('HSN: ${item.hsnCode}', Colors.grey), ], ), - 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), + IconButton( + icon: const Icon(LucideIcons.edit2, size: 18, color: Colors.blue), + tooltip: 'Edit Item', + onPressed: () => _openAddItemSheet(editIndex: index), + ), + IconButton( + icon: const Icon(LucideIcons.trash2, size: 18, color: Colors.red), + tooltip: 'Remove Item', + onPressed: () { + setState(() => _items.removeAt(index)); + }, + ), ], ), - ), + const Divider(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Metal: ${weight.toStringAsFixed(3)}g × ₹${item.unitPrice.toStringAsFixed(2)}', + style: TextStyle(fontSize: 13, color: Colors.grey.shade600), + ), + Text( + '₹${metalAmount.toStringAsFixed(2)}', + style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13), + ), + ], + ), + const SizedBox(height: 4), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Making Charges (${item.makingChargesType == 'PERCENTAGE' ? '${item.makingCharge}%' : (item.makingChargesType == 'PER_PIECE' ? '₹${item.makingCharge}/pc' : '₹${item.makingCharge}/g')}):', + style: TextStyle(fontSize: 13, color: Colors.grey.shade600), + ), + Text( + '₹${makingChargeAmt.toStringAsFixed(2)}', + style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13, color: Colors.indigo), + ), + ], + ), + if (item.taxRate > 0) ...[ + const SizedBox(height: 4), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'GST (${item.taxRate.toStringAsFixed(1)}%):', + style: TextStyle(fontSize: 12, color: Colors.grey.shade500), + ), + Text( + '+ ₹${((metalAmount + makingChargeAmt) * (item.taxRate / 100.0)).toStringAsFixed(2)}', + style: TextStyle(fontSize: 12, color: Colors.grey.shade600), + ), + ], + ), + ], + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Item Total:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)), + Text( + '₹${calculatedTotal.toStringAsFixed(2)}', + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Colors.green), + ), + ], + ), + ], + ), + ); + } + + Widget _buildSmallBadge(String text, Color color) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: color.withValues(alpha: 0.3), width: 0.5), + ), + child: Text( + text, + style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: color), + ), + ); + } + + Widget _buildTotalsSummaryCard(bool isDark) { + final products = ref.watch(productsProvider).value ?? []; + final businessStateId = ref.watch(businessProfileProvider).value?.stateId; + final customers = ref.watch(customersProvider).value ?? []; + final customer = _selectedCustomerId == null + ? null + : customers.where((c) => c.id == _selectedCustomerId).firstOrNull; + + final discountAmount = double.tryParse(_discountCtrl.text) ?? 0.0; + final isSameState = businessStateId != null && customer?.stateId != null && businessStateId == customer!.stateId; + + double metalSubtotal = 0; + double makingChargesTotal = 0; + double rawTaxableTotal = 0; + double taxTotal = 0; + + for (var item in _items) { + final product = products.where((p) => p.id == item.productId).firstOrNull; + final gstRate = item.taxRate > 0 ? item.taxRate : (product?.gstRate ?? 3.0); + final weight = item.weight ?? item.quantity; + final metalAmt = weight * item.unitPrice; + + double makingAmt = 0.0; + if (item.makingChargesType == 'PERCENTAGE') { + makingAmt = metalAmt * (item.makingCharge / 100.0); + } else if (item.makingChargesType == 'PER_PIECE') { + makingAmt = item.makingCharge; + } else { + makingAmt = weight * item.makingCharge; + } + + metalSubtotal += metalAmt; + makingChargesTotal += makingAmt; + rawTaxableTotal += (metalAmt + makingAmt + item.otherCharges); + } + + final effectiveTaxable = (rawTaxableTotal - discountAmount).clamp(0.0, double.infinity); + final avgGstRate = _items.isNotEmpty + ? (_items.map((i) => i.taxRate > 0 ? i.taxRate : 3.0).reduce((a, b) => a + b) / _items.length) + : 3.0; + taxTotal = (effectiveTaxable * avgGstRate) / 100.0; + final grandTotal = effectiveTaxable + taxTotal; + + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1E293B) : Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: isDark ? Colors.white12 : Colors.grey.shade200), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: isDark ? 0.2 : 0.05), + blurRadius: 15, + offset: const Offset(0, 5), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('INVOICE SUMMARY', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold, letterSpacing: 0.8, color: Colors.grey)), + const SizedBox(height: 14), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Metal Subtotal:'), + Text('₹${metalSubtotal.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.w600)), + ], + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Making Charges:'), + Text('₹${makingChargesTotal.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.w600)), + ], + ), + if (discountAmount > 0) ...[ + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Discount:', style: TextStyle(color: Colors.red, fontWeight: FontWeight.w600)), + Text('- ₹${discountAmount.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.red)), + ], + ), + ], + const SizedBox(height: 8), + if (isSameState) ...[ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('CGST (${(taxTotal > 0 ? '1.5%' : '0%')}):', style: TextStyle(color: Colors.grey.shade600)), + Text('₹${(taxTotal / 2.0).toStringAsFixed(2)}'), + ], + ), + const SizedBox(height: 6), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('SGST (${(taxTotal > 0 ? '1.5%' : '0%')}):', style: TextStyle(color: Colors.grey.shade600)), + Text('₹${(taxTotal / 2.0).toStringAsFixed(2)}'), + ], + ), + ] else ...[ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('IGST (${(taxTotal > 0 ? '3.0%' : '0%')}):', style: TextStyle(color: Colors.grey.shade600)), + Text('₹${taxTotal.toStringAsFixed(2)}'), + ], + ), + ], + const Divider(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Grand Total:', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + Text( + '₹${grandTotal.toStringAsFixed(2)}', + style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.green), + ), + ], + ), + ], + ), + ); + } + + Widget _buildPaymentSection(bool isDark) { + final wallets = ref.watch(walletProvider).value ?? []; + final activeWallets = wallets.where((w) => w.nature == 'CASH' || w.nature == 'SAVINGS').toList(); + + // Compute grand total for live balance due + final products = ref.watch(productsProvider).value ?? []; + final discountAmount = double.tryParse(_discountCtrl.text) ?? 0.0; + double rawTaxable = 0; + for (var item in _items) { + final weight = item.weight ?? item.quantity; + final metalAmt = weight * item.unitPrice; + double makingAmt = 0.0; + if (item.makingChargesType == 'PERCENTAGE') { + makingAmt = metalAmt * (item.makingCharge / 100.0); + } else if (item.makingChargesType == 'PER_PIECE') { + makingAmt = item.makingCharge; + } else { + makingAmt = weight * item.makingCharge; + } + rawTaxable += (metalAmt + makingAmt + item.otherCharges); + } + final effectiveTaxable = (rawTaxable - discountAmount).clamp(0.0, double.infinity); + final avgGst = _items.isNotEmpty + ? (_items.map((i) => i.taxRate > 0 ? i.taxRate : 3.0).reduce((a, b) => a + b) / _items.length) + : 3.0; + final grandTotal = effectiveTaxable + (effectiveTaxable * avgGst / 100.0); + + // If user has not manually changed amount paid and grandTotal > 0, auto-track grand total + if (!_isAmountPaidEdited && grandTotal > 0 && _amountPaidCtrl.text != grandTotal.toStringAsFixed(2)) { + _amountPaidCtrl.text = grandTotal.toStringAsFixed(2); + } + + final enteredAmount = double.tryParse(_amountPaidCtrl.text) ?? (_isAmountPaidEdited ? 0.0 : grandTotal); + final amountPaid = enteredAmount > grandTotal ? grandTotal : enteredAmount; + final balanceDue = grandTotal - amountPaid; + + Widget? statusBadge; + if (grandTotal > 0 && (_isAmountPaidEdited || _amountPaidCtrl.text.isNotEmpty)) { + if (amountPaid >= grandTotal) { + statusBadge = Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: Colors.green.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(6), + ), + child: const Text( + 'Fully Paid', + style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: Colors.green), + ), + ); + } else if (amountPaid > 0) { + statusBadge = Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: Colors.orange.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(6), + ), + child: const Text( + 'Partial Payment', + style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: Colors.orange), + ), + ); + } else { + statusBadge = Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(6), + ), + child: const Text( + 'Unpaid', + style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: Colors.red), + ), + ); + } + } + + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1E293B) : Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: isDark ? Colors.white12 : Colors.grey.shade200), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('PAYMENT & SETTLEMENT', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold, letterSpacing: 0.8, color: Colors.grey)), + if (statusBadge != null) + Flexible(child: statusBadge), + ], + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: PremiumTextField( + controller: _amountPaidCtrl, + labelText: 'Amount Received (₹)', + keyboardType: const TextInputType.numberWithOptions(decimal: true), + prefixIcon: const Icon(LucideIcons.indianRupee), + onChanged: (val) { + setState(() => _isAmountPaidEdited = true); + }, + ), + ), + const SizedBox(width: 12), + Expanded( + child: DropdownButtonFormField( + value: _paymentMethod, + decoration: InputDecoration( + labelText: 'Method', + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + ), + items: const [ + DropdownMenuItem(value: 'Cash', child: Text('Cash')), + DropdownMenuItem(value: 'UPI', child: Text('UPI / QR')), + DropdownMenuItem(value: 'Bank Transfer', child: Text('Bank Transfer')), + DropdownMenuItem(value: 'Card', child: Text('Card')), + ], + onChanged: (val) => setState(() => _paymentMethod = val!), + ), + ), + ], + ), + const SizedBox(height: 14), + if (activeWallets.isNotEmpty) + DropdownButtonFormField( + value: _selectedWalletId ?? activeWallets.first.id, + decoration: InputDecoration( + labelText: 'Deposit To Account', + prefixIcon: const Icon(LucideIcons.wallet), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + ), + items: activeWallets.map((w) => DropdownMenuItem( + value: w.id, + child: Text('${w.name} (₹${w.balance.toStringAsFixed(0)})'), + )).toList(), + onChanged: (val) => setState(() => _selectedWalletId = val), + ), + + if (balanceDue > 0) ...[ + const SizedBox(height: 14), + Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: (amountPaid > 0 ? Colors.orange : Colors.red).withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: (amountPaid > 0 ? Colors.orange : Colors.red).withValues(alpha: 0.25)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Icon( + amountPaid > 0 ? LucideIcons.circleDot : LucideIcons.alertCircle, + size: 16, + color: amountPaid > 0 ? Colors.orange : Colors.red, + ), + const SizedBox(width: 8), + Text( + amountPaid > 0 ? 'Remaining Balance Due:' : 'Total Amount Due:', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: isDark ? Colors.white70 : Colors.black87, + ), + ), + ], + ), + Text( + '₹${balanceDue.toStringAsFixed(2)}', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + color: amountPaid > 0 ? Colors.orange.shade700 : Colors.red.shade700, + ), + ), + ], + ), + ), + const SizedBox(height: 14), + InkWell( + onTap: () async { + final picked = await showDatePicker( + context: context, + initialDate: _nextPaymentDate ?? DateTime.now().add(const Duration(days: 30)), + firstDate: DateTime.now(), + lastDate: DateTime(2100), + ); + if (picked != null) { + setState(() => _nextPaymentDate = picked); + } + }, + child: InputDecorator( + decoration: InputDecoration( + labelText: 'Next Due / Balance Settlement Date', + prefixIcon: const Icon(LucideIcons.calendarClock), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + ), + child: Text( + _nextPaymentDate != null ? DateFormat('dd MMM yyyy').format(_nextPaymentDate!) : 'Select Settlement Date (Default: 30 Days)', + style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13), + ), + ), + ), + ], + + const Divider(height: 28), + + // EMI Option Toggle + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Row( + children: [ + Icon(LucideIcons.creditCard, size: 18, color: Colors.purple), + SizedBox(width: 8), + Text('Enable EMI Installments', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)), + ], + ), + Switch( + value: _isEmi, + activeColor: Colors.purple, + onChanged: (val) => setState(() => _isEmi = val), + ), + ], + ), + + if (_isEmi) ...[ + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: PremiumTextField( + controller: _emiAmountCtrl, + labelText: 'EMI Amount (₹)', + keyboardType: const TextInputType.numberWithOptions(decimal: true), + prefixIcon: const Icon(LucideIcons.indianRupee), + ), + ), + const SizedBox(width: 12), + Expanded( + child: DropdownButtonFormField( + value: _emiCycle, + decoration: InputDecoration( + labelText: 'Cycle', + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + ), + items: const [ + DropdownMenuItem(value: 'MONTHLY', child: Text('Monthly')), + DropdownMenuItem(value: 'WEEKLY', child: Text('Weekly')), + ], + onChanged: (val) => setState(() => _emiCycle = val!), + ), + ), + ], + ), + ], + ], + ), + ); + } +} + +class _AddSalesItemSheet extends ConsumerStatefulWidget { + final ValueChanged onItemAdded; + final InvoiceItem? initialItem; + + const _AddSalesItemSheet({ + required this.onItemAdded, + this.initialItem, + }); + + @override + ConsumerState<_AddSalesItemSheet> createState() => _AddSalesItemSheetState(); +} + +class _AddSalesItemSheetState extends ConsumerState<_AddSalesItemSheet> { + final _searchCtrl = TextEditingController(); + Product? _selectedProduct; + InventoryItem? _selectedInventoryItem; + + final _makingChargeCtrl = TextEditingController(text: '0.0'); + String _makingChargeType = 'PER_GRAM'; + final _discountCtrl = TextEditingController(text: '0.0'); + + @override + void initState() { + super.initState(); + Future.microtask(() => ref.invalidate(inventoryItemsProvider)); + if (widget.initialItem != null) { + final it = widget.initialItem!; + _makingChargeCtrl.text = it.makingCharge.toStringAsFixed(2); + _makingChargeType = it.makingChargesType ?? 'PER_GRAM'; + _discountCtrl.text = it.discount.toStringAsFixed(2); + + WidgetsBinding.instance.addPostFrameCallback((_) { + final products = ref.read(productsProvider).value ?? []; + final invItems = ref.read(inventoryItemsProvider).value ?? []; + setState(() { + _selectedProduct = products.where((p) => p.id == it.productId).firstOrNull ?? + Product( + id: it.productId, + name: it.productName ?? 'Item', + sku: it.sku, + hsnCode: it.hsnCode, + gstRate: it.taxRate, + makingCharges: it.makingCharge, + makingChargesType: it.makingChargesType, + ); + if (it.inventoryItemId != null) { + _selectedInventoryItem = invItems.where((i) => i.id == it.inventoryItemId).firstOrNull; + } + }); + }); + } + } + + @override + void dispose() { + _searchCtrl.dispose(); + _makingChargeCtrl.dispose(); + _discountCtrl.dispose(); + super.dispose(); + } + + void _onProductSelected(Product product, ProductCategory? category, double liveCommodityRate) { + setState(() { + _selectedProduct = product; + _selectedInventoryItem = null; + + if (product.makingCharges != null && product.makingCharges! > 0) { + _makingChargeCtrl.text = product.makingCharges!.toStringAsFixed(2); + _makingChargeType = product.makingChargesType ?? (category?.makingChargeType ?? 'PER_GRAM'); + } else if (category?.defaultMakingCharge != null && category!.defaultMakingCharge! > 0) { + _makingChargeCtrl.text = category.defaultMakingCharge!.toStringAsFixed(2); + _makingChargeType = category.makingChargeType ?? 'PER_GRAM'; + } else { + _makingChargeCtrl.text = '0.0'; + _makingChargeType = category?.makingChargeType ?? 'PER_GRAM'; + } + }); + } + + void _onInventoryItemSelected(InventoryItem item, Product product, ProductCategory? category) { + setState(() { + _selectedProduct = product; + _selectedInventoryItem = item; + + if (item.makingCharges != null && item.makingCharges! > 0) { + _makingChargeCtrl.text = item.makingCharges!.toStringAsFixed(2); + _makingChargeType = item.makingChargeType ?? 'PER_GRAM'; + } else if (product.makingCharges != null && product.makingCharges! > 0) { + _makingChargeCtrl.text = product.makingCharges!.toStringAsFixed(2); + _makingChargeType = product.makingChargesType ?? (category?.makingChargeType ?? 'PER_GRAM'); + } else if (category?.defaultMakingCharge != null && category!.defaultMakingCharge! > 0) { + _makingChargeCtrl.text = category.defaultMakingCharge!.toStringAsFixed(2); + _makingChargeType = category.makingChargeType ?? 'PER_GRAM'; + } else { + _makingChargeCtrl.text = '0.0'; + _makingChargeType = category?.makingChargeType ?? 'PER_GRAM'; + } + }); + } + + Future _scanBarcodeInSheet() async { + final scanned = await Navigator.push( + context, + MaterialPageRoute(builder: (_) => const BarcodeScannerScreen()), + ); + if (scanned != null && scanned.isNotEmpty) { + _searchCtrl.text = scanned; + setState(() {}); + } + } + + void _submitItem(ProductCategory? category, double unitRate) { + if (_selectedProduct == null) return; + + final weight = _selectedInventoryItem?.grossWeight ?? 1.0; + final makingCharge = double.tryParse(_makingChargeCtrl.text) ?? 0.0; + final discount = double.tryParse(_discountCtrl.text) ?? 0.0; + + final metalAmount = weight * unitRate; + double makingAmt = 0.0; + if (_makingChargeType == 'PERCENTAGE') { + makingAmt = metalAmount * (makingCharge / 100.0); + } else if (_makingChargeType == 'PER_PIECE') { + makingAmt = makingCharge; + } else { + makingAmt = weight * makingCharge; + } + + final taxable = metalAmount + makingAmt - discount; + final gstRate = _selectedProduct?.gstRate ?? (category?.defaultGst ?? 3.0); + final taxAmount = (taxable * gstRate) / 100.0; + final total = taxable + taxAmount; + + final item = InvoiceItem( + productId: _selectedProduct!.id, + inventoryItemId: _selectedInventoryItem?.id, + productName: _selectedProduct!.name, + categoryName: category?.name, + commodityCode: category?.commodityCode, + hsnCode: category?.defaultHsn ?? _selectedProduct?.hsnCode, + sku: _selectedInventoryItem?.sku ?? _selectedProduct?.sku, + huid: _selectedInventoryItem?.huid, + description: _selectedProduct!.name, + quantity: 1.0, + weight: weight, + unitPrice: unitRate, + makingCharge: makingCharge, + makingChargesType: _makingChargeType, + taxRate: gstRate, + discount: discount, + photoUrl: _selectedInventoryItem?.tagNumber, + total: total, + ); + + widget.onItemAdded(item); + Navigator.pop(context); + } + + @override + Widget build(BuildContext context) { + final products = ref.watch(productsProvider).value ?? []; + final categories = ref.watch(productCategoriesProvider).value ?? []; + final commodityRates = ref.watch(commodityRatesProvider).value ?? []; + final inventoryItems = ref.watch(inventoryItemsProvider).value ?? []; + + final isDark = Theme.of(context).brightness == Brightness.dark; + + ProductCategory? category; + if (_selectedProduct?.categoryId != null) { + category = categories.where((c) => c.id == _selectedProduct!.categoryId).firstOrNull; + } + + double commodityRate = 0.0; + if (category?.commodityCode != null) { + final match = commodityRates.where((r) => r.commodityCode.toUpperCase() == category!.commodityCode!.toUpperCase()).firstOrNull; + if (match != null) { + commodityRate = match.rate; + } + } + final purityFactor = resolvePurity(categoryPurity: category?.purityFactor, productPurity: _selectedProduct?.purityFactor); + final effectiveUnitRate = commodityRate > 0 ? (commodityRate * purityFactor) : (_selectedProduct?.sellingPrice ?? 0.0); + + final weight = _selectedInventoryItem?.grossWeight ?? 1.0; + final metalAmount = weight * effectiveUnitRate; + final makingCharge = double.tryParse(_makingChargeCtrl.text) ?? 0.0; + + double makingAmt = 0.0; + if (_makingChargeType == 'PERCENTAGE') { + makingAmt = metalAmount * (makingCharge / 100.0); + } else if (_makingChargeType == 'PER_PIECE') { + makingAmt = makingCharge; + } else { + makingAmt = weight * makingCharge; + } + + final discount = double.tryParse(_discountCtrl.text) ?? 0.0; + final taxable = metalAmount + makingAmt - discount; + final gstRate = _selectedProduct?.gstRate ?? (category?.defaultGst ?? 3.0); + final taxAmount = (taxable * gstRate) / 100.0; + final total = taxable + taxAmount; + + final query = _searchCtrl.text.trim().toLowerCase(); + final availableItems = inventoryItems + .where((item) => item.status == null || item.status == 'AVAILABLE') + .toList(); + + availableItems.sort((a, b) { + if (a.createdAt == null && b.createdAt == null) return 0; + if (a.createdAt == null) return 1; + if (b.createdAt == null) return -1; + return b.createdAt!.compareTo(a.createdAt!); + }); + + final List displayInventoryItems; + if (query.isEmpty) { + displayInventoryItems = availableItems.take(20).toList(); + } else { + displayInventoryItems = availableItems.where((item) { + final p = products.where((pr) => pr.id == item.productId).firstOrNull; + final cat = p != null ? categories.where((c) => c.id == p.categoryId).firstOrNull : null; + + final matchesHuid = item.huid != null && item.huid!.toLowerCase().contains(query); + final matchesSku = (item.sku != null && item.sku!.toLowerCase().contains(query)) || + (p?.sku != null && p!.sku!.toLowerCase().contains(query)); + final matchesName = p != null && p.name.toLowerCase().contains(query); + final matchesBarcode = p != null && p.barcode != null && p.barcode!.toLowerCase().contains(query); + final matchesCategory = cat != null && cat.name.toLowerCase().contains(query); + final matchesTag = item.tagNumber != null && item.tagNumber!.toLowerCase().contains(query); + + return matchesHuid || matchesSku || matchesName || matchesBarcode || matchesCategory || matchesTag; + }).take(20).toList(); + } + + final keyboardHeight = MediaQuery.of(context).viewInsets.bottom; + + return AnimatedPadding( + padding: EdgeInsets.only(bottom: keyboardHeight), + duration: const Duration(milliseconds: 150), + curve: Curves.easeOut, + child: GestureDetector( + onTap: () => FocusScope.of(context).unfocus(), + behavior: HitTestBehavior.translucent, + child: Container( + height: MediaQuery.of(context).size.height * 0.88, + decoration: BoxDecoration( + color: Theme.of(context).scaffoldBackgroundColor, + borderRadius: const BorderRadius.vertical(top: Radius.circular(28)), + ), + child: Column( + children: [ + Center( + child: Container( + margin: const EdgeInsets.only(top: 12, bottom: 8), + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.grey.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Add Item to Invoice', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (keyboardHeight > 0) + TextButton.icon( + style: TextButton.styleFrom( + backgroundColor: Colors.blue.withValues(alpha: 0.12), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + icon: const Icon(LucideIcons.keyboard, size: 16, color: Colors.blue), + label: const Text('Done', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.blue)), + onPressed: () => FocusScope.of(context).unfocus(), + ), + IconButton( + icon: const Icon(LucideIcons.x, size: 20), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ], + ), + ), + const Divider(height: 1), + + // Keyboard Toolbar when active on iOS/Android + if (keyboardHeight > 0) + Container( + color: isDark ? const Color(0xFF1E293B) : Colors.grey.shade100, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Editing ${_selectedProduct?.name ?? 'Item'}', + style: TextStyle(fontSize: 12, color: Colors.grey.shade600, fontWeight: FontWeight.w500), + ), + TextButton.icon( + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + backgroundColor: Colors.blue, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + icon: const Icon(LucideIcons.check, size: 15), + label: const Text('Done / Dismiss', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), + onPressed: () => FocusScope.of(context).unfocus(), + ), + ], + ), + ), + + Expanded( + child: ListView( + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + padding: const EdgeInsets.fromLTRB(20, 16, 20, 40), + children: [ + // Search Input with Barcode Action & Rounded Borders + TextField( + controller: _searchCtrl, + decoration: InputDecoration( + labelText: 'Search Product by HUID / Name / SKU / Barcode', + prefixIcon: const Icon(LucideIcons.search), + suffixIcon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (_searchCtrl.text.isNotEmpty) + IconButton( + icon: const Icon(LucideIcons.x), + onPressed: () => setState(() => _searchCtrl.clear()), + ), + IconButton( + icon: const Icon(LucideIcons.qrCode, color: Colors.blue), + tooltip: 'Scan Barcode', + onPressed: _scanBarcodeInSheet, + ), + ], + ), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + ), + onChanged: (val) => setState(() {}), + ), + const SizedBox(height: 12), + + if (_selectedProduct == null) ...[ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + query.isEmpty + ? 'Available Stock in Inventory (${displayInventoryItems.length}):' + : 'Matched Inventory Items (${displayInventoryItems.length}):', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: isDark ? Colors.grey.shade400 : Colors.grey.shade700, + ), + ), + if (query.isEmpty && availableItems.length > 20) + Text( + 'Showing 20 newest', + style: TextStyle(fontSize: 11, color: Colors.grey.shade500, fontStyle: FontStyle.italic), + ), + ], + ), + const SizedBox(height: 10), + if (displayInventoryItems.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 40.0), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(LucideIcons.packageOpen, size: 40, color: Colors.grey.shade400), + const SizedBox(height: 10), + Text( + query.isEmpty + ? 'No items available in stock' + : 'No matching inventory items found', + style: TextStyle(color: Colors.grey.shade500, fontSize: 14), + ), + ], + ), + ), + ) + else + ...displayInventoryItems.map((item) { + final p = products.where((pr) => pr.id == item.productId).firstOrNull ?? + Product( + id: item.productId, + name: item.sku ?? 'Inventory Item', + sku: item.sku, + ); + final cat = categories.where((c) => c.id == p.categoryId).firstOrNull; + double commRate = 0.0; + if (cat?.commodityCode != null) { + final m = commodityRates.where((r) => r.commodityCode.toUpperCase() == cat!.commodityCode!.toUpperCase()).firstOrNull; + if (m != null) commRate = m.rate; + } + return _buildSearchItemCard( + product: p, + category: cat, + inventoryItem: item, + commodityRate: commRate, + isDark: isDark, + onTap: () => _onInventoryItemSelected(item, p, cat), + ); + }), + ] else ...[ + // SELECTED PRODUCT DETAILS CARD + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1E293B) : Colors.blue.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.blue.withValues(alpha: 0.3)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + _selectedProduct!.name, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + ), + TextButton( + onPressed: () => setState(() { + _selectedProduct = null; + _selectedInventoryItem = null; + }), + child: const Text('Change'), + ), + ], + ), + const SizedBox(height: 6), + Wrap( + spacing: 8, + runSpacing: 4, + children: [ + if (category?.name != null) + _buildSheetBadge(category!.name, const Color(0xFFD4AF37)), + if (_selectedProduct!.sku != null) + _buildSheetBadge('SKU: ${_selectedProduct!.sku}', Colors.blue), + if (_selectedInventoryItem?.huid != null) + _buildSheetBadge('HUID: ${_selectedInventoryItem!.huid}', Colors.purple), + if (category?.commodityCode != null) + _buildSheetBadge('${category!.commodityCode} (${formatPurity(purityFactor)})', Colors.teal), + _buildSheetBadge('Purity: ${formatPurity(purityFactor)}', Colors.amber.shade800), + ], + ), + ], + ), + ), + + const SizedBox(height: 16), + + // DISABLED FIELDS: Rounded 12px borders + Row( + children: [ + Expanded( + child: TextFormField( + initialValue: category?.defaultHsn ?? _selectedProduct?.hsnCode ?? '7113', + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600), + enabled: false, + decoration: InputDecoration( + labelText: 'HSN Code', + prefixIcon: const Icon(LucideIcons.hash, size: 18), + contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 12), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: TextFormField( + initialValue: '${weight.toStringAsFixed(3)} g', + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600), + enabled: false, + decoration: InputDecoration( + labelText: 'Weight', + prefixIcon: const Icon(LucideIcons.scale, size: 18), + contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 12), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + ), + ], + ), + const SizedBox(height: 12), + + Row( + children: [ + Expanded( + child: TextFormField( + key: ValueKey(effectiveUnitRate), + initialValue: '₹${effectiveUnitRate.toStringAsFixed(2)}/g', + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600), + enabled: false, + decoration: InputDecoration( + labelText: 'Live Rate', + prefixIcon: const Icon(LucideIcons.trendingUp, size: 18), + contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 12), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: TextFormField( + key: ValueKey(metalAmount), + initialValue: '₹${metalAmount.toStringAsFixed(2)}', + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600), + enabled: false, + decoration: InputDecoration( + labelText: 'Metal Amount', + prefixIcon: const Icon(LucideIcons.coins, size: 18), + contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 12), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + ), + ], + ), + + const SizedBox(height: 16), + + // ENABLED FIELDS: Making Charges & Type + Row( + children: [ + Expanded( + child: PremiumTextField( + controller: _makingChargeCtrl, + labelText: 'Making Charges', + keyboardType: const TextInputType.numberWithOptions(decimal: true), + prefixIcon: const Icon(LucideIcons.hammer), + onChanged: (_) => setState(() {}), + ), + ), + const SizedBox(width: 12), + Expanded( + child: DropdownButtonFormField( + value: _makingChargeType, + decoration: InputDecoration( + labelText: 'Type', + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + ), + items: const [ + DropdownMenuItem(value: 'PER_GRAM', child: Text('Per Gram')), + DropdownMenuItem(value: 'PER_PIECE', child: Text('Per Piece')), + DropdownMenuItem(value: 'PERCENTAGE', child: Text('Percentage %')), + ], + onChanged: (val) => setState(() => _makingChargeType = val!), + ), + ), + ], + ), + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Colors.indigo.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.indigo.withValues(alpha: 0.2)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Calculated Making Charge:', + style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500), + ), + Text( + '₹${makingAmt.toStringAsFixed(2)}', + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14, color: Colors.indigo), + ), + ], + ), + ), + + const SizedBox(height: 16), + + // Calculated Subtotal & GST Display + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: isDark ? Colors.black26 : Colors.grey.shade100, + borderRadius: BorderRadius.circular(14), + ), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Taxable Subtotal:'), + Text('₹${taxable.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.bold)), + ], + ), + const SizedBox(height: 6), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('GST (${gstRate.toStringAsFixed(1)}%):'), + Text('+ ₹${taxAmount.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.indigo)), + ], + ), + const Divider(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Item Total:', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + Text('₹${total.toStringAsFixed(2)}', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.green)), + ], + ), + ], + ), + ), + + const SizedBox(height: 24), + + ElevatedButton( + onPressed: () => _submitItem(category, effectiveUnitRate), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + backgroundColor: Colors.blue, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + child: const Text('Add to Invoice', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + ), + ], + ], + ), + ), + ], + ), + ), + ), +); +} + + Widget _buildSheetBadge(String text, Color color) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: color.withValues(alpha: 0.4)), + ), + child: Text( + text, + style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: color), + ), + ); + } + + Widget _buildSearchItemCard({ + required Product product, + ProductCategory? category, + InventoryItem? inventoryItem, + required double commodityRate, + required bool isDark, + required VoidCallback onTap, + }) { + final purity = resolvePurity(categoryPurity: category?.purityFactor, productPurity: product.purityFactor); + final unitRate = commodityRate > 0 ? (commodityRate * purity) : (product.sellingPrice ?? 0.0); + final weight = inventoryItem?.grossWeight ?? 1.0; + final metalAmount = weight * unitRate; + + final makingCharge = (inventoryItem?.makingCharges != null && inventoryItem!.makingCharges! > 0) + ? inventoryItem.makingCharges! + : ((product.makingCharges != null && product.makingCharges! > 0) + ? product.makingCharges! + : (category?.defaultMakingCharge ?? 0.0)); + final makingType = (inventoryItem?.makingChargeType != null && inventoryItem!.makingChargeType!.isNotEmpty) + ? inventoryItem.makingChargeType! + : ((product.makingCharges != null && product.makingCharges! > 0 && product.makingChargesType != null) + ? product.makingChargesType! + : (category?.makingChargeType ?? 'PER_GRAM')); + + double makingAmt = 0.0; + if (makingType == 'PERCENTAGE') { + makingAmt = metalAmount * (makingCharge / 100.0); + } else if (makingType == 'PER_PIECE') { + makingAmt = makingCharge; + } else { + makingAmt = weight * makingCharge; + } + + final gstRate = product.gstRate ?? (category?.defaultGst ?? 3.0); + final taxable = metalAmount + makingAmt; + final taxAmount = (taxable * gstRate) / 100.0; + final totalWithTax = taxable + taxAmount; + + final huid = inventoryItem?.huid; + final sku = inventoryItem?.sku ?? product.sku; + + return Container( + margin: const EdgeInsets.only(bottom: 12), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1E293B) : Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: isDark ? Colors.white12 : Colors.grey.shade200), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: isDark ? 0.2 : 0.04), + blurRadius: 8, + offset: const Offset(0, 3), + ), + ], + ), + child: Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(16), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(16), + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: (huid != null ? Colors.purple : Colors.blue).withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + huid != null ? LucideIcons.gem : LucideIcons.tag, + size: 20, + color: huid != null ? Colors.purple : Colors.blue, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + product.name, + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15), + ), + const SizedBox(height: 4), + Wrap( + spacing: 6, + runSpacing: 4, + children: [ + if (category?.name != null) + _buildSheetBadge(category!.name, const Color(0xFFD4AF37)), + if (sku != null && sku.isNotEmpty) + _buildSheetBadge('SKU: $sku', Colors.blue), + if (huid != null && huid.isNotEmpty) + _buildSheetBadge('HUID: $huid', Colors.purple), + _buildSheetBadge('Purity: ${formatPurity(purity)}', Colors.amber.shade900), + ], + ), + ], + ), + ), + ], + ), + const Divider(height: 18), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Rate: ₹${unitRate.toStringAsFixed(2)}/g • ${weight.toStringAsFixed(3)}g', + style: TextStyle(fontSize: 13, color: Colors.grey.shade600, fontWeight: FontWeight.w500), + ), + Text( + 'Metal: ₹${metalAmount.toStringAsFixed(2)}', + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600), + ), + ], + ), + const SizedBox(height: 6), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: Colors.indigo.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: Colors.indigo.withValues(alpha: 0.2)), + ), + child: Text( + 'Making: ${makingType == 'PERCENTAGE' ? '$makingCharge%' : (makingType == 'PER_PIECE' ? '₹$makingCharge/pc' : '₹$makingCharge/g')} (₹${makingAmt.toStringAsFixed(2)})', + style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: Colors.indigo), + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '₹${totalWithTax.toStringAsFixed(2)}', + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Colors.green), + ), + Text( + 'incl. ${gstRate.toStringAsFixed(1)}% GST', + style: TextStyle(fontSize: 10, color: Colors.grey.shade500), + ), + ], + ), + ], + ), + ], + ), + ), ), ), ); diff --git a/kifi-app/lib/features/sales/presentation/invoice_details_screen.dart b/kifi-app/lib/features/sales/presentation/invoice_details_screen.dart index ff25894..4e052fc 100644 --- a/kifi-app/lib/features/sales/presentation/invoice_details_screen.dart +++ b/kifi-app/lib/features/sales/presentation/invoice_details_screen.dart @@ -10,11 +10,27 @@ 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 '../../inventory/providers/product_categories_provider.dart'; import '../domain/invoice.dart'; import '../providers/invoices_provider.dart'; import '../providers/customers_provider.dart'; import '../../business/providers/business_provider.dart'; +import '../../transactions/presentation/widgets/attachment_gallery_screen.dart'; +import '../../../core/network/dio_client.dart'; import 'widgets/receive_payment_sheet.dart'; +import 'invoice_builder_screen.dart'; + +String _resolveImageUrl(String path) { + if (path.startsWith('http://') || path.startsWith('https://')) { + return path; + } + final base = DioClient().dio.options.baseUrl; + final cleanPath = path.startsWith('/') ? path.substring(1) : path; + if (cleanPath.startsWith('api/kifi-v2/upload/view') || cleanPath.startsWith('upload/view')) { + return '$base/${cleanPath.replaceFirst('api/kifi-v2/', '')}'; + } + return '$base/upload/view?path=${Uri.encodeComponent(cleanPath)}'; +} class InvoiceDetailsScreen extends ConsumerStatefulWidget { final Invoice invoice; @@ -22,18 +38,13 @@ class InvoiceDetailsScreen extends ConsumerStatefulWidget { const InvoiceDetailsScreen({super.key, required this.invoice}); @override - ConsumerState createState() => - _InvoiceDetailsScreenState(); + ConsumerState createState() => _InvoiceDetailsScreenState(); } class _InvoiceDetailsScreenState extends ConsumerState { final ScreenshotController _screenshotController = ScreenshotController(); - void _showReceivePaymentSheet( - BuildContext context, - WidgetRef ref, - Invoice latestInvoice, - ) async { + void _showReceivePaymentSheet(BuildContext context, WidgetRef ref, Invoice latestInvoice) async { final result = await showModalBottomSheet( context: context, isScrollControlled: true, @@ -45,49 +56,56 @@ class _InvoiceDetailsScreenState extends ConsumerState { } } + void _editInvoice(Invoice invoice) { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => InvoiceBuilderScreen(existingInvoice: invoice), + ), + ); + } + @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 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 customer = customersState.value?.where((c) => c.id == latestInvoice.customerId).firstOrNull; final businessState = ref.watch(businessProfileProvider); final business = businessState.value; - // Watch products so the UI rebuilds if products are loaded asynchronously - ref.watch(productsProvider); + final products = ref.watch(productsProvider).value ?? []; + final categories = ref.watch(productCategoriesProvider).value ?? []; + final isDark = Theme.of(context).brightness == Brightness.dark; final formatCurrency = NumberFormat.currency(symbol: '₹'); final formatDate = DateFormat('dd MMM yyyy'); double remaining = latestInvoice.totalAmount - latestInvoice.amountPaid; return Scaffold( - backgroundColor: Colors.grey[50], + backgroundColor: isDark ? const Color(0xFF0F172A) : 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.edit3, size: 20), + tooltip: 'Edit Invoice', + onPressed: () => _editInvoice(latestInvoice), + ), IconButton( icon: const Icon(LucideIcons.share2, size: 20), - onPressed: () => - _showShareOptions(context, latestInvoice.invoiceNumber), + tooltip: 'Share Invoice', + onPressed: () => _showShareOptions(context, latestInvoice.invoiceNumber), ), ], ), @@ -95,814 +113,387 @@ class _InvoiceDetailsScreenState extends ConsumerState { child: Screenshot( controller: _screenshotController, child: Container( - color: Colors.grey[50], - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), + color: isDark ? const Color(0xFF0F172A) : Colors.grey[50], + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // Biller Header Card (Premium Dark) + // Top Header Card: Business info Container( - padding: const EdgeInsets.all(24), + padding: const EdgeInsets.all(16), decoration: BoxDecoration( - gradient: LinearGradient( - colors: [Colors.blue.shade900, Colors.blue.shade800], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), + color: isDark ? const Color(0xFF1E293B) : Colors.white, borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: Colors.blue.withOpacity(0.2), - blurRadius: 15, - offset: const Offset(0, 5), - ), - ], + border: Border.all(color: isDark ? Colors.white12 : Colors.grey.shade200), ), - 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, + mainAxisAlignment: MainAxisAlignment.spaceBetween, 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, - ), - ), - ], - ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + business?.businessName ?? 'KIFI JEWELLERS', + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + if (business?.address != null) + Text(business!.address!, style: TextStyle(fontSize: 12, color: Colors.grey.shade600)), + if (business?.gstin != null) + Text('GSTIN: ${business!.gstin}', style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Colors.blue)), + ], ), ), - 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, - ), - ), - ], - ], + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: _getStatusColor(latestInvoice.status).withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + latestInvoice.status, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: _getStatusColor(latestInvoice.status), ), ), ), ], ), ), - - 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; + // Invoice metadata & Customer details + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Invoice metadata + Expanded( + flex: 2, + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1E293B) : Colors.white, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: isDark ? Colors.white12 : Colors.grey.shade200), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('INVOICE NO', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.grey)), + const SizedBox(height: 2), + Text(latestInvoice.invoiceNumber, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold, color: Colors.blue)), + const SizedBox(height: 10), + const Text('INVOICE DATE', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.grey)), + const SizedBox(height: 2), + Text(formatDate.format(latestInvoice.issueDate), style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)), + ], + ), + ), + ), + const SizedBox(width: 12), + // Customer Details + Expanded( + flex: 3, + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1E293B) : Colors.white, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: isDark ? Colors.white12 : Colors.grey.shade200), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('BILL TO', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.grey)), + const SizedBox(height: 4), + if (customer != null) ...[ + Text(customer.name, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)), + if (customer.phone != null) + Text('Ph: ${customer.phone}', style: TextStyle(fontSize: 12, color: Colors.grey.shade600)), + if (customer.gstin != null) + Text('GSTIN: ${customer.gstin}', style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: Colors.blue)), + ] else ...[ + const Text('Walk-in Customer', style: TextStyle(fontSize: 13, fontStyle: FontStyle.italic, color: Colors.grey)), + ], + ], + ), + ), + ), + ], + ), + const SizedBox(height: 20), + + // Itemized Details Header + const Text('Itemized Particulars', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + const SizedBox(height: 10), + + // Item Cards + ...(latestInvoice.items).map((item) { + final product = products.where((p) => p.id == item.productId).firstOrNull; + final category = categories.where((c) => c.id == product?.categoryId).firstOrNull; + final weight = item.weight ?? item.quantity; + final metalAmount = weight * item.unitPrice; + + double makingChargeAmt = 0.0; + if (item.makingChargesType == 'PERCENTAGE') { + makingChargeAmt = metalAmount * (item.makingCharge / 100.0); + } else if (item.makingChargesType == 'PER_PIECE') { + makingChargeAmt = item.makingCharge; + } else { + makingChargeAmt = weight * item.makingCharge; + } return Container( margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: Colors.white, + color: isDark ? const Color(0xFF1E293B) : 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), - ), - ], + border: Border.all(color: isDark ? Colors.white12 : Colors.grey.shade200), ), - 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, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (item.photoUrl != null && item.photoUrl!.isNotEmpty) + Container( + width: 44, + height: 44, + margin: const EdgeInsets.only(right: 12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + image: DecorationImage( + image: NetworkImage(_resolveImageUrl(item.photoUrl!)), + fit: BoxFit.cover, ), ), ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.productName ?? product?.name ?? item.description ?? 'Jewellery Item', + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15), + ), + const SizedBox(height: 4), + Wrap( + spacing: 6, + runSpacing: 4, + children: [ + if (item.categoryName != null || category?.name != null) + _buildDetailBadge(item.categoryName ?? category!.name, const Color(0xFFD4AF37)), + if (item.sku != null || product?.sku != null) + _buildDetailBadge('SKU: ${item.sku ?? product!.sku}', Colors.blue), + if (item.huid != null) + _buildDetailBadge('HUID: ${item.huid}', Colors.purple), + if (item.hsnCode != null) + _buildDetailBadge('HSN: ${item.hsnCode}', Colors.grey), + ], + ), + ], + ), + ), + Text( + formatCurrency.format(item.total), + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.green), + ), + ], + ), + const Divider(height: 18), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Metal (${weight.toStringAsFixed(3)}g @ ₹${item.unitPrice.toStringAsFixed(2)}/g):', + style: TextStyle(fontSize: 12, color: Colors.grey.shade600), + ), + Text(formatCurrency.format(metalAmount), style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 12)), + ], + ), + const SizedBox(height: 4), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Making Charges (${item.makingChargesType == 'PERCENTAGE' ? '${item.makingCharge}%' : (item.makingChargesType == 'PER_PIECE' ? '₹${item.makingCharge}/pc' : '₹${item.makingCharge}/g')}):', + style: TextStyle(fontSize: 12, color: Colors.grey.shade600), + ), + Text(formatCurrency.format(makingChargeAmt), style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 12, color: Colors.indigo)), + ], + ), + if (item.taxRate > 0) ...[ + const SizedBox(height: 4), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('GST (${item.taxRate.toStringAsFixed(1)}%):', style: TextStyle(fontSize: 11, color: Colors.grey.shade500)), Text( - formatCurrency.format(item.total), - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Colors.black, - ), + '+ ${formatCurrency.format(((metalAmount + makingChargeAmt) * (item.taxRate / 100.0)))}', + style: TextStyle(fontSize: 11, color: Colors.grey.shade600), ), ], ), - 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, - ), - ), - ), - if (item.hsnCode != null && item.hsnCode!.isNotEmpty) - Padding( - padding: const EdgeInsets.only(top: 4), - child: Text( - 'HSN: ${item.hsnCode}', - style: TextStyle( - fontSize: 12, - color: Colors.grey.shade600, - fontWeight: FontWeight.w600, - ), - ), - ) - else if (product?.hsnCode != null && - product!.hsnCode!.isNotEmpty) - Padding( - padding: const EdgeInsets.only(top: 4), - child: Text( - 'HSN: ${product.hsnCode}', - 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 + // Grand Totals Summary Container( - padding: const EdgeInsets.all(20), + padding: const EdgeInsets.all(18), decoration: BoxDecoration( - color: Colors.white, + color: isDark ? const Color(0xFF1E293B) : 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), - ), - ], + border: Border.all(color: isDark ? Colors.white12 : Colors.grey.shade200), ), child: Column( children: [ - _buildSummaryRow( - 'Subtotal', - formatCurrency.format(latestInvoice.subtotal), - ), + _buildSummaryRow('Subtotal', formatCurrency.format(latestInvoice.subtotal)), + if (latestInvoice.cgstTotal > 0 && latestInvoice.sgstTotal > 0) ...[ + _buildSummaryRow('CGST', formatCurrency.format(latestInvoice.cgstTotal)), + _buildSummaryRow('SGST', formatCurrency.format(latestInvoice.sgstTotal)), + ] else if (latestInvoice.igstTotal > 0) ...[ + _buildSummaryRow('IGST', formatCurrency.format(latestInvoice.igstTotal)), + ] else if (latestInvoice.taxTotal > 0) ...[ + _buildSummaryRow('Tax', formatCurrency.format(latestInvoice.taxTotal)), + ], 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), - + _buildSummaryRow('Discount', '-${formatCurrency.format(latestInvoice.discountTotal)}', color: Colors.green), + const Divider(height: 20), + _buildSummaryRow('Grand Total', formatCurrency.format(latestInvoice.totalAmount), isBold: true, fontSize: 18), + const SizedBox(height: 10), Container( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), decoration: BoxDecoration( - color: Colors.grey.shade50, - borderRadius: BorderRadius.circular(12), + color: isDark ? Colors.black26 : Colors.grey.shade100, + borderRadius: BorderRadius.circular(10), ), 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, - ), + _buildSummaryRow('Amount Received', formatCurrency.format(latestInvoice.amountPaid), color: Colors.green, isBold: true), + const SizedBox(height: 6), + _buildSummaryRow('Balance Due', formatCurrency.format(remaining), color: remaining > 0 ? Colors.red : Colors.green, isBold: true, fontSize: 15), ], ), ), ], ), ), + const SizedBox(height: 20), - const SizedBox(height: 24), + // Attached Invoice Bill if available + if (latestInvoice.invoiceUrl != null && latestInvoice.invoiceUrl!.isNotEmpty) ...[ + const Text('Attached Document', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + ListTile( + tileColor: isDark ? const Color(0xFF1E293B) : Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + leading: const Icon(LucideIcons.image, color: Colors.blue), + title: const Text('View Attached Sales Slip'), + subtitle: const Text('Tap to open photo gallery', style: TextStyle(fontSize: 12, color: Colors.blue)), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => AttachmentGalleryScreen( + images: [NetworkImage(_resolveImageUrl(latestInvoice.invoiceUrl!))], + initialIndex: 0, + ), + ), + ); + }, + ), + const SizedBox(height: 20), + ], - // 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), + // Bottom Action: Receive Payment if balance due + if (remaining > 0) + ElevatedButton.icon( + onPressed: () => _showReceivePaymentSheet(context, ref, latestInvoice), + icon: const Icon(LucideIcons.plusCircle, size: 18), + label: const Text('Record Payment', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + backgroundColor: Colors.green, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + const SizedBox(height: 30), ], ), ), ), ), - 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, - }) { + Widget _buildDetailBadge(String text, Color color) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: color.withValues(alpha: 0.3), width: 0.5), + ), + child: Text( + text, + style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: color), + ), + ); + } + + Widget _buildSummaryRow(String label, String value, {bool isBold = false, double fontSize = 14, Color? color}) { return Padding( - padding: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.symmetric(vertical: 3), 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, - ), - ), + Text(label, style: TextStyle(fontSize: fontSize, fontWeight: isBold ? FontWeight.bold : FontWeight.normal)), + Text(value, style: TextStyle(fontSize: fontSize, fontWeight: isBold ? FontWeight.bold : FontWeight.w600, color: color)), ], ), ); } + Color _getStatusColor(String status) { + switch (status) { + case 'PAID': + return Colors.green; + case 'PARTIAL': + return Colors.blue; + case 'FINALIZED': + return Colors.orange; + case 'DRAFT': + default: + return Colors.grey; + } + } + void _showShareOptions(BuildContext context, String invoiceNumber) { showModalBottomSheet( context: context, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))), builder: (ctx) => SafeArea( - child: Wrap( + child: Column( + mainAxisSize: MainAxisSize.min, children: [ - Padding( - padding: const EdgeInsets.all(16.0), - child: Text( - 'Share Invoice $invoiceNumber', - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), + const Padding( + padding: EdgeInsets.all(16), + child: Text('Share Invoice', style: 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'), + subtitle: const Text('High-resolution receipt preview'), onTap: () { Navigator.pop(ctx); _shareAsImage(invoiceNumber); @@ -910,8 +501,8 @@ class _InvoiceDetailsScreenState extends ConsumerState { ), ListTile( leading: const Icon(LucideIcons.fileText, color: Colors.red), - title: const Text('Share as PDF'), - subtitle: const Text('Professional document format'), + title: const Text('Share as PDF (Tax Invoice)'), + subtitle: const Text('Official jewellery tax invoice format'), onTap: () { Navigator.pop(ctx); _shareAsPdf(invoiceNumber); @@ -926,70 +517,43 @@ class _InvoiceDetailsScreenState extends ConsumerState { Future _shareAsImage(String invoiceNumber) async { try { - final Uint8List? image = await _screenshotController.capture( - pixelRatio: 3.0, - ); + 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(); + final imagePath = await File('${directory.path}/Invoice_$invoiceNumber.png').create(); await imagePath.writeAsBytes(image); - await Share.shareXFiles([ - XFile(imagePath.path), - ], text: 'Invoice $invoiceNumber'); + await Share.shareXFiles([XFile(imagePath.path)], text: 'Invoice $invoiceNumber'); } catch (e) { - if (mounted) - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Error sharing image: $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 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 customer = customers.where((c) => c.id == latestInvoice.customerId).firstOrNull; - final businessState = ref.read(businessProfileProvider); - final business = businessState.value; - - final formatCurrency = NumberFormat.currency( - symbol: 'Rs. ', - decimalDigits: 2, - ); + final business = ref.read(businessProfileProvider).value; + final formatCurrency = NumberFormat.currency(symbol: '₹ ', 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), + margin: const pw.EdgeInsets.all(28), build: (pw.Context context) { return [ - // Header + // Header with Business Details & TAX INVOICE title pw.Row( mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, crossAxisAlignment: pw.CrossAxisAlignment.start, @@ -998,311 +562,190 @@ class _InvoiceDetailsScreenState extends ConsumerState { crossAxisAlignment: pw.CrossAxisAlignment.start, children: [ pw.Text( - business?.businessName ?? 'Your Company', - style: pw.TextStyle( - fontSize: 24, - fontWeight: pw.FontWeight.bold, - ), + business?.businessName ?? 'KIFI JEWELLERS', + style: pw.TextStyle(fontSize: 22, fontWeight: pw.FontWeight.bold, color: PdfColors.blue900), ), pw.SizedBox(height: 4), if (business?.address != null) - pw.Text( - business!.address!, - style: const pw.TextStyle(fontSize: 12), - ), + pw.Text(business!.address!, style: const pw.TextStyle(fontSize: 10)), if (business?.contactNumber != null) - pw.Text( - 'Phone: ${business!.contactNumber}', - style: const pw.TextStyle(fontSize: 12), - ), + pw.Text('Phone: ${business!.contactNumber}', style: const pw.TextStyle(fontSize: 10)), if (business?.gstin != null) - pw.Text( - 'GSTIN: ${business!.gstin}', - style: pw.TextStyle( - fontSize: 12, - fontWeight: pw.FontWeight.bold, - ), - ), + pw.Text('GSTIN: ${business!.gstin}', style: pw.TextStyle(fontSize: 10, fontWeight: pw.FontWeight.bold, color: PdfColors.blue800)), ], ), pw.Column( crossAxisAlignment: pw.CrossAxisAlignment.end, children: [ pw.Text( - 'INVOICE', - style: pw.TextStyle( - fontSize: 28, - fontWeight: pw.FontWeight.bold, - color: PdfColors.blue800, - ), + 'TAX INVOICE', + style: pw.TextStyle(fontSize: 24, fontWeight: pw.FontWeight.bold, color: PdfColors.blue900), ), - 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.Text('Invoice #: ${latestInvoice.invoiceNumber}', style: pw.TextStyle(fontSize: 12, fontWeight: pw.FontWeight.bold)), + pw.Text('Date: ${formatDate.format(latestInvoice.issueDate)}', style: const pw.TextStyle(fontSize: 10)), + if (latestInvoice.dueDate != null) + pw.Text('Due Date: ${formatDate.format(latestInvoice.dueDate!)}', style: const pw.TextStyle(fontSize: 10)), ], ), ], ), - pw.SizedBox(height: 32), + pw.SizedBox(height: 20), - // Bill To - pw.Text( - 'BILL TO:', - style: pw.TextStyle( - fontSize: 12, - fontWeight: pw.FontWeight.bold, - color: PdfColors.grey600, + // Bill To Box + pw.Container( + padding: const pw.EdgeInsets.all(10), + decoration: pw.BoxDecoration( + border: pw.Border.all(color: PdfColors.grey400, width: 0.5), + borderRadius: const pw.BorderRadius.all(pw.Radius.circular(6)), + ), + child: pw.Row( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Expanded( + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text('BILL TO (CUSTOMER):', style: pw.TextStyle(fontSize: 9, fontWeight: pw.FontWeight.bold, color: PdfColors.grey700)), + pw.SizedBox(height: 2), + pw.Text(customer?.name ?? 'Walk-in Customer', style: pw.TextStyle(fontSize: 12, fontWeight: pw.FontWeight.bold)), + if (customer?.phone != null) pw.Text('Phone: ${customer!.phone}', style: const pw.TextStyle(fontSize: 9)), + if (customer?.address != null) pw.Text('Address: ${customer!.address}', style: const pw.TextStyle(fontSize: 9)), + if (customer?.gstin != null) pw.Text('GSTIN: ${customer!.gstin}', style: pw.TextStyle(fontSize: 9, fontWeight: pw.FontWeight.bold)), + ], + ), + ), + ], ), ), - 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: 16), - pw.SizedBox(height: 32), - - // Items Table + // Table of Items with HUID, SKU, Making Charges, etc. 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, + bottom: pw.BorderSide(color: PdfColors.grey300, width: 0.5), + horizontalInside: pw.BorderSide(color: PdfColors.grey300, width: 0.5), ), + headerStyle: pw.TextStyle(fontWeight: pw.FontWeight.bold, color: PdfColors.white, fontSize: 9), + headerDecoration: const pw.BoxDecoration(color: PdfColors.blue900), + cellStyle: const pw.TextStyle(fontSize: 8.5), cellAlignments: { 0: pw.Alignment.centerLeft, - 1: pw.Alignment.centerRight, + 1: pw.Alignment.centerLeft, 2: pw.Alignment.centerRight, 3: pw.Alignment.centerRight, + 4: pw.Alignment.centerRight, + 5: pw.Alignment.centerRight, + 6: 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; - final hsnToDisplay = item.hsnCode ?? product?.hsnCode; - String itemDesc = item.description ?? 'Item'; - if (skuToDisplay != null && skuToDisplay.isNotEmpty) - itemDesc += '\nSKU: $skuToDisplay'; - if (hsnToDisplay != null && hsnToDisplay.isNotEmpty) - itemDesc += '\nHSN: $hsnToDisplay'; - 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)}'; - } + headers: ['Item / Particulars', 'HSN / HUID', 'Weight', 'Rate', 'Making Chg', 'GST', 'Total'], + data: latestInvoice.items.map((item) { + final weight = item.weight ?? item.quantity; + final metalAmt = weight * item.unitPrice; - return [ - itemDesc, - item.quantity.toStringAsFixed( - item.quantity.truncateToDouble() == item.quantity - ? 0 - : 2, - ), - formatCurrency.format(item.unitPrice), - formatCurrency.format(item.total), - ]; - }), - ], + double makingAmt = 0.0; + if (item.makingChargesType == 'PERCENTAGE') { + makingAmt = metalAmt * (item.makingCharge / 100.0); + } else if (item.makingChargesType == 'PER_PIECE') { + makingAmt = item.makingCharge; + } else { + makingAmt = weight * item.makingCharge; + } + + String particulars = item.productName ?? item.description ?? 'Item'; + if (item.sku != null && item.sku!.isNotEmpty) particulars += '\nSKU: ${item.sku}'; + if (item.categoryName != null) particulars += ' (${item.categoryName})'; + + String hsnHuid = 'HSN: ${item.hsnCode ?? '-'}'; + if (item.huid != null && item.huid!.isNotEmpty) hsnHuid += '\nHUID: ${item.huid}'; + + return [ + particulars, + hsnHuid, + '${weight.toStringAsFixed(3)} g', + formatCurrency.format(item.unitPrice), + formatCurrency.format(makingAmt), + '${item.taxRate.toStringAsFixed(1)}%', + formatCurrency.format(item.total), + ]; + }).toList(), ), + pw.SizedBox(height: 16), - pw.SizedBox(height: 24), - - // Totals + // Summary Section pw.Row( mainAxisAlignment: pw.MainAxisAlignment.end, children: [ pw.Container( - width: 250, + width: 240, 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.Text('Metal Subtotal:', style: const pw.TextStyle(fontSize: 10)), + pw.Text(formatCurrency.format(latestInvoice.subtotal), style: const pw.TextStyle(fontSize: 10)), + ], + ), + if (latestInvoice.cgstTotal > 0 && latestInvoice.sgstTotal > 0) ...[ + pw.SizedBox(height: 2), + pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + children: [ + pw.Text('CGST:', style: const pw.TextStyle(fontSize: 9)), + pw.Text(formatCurrency.format(latestInvoice.cgstTotal), style: const pw.TextStyle(fontSize: 9)), + ], + ), + pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + children: [ + pw.Text('SGST:', style: const pw.TextStyle(fontSize: 9)), + pw.Text(formatCurrency.format(latestInvoice.sgstTotal), style: const pw.TextStyle(fontSize: 9)), + ], + ), + ] else if (latestInvoice.igstTotal > 0) ...[ + pw.SizedBox(height: 2), + pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + children: [ + pw.Text('IGST:', style: const pw.TextStyle(fontSize: 9)), + pw.Text(formatCurrency.format(latestInvoice.igstTotal), style: const pw.TextStyle(fontSize: 9)), + ], + ), + ], + if (latestInvoice.discountTotal > 0) ...[ + pw.SizedBox(height: 2), + pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + children: [ + pw.Text('Discount:', style: const pw.TextStyle(fontSize: 9, color: PdfColors.green700)), + pw.Text('-${formatCurrency.format(latestInvoice.discountTotal)}', style: const pw.TextStyle(fontSize: 9, color: PdfColors.green700)), + ], + ), + ], + pw.Divider(color: PdfColors.grey400, height: 10), + pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + children: [ + pw.Text('Grand Total:', style: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 13)), + pw.Text(formatCurrency.format(latestInvoice.totalAmount), style: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 13)), ], ), 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.Text('Amount Received:', style: const pw.TextStyle(fontSize: 10, color: PdfColors.green800)), + pw.Text(formatCurrency.format(latestInvoice.amountPaid), style: const pw.TextStyle(fontSize: 10, color: PdfColors.green800)), ], ), - 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.Text('Balance Due:', style: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 11, color: PdfColors.red800)), + pw.Text(formatCurrency.format(latestInvoice.totalAmount - latestInvoice.amountPaid), style: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 11, color: PdfColors.red800)), ], ), ], @@ -1310,68 +753,31 @@ class _InvoiceDetailsScreenState extends ConsumerState { ), ], ), + pw.SizedBox(height: 30), - 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( + // Terms & Signatory + pw.Divider(color: PdfColors.grey300), + pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + crossAxisAlignment: pw.CrossAxisAlignment.end, + children: [ + 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.Text('Terms & Conditions:', style: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 8)), + pw.Text('1. Goods once sold are subject to standard hallmarking certification.', style: const pw.TextStyle(fontSize: 7, color: PdfColors.grey700)), + pw.Text('2. Weight & purity tested under industry standard electronic balance.', style: const pw.TextStyle(fontSize: 7, 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, + pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.center, + children: [ + pw.Text('For ${business?.businessName ?? "KIFI JEWELLERS"}', style: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 9)), + pw.SizedBox(height: 24), + pw.Text('Authorized Signatory', style: const pw.TextStyle(fontSize: 8, color: PdfColors.grey700)), + ], ), - ), + ], ), ]; }, @@ -1379,19 +785,12 @@ class _InvoiceDetailsScreenState extends ConsumerState { ); final directory = await getTemporaryDirectory(); - final pdfPath = await File( - '${directory.path}/Invoice_$invoiceNumber.pdf', - ).create(); + final pdfPath = await File('${directory.path}/TaxInvoice_$invoiceNumber.pdf').create(); await pdfPath.writeAsBytes(await pdf.save()); - await Share.shareXFiles([ - XFile(pdfPath.path), - ], text: 'Invoice $invoiceNumber'); + await Share.shareXFiles([XFile(pdfPath.path)], text: 'Tax Invoice $invoiceNumber'); } catch (e) { - if (mounted) - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Error sharing PDF: $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 index ad71195..324d6f4 100644 --- a/kifi-app/lib/features/sales/presentation/invoices_list_screen.dart +++ b/kifi-app/lib/features/sales/presentation/invoices_list_screen.dart @@ -2,14 +2,12 @@ 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 '../providers/customers_provider.dart'; +import '../domain/customer.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}); @@ -24,277 +22,115 @@ class _InvoicesListScreenState extends ConsumerState { 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'; + if (invoice.status == 'PAID' || invoice.amountPaid >= invoice.totalAmount && invoice.totalAmount > 0) return 'PAID'; + if (invoice.amountPaid > 0) return 'PARTIAL'; 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; + final label = _getStatusLabel(invoice); + switch (label) { case 'PAID': return Colors.green; case 'PARTIAL': return Colors.blue; + case 'FINALIZED': + return Colors.orange; case 'OVERDUE': return Colors.red; - case 'CANCELLED': - return Colors.black; + case 'DRAFT': 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 + void dispose() { + _searchController.dispose(); + super.dispose(); } @override Widget build(BuildContext context) { final invoicesState = ref.watch(invoicesProvider); final customersState = ref.watch(customersProvider); - final customers = customersState.value ?? []; - + final isDark = Theme.of(context).brightness == Brightness.dark; final formatCurrency = NumberFormat.currency(symbol: '₹'); final formatDate = DateFormat('MMM dd, yyyy'); return Scaffold( - backgroundColor: Colors.grey[100], + backgroundColor: isDark ? const Color(0xFF0F172A) : const Color(0xFFF8FAFC), appBar: AppBar( title: const Text( - 'Invoices', + 'Sales Invoices', style: TextStyle(fontWeight: FontWeight.bold), ), elevation: 0, - backgroundColor: Colors.white, - foregroundColor: Colors.black, + backgroundColor: isDark ? const Color(0xFF1E293B) : Colors.white, + foregroundColor: isDark ? Colors.white : Colors.black, centerTitle: true, ), body: Column( children: [ + // Unified Search Header Container( - color: Colors.white, + color: isDark ? const Color(0xFF1E293B) : Colors.white, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: TextField( controller: _searchController, decoration: InputDecoration( - hintText: 'Search by Invoice # or Customer', + hintText: 'Search Invoice # or Customer...', prefixIcon: const Icon(LucideIcons.search, color: Colors.grey), contentPadding: const EdgeInsets.symmetric(vertical: 14), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: isDark ? Colors.white12 : Colors.grey.shade300), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: isDark ? Colors.white12 : Colors.grey.shade300), + ), suffixIcon: _searchQuery.isNotEmpty ? IconButton( - icon: const Icon(LucideIcons.x, size: 20), + icon: const Icon(LucideIcons.xCircle, size: 20), onPressed: () { _searchController.clear(); - setState(() { - _searchQuery = ''; - }); + setState(() => _searchQuery = ''); }, ) : null, ), - onChanged: (value) { - setState(() { - _searchQuery = value.toLowerCase(); - }); - }, + onChanged: (val) => setState(() => _searchQuery = val.trim()), ), ), 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; + data: (invoices) { + final customers = customersState.value ?? []; + final filtered = invoices.where((inv) { + final matchesInv = inv.invoiceNumber.toLowerCase().contains(_searchQuery.toLowerCase()); + final customer = customers.where((c) => c.id == inv.customerId).firstOrNull; + final matchesCust = customer != null && customer.name.toLowerCase().contains(_searchQuery.toLowerCase()); + return matchesInv || matchesCust; }).toList(); - if (invoices.isEmpty) { + if (filtered.isEmpty) { return RefreshIndicator( - onRefresh: () async { - await ref.read(invoicesProvider.notifier).refresh(); - }, + onRefresh: () => ref.refresh(invoicesProvider.future), child: ListView( physics: const AlwaysScrollableScrollPhysics(), children: const [ - SizedBox(height: 100), + SizedBox(height: 120), Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon( - LucideIcons.receipt, - size: 64, - color: Colors.grey, - ), + Icon(LucideIcons.fileText, size: 64, color: Colors.grey), SizedBox(height: 16), Text( - 'No invoices found.', - style: TextStyle( - color: Colors.grey, - fontSize: 16, - ), + 'No sales invoices found.', + style: TextStyle(color: Colors.grey, fontSize: 16), ), ], ), @@ -305,32 +141,25 @@ class _InvoicesListScreenState extends ConsumerState { } return RefreshIndicator( - onRefresh: () async { - await ref.read(invoicesProvider.notifier).refresh(); - }, + onRefresh: () => ref.refresh(invoicesProvider.future), child: ListView.builder( - physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.all(16), - itemCount: invoices.length, + itemCount: filtered.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; + final invoice = filtered[index]; + final customer = customers.where((c) => c.id == invoice.customerId).firstOrNull; + final statusColor = _getStatusColor(invoice); + final statusLabel = _getStatusLabel(invoice); return Container( margin: const EdgeInsets.only(bottom: 16), decoration: BoxDecoration( - color: Colors.white, + color: isDark ? const Color(0xFF1E293B) : Colors.white, borderRadius: BorderRadius.circular(20), + border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200), boxShadow: [ BoxShadow( - color: Colors.grey.withOpacity(0.08), + color: Colors.black.withValues(alpha: isDark ? 0.2 : 0.04), blurRadius: 10, offset: const Offset(0, 4), ), @@ -341,13 +170,21 @@ class _InvoicesListScreenState extends ConsumerState { borderRadius: BorderRadius.circular(20), child: InkWell( onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => - InvoiceDetailsScreen(invoice: invoice), - ), - ); + if (invoice.status == 'DRAFT') { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => InvoiceBuilderScreen(existingInvoice: invoice), + ), + ); + } else { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => InvoiceDetailsScreen(invoice: invoice), + ), + ); + } }, borderRadius: BorderRadius.circular(20), child: Padding( @@ -355,49 +192,28 @@ class _InvoicesListScreenState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // Row 1: Invoice Number + Status Pill Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, + 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, - ), - ), - ], + Text( + invoice.invoiceNumber, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), ), Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), decoration: BoxDecoration( - color: _getStatusColor( - invoice, - ).withOpacity(0.1), - borderRadius: BorderRadius.circular( - 12, - ), + color: statusColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: statusColor.withValues(alpha: 0.3), width: 0.8), ), child: Text( - _getStatusLabel(invoice), + statusLabel, style: TextStyle( - color: _getStatusColor(invoice), + color: statusColor, fontSize: 12, fontWeight: FontWeight.bold, ), @@ -406,174 +222,50 @@ class _InvoicesListScreenState extends ConsumerState { ], ), const SizedBox(height: 12), + + // Row 2: Customer Name 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, - ), - ), - ], + const Icon(LucideIcons.user, size: 16, color: Colors.grey), + const SizedBox(width: 8), + Expanded( + child: Text( + customer?.name ?? 'Walk-in Customer', + style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), ), + if (invoice.items.isNotEmpty) + Text( + '${invoice.items.length} ${invoice.items.length == 1 ? "item" : "items"}', + style: TextStyle(fontSize: 12, color: Colors.grey.shade500), + ), ], ), - 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), + const SizedBox(height: 10), + + // Row 3: Date & Total Amount 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, - ), - ), - ), + const Icon(LucideIcons.calendar, size: 16, color: Colors.grey), + const SizedBox(width: 8), + Text( + formatDate.format(invoice.issueDate), + style: TextStyle( + color: Colors.grey.shade600, + fontSize: 13, ), - 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, - ), - ), - ), + ), + const Spacer(), + Text( + formatCurrency.format(invoice.totalAmount), + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + color: Colors.green, ), + ), ], ), ], @@ -586,6 +278,8 @@ class _InvoicesListScreenState extends ConsumerState { ), ); }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, stack) => Center(child: Text('Error: $e')), ), ), ], @@ -598,8 +292,9 @@ class _InvoicesListScreenState extends ConsumerState { ); }, icon: const Icon(LucideIcons.plus), - label: const Text('Create Invoice'), + label: const Text('Create Invoice', style: TextStyle(fontWeight: FontWeight.bold)), backgroundColor: Colors.blue, + foregroundColor: Colors.white, ), ); } diff --git a/kifi-app/lib/features/sales/presentation/widgets/quick_add_customer_sheet.dart b/kifi-app/lib/features/sales/presentation/widgets/quick_add_customer_sheet.dart new file mode 100644 index 0000000..9b63a30 --- /dev/null +++ b/kifi-app/lib/features/sales/presentation/widgets/quick_add_customer_sheet.dart @@ -0,0 +1,171 @@ +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 '../../../../core/widgets/smart_search_dropdown.dart'; +import '../../providers/customers_provider.dart'; +import '../../domain/customer.dart'; +import '../../../business/providers/indian_states_provider.dart'; + +class QuickAddCustomerSheet extends ConsumerStatefulWidget { + const QuickAddCustomerSheet({super.key}); + + @override + ConsumerState createState() => _QuickAddCustomerSheetState(); +} + +class _QuickAddCustomerSheetState extends ConsumerState { + final _formKey = GlobalKey(); + final _nameCtrl = TextEditingController(); + final _phoneCtrl = TextEditingController(); + final _addressCtrl = TextEditingController(); + final _gstinCtrl = TextEditingController(); + int? _selectedStateId; + bool _isLoading = false; + + @override + void dispose() { + _nameCtrl.dispose(); + _phoneCtrl.dispose(); + _addressCtrl.dispose(); + _gstinCtrl.dispose(); + super.dispose(); + } + + Future _save() async { + if (!_formKey.currentState!.validate()) return; + + setState(() => _isLoading = true); + try { + final customer = Customer( + name: _nameCtrl.text.trim(), + phone: _phoneCtrl.text.trim().isEmpty ? null : _phoneCtrl.text.trim(), + address: _addressCtrl.text.trim().isEmpty ? null : _addressCtrl.text.trim(), + gstin: _gstinCtrl.text.trim().isEmpty ? null : _gstinCtrl.text.trim(), + stateId: _selectedStateId, + ); + + final created = await ref.read(customersProvider.notifier).addCustomer(customer); + if (mounted) Navigator.pop(context, created ?? true); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e'))); + } + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: Theme.of(context).scaffoldBackgroundColor, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 0), + child: Row( + children: [ + IconButton( + icon: const Icon(LucideIcons.chevronLeft), + onPressed: () => Navigator.pop(context), + ), + const Expanded( + child: Text( + 'Quick Add Customer', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), + ), + ], + ), + ), + const Divider(), + Padding( + padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + PremiumTextField( + controller: _nameCtrl, + labelText: 'Customer Name *', + prefixIcon: const Icon(LucideIcons.user), + textCapitalization: TextCapitalization.words, + 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), + Consumer( + builder: (context, ref, _) { + final statesState = ref.watch(indianStatesProvider); + return statesState.when( + data: (states) => SmartSearchDropdown( + labelText: 'State', + hintText: 'Select State', + value: _selectedStateId, + items: states.map((s) => s.id).toList(), + itemAsString: (id) => states.firstWhere((s) => s.id == id).name, + onChanged: (val) { + setState(() { + _selectedStateId = val; + }); + }, + ), + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Text('Error loading states: $e'), + ); + }, + ), + const SizedBox(height: 16), + PremiumTextField( + controller: _gstinCtrl, + labelText: 'GSTIN (Optional)', + prefixIcon: const Icon(LucideIcons.fileText), + textCapitalization: TextCapitalization.characters, + ), + const SizedBox(height: 16), + PremiumTextField( + controller: _addressCtrl, + labelText: 'Address', + prefixIcon: const Icon(LucideIcons.home), + maxLines: 2, + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: _isLoading ? null : _save, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + backgroundColor: Colors.blue, + foregroundColor: Colors.white, + ), + child: _isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), + ) + : const Text('Save Customer', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + ), + ], + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/kifi-app/lib/features/sales/providers/customers_provider.dart b/kifi-app/lib/features/sales/providers/customers_provider.dart index 701581d..8b03ff6 100644 --- a/kifi-app/lib/features/sales/providers/customers_provider.dart +++ b/kifi-app/lib/features/sales/providers/customers_provider.dart @@ -42,7 +42,7 @@ class CustomersNotifier extends AsyncNotifier> { } } - Future addCustomer(Customer customer, {XFile? photo}) async { + Future addCustomer(Customer customer, {XFile? photo}) async { try { final response = await DioClient().dio.post( '/customers', @@ -55,14 +55,18 @@ class CustomersNotifier extends AsyncNotifier> { } await refresh(); + if (response.data != null) { + return Customer.fromJson(response.data); + } } catch (e) { if (e is DioException) { throw Exception( - 'Failed to add customer: ${e.response?.statusCode} - ${e.response?.data}', + e.response?.data?['message'] ?? 'Failed to add customer', ); } - throw Exception('Failed to add customer: $e'); + throw Exception('Error adding customer: $e'); } + return null; } Future updateCustomer(int id, Customer customer, {XFile? photo}) async { diff --git a/kifi-app/lib/features/sales/providers/invoices_provider.dart b/kifi-app/lib/features/sales/providers/invoices_provider.dart index a635b56..ca5c292 100644 --- a/kifi-app/lib/features/sales/providers/invoices_provider.dart +++ b/kifi-app/lib/features/sales/providers/invoices_provider.dart @@ -34,10 +34,14 @@ class InvoicesNotifier extends AsyncNotifier> { } } - Future createInvoice(Invoice invoice) async { + Future createInvoice(Invoice invoice) async { try { - await DioClient().dio.post('/invoices', data: invoice.toJson()); + final response = await DioClient().dio.post('/invoices', data: invoice.toJson()); await refresh(); + if (response.data != null) { + return Invoice.fromJson(response.data); + } + return null; } catch (e) { if (e is DioException) { throw Exception( diff --git a/kifi-app/lib/features/transactions/providers/paginated_transaction_provider.dart b/kifi-app/lib/features/transactions/providers/paginated_transaction_provider.dart index 42c4847..115b12b 100644 --- a/kifi-app/lib/features/transactions/providers/paginated_transaction_provider.dart +++ b/kifi-app/lib/features/transactions/providers/paginated_transaction_provider.dart @@ -1,6 +1,5 @@ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../data/repository.dart'; import '../data/models.dart'; import 'providers.dart'; @@ -98,9 +97,8 @@ class PaginatedTransactionNotifier extends Notifier { isLoading: false, hasMore: _currentPage < totalPages, ); - } catch (e) { + } catch (_) { state = state.copyWith(isLoading: false); - print("Error loading paginated transactions: $e"); } } diff --git a/kifi-app/lib/features/vendor/presentation/purchase_order_builder_screen.dart b/kifi-app/lib/features/vendor/presentation/purchase_order_builder_screen.dart index 524a995..43e2c3b 100644 --- a/kifi-app/lib/features/vendor/presentation/purchase_order_builder_screen.dart +++ b/kifi-app/lib/features/vendor/presentation/purchase_order_builder_screen.dart @@ -21,6 +21,8 @@ import '../domain/vendor.dart'; import '../../inventory/domain/product.dart'; import 'pay_vendor_sheet.dart' as import_pay; import 'widgets/quick_add_vendor_sheet.dart'; +import '../../../core/widgets/barcode_scanner_screen.dart'; +import '../../../core/utils/purity_utils.dart'; String _resolveImageUrl(String path) { if (path.startsWith('http://') || path.startsWith('https://')) { @@ -47,6 +49,7 @@ class _PurchaseOrderBuilderScreenState DateTime? _dueDate; final TextEditingController _poNumberCtrl = TextEditingController(); final TextEditingController _notesCtrl = TextEditingController(); + final TextEditingController _discountCtrl = TextEditingController(text: '0.0'); List _items = []; bool _isLoading = false; @@ -64,6 +67,9 @@ class _PurchaseOrderBuilderScreenState _notesCtrl.text = widget.existingPo!.notes ?? ''; _items = List.from(widget.existingPo!.items); _invoiceUrl = widget.existingPo!.vendorInvoiceUrl; + if (widget.existingPo!.discountTotal > 0) { + _discountCtrl.text = widget.existingPo!.discountTotal.toStringAsFixed(2); + } if (_items.isEmpty && widget.existingPo!.id != null) { _loadExistingItems(widget.existingPo!.id!); } @@ -90,22 +96,10 @@ class _PurchaseOrderBuilderScreenState void dispose() { _poNumberCtrl.dispose(); _notesCtrl.dispose(); + _discountCtrl.dispose(); super.dispose(); } - - double get _subtotal { - return _items.fold( - 0, - (sum, item) => sum + (item.total), - ); - } - - - double get _totalAmount { - return _subtotal; - } - Future _pickInvoiceFile() async { final picker = ImagePicker(); final picked = await picker.pickImage( @@ -230,6 +224,9 @@ class _PurchaseOrderBuilderScreenState )); } + final discountAmount = double.tryParse(_discountCtrl.text) ?? 0.0; + final grandTotal = (totalSubtotal - discountAmount).clamp(0.0, double.infinity) + totalTax; + final po = PurchaseOrder( id: widget.existingPo?.id, vendorId: _selectedVendorId, @@ -237,18 +234,17 @@ class _PurchaseOrderBuilderScreenState issueDate: _issueDate, dueDate: _dueDate, subtotal: totalSubtotal, + discountTotal: discountAmount, taxTotal: totalTax, cgstTotal: totalCgst, sgstTotal: totalSgst, igstTotal: totalIgst, - totalAmount: totalSubtotal + totalTax, + totalAmount: grandTotal, notes: _notesCtrl.text, vendorInvoiceUrl: finalInvoiceUrl, items: processedItems, ); - print('--- SAVING PO ---'); - print(po.toJson()); if (widget.existingPo == null) { await ref.read(purchaseOrdersProvider.notifier).createPurchaseOrder(po); } else { @@ -319,18 +315,72 @@ class _PurchaseOrderBuilderScreenState }); } + Future _scanBarcode() async { + final scannedCode = await Navigator.push( + context, + MaterialPageRoute(builder: (_) => const BarcodeScannerScreen()), + ); + if (scannedCode != null && scannedCode.isNotEmpty) { + final products = ref.read(productsProvider).value ?? []; + final categories = ref.read(productCategoriesProvider).value ?? []; + final commodityRates = ref.read(commodityRatesProvider).value ?? []; + + final matchedProduct = products.where((p) => + (p.barcode != null && p.barcode == scannedCode) || + (p.sku != null && p.sku!.toUpperCase() == scannedCode.toUpperCase()) + ).firstOrNull; + + if (matchedProduct != null) { + final cat = categories.where((c) => c.id == matchedProduct.categoryId).firstOrNull; + double commodityRate = 0.0; + if (cat?.commodityCode != null) { + final match = commodityRates.where((r) => r.commodityCode.toUpperCase() == cat!.commodityCode!.toUpperCase()).firstOrNull; + if (match != null) commodityRate = match.rate; + } + final purity = resolvePurity(categoryPurity: cat?.purityFactor, productPurity: matchedProduct.purityFactor); + final initialRate = commodityRate > 0 ? (commodityRate * purity) : (matchedProduct.sellingPrice ?? 0.0); + + final newItem = PurchaseOrderItem( + productId: matchedProduct.id, + quantity: 1.0, + unitPrice: initialRate, + total: initialRate, + ); + + setState(() => _items.add(newItem)); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Added ${matchedProduct.name} via barcode scan!'), backgroundColor: Colors.green), + ); + } + } else { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('No product found for barcode: $scannedCode'), backgroundColor: Colors.orange), + ); + } + } + } + } + @override Widget build(BuildContext context) { final vendorsState = ref.watch(vendorsProvider); + final isDark = Theme.of(context).brightness == Brightness.dark; return Scaffold( appBar: AppBar( title: Text( widget.existingPo == null - ? 'New Invoice' + ? 'New Purchase Invoice' : 'Edit Invoice ${_poNumberCtrl.text}', ), actions: [ + IconButton( + icon: const Icon(LucideIcons.qrCode), + tooltip: 'Scan Barcode/SKU', + onPressed: _scanBarcode, + ), if (_isLoading) const Padding( padding: EdgeInsets.all(16.0), @@ -384,89 +434,181 @@ class _PurchaseOrderBuilderScreenState ], ], ), - body: SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - vendorsState.when( - data: (vendors) => SmartSearchDropdown( - labelText: 'Vendor *', - hintText: 'Select Vendor', - value: _selectedVendorId, - items: vendors.map((v) => v.id!).toList(), - itemAsString: (id) => - vendors.where((v) => v.id == id).firstOrNull?.name ?? 'Unknown', - onChanged: (val) => setState(() => _selectedVendorId = val), - emptyActionText: '+ Quick Add Vendor', - onEmptyActionPressed: () { - showModalBottomSheet( - context: context, - isScrollControlled: true, - builder: (ctx) => const QuickAddVendorSheet(), - ); - }, - ), - loading: () => const CircularProgressIndicator(), - error: (e, stack) => Text('Error: $e'), - ), - const SizedBox(height: 16), + body: GestureDetector( + onTap: () => FocusScope.of(context).unfocus(), + behavior: HitTestBehavior.translucent, + child: SingleChildScrollView( + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Vendor Selection Row + Quick Add Vendor Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( - child: PremiumTextField( - controller: _poNumberCtrl, - labelText: 'Invoice Number *', + child: vendorsState.when( + data: (vendors) => SmartSearchDropdown( + labelText: 'Vendor *', + hintText: 'Search Vendor by Name / Phone / Company / GSTIN', + value: vendors.where((v) => v.id == _selectedVendorId).firstOrNull, + items: vendors, + itemAsString: (v) => v.name, + filterFn: (v, query) { + final q = query.toLowerCase(); + return v.name.toLowerCase().contains(q) || + (v.phone != null && v.phone!.contains(q)) || + (v.contactPerson != null && v.contactPerson!.toLowerCase().contains(q)) || + (v.gstin != null && v.gstin!.toLowerCase().contains(q)) || + (v.address != null && v.address!.toLowerCase().contains(q)); + }, + itemBuilder: (context, v) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 14.0, vertical: 10.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CircleAvatar( + radius: 18, + backgroundColor: Colors.amber.withValues(alpha: 0.15), + child: Text( + v.name.isNotEmpty ? v.name[0].toUpperCase() : 'V', + style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.amber, fontSize: 14), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + v.name, + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14), + ), + const SizedBox(height: 3), + Wrap( + spacing: 6, + runSpacing: 3, + children: [ + if (v.contactPerson != null && v.contactPerson!.isNotEmpty) + _buildPOBadge(v.contactPerson!, const Color(0xFFD4AF37)), + if (v.phone != null && v.phone!.isNotEmpty) + _buildPOBadge(v.phone!, Colors.teal), + if (v.address != null && v.address!.isNotEmpty) + _buildPOBadge(v.address!, Colors.purple), + if (v.gstin != null && v.gstin!.isNotEmpty) + _buildPOBadge('GST: ${v.gstin}', Colors.indigo), + ], + ), + ], + ), + ), + ], + ), + ); + }, + onChanged: (val) => setState(() => _selectedVendorId = val?.id), + ), + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, stack) => Text('Error: $e'), ), ), - const SizedBox(width: 16), - Expanded( - child: InkWell( - onTap: () async { - final d = await showDatePicker( + const SizedBox(width: 8), + Padding( + padding: const EdgeInsets.only(top: 4.0), + child: IconButton.filledTonal( + onPressed: () { + showModalBottomSheet( context: context, - initialDate: _issueDate, - firstDate: DateTime(2000), - lastDate: DateTime(2100), + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (ctx) => const QuickAddVendorSheet(), ); - if (d != null) setState(() => _issueDate = d); }, - child: InputDecorator( - decoration: const InputDecoration( - labelText: 'Issue Date', - ), - child: Text(DateFormat('dd MMM yyyy').format(_issueDate)), - ), + icon: const Icon(LucideIcons.userPlus, size: 20), + tooltip: 'Quick Add Vendor', ), ), ], ), + const SizedBox(height: 16), + PremiumTextField( + controller: _poNumberCtrl, + labelText: 'Invoice Number *', + prefixIcon: const Icon(LucideIcons.fileText), + ), + const SizedBox(height: 16), + InkWell( + onTap: () async { + final d = await showDatePicker( + context: context, + initialDate: _issueDate, + firstDate: DateTime(2000), + lastDate: DateTime(2100), + ); + if (d != null) setState(() => _issueDate = d); + }, + child: InputDecorator( + decoration: InputDecoration( + labelText: 'Issue Date', + prefixIcon: const Icon(LucideIcons.calendar), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + ), + child: Text( + DateFormat('dd MMM yyyy').format(_issueDate), + style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14), + ), + ), + ), const SizedBox(height: 24), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - 'Items', - style: Theme.of( - context, - ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + 'Invoice Items (${_items.length})', + style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), ), - TextButton.icon( - onPressed: _showAddItemSheet, - icon: const Icon(LucideIcons.plus), - label: const Text('Add Item'), + Row( + children: [ + IconButton.filledTonal( + onPressed: _scanBarcode, + icon: const Icon(LucideIcons.qrCode, size: 18), + tooltip: 'Scan Barcode', + ), + const SizedBox(width: 8), + FilledButton.tonalIcon( + onPressed: _showAddItemSheet, + icon: const Icon(LucideIcons.plus, size: 16), + label: const Text('Add Item'), + ), + ], ), ], ), - const Divider(), + const SizedBox(height: 12), if (_items.isEmpty) - const Padding( - padding: EdgeInsets.all(24.0), - child: Center( - child: Text( - 'No items added yet', - style: TextStyle(color: Colors.grey), - ), + Container( + padding: const EdgeInsets.all(32), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1E293B) : Colors.grey.shade100, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.grey.withValues(alpha: 0.2)), + ), + child: Column( + children: [ + Icon(LucideIcons.packageOpen, size: 48, color: Colors.grey.shade400), + const SizedBox(height: 12), + Text( + 'No items added yet', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Colors.grey.shade600), + ), + const SizedBox(height: 4), + Text( + 'Tap "Add Item" or scan a barcode/SKU to add purchase items', + style: TextStyle(fontSize: 12, color: Colors.grey.shade500), + ), + ], ), ) else @@ -544,14 +686,14 @@ class _PurchaseOrderBuilderScreenState }, onDelete: () => setState(() => _items.removeAt(itemIndex)), ); - }).toList(), + }), Container( margin: const EdgeInsets.only(bottom: 16), padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: Colors.blue.withOpacity(0.05), + color: Colors.blue.withValues(alpha: 0.05), borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.blue.withOpacity(0.2)), + border: Border.all(color: Colors.blue.withValues(alpha: 0.2)), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -612,45 +754,124 @@ class _PurchaseOrderBuilderScreenState ); }, ), - const SizedBox(height: 24), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text( - 'Total Amount', - style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - ), - Builder( - builder: (context) { - final productsState = ref.watch(productsProvider); - final products = productsState.value ?? []; - double grandTotal = 0; - - final Map groupSubtotals = {}; - for (final item in _items) { - if (item.productId != null) groupSubtotals[item.productId!] = (groupSubtotals[item.productId!] ?? 0) + item.total; - } - - for (final entry in groupSubtotals.entries) { - final productId = entry.key; - final sub = entry.value; - final product = products.firstWhere((p) => p.id == productId, orElse: () => products.first); - final gstRate = product.gstRate ?? 0.0; - final taxAmount = (sub * gstRate) / 100.0; - grandTotal += (sub + taxAmount); - } - return Text( - '₹${grandTotal.toStringAsFixed(2)}', - style: const TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: Colors.green, + const SizedBox(height: 20), + + // Invoice Discount Input Field before Summary + PremiumTextField( + controller: _discountCtrl, + labelText: 'Invoice Discount (₹)', + prefixIcon: const Icon(LucideIcons.tag, color: Colors.red), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + onChanged: (_) => setState(() {}), + ), + + const SizedBox(height: 20), + + // INVOICE SUMMARY CARD + Builder( + builder: (context) { + final products = ref.watch(productsProvider).value ?? []; + final categories = ref.watch(productCategoriesProvider).value ?? []; + final businessState = ref.watch(businessProfileProvider).value?.stateId; + final vendorState = _selectedVendorId == null + ? null + : vendorsState.value?.where((v) => v.id == _selectedVendorId).firstOrNull?.stateId; + final isSameState = businessState != null && vendorState != null && businessState == vendorState; + final discountAmount = double.tryParse(_discountCtrl.text) ?? 0.0; + + double rawSubtotal = 0; + double taxTotal = 0; + + for (var item in _items) { + final p = products.where((pr) => pr.id == item.productId).firstOrNull; + final cat = categories.where((c) => c.id == p?.categoryId).firstOrNull; + final gstRate = item.taxRate > 0 ? item.taxRate : (p?.gstRate ?? (cat?.defaultGst ?? 3.0)); + + rawSubtotal += item.total; + taxTotal += (item.total * gstRate) / 100.0; + } + + final effectiveTaxable = (rawSubtotal - discountAmount).clamp(0.0, double.infinity); + final grandTotal = effectiveTaxable + taxTotal; + + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1E293B) : Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: isDark ? Colors.white12 : Colors.grey.shade200), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: isDark ? 0.2 : 0.05), + blurRadius: 15, + offset: const Offset(0, 5), ), - ); - } - ), - ], + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('INVOICE SUMMARY', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold, letterSpacing: 0.8, color: Colors.grey)), + const SizedBox(height: 14), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Items Subtotal:'), + Text('₹${rawSubtotal.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.w600)), + ], + ), + if (discountAmount > 0) ...[ + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Discount:', style: TextStyle(color: Colors.red, fontWeight: FontWeight.w600)), + Text('- ₹${discountAmount.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.red)), + ], + ), + ], + const SizedBox(height: 8), + if (isSameState) ...[ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('CGST (${(taxTotal > 0 ? '1.5%' : '0%')}):', style: TextStyle(color: Colors.grey.shade600)), + Text('₹${(taxTotal / 2.0).toStringAsFixed(2)}'), + ], + ), + const SizedBox(height: 6), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('SGST (${(taxTotal > 0 ? '1.5%' : '0%')}):', style: TextStyle(color: Colors.grey.shade600)), + Text('₹${(taxTotal / 2.0).toStringAsFixed(2)}'), + ], + ), + ] else ...[ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('IGST (${(taxTotal > 0 ? '3.0%' : '0%')}):', style: TextStyle(color: Colors.grey.shade600)), + Text('₹${taxTotal.toStringAsFixed(2)}'), + ], + ), + ], + const Divider(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Grand Total:', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + Text( + '₹${grandTotal.toStringAsFixed(2)}', + style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.green), + ), + ], + ), + ], + ), + ); + }, ), const SizedBox(height: 24), PremiumTextField( @@ -718,6 +939,22 @@ class _PurchaseOrderBuilderScreenState ], ), ), + ), + ); +} + + Widget _buildPOBadge(String text, Color color) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: color.withValues(alpha: 0.3), width: 0.5), + ), + child: Text( + text, + style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: color), + ), ); } } @@ -730,40 +967,47 @@ class _AddItemSheet extends ConsumerStatefulWidget { } class _AddItemSheetState extends ConsumerState<_AddItemSheet> { - int? _selectedProductId; - final TextEditingController _qtyCtrl = TextEditingController(text: '1'); - void _save() { - if (_selectedProductId == null) return; - final qty = int.tryParse(_qtyCtrl.text) ?? 1; + final _searchCtrl = TextEditingController(); + final Map _qtyControllers = {}; - final products = ref.read(productsProvider).value ?? []; - final categories = ref.read(productCategoriesProvider).value ?? []; - final commodityRates = ref.read(commodityRatesProvider).value ?? []; - - final selectedProduct = products.where((p) => p.id == _selectedProductId).firstOrNull; - final selectedCat = categories.where((c) => c.id == selectedProduct?.categoryId).firstOrNull; - - double initialRate = 0.0; - if (selectedCat?.commodityCode != null) { - final matchRate = commodityRates.where( - (r) => r.commodityCode.toUpperCase() == selectedCat!.commodityCode!.toUpperCase() - ).firstOrNull; - if (matchRate != null && matchRate.rate > 0) { - final purity = selectedCat?.purityFactor ?? (selectedProduct?.purityFactor ?? 1.0); - initialRate = matchRate.rate * purity; - } + @override + void dispose() { + _searchCtrl.dispose(); + for (final ctrl in _qtyControllers.values) { + ctrl.dispose(); } - if (initialRate == 0.0) { - initialRate = selectedProduct?.sellingPrice ?? 0.0; + super.dispose(); + } + + TextEditingController _getQtyController(int productId) { + return _qtyControllers.putIfAbsent(productId, () => TextEditingController(text: '1')); + } + + Future _scanBarcodeInSheet() async { + final scanned = await Navigator.push( + context, + MaterialPageRoute(builder: (_) => const BarcodeScannerScreen()), + ); + if (scanned != null && scanned.isNotEmpty) { + _searchCtrl.text = scanned; + setState(() {}); } + } + + void _addItemsFromCard(Product product, ProductCategory? category, int qty) { + if (qty <= 0) return; + final gstRate = product.gstRate ?? (category?.defaultGst ?? 3.0); final List items = []; for (int i = 0; i < qty; i++) { items.add(PurchaseOrderItem( - productId: _selectedProductId, + productId: product.id, quantity: 1.0, - unitPrice: initialRate, - total: initialRate > 0 ? initialRate : 0.0, + weight: 0.0, + unitPrice: 0.0, + taxRate: gstRate, + makingCharge: 0.0, + total: 0.0, )); } Navigator.pop(context, items); @@ -771,62 +1015,252 @@ class _AddItemSheetState extends ConsumerState<_AddItemSheet> { @override Widget build(BuildContext context) { - final productsState = ref.watch(productsProvider); + final products = ref.watch(productsProvider).value ?? []; + final categories = ref.watch(productCategoriesProvider).value ?? []; + final commodityRates = ref.watch(commodityRatesProvider).value ?? []; + final isDark = Theme.of(context).brightness == Brightness.dark; - 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 PO Item', - style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + final query = _searchCtrl.text.trim().toLowerCase(); + final filteredProducts = query.isEmpty + ? products + : products.where((p) => + p.name.toLowerCase().contains(query) || + (p.sku != null && p.sku!.toLowerCase().contains(query)) || + (p.barcode != null && p.barcode!.toLowerCase().contains(query))).toList(); + + final keyboardHeight = MediaQuery.of(context).viewInsets.bottom; + + return AnimatedPadding( + padding: EdgeInsets.only(bottom: keyboardHeight), + duration: const Duration(milliseconds: 150), + curve: Curves.easeOut, + child: GestureDetector( + onTap: () => FocusScope.of(context).unfocus(), + behavior: HitTestBehavior.translucent, + child: Container( + height: MediaQuery.of(context).size.height * 0.85, + decoration: BoxDecoration( + color: Theme.of(context).scaffoldBackgroundColor, + borderRadius: const BorderRadius.vertical(top: Radius.circular(28)), ), - const SizedBox(height: 16), - productsState.when( - data: (products) => SmartSearchDropdown( - labelText: 'Product', - hintText: 'Select Product', - value: _selectedProductId, - items: products.map((p) => p.id!).toList(), - itemAsString: (id) => products.where((p) => p.id == id).firstOrNull?.name ?? 'Unknown', - onChanged: (val) { - setState(() { - _selectedProductId = val; - }); - }, - ), - loading: () => const CircularProgressIndicator(), - error: (e, stack) => Text('Error: $e'), - ), - const SizedBox(height: 16), - PremiumTextField( - controller: _qtyCtrl, - labelText: 'Number of Items', - keyboardType: TextInputType.number, - ), - const SizedBox(height: 24), - SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: _save, - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 16), - backgroundColor: Colors.blue, - foregroundColor: Colors.white, + child: Column( + children: [ + Center( + child: Container( + margin: const EdgeInsets.only(top: 12, bottom: 8), + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.grey.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ), ), - child: const Text('Add Item'), - ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Add Items to Invoice', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + IconButton( + icon: const Icon(LucideIcons.x, size: 20), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ), + const Divider(height: 1), + + Expanded( + child: ListView( + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + padding: const EdgeInsets.fromLTRB(20, 16, 20, 40), + children: [ + TextField( + controller: _searchCtrl, + decoration: InputDecoration( + labelText: 'Search Product by Name / SKU / Barcode', + prefixIcon: const Icon(LucideIcons.search), + suffixIcon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (_searchCtrl.text.isNotEmpty) + IconButton( + icon: const Icon(LucideIcons.x), + onPressed: () => setState(() => _searchCtrl.clear()), + ), + IconButton( + icon: const Icon(LucideIcons.qrCode, color: Colors.blue), + tooltip: 'Scan Barcode', + onPressed: _scanBarcodeInSheet, + ), + ], + ), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + ), + onChanged: (val) => setState(() {}), + ), + const SizedBox(height: 16), + + const Text('Products & Catalogue:', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: Colors.grey)), + const SizedBox(height: 10), + + if (filteredProducts.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 32.0), + child: Center( + child: Text( + 'No matching products found', + style: TextStyle(color: Colors.grey.shade500), + ), + ), + ) + else + ...filteredProducts.map((p) { + final cat = categories.where((c) => c.id == p.categoryId).firstOrNull; + double commRate = 0.0; + if (cat?.commodityCode != null) { + final m = commodityRates.where((r) => r.commodityCode.toUpperCase() == cat!.commodityCode!.toUpperCase()).firstOrNull; + if (m != null) commRate = m.rate; + } + return _buildPOSearchCard( + product: p, + category: cat, + commodityRate: commRate, + isDark: isDark, + ); + }), + ], + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildPOSheetBadge(String text, Color color) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: color.withValues(alpha: 0.4)), + ), + child: Text( + text, + style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: color), + ), + ); + } + + Widget _buildPOSearchCard({ + required Product product, + ProductCategory? category, + required double commodityRate, + required bool isDark, + }) { + final sku = product.sku; + final qtyCtrl = _getQtyController(product.id ?? 0); + + return Container( + margin: const EdgeInsets.only(bottom: 12), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1E293B) : Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: isDark ? Colors.white12 : Colors.grey.shade200), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: isDark ? 0.2 : 0.04), + blurRadius: 8, + offset: const Offset(0, 3), ), - const SizedBox(height: 24), ], ), + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.blue.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + ), + child: const Icon( + LucideIcons.tag, + size: 20, + color: Colors.blue, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + product.name, + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15), + ), + const SizedBox(height: 4), + Wrap( + spacing: 6, + runSpacing: 4, + children: [ + if (category?.name != null) + _buildPOSheetBadge(category!.name, const Color(0xFFD4AF37)), + if (sku != null && sku.isNotEmpty) + _buildPOSheetBadge('SKU: $sku', Colors.blue), + _buildPOSheetBadge('Purity: ${formatPurity(resolvePurity(categoryPurity: category?.purityFactor, productPurity: product.purityFactor))}', Colors.teal), + ], + ), + ], + ), + ), + const SizedBox(width: 12), + // Small box to enter number of items + SizedBox( + width: 75, + child: TextFormField( + controller: qtyCtrl, + keyboardType: TextInputType.number, + textAlign: TextAlign.center, + decoration: InputDecoration( + labelText: 'Qty', + isDense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)), + ), + ), + ), + ], + ), + const Divider(height: 20), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () { + final qty = int.tryParse(qtyCtrl.text.trim()) ?? 1; + _addItemsFromCard(product, category, qty); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 10), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + icon: const Icon(LucideIcons.plus, size: 16), + label: const Text('Add to Invoice', style: TextStyle(fontWeight: FontWeight.bold)), + ), + ), + ], + ), + ), ); } } @@ -866,10 +1300,10 @@ class _POItemRowState extends State<_POItemRow> { @override void initState() { super.initState(); - _weightCtrl = TextEditingController(text: widget.item.weight?.toString() ?? ''); - _rateCtrl = TextEditingController(text: widget.item.unitPrice > 0 ? widget.item.unitPrice.toString() : ''); + _weightCtrl = TextEditingController(text: widget.item.weight != null && widget.item.weight! > 0 ? widget.item.weight.toString() : '0'); + _rateCtrl = TextEditingController(text: widget.item.unitPrice > 0 ? widget.item.unitPrice.toString() : '0'); _huidCtrl = TextEditingController(text: widget.item.huid ?? ''); - _totalCtrl = TextEditingController(text: widget.item.total > 0 ? widget.item.total.toString() : ''); + _totalCtrl = TextEditingController(text: widget.item.total > 0 ? widget.item.total.toStringAsFixed(2) : '0.00'); } @override @@ -882,12 +1316,13 @@ class _POItemRowState extends State<_POItemRow> { } void _updateItem() { - final weight = double.tryParse(_weightCtrl.text); + final weight = double.tryParse(_weightCtrl.text) ?? 0.0; final rate = double.tryParse(_rateCtrl.text) ?? 0.0; - final total = (weight ?? 0.0) * rate; - if (_totalCtrl.text != total.toStringAsFixed(2)) { - _totalCtrl.text = total.toStringAsFixed(2); + final total = weight * rate; + final totalFormatted = total.toStringAsFixed(2); + if (_totalCtrl.text != totalFormatted) { + _totalCtrl.text = totalFormatted; } final updated = widget.item.copyWith( @@ -1029,6 +1464,18 @@ class _POItemRowState extends State<_POItemRow> { style: TextStyle(fontSize: 10, color: Colors.grey[700]), ), ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.amber.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: Colors.amber.withValues(alpha: 0.4)), + ), + child: Text( + 'Purity: ${formatPurity(resolvePurity(categoryPurity: widget.category?.purityFactor, productPurity: widget.product?.purityFactor))}', + style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.amber.shade900), + ), + ), ], ), ], @@ -1060,7 +1507,7 @@ class _POItemRowState extends State<_POItemRow> { labelText: 'Weight/Pcs', keyboardType: const TextInputType.numberWithOptions(decimal: true), onChanged: (_) => _updateItem(), - suffixIcon: widget.category?.baseUnit != null && widget.category!.baseUnit!.isNotEmpty + suffixIcon: (widget.category?.baseUnit != null && widget.category!.baseUnit.isNotEmpty) ? Container( padding: const EdgeInsets.symmetric(horizontal: 12), decoration: BoxDecoration( @@ -1071,7 +1518,7 @@ class _POItemRowState extends State<_POItemRow> { child: Column( mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, - children: [Text(widget.category!.baseUnit!, style: TextStyle(color: Colors.grey[700]))], + children: [Text(widget.category?.baseUnit ?? '', style: TextStyle(color: Colors.grey[700]))], ), ) : null, @@ -1089,7 +1536,7 @@ class _POItemRowState extends State<_POItemRow> { labelText: 'Rate', keyboardType: const TextInputType.numberWithOptions(decimal: true), onChanged: (_) => _updateItem(), - suffixIcon: widget.category?.baseUnit != null && widget.category!.baseUnit!.isNotEmpty + suffixIcon: (widget.category?.baseUnit != null && widget.category!.baseUnit.isNotEmpty) ? Container( padding: const EdgeInsets.symmetric(horizontal: 12), decoration: BoxDecoration( @@ -1100,7 +1547,7 @@ class _POItemRowState extends State<_POItemRow> { child: Column( mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, - children: [Text('per ${widget.category!.baseUnit}', style: TextStyle(color: Colors.grey[700]))], + children: [Text('per ${widget.category?.baseUnit ?? ''}', style: TextStyle(color: Colors.grey[700]))], ), ) : null, diff --git a/kifi-app/lib/features/vendor/presentation/purchase_order_details_screen.dart b/kifi-app/lib/features/vendor/presentation/purchase_order_details_screen.dart index c5f6d12..b3f8abe 100644 --- a/kifi-app/lib/features/vendor/presentation/purchase_order_details_screen.dart +++ b/kifi-app/lib/features/vendor/presentation/purchase_order_details_screen.dart @@ -1,5 +1,4 @@ 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'; @@ -1143,11 +1142,11 @@ class _PurchaseOrderDetailsScreenState ], ), ); - }).toList(), + }), Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: Colors.blue.shade50.withOpacity(0.5), + color: Colors.blue.shade50.withValues(alpha: 0.5), borderRadius: const BorderRadius.only( bottomLeft: Radius.circular(16), bottomRight: Radius.circular(16), diff --git a/kifi-app/lib/features/vendor/presentation/purchase_orders_list_screen.dart b/kifi-app/lib/features/vendor/presentation/purchase_orders_list_screen.dart index d249146..6aaaaf2 100644 --- a/kifi-app/lib/features/vendor/presentation/purchase_orders_list_screen.dart +++ b/kifi-app/lib/features/vendor/presentation/purchase_orders_list_screen.dart @@ -56,34 +56,44 @@ class _PurchaseOrdersListScreenState Widget build(BuildContext context) { final poState = ref.watch(purchaseOrdersProvider); final vendorsState = ref.watch(vendorsProvider); - final darkTheme = Theme.of(context).brightness == Brightness.dark; + final isDark = Theme.of(context).brightness == Brightness.dark; + final formatCurrency = NumberFormat.currency(symbol: '₹'); + final formatDate = DateFormat('MMM dd, yyyy'); return Scaffold( - backgroundColor: Colors.grey[100], + backgroundColor: isDark ? const Color(0xFF0F172A) : const Color(0xFFF8FAFC), appBar: AppBar( title: const Text( 'Purchase Invoices', style: TextStyle(fontWeight: FontWeight.bold), ), elevation: 0, - backgroundColor: Colors.white, - foregroundColor: Colors.black, + backgroundColor: isDark ? const Color(0xFF1E293B) : Colors.white, + foregroundColor: isDark ? Colors.white : Colors.black, centerTitle: true, ), body: Column( children: [ Container( - color: Colors.white, + color: isDark ? const Color(0xFF1E293B) : Colors.white, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: TextField( controller: _searchController, decoration: InputDecoration( - hintText: 'Search PO Number...', + hintText: 'Search PO Number or Vendor...', prefixIcon: const Icon(LucideIcons.search, color: Colors.grey), contentPadding: const EdgeInsets.symmetric(vertical: 14), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: isDark ? Colors.white12 : Colors.grey.shade300), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: isDark ? Colors.white12 : Colors.grey.shade300), + ), suffixIcon: _searchQuery.isNotEmpty ? IconButton( - icon: const Icon(LucideIcons.xCircle), + icon: const Icon(LucideIcons.xCircle, size: 20), onPressed: () { _searchController.clear(); setState(() => _searchQuery = ''); @@ -91,32 +101,43 @@ class _PurchaseOrdersListScreenState ) : null, ), - onChanged: (val) => setState(() => _searchQuery = val), + onChanged: (val) => setState(() => _searchQuery = val.trim()), ), ), Expanded( child: poState.when( data: (pos) { + final vendors = vendorsState.value ?? []; final filtered = pos.where((p) { - return p.poNumber.toLowerCase().contains( - _searchQuery.toLowerCase(), - ); + final matchesPo = p.poNumber.toLowerCase().contains(_searchQuery.toLowerCase()); + final vendor = vendors.where((v) => v.id == p.vendorId).firstOrNull; + final matchesVendor = vendor != null && vendor.name.toLowerCase().contains(_searchQuery.toLowerCase()); + return matchesPo || matchesVendor; }).toList(); if (filtered.isEmpty) { - return const Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - LucideIcons.clipboardList, - size: 64, - color: Colors.grey, - ), - SizedBox(height: 16), - Text( - 'No purchase invoices found.', - style: TextStyle(color: Colors.grey, fontSize: 16), + return RefreshIndicator( + onRefresh: () => ref.refresh(purchaseOrdersProvider.future), + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: const [ + SizedBox(height: 120), + Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + LucideIcons.clipboardList, + size: 64, + color: Colors.grey, + ), + SizedBox(height: 16), + Text( + 'No purchase invoices found.', + style: TextStyle(color: Colors.grey, fontSize: 16), + ), + ], + ), ), ], ), @@ -130,20 +151,19 @@ class _PurchaseOrdersListScreenState itemCount: filtered.length, itemBuilder: (context, index) { final po = filtered[index]; - final vendors = vendorsState.value ?? []; - final vendor = vendors.firstWhere( - (v) => v.id == po.vendorId, - orElse: () => po.vendor!, - ); + final vendor = vendors.where((v) => v.id == po.vendorId).firstOrNull; + final statusColor = _getStatusColor(po); + final statusLabel = _getStatusLabel(po); return Container( margin: const EdgeInsets.only(bottom: 16), decoration: BoxDecoration( - color: Colors.white, + color: isDark ? const Color(0xFF1E293B) : Colors.white, borderRadius: BorderRadius.circular(20), + border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200), boxShadow: [ BoxShadow( - color: Colors.grey.withOpacity(0.08), + color: Colors.black.withValues(alpha: isDark ? 0.2 : 0.04), blurRadius: 10, offset: const Offset(0, 4), ), @@ -193,20 +213,17 @@ class _PurchaseOrdersListScreenState Container( padding: const EdgeInsets.symmetric( horizontal: 12, - vertical: 6, + vertical: 5, ), decoration: BoxDecoration( - color: _getStatusColor( - po, - ).withOpacity(0.1), - borderRadius: BorderRadius.circular( - 20, - ), + color: statusColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: statusColor.withValues(alpha: 0.3), width: 0.8), ), child: Text( - _getStatusLabel(po), + statusLabel, style: TextStyle( - color: _getStatusColor(po), + color: statusColor, fontSize: 12, fontWeight: FontWeight.bold, ), @@ -218,24 +235,30 @@ class _PurchaseOrdersListScreenState Row( children: [ const Icon( - LucideIcons.user, + LucideIcons.truck, size: 16, color: Colors.grey, ), const SizedBox(width: 8), Expanded( child: Text( - vendor.name, + vendor?.name ?? po.vendor?.name ?? 'Vendor', style: const TextStyle( - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, + fontSize: 14, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ), + if (po.items.isNotEmpty) + Text( + '${po.items.length} ${po.items.length == 1 ? "item" : "items"}', + style: TextStyle(fontSize: 12, color: Colors.grey.shade500), + ), ], ), - const SizedBox(height: 8), + const SizedBox(height: 10), Row( children: [ const Icon( @@ -245,20 +268,19 @@ class _PurchaseOrdersListScreenState ), const SizedBox(width: 8), Text( - DateFormat( - 'MMM dd, yyyy', - ).format(po.issueDate), - style: const TextStyle( - color: Colors.grey, - fontSize: 14, + formatDate.format(po.issueDate), + style: TextStyle( + color: Colors.grey.shade600, + fontSize: 13, ), ), const Spacer(), Text( - '₹${po.totalAmount.toStringAsFixed(2)}', + formatCurrency.format(po.totalAmount), style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 16, + color: Colors.green, ), ), ], @@ -289,7 +311,9 @@ class _PurchaseOrdersListScreenState ); }, icon: const Icon(LucideIcons.plus), - label: const Text('New PO'), + label: const Text('Create PO', style: TextStyle(fontWeight: FontWeight.bold)), + backgroundColor: Colors.blue, + foregroundColor: Colors.white, ), ); } diff --git a/kifi-app/lib/features/vendor/presentation/vendors_list_screen.dart b/kifi-app/lib/features/vendor/presentation/vendors_list_screen.dart index fd40bd6..38825af 100644 --- a/kifi-app/lib/features/vendor/presentation/vendors_list_screen.dart +++ b/kifi-app/lib/features/vendor/presentation/vendors_list_screen.dart @@ -17,7 +17,6 @@ class _VendorsListScreenState extends ConsumerState { @override Widget build(BuildContext context) { final vendorsState = ref.watch(vendorsProvider); - final darkTheme = Theme.of(context).brightness == Brightness.dark; return Scaffold( backgroundColor: Colors.grey[100], @@ -82,7 +81,7 @@ class _VendorsListScreenState extends ConsumerState { borderRadius: BorderRadius.circular(20), boxShadow: [ BoxShadow( - color: Colors.grey.withOpacity(0.08), + color: Colors.grey.withValues(alpha: 0.08), blurRadius: 10, offset: const Offset(0, 4), ), @@ -107,8 +106,8 @@ class _VendorsListScreenState extends ConsumerState { children: [ CircleAvatar( radius: 24, - backgroundColor: Colors.orange.withOpacity( - 0.1, + backgroundColor: Colors.orange.withValues( + alpha: 0.1, ), child: vendor.photoUrl != null ? ClipOval( diff --git a/kifi-app/lib/features/vendor/providers/purchase_orders_provider.dart b/kifi-app/lib/features/vendor/providers/purchase_orders_provider.dart index b3c408f..63bf82f 100644 --- a/kifi-app/lib/features/vendor/providers/purchase_orders_provider.dart +++ b/kifi-app/lib/features/vendor/providers/purchase_orders_provider.dart @@ -1,4 +1,3 @@ -import 'package:dio/dio.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/network/dio_client.dart'; import '../domain/purchase_order.dart'; diff --git a/kifi-app/lib/features/vendor/providers/vendors_provider.dart b/kifi-app/lib/features/vendor/providers/vendors_provider.dart index 5d4a23c..9e87dd3 100644 --- a/kifi-app/lib/features/vendor/providers/vendors_provider.dart +++ b/kifi-app/lib/features/vendor/providers/vendors_provider.dart @@ -1,4 +1,3 @@ -import 'package:dio/dio.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/network/dio_client.dart'; import '../domain/vendor.dart'; diff --git a/kifi-app/lib/main.dart b/kifi-app/lib/main.dart index 492eebf..b405ce4 100644 --- a/kifi-app/lib/main.dart +++ b/kifi-app/lib/main.dart @@ -5,6 +5,7 @@ import 'package:local_auth/local_auth.dart'; import 'core/theme/app_theme.dart'; import 'core/theme/theme_provider.dart'; import 'features/auth/presentation/auth_screen.dart'; +import 'features/auth/presentation/setup_wizard_screen.dart'; import 'features/dashboard/presentation/dashboard_screen.dart'; import 'features/onboarding/presentation/onboarding_screen.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -31,6 +32,7 @@ class KifiApp extends ConsumerStatefulWidget { class _KifiAppState extends ConsumerState { bool _isLoading = true; bool _isAuthenticated = false; + bool _isSetupCompleted = true; bool _hasSeenOnboarding = false; final LocalAuthentication _localAuth = LocalAuthentication(); String? _startupError; @@ -43,11 +45,11 @@ class _KifiAppState extends ConsumerState { Future _authenticateWithBiometrics() async { try { - final canCheckBiometrics = await _localAuth.canCheckBiometrics; + final isAvailable = await _localAuth.canCheckBiometrics; final isDeviceSupported = await _localAuth.isDeviceSupported(); - if (!canCheckBiometrics && !isDeviceSupported) { - return true; // Pass if device doesn't support biometrics to avoid locking users out + if (!isAvailable || !isDeviceSupported) { + return true; // If device does not support biometrics, allow access via stored token } return await _localAuth.authenticate( @@ -79,11 +81,27 @@ class _KifiAppState extends ConsumerState { return; // User failed biometrics, leave them on login screen } - setState(() { - _isAuthenticated = true; - _hasSeenOnboarding = hasSeenOnboarding; - _isLoading = false; - }); + // Validate token and check account setup status with backend + try { + final res = await DioClient().dio.get('/account/setup/status'); + final status = res.data != null ? res.data['status'] : null; + + setState(() { + _isAuthenticated = true; + _isSetupCompleted = status == 'COMPLETED'; + _hasSeenOnboarding = hasSeenOnboarding; + _isLoading = false; + }); + } catch (_) { + // Token is stale / invalid / user was wiped from DB -> clear token and go to AuthScreen + await storage.delete(key: 'jwt_token'); + await DioClient().clearToken(); + setState(() { + _isAuthenticated = false; + _hasSeenOnboarding = hasSeenOnboarding; + _isLoading = false; + }); + } } else { setState(() { _isAuthenticated = false; @@ -91,7 +109,7 @@ class _KifiAppState extends ConsumerState { _isLoading = false; }); } - } catch (e, stacktrace) { + } catch (e) { setState(() { _startupError = e.toString(); _isLoading = false; @@ -116,7 +134,9 @@ class _KifiAppState extends ConsumerState { ? const Scaffold(body: Center(child: CircularProgressIndicator())) : (!_hasSeenOnboarding ? const OnboardingScreen() - : (_isAuthenticated ? const DashboardScreen() : const AuthScreen())), + : (_isAuthenticated + ? (_isSetupCompleted ? const DashboardScreen() : const SetupWizardScreen()) + : const AuthScreen())), ); } } diff --git a/kifi-app/lib/test_dio.dart b/kifi-app/lib/test_dio.dart deleted file mode 100644 index 4903588..0000000 --- a/kifi-app/lib/test_dio.dart +++ /dev/null @@ -1,6 +0,0 @@ -import 'package:dio/dio.dart'; -void main() { - final dio = Dio(BaseOptions(baseUrl: 'https://app.technobeesolutions.in/api/kifi-v2')); - final req = RequestOptions(path: '/account/setup/status', baseUrl: dio.options.baseUrl); - print(req.uri); -}