Revamp Done - Support for Individual and Jwellery module added

This commit is contained in:
2026-08-29 12:01:00 +05:30
parent eacca6aaea
commit cdc58c9bce
37 changed files with 4334 additions and 2935 deletions

View File

@@ -34,6 +34,7 @@ public class ProductCategoryController {
public Mono<ResponseEntity<ProductCategory>> createCategory(Authentication authentication, @RequestBody ProductCategory category) { public Mono<ResponseEntity<ProductCategory>> createCategory(Authentication authentication, @RequestBody ProductCategory category) {
Long userId = Long.valueOf(authentication.getDetails().toString()); Long userId = Long.valueOf(authentication.getDetails().toString());
category.setUserId(userId); category.setUserId(userId);
category.setPurityFactor(normalizePurity(category.getPurityFactor()));
category.setCreatedAt(LocalDateTime.now()); category.setCreatedAt(LocalDateTime.now());
return categoryRepository.save(category) return categoryRepository.save(category)
.map(ResponseEntity::ok); .map(ResponseEntity::ok);
@@ -47,7 +48,7 @@ public class ProductCategoryController {
existing.setParentCategoryId(category.getParentCategoryId()); existing.setParentCategoryId(category.getParentCategoryId());
existing.setHasChild(category.getHasChild()); existing.setHasChild(category.getHasChild());
existing.setCommodityCode(category.getCommodityCode()); existing.setCommodityCode(category.getCommodityCode());
existing.setPurityFactor(category.getPurityFactor() != null ? category.getPurityFactor() : 1.0); existing.setPurityFactor(normalizePurity(category.getPurityFactor()));
existing.setDefaultHsn(category.getDefaultHsn()); existing.setDefaultHsn(category.getDefaultHsn());
existing.setDefaultGst(category.getDefaultGst()); existing.setDefaultGst(category.getDefaultGst());
existing.setHuidRequired(category.getHuidRequired()); existing.setHuidRequired(category.getHuidRequired());
@@ -63,4 +64,12 @@ public class ProductCategoryController {
.defaultIfEmpty(ResponseEntity.notFound().build()); .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;
}
} }

View File

@@ -43,6 +43,15 @@ public class Invoice {
@Column("tax_total") @Column("tax_total")
private BigDecimal taxTotal; private BigDecimal taxTotal;
@Column("cgst_total")
private BigDecimal cgstTotal;
@Column("sgst_total")
private BigDecimal sgstTotal;
@Column("igst_total")
private BigDecimal igstTotal;
@Column("discount_total") @Column("discount_total")
private BigDecimal discountTotal; private BigDecimal discountTotal;

View File

@@ -46,8 +46,20 @@ public class InvoiceItem {
@Column("making_charge") @Column("making_charge")
private BigDecimal makingCharge; private BigDecimal makingCharge;
@Column("making_charges_type")
private String makingChargesType;
@Column("other_charges") @Column("other_charges")
private BigDecimal otherCharges; 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; private BigDecimal total;
} }

View File

@@ -28,6 +28,8 @@ public class SetupService {
public Mono<java.util.Map<String, String>> getSetupStatus(Long userId) { public Mono<java.util.Map<String, String>> getSetupStatus(Long userId) {
return userRepository.findById(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 -> { .map(u -> {
java.util.Map<String, String> map = new java.util.HashMap<>(); java.util.Map<String, String> map = new java.util.HashMap<>();
map.put("status", u.getSetupStatus() != null ? u.getSetupStatus() : "NOT_STARTED"); 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("name", u.getName() != null ? u.getName() : "");
map.put("email", u.getEmail() != null ? u.getEmail() : ""); map.put("email", u.getEmail() != null ? u.getEmail() : "");
return map; return map;
}) });
.defaultIfEmpty(java.util.Map.of("status", "NOT_STARTED", "profileType", "INDIVIDUAL"));
} }
public Mono<Boolean> isUsernameAvailable(String username) { public Mono<Boolean> isUsernameAvailable(String username) {

View File

@@ -54,10 +54,10 @@ public class CommodityRateService {
.flatMap(latestRate -> .flatMap(latestRate ->
productCategoryRepository.findByUserIdAndCommodityCode(userId, commodityCode) productCategoryRepository.findByUserIdAndCommodityCode(userId, commodityCode)
.flatMap(category -> { .flatMap(category -> {
double categoryPurity = category.getPurityFactor() != null ? category.getPurityFactor() : 1.0; final double categoryPurity = normalizePurity(category.getPurityFactor());
return productRepository.findByCategoryId(category.getId()) return productRepository.findByCategoryId(category.getId())
.flatMap(product -> { .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)); BigDecimal baseRate = latestRate.getRate().multiply(BigDecimal.valueOf(productPurity));
if ("WEIGHT_BASED".equalsIgnoreCase(product.getPriceCalcRule())) { if ("WEIGHT_BASED".equalsIgnoreCase(product.getPriceCalcRule())) {
@@ -79,4 +79,12 @@ public class CommodityRateService {
) )
.defaultIfEmpty(0); .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;
}
} }

View File

@@ -68,7 +68,9 @@ public class ProductService {
product.setUpdatedAt(LocalDateTime.now()); product.setUpdatedAt(LocalDateTime.now());
if (product.getIsActive() == null) product.setIsActive(true); if (product.getIsActive() == null) product.setIsActive(true);
if (product.getPriceCalcRule() == null) product.setPriceCalcRule("MANUAL"); 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); return productRepository.save(product);
} }
@@ -87,7 +89,7 @@ public class ProductService {
existingProduct.setSize(updatedProduct.getSize()); existingProduct.setSize(updatedProduct.getSize());
existingProduct.setDimensions(updatedProduct.getDimensions()); existingProduct.setDimensions(updatedProduct.getDimensions());
existingProduct.setPriceCalcRule(updatedProduct.getPriceCalcRule()); existingProduct.setPriceCalcRule(updatedProduct.getPriceCalcRule());
existingProduct.setPurityFactor(updatedProduct.getPurityFactor()); existingProduct.setPurityFactor(normalizePurity(updatedProduct.getPurityFactor()));
existingProduct.setMakingCharges(updatedProduct.getMakingCharges()); existingProduct.setMakingCharges(updatedProduct.getMakingCharges());
existingProduct.setMakingChargesType(updatedProduct.getMakingChargesType()); existingProduct.setMakingChargesType(updatedProduct.getMakingChargesType());
existingProduct.setWastagePercentage(updatedProduct.getWastagePercentage()); existingProduct.setWastagePercentage(updatedProduct.getWastagePercentage());
@@ -288,4 +290,13 @@ public class ProductService {
.switchIfEmpty(Mono.error(new RuntimeException("Access denied"))) .switchIfEmpty(Mono.error(new RuntimeException("Access denied")))
.flatMap(p -> productBomRepository.delete(bom))); .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;
}
} }

View File

@@ -1,169 +1,43 @@
-- ==========================================================
-- KIFI PLATFORM CONSOLIDATED DATABASE SCHEMA
-- ==========================================================
-- 1. Core Users Table
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL, email VARCHAR(255) UNIQUE NOT NULL,
password VARCHAR(255), 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, enabled BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
); );
CREATE TABLE IF NOT EXISTS merchants ( -- 2. User Consents
id SERIAL PRIMARY KEY, CREATE TABLE IF NOT EXISTS user_consents (
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 (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
business_name VARCHAR(255) NOT NULL, consent_type VARCHAR(100) NOT NULL,
industry VARCHAR(100), policy_version VARCHAR(50) NOT NULL,
tax_number VARCHAR(100), accepted BOOLEAN DEFAULT TRUE,
currency VARCHAR(10) DEFAULT 'INR', accepted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
tax_included_in_price BOOLEAN DEFAULT FALSE, ip_address VARCHAR(100),
address TEXT, user_agent TEXT,
state_id INTEGER, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
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)
); );
-- 3. Business Profiles & Features
CREATE TABLE IF NOT EXISTS indian_states ( CREATE TABLE IF NOT EXISTS indian_states (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL, name VARCHAR(100) NOT NULL,
gst_code VARCHAR(2) NOT NULL gst_code VARCHAR(2) NOT NULL
); );
-- Seed Indian States if empty
INSERT INTO indian_states (name, gst_code) VALUES INSERT INTO indian_states (name, gst_code) VALUES
('Jammu & Kashmir', '01'), ('Himachal Pradesh', '02'), ('Punjab', '03'), ('Jammu & Kashmir', '01'), ('Himachal Pradesh', '02'), ('Punjab', '03'),
('Chandigarh', '04'), ('Uttarakhand', '05'), ('Haryana', '06'), ('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') ('Telangana', '36'), ('Andhra Pradesh', '37'), ('Ladakh', '38')
ON CONFLICT DO NOTHING; 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 ( CREATE TABLE IF NOT EXISTS business_features (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
@@ -187,20 +83,128 @@ CREATE TABLE IF NOT EXISTS business_features (
sales_management BOOLEAN DEFAULT FALSE, sales_management BOOLEAN DEFAULT FALSE,
multi_location BOOLEAN DEFAULT FALSE, multi_location BOOLEAN DEFAULT FALSE,
bom_reduction_strategy VARCHAR(50) DEFAULT 'COMPONENTS_ONLY', bom_reduction_strategy VARCHAR(50) DEFAULT 'COMPONENTS_ONLY',
stock_deduction_on_invoice BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id) 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 ( CREATE TABLE IF NOT EXISTS product_categories (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL, 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, has_child BOOLEAN DEFAULT FALSE,
default_hsn VARCHAR(50), default_hsn VARCHAR(50),
default_gst DECIMAL(5, 2), default_gst DECIMAL(5, 2),
huid_required BOOLEAN DEFAULT FALSE, huid_required BOOLEAN DEFAULT FALSE,
default_making_charge DECIMAL(15, 2), default_making_charge DECIMAL(15, 2),
making_charge_type VARCHAR(50),
commodity_code VARCHAR(10), commodity_code VARCHAR(10),
purity_factor DECIMAL(5, 4) DEFAULT 1.0, purity_factor DECIMAL(5, 4) DEFAULT 1.0,
base_unit VARCHAR(20) DEFAULT 'pcs', base_unit VARCHAR(20) DEFAULT 'pcs',
@@ -217,38 +221,10 @@ CREATE TABLE IF NOT EXISTS commodity_rate_history (
rate DECIMAL(15, 2) NOT NULL, rate DECIMAL(15, 2) NOT NULL,
effective_at TIMESTAMP NOT NULL, effective_at TIMESTAMP NOT NULL,
source VARCHAR(100), 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 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 ( CREATE TABLE IF NOT EXISTS units_of_measure (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
@@ -260,8 +236,8 @@ CREATE TABLE IF NOT EXISTS units_of_measure (
CREATE TABLE IF NOT EXISTS products ( CREATE TABLE IF NOT EXISTS products (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
category_id INTEGER REFERENCES product_categories(id), category_id INTEGER REFERENCES product_categories(id) ON DELETE SET NULL,
uom_id INTEGER REFERENCES units_of_measure(id), uom_id INTEGER REFERENCES units_of_measure(id) ON DELETE SET NULL,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
sku VARCHAR(100), sku VARCHAR(100),
barcode VARCHAR(100), barcode VARCHAR(100),
@@ -274,9 +250,9 @@ CREATE TABLE IF NOT EXISTS products (
color VARCHAR(50), color VARCHAR(50),
size VARCHAR(50), size VARCHAR(50),
price_calc_rule VARCHAR(50) DEFAULT 'MANUAL', 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 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, wastage_percentage DECIMAL(5, 2) DEFAULT 0.0,
min_stock DECIMAL(10, 2) DEFAULT 0, min_stock DECIMAL(10, 2) DEFAULT 0,
reorder_level 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 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 ( CREATE TABLE IF NOT EXISTS inventory_movements (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
location_id INTEGER REFERENCES inventory_locations(id), location_id INTEGER REFERENCES inventory_locations(id) ON DELETE SET NULL,
type VARCHAR(50) NOT NULL, -- ADDITION, REDUCTION, TRANSFER, ADJUSTMENT type VARCHAR(50) NOT NULL,
reference_transaction_id INTEGER REFERENCES transactions(id), reference_transaction_id INTEGER REFERENCES transactions(id) ON DELETE SET NULL,
notes TEXT, notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 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 ( CREATE TABLE IF NOT EXISTS inventory_movement_items (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
movement_id INTEGER REFERENCES inventory_movements(id) ON DELETE CASCADE, 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, quantity DECIMAL(10, 2) NOT NULL,
unit_price DECIMAL(10, 2), unit_price DECIMAL(10, 2),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
@@ -340,9 +416,7 @@ CREATE TABLE IF NOT EXISTS inventory_balances (
UNIQUE(product_id, location_id) UNIQUE(product_id, location_id)
); );
-- SALES & INVOICES (Phase 2) -- 8. Customers, Sales & Invoices
ALTER TABLE business_features ADD COLUMN IF NOT EXISTS stock_deduction_on_invoice BOOLEAN DEFAULT TRUE;
CREATE TABLE IF NOT EXISTS customers ( CREATE TABLE IF NOT EXISTS customers (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
@@ -351,13 +425,14 @@ CREATE TABLE IF NOT EXISTS customers (
phone VARCHAR(50), phone VARCHAR(50),
address TEXT, address TEXT,
gstin VARCHAR(50), gstin VARCHAR(50),
photo_url VARCHAR(1024),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
); );
CREATE TABLE IF NOT EXISTS invoices ( CREATE TABLE IF NOT EXISTS invoices (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
customer_id INTEGER REFERENCES customers(id), customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
invoice_number VARCHAR(100) NOT NULL, invoice_number VARCHAR(100) NOT NULL,
issue_date DATE NOT NULL, issue_date DATE NOT NULL,
due_date DATE, due_date DATE,
@@ -365,15 +440,12 @@ CREATE TABLE IF NOT EXISTS invoices (
tax_total DECIMAL(15, 2) DEFAULT 0.0, tax_total DECIMAL(15, 2) DEFAULT 0.0,
discount_total DECIMAL(15, 2) DEFAULT 0.0, discount_total DECIMAL(15, 2) DEFAULT 0.0,
total_amount DECIMAL(15, 2) NOT NULL, total_amount DECIMAL(15, 2) NOT NULL,
status VARCHAR(50) DEFAULT 'DRAFT', -- DRAFT, SENT, PAID, PARTIAL, OVERDUE, CANCELLED status VARCHAR(50) DEFAULT 'DRAFT',
notes TEXT, notes TEXT,
-- EMI tracking fields
is_emi BOOLEAN DEFAULT FALSE, is_emi BOOLEAN DEFAULT FALSE,
emi_amount DECIMAL(15, 2), emi_amount DECIMAL(15, 2),
emi_cycle VARCHAR(20), -- MONTHLY, WEEKLY emi_cycle VARCHAR(20),
emi_start_date DATE, emi_start_date DATE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
); );
@@ -381,8 +453,8 @@ CREATE TABLE IF NOT EXISTS invoices (
CREATE TABLE IF NOT EXISTS invoice_items ( CREATE TABLE IF NOT EXISTS invoice_items (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
invoice_id INTEGER REFERENCES invoices(id) ON DELETE CASCADE, invoice_id INTEGER REFERENCES invoices(id) ON DELETE CASCADE,
product_id INTEGER REFERENCES products(id), product_id INTEGER REFERENCES products(id) ON DELETE SET NULL,
inventory_item_id INTEGER REFERENCES inventory_items(id), inventory_item_id INTEGER REFERENCES inventory_items(id) ON DELETE SET NULL,
hsn_code VARCHAR(50), hsn_code VARCHAR(50),
description VARCHAR(255), description VARCHAR(255),
quantity DECIMAL(10, 3) NOT NULL, quantity DECIMAL(10, 3) NOT NULL,
@@ -397,19 +469,20 @@ CREATE TABLE IF NOT EXISTS invoice_items (
CREATE TABLE IF NOT EXISTS invoice_payments ( CREATE TABLE IF NOT EXISTS invoice_payments (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
invoice_id INTEGER REFERENCES invoices(id) ON DELETE CASCADE, invoice_id INTEGER REFERENCES invoices(id) ON DELETE CASCADE,
transaction_id INTEGER REFERENCES transactions(id) ON DELETE CASCADE, transaction_id INTEGER REFERENCES transactions(id) ON DELETE SET NULL,
amount DECIMAL(15, 2) NOT NULL, amount DECIMAL(15, 2) NOT NULL,
payment_date DATE NOT NULL, payment_date DATE NOT NULL,
emi_installment_number INTEGER, emi_installment_number INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
); );
-- 9. Projects & Task Management
CREATE TABLE IF NOT EXISTS projects ( CREATE TABLE IF NOT EXISTS projects (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
description TEXT, description TEXT,
customer_id INTEGER REFERENCES customers(id), customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
user_id INTEGER REFERENCES users(id), user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
budget DECIMAL(15, 2) DEFAULT 0.00, budget DECIMAL(15, 2) DEFAULT 0.00,
status VARCHAR(50) DEFAULT 'ACTIVE', status VARCHAR(50) DEFAULT 'ACTIVE',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
@@ -421,9 +494,9 @@ CREATE TABLE IF NOT EXISTS project_tasks (
title VARCHAR(255) NOT NULL, title VARCHAR(255) NOT NULL,
description TEXT, description TEXT,
assignee_name VARCHAR(255), assignee_name VARCHAR(255),
assignee_user_id INTEGER REFERENCES users(id), assignee_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
status VARCHAR(50) DEFAULT 'TODO', -- TODO, IN_PROGRESS, REVIEW, DONE status VARCHAR(50) DEFAULT 'TODO',
is_billable BOOLEAN DEFAULT false, is_billable BOOLEAN DEFAULT FALSE,
hourly_rate DECIMAL(15, 2) DEFAULT 0.00, hourly_rate DECIMAL(15, 2) DEFAULT 0.00,
hours_logged DECIMAL(10, 2) DEFAULT 0.00, hours_logged DECIMAL(10, 2) DEFAULT 0.00,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 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 ( CREATE TABLE IF NOT EXISTS project_task_comments (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
task_id INTEGER REFERENCES project_tasks(id) ON DELETE CASCADE, 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, content TEXT NOT NULL,
images TEXT, images TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 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
);

View File

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

View File

@@ -1,26 +1,13 @@
package com.kifi.api; package com.kifi.api;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest; 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 @SpringBootTest
public class DBTest { public class DBTest {
@Autowired
private BusinessProfileRepository repo;
@Test @Test
public void test() { public void contextLoads() {
BusinessProfile bp = BusinessProfile.builder().userId(100L).build(); System.out.println("Spring Boot test context loaded successfully.");
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();
} }
} }

View File

@@ -26,9 +26,9 @@ class DioClient {
DioClient._internal() DioClient._internal()
: dio = Dio(BaseOptions( : 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.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), connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 10),
)), )),

View File

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

View File

@@ -3,6 +3,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons/lucide_icons.dart';
import '../providers/auth_provider.dart'; import '../providers/auth_provider.dart';
import '../../dashboard/presentation/dashboard_screen.dart'; import '../../dashboard/presentation/dashboard_screen.dart';
import 'setup_wizard_screen.dart';
import '../../../../core/network/dio_client.dart';
class OtpScreen extends ConsumerStatefulWidget { class OtpScreen extends ConsumerStatefulWidget {
final String email; final String email;
@@ -23,11 +25,32 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
.read(authControllerProvider.notifier) .read(authControllerProvider.notifier)
.verifyOtp(widget.email, otp); .verifyOtp(widget.email, otp);
if (success && mounted) { if (success && mounted) {
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( Navigator.pushAndRemoveUntil(
context, context,
MaterialPageRoute(builder: (_) => const DashboardScreen()), MaterialPageRoute(builder: (_) => const DashboardScreen()),
(route) => false, (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,
);
}
}
} }
} }

View File

@@ -11,10 +11,23 @@ import 'auth_screen.dart';
import '../../../core/network/dio_client.dart'; import '../../../core/network/dio_client.dart';
import '../providers/auth_provider.dart'; import '../providers/auth_provider.dart';
import '../../transactions/providers/providers.dart'; import '../../transactions/providers/providers.dart';
import '../../transactions/providers/paginated_transaction_provider.dart';
import '../../../core/theme/theme_provider.dart'; import '../../../core/theme/theme_provider.dart';
import '../../business/providers/business_mode_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_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 '../../business/presentation/settings/business_settings_screen.dart';
import 'package:shared_preferences/shared_preferences.dart';
class ProfileScreen extends ConsumerStatefulWidget { class ProfileScreen extends ConsumerStatefulWidget {
const ProfileScreen({super.key}); const ProfileScreen({super.key});
@@ -86,6 +99,35 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen>
Future<void> _logout() async { Future<void> _logout() async {
await DioClient().clearToken(); 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) { if (mounted) {
Navigator.pushAndRemoveUntil( Navigator.pushAndRemoveUntil(
context, context,

View File

@@ -78,6 +78,12 @@ class AuthController extends AsyncNotifier<void> {
return false; return false;
} }
} }
Future<void> logout() async {
try {
await DioClient().clearToken();
} catch (_) {}
}
} }
final authControllerProvider = AsyncNotifierProvider<AuthController, void>(() { final authControllerProvider = AsyncNotifierProvider<AuthController, void>(() {

View File

@@ -1,19 +1,37 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'business_provider.dart';
class BusinessModeNotifier extends Notifier<bool> { class BusinessModeNotifier extends Notifier<bool> {
@override @override
bool build() { bool build() {
_loadState(); final businessProfile = ref.watch(businessProfileProvider).asData?.value;
return false; // Default until loaded if (businessProfile == null) {
return false;
}
_loadPreference();
return true;
} }
Future<void> _loadState() async { Future<void> _loadPreference() async {
final businessProfile = ref.read(businessProfileProvider).asData?.value;
if (businessProfile == null) {
state = false;
return;
}
final prefs = await SharedPreferences.getInstance(); 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<void> toggleMode() async { Future<void> toggleMode() async {
final businessProfile = ref.read(businessProfileProvider).asData?.value;
if (businessProfile == null) {
state = false;
return;
}
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
state = !state; state = !state;
await prefs.setBool('is_business_mode', state); await prefs.setBool('is_business_mode', state);

View File

@@ -41,7 +41,7 @@ class Product {
this.size, this.size,
this.priceCalcRule = 'MANUAL', this.priceCalcRule = 'MANUAL',
this.autoCalculatePrice = false, this.autoCalculatePrice = false,
this.purityFactor = 1.0, this.purityFactor,
this.makingCharges = 0.0, this.makingCharges = 0.0,
this.makingChargesType = 'FLAT', this.makingChargesType = 'FLAT',
this.wastagePercentage = 0.0, this.wastagePercentage = 0.0,
@@ -72,8 +72,7 @@ class Product {
autoCalculatePrice: autoCalculatePrice:
json['autoCalculatePrice'] ?? json['auto_calculate_price'] ?? false, json['autoCalculatePrice'] ?? json['auto_calculate_price'] ?? false,
purityFactor: purityFactor:
(json['purityFactor'] ?? json['purity_factor'] as num?)?.toDouble() ?? (json['purityFactor'] ?? json['purity_factor'] as num?)?.toDouble(),
1.0,
makingCharges: makingCharges:
(json['makingCharges'] ?? json['making_charges'] as num?) (json['makingCharges'] ?? json['making_charges'] as num?)
?.toDouble() ?? ?.toDouble() ??

View File

@@ -24,6 +24,8 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
final _hsnController = TextEditingController(); final _hsnController = TextEditingController();
final _gstController = TextEditingController(); final _gstController = TextEditingController();
final _makingChargesController = TextEditingController();
String _makingChargesType = 'PER_GRAM';
// Basic // Basic
String _name = ''; String _name = '';
@@ -55,6 +57,8 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
_uomId = p.uomId; _uomId = p.uomId;
_color = p.color ?? ''; _color = p.color ?? '';
_gstController.text = p.gstRate != null && p.gstRate! > 0 ? p.gstRate.toString() : ''; _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) { if (p.imageIds.isNotEmpty) {
_existingImageIds.addAll(p.imageIds); _existingImageIds.addAll(p.imageIds);
@@ -75,6 +79,7 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
void dispose() { void dispose() {
_hsnController.dispose(); _hsnController.dispose();
_gstController.dispose(); _gstController.dispose();
_makingChargesController.dispose();
super.dispose(); super.dispose();
} }
@@ -142,6 +147,8 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
uomId: _uomId, uomId: _uomId,
color: _color.isNotEmpty ? _color : null, color: _color.isNotEmpty ? _color : null,
gstRate: double.tryParse(_gstController.text) ?? 0, gstRate: double.tryParse(_gstController.text) ?? 0,
makingCharges: double.tryParse(_makingChargesController.text) ?? 0.0,
makingChargesType: _makingChargesType,
priceCalcRule: 'MANUAL', priceCalcRule: 'MANUAL',
autoCalculatePrice: false, autoCalculatePrice: false,
); );
@@ -245,6 +252,12 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
if (val.defaultGst != null && val.defaultGst! > 0) { if (val.defaultGst != null && val.defaultGst! > 0) {
_gstController.text = val.defaultGst.toString(); _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<AddProductScreen> {
), ),
], ],
), ),
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<String>(
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 SizedBox(height: 24),
const Text('Product Images', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), const Text('Product Images', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),

View File

@@ -6,6 +6,7 @@ import '../../business/providers/business_mode_provider.dart';
import '../../../core/theme/app_theme.dart'; import '../../../core/theme/app_theme.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons/lucide_icons.dart';
import 'dart:ui'; import 'dart:ui';
import '../../../core/utils/purity_utils.dart';
class CategoryManagementScreen extends ConsumerStatefulWidget { class CategoryManagementScreen extends ConsumerStatefulWidget {
const CategoryManagementScreen({super.key}); const CategoryManagementScreen({super.key});
@@ -272,7 +273,7 @@ class _CategoryFormSheetState extends ConsumerState<CategoryFormSheet> {
_hsnController.text = c.defaultHsn ?? ''; _hsnController.text = c.defaultHsn ?? '';
_gstController.text = c.defaultGst?.toString() ?? ''; _gstController.text = c.defaultGst?.toString() ?? '';
_makingChargeController.text = c.defaultMakingCharge?.toString() ?? ''; _makingChargeController.text = c.defaultMakingCharge?.toString() ?? '';
_purityFactorController.text = (c.purityFactor ?? 1.0).toString(); _purityFactorController.text = formatPurity(c.purityFactor);
_huidRequired = c.huidRequired; _huidRequired = c.huidRequired;
_commodityCode = c.commodityCode ?? 'XAU'; _commodityCode = c.commodityCode ?? 'XAU';
@@ -307,7 +308,7 @@ class _CategoryFormSheetState extends ConsumerState<CategoryFormSheet> {
parentCategoryId: _selectedParentId, parentCategoryId: _selectedParentId,
hasChild: !_isLeaf, hasChild: !_isLeaf,
commodityCode: _isLeaf ? _commodityCode : null, 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, defaultHsn: _isLeaf ? _hsnController.text.trim() : null,
defaultGst: _isLeaf ? double.tryParse(_gstController.text) : null, defaultGst: _isLeaf ? double.tryParse(_gstController.text) : null,
huidRequired: _isLeaf ? _huidRequired : false, huidRequired: _isLeaf ? _huidRequired : false,
@@ -483,7 +484,7 @@ class _CategoryFormSheetState extends ConsumerState<CategoryFormSheet> {
flex: 2, flex: 2,
child: _buildTextField( child: _buildTextField(
controller: _purityFactorController, controller: _purityFactorController,
label: 'Purity (e.g. 0.916)', label: 'Purity (0 to 1, e.g. 0.916)',
icon: LucideIcons.gem, icon: LucideIcons.gem,
keyboardType: const TextInputType.numberWithOptions(decimal: true), keyboardType: const TextInputType.numberWithOptions(decimal: true),
), ),

View File

@@ -7,6 +7,7 @@ import '../providers/commodity_rates_provider.dart';
import '../domain/commodity_rate.dart'; import '../domain/commodity_rate.dart';
import '../../../core/theme/app_theme.dart'; import '../../../core/theme/app_theme.dart';
import '../../../core/utils/snackbar_service.dart'; import '../../../core/utils/snackbar_service.dart';
import '../../../core/utils/purity_utils.dart';
class DailyRatesScreen extends ConsumerStatefulWidget { class DailyRatesScreen extends ConsumerStatefulWidget {
const DailyRatesScreen({super.key}); const DailyRatesScreen({super.key});
@@ -699,7 +700,7 @@ class _DailyRatesScreenState extends ConsumerState<DailyRatesScreen> {
spacing: 8, spacing: 8,
runSpacing: 8, runSpacing: 8,
children: linkedCategories.map((cat) { children: linkedCategories.map((cat) {
final purity = cat.purityFactor ?? 1.0; final purity = normalizePurity(cat.purityFactor);
final effectiveUnitRate = currentRate * purity; final effectiveUnitRate = currentRate * purity;
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
@@ -725,7 +726,7 @@ class _DailyRatesScreenState extends ConsumerState<DailyRatesScreen> {
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
), ),
child: Text( 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), style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: color),
), ),
), ),

View File

@@ -133,6 +133,15 @@ class ProductDetailScreen extends ConsumerWidget {
"GST Rate", "GST Rate",
"${p.gstRate?.toStringAsFixed(1) ?? '0'}%", "${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"),
),
], ],
), ),
), ),

View File

@@ -10,6 +10,7 @@ import 'package:intl/intl.dart';
import 'package:lucide_icons/lucide_icons.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/presentation/purchase_order_details_screen.dart';
import 'package:kifi_app/features/vendor/providers/purchase_orders_provider.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 { class StockLedgerTab extends ConsumerStatefulWidget {
final Product product; final Product product;
@@ -71,7 +72,7 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
).firstOrNull; ).firstOrNull;
final String unit = category?.baseUnit ?? 'g'; 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; double commodityRate = 0.0;
if (category?.commodityCode != null && commodityRatesState.value != null) { if (category?.commodityCode != null && commodityRatesState.value != null) {
@@ -221,6 +222,23 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
fontSize: 14, 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,
),
),
),
], ],
), ),
], ],

View File

@@ -3,16 +3,26 @@ class InvoiceItem {
final int? invoiceId; final int? invoiceId;
final int? productId; final int? productId;
final int? inventoryItemId; final int? inventoryItemId;
final String? productName;
final String? categoryName;
final String? commodityCode;
final String? sku; final String? sku;
final String? huid;
final String? hsnCode; final String? hsnCode;
final String? description; final String? description;
final double quantity; final double quantity;
final double unitPrice; final double? weight;
final double taxRate; final double unitPrice; // metal rate
final double discount;
final double makingCharge; 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 double otherCharges;
final String? photoUrl;
final String? localPhotoPath;
final double total; final double total;
InvoiceItem({ InvoiceItem({
@@ -20,15 +30,26 @@ class InvoiceItem {
this.invoiceId, this.invoiceId,
this.productId, this.productId,
this.inventoryItemId, this.inventoryItemId,
this.productName,
this.categoryName,
this.commodityCode,
this.hsnCode, this.hsnCode,
this.sku, this.sku,
this.huid,
this.description, this.description,
required this.quantity, required this.quantity,
this.weight,
required this.unitPrice, required this.unitPrice,
this.taxRate = 0.0,
this.discount = 0.0,
this.makingCharge = 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.otherCharges = 0.0,
this.photoUrl,
this.localPhotoPath,
required this.total, required this.total,
}); });
@@ -37,15 +58,26 @@ class InvoiceItem {
int? invoiceId, int? invoiceId,
int? productId, int? productId,
int? inventoryItemId, int? inventoryItemId,
String? productName,
String? categoryName,
String? commodityCode,
String? hsnCode, String? hsnCode,
String? sku, String? sku,
String? huid,
String? description, String? description,
double? quantity, double? quantity,
double? weight,
double? unitPrice, double? unitPrice,
double? taxRate,
double? discount,
double? makingCharge, double? makingCharge,
String? makingChargesType,
double? taxRate,
double? cgst,
double? sgst,
double? igst,
double? discount,
double? otherCharges, double? otherCharges,
String? photoUrl,
String? localPhotoPath,
double? total, double? total,
}) { }) {
return InvoiceItem( return InvoiceItem(
@@ -53,15 +85,26 @@ class InvoiceItem {
invoiceId: invoiceId ?? this.invoiceId, invoiceId: invoiceId ?? this.invoiceId,
productId: productId ?? this.productId, productId: productId ?? this.productId,
inventoryItemId: inventoryItemId ?? this.inventoryItemId, inventoryItemId: inventoryItemId ?? this.inventoryItemId,
productName: productName ?? this.productName,
categoryName: categoryName ?? this.categoryName,
commodityCode: commodityCode ?? this.commodityCode,
hsnCode: hsnCode ?? this.hsnCode, hsnCode: hsnCode ?? this.hsnCode,
sku: sku ?? this.sku, sku: sku ?? this.sku,
huid: huid ?? this.huid,
description: description ?? this.description, description: description ?? this.description,
quantity: quantity ?? this.quantity, quantity: quantity ?? this.quantity,
weight: weight ?? this.weight,
unitPrice: unitPrice ?? this.unitPrice, unitPrice: unitPrice ?? this.unitPrice,
taxRate: taxRate ?? this.taxRate,
discount: discount ?? this.discount,
makingCharge: makingCharge ?? this.makingCharge, 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, otherCharges: otherCharges ?? this.otherCharges,
photoUrl: photoUrl ?? this.photoUrl,
localPhotoPath: localPhotoPath ?? this.localPhotoPath,
total: total ?? this.total, total: total ?? this.total,
); );
} }
@@ -69,19 +112,29 @@ class InvoiceItem {
factory InvoiceItem.fromJson(Map<String, dynamic> json) { factory InvoiceItem.fromJson(Map<String, dynamic> json) {
return InvoiceItem( return InvoiceItem(
id: json['id'], id: json['id'],
invoiceId: json['invoiceId'], invoiceId: json['invoiceId'] ?? json['invoice_id'],
productId: json['productId'], productId: json['productId'] ?? json['product_id'],
inventoryItemId: json['inventoryItemId'], 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'], hsnCode: json['hsnCode'] ?? json['hsn_code'],
sku: json['sku'], sku: json['sku'],
huid: json['huid'],
description: json['description'], description: json['description'],
quantity: json['quantity'].toDouble(), quantity: (json['quantity'] as num?)?.toDouble() ?? 1.0,
unitPrice: json['unitPrice'].toDouble(), weight: (json['weight'] as num?)?.toDouble(),
taxRate: json['taxRate']?.toDouble() ?? 0.0, unitPrice: (json['unitPrice'] ?? json['unit_price'] as num?)?.toDouble() ?? 0.0,
discount: json['discount']?.toDouble() ?? 0.0, makingCharge: (json['makingCharge'] ?? json['making_charge'] as num?)?.toDouble() ?? 0.0,
makingCharge: json['makingCharge']?.toDouble() ?? 0.0, makingChargesType: json['makingChargesType'] ?? json['making_charges_type'] ?? 'PER_GRAM',
otherCharges: json['otherCharges']?.toDouble() ?? 0.0, taxRate: (json['taxRate'] ?? json['tax_rate'] as num?)?.toDouble() ?? 0.0,
total: json['total'].toDouble(), 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 (invoiceId != null) data['invoiceId'] = invoiceId;
if (productId != null) data['productId'] = productId; if (productId != null) data['productId'] = productId;
if (inventoryItemId != null) data['inventoryItemId'] = inventoryItemId; 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 (hsnCode != null) data['hsnCode'] = hsnCode;
if (sku != null) data['sku'] = sku; if (sku != null) data['sku'] = sku;
if (huid != null) data['huid'] = huid;
if (description != null) data['description'] = description; if (description != null) data['description'] = description;
data['quantity'] = quantity; data['quantity'] = quantity;
if (weight != null) data['weight'] = weight;
data['unitPrice'] = unitPrice; data['unitPrice'] = unitPrice;
data['taxRate'] = taxRate;
data['discount'] = discount;
data['makingCharge'] = makingCharge; data['makingCharge'] = makingCharge;
data['makingChargesType'] = makingChargesType;
data['taxRate'] = taxRate;
data['cgst'] = cgst;
data['sgst'] = sgst;
data['igst'] = igst;
data['discount'] = discount;
data['otherCharges'] = otherCharges; data['otherCharges'] = otherCharges;
if (photoUrl != null) data['photoUrl'] = photoUrl;
data['total'] = total; data['total'] = total;
return data; return data;
} }
@@ -113,6 +176,9 @@ class Invoice {
final DateTime? dueDate; final DateTime? dueDate;
final double subtotal; final double subtotal;
final double taxTotal; final double taxTotal;
final double cgstTotal;
final double sgstTotal;
final double igstTotal;
final double discountTotal; final double discountTotal;
final double totalAmount; final double totalAmount;
final double amountPaid; final double amountPaid;
@@ -121,6 +187,7 @@ class Invoice {
final int? paymentWalletId; final int? paymentWalletId;
final String status; final String status;
final String? notes; final String? notes;
final String? invoiceUrl;
final bool isEmi; final bool isEmi;
final double? emiAmount; final double? emiAmount;
final String? emiCycle; final String? emiCycle;
@@ -136,6 +203,9 @@ class Invoice {
this.dueDate, this.dueDate,
required this.subtotal, required this.subtotal,
this.taxTotal = 0.0, this.taxTotal = 0.0,
this.cgstTotal = 0.0,
this.sgstTotal = 0.0,
this.igstTotal = 0.0,
this.discountTotal = 0.0, this.discountTotal = 0.0,
required this.totalAmount, required this.totalAmount,
this.amountPaid = 0.0, this.amountPaid = 0.0,
@@ -144,6 +214,7 @@ class Invoice {
this.paymentWalletId, this.paymentWalletId,
this.status = 'DRAFT', this.status = 'DRAFT',
this.notes, this.notes,
this.invoiceUrl,
this.isEmi = false, this.isEmi = false,
this.emiAmount, this.emiAmount,
this.emiCycle, this.emiCycle,
@@ -155,28 +226,34 @@ class Invoice {
factory Invoice.fromJson(Map<String, dynamic> json) { factory Invoice.fromJson(Map<String, dynamic> json) {
return Invoice( return Invoice(
id: json['id'], id: json['id'],
customerId: json['customerId'], customerId: json['customerId'] ?? json['customer_id'],
invoiceNumber: json['invoiceNumber'], invoiceNumber: json['invoiceNumber'] ?? json['invoice_number'] ?? '',
issueDate: DateTime.parse(json['issueDate']), issueDate: json['issueDate'] != null
dueDate: json['dueDate'] != null ? DateTime.parse(json['dueDate']) : null, ? DateTime.parse(json['issueDate'])
subtotal: json['subtotal'].toDouble(), : (json['issue_date'] != null ? DateTime.parse(json['issue_date']) : DateTime.now()),
taxTotal: json['taxTotal']?.toDouble() ?? 0.0, dueDate: json['dueDate'] != null ? DateTime.parse(json['dueDate']) : (json['due_date'] != null ? DateTime.parse(json['due_date']) : null),
discountTotal: json['discountTotal']?.toDouble() ?? 0.0, subtotal: (json['subtotal'] as num?)?.toDouble() ?? 0.0,
totalAmount: json['totalAmount'].toDouble(), taxTotal: (json['taxTotal'] ?? json['tax_total'] as num?)?.toDouble() ?? 0.0,
amountPaid: json['amountPaid']?.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 nextPaymentDate: json['nextPaymentDate'] != null
? DateTime.parse(json['nextPaymentDate']) ? DateTime.parse(json['nextPaymentDate'])
: null, : (json['next_payment_date'] != null ? DateTime.parse(json['next_payment_date']) : null),
paymentMethod: json['paymentMethod'], paymentMethod: json['paymentMethod'] ?? json['payment_method'],
paymentWalletId: json['paymentWalletId'], paymentWalletId: json['paymentWalletId'] ?? json['payment_wallet_id'],
status: json['status'] ?? 'DRAFT', status: json['status'] ?? 'DRAFT',
notes: json['notes'], notes: json['notes'],
isEmi: json['isEmi'] ?? false, invoiceUrl: json['invoiceUrl'] ?? json['invoice_url'],
emiAmount: json['emiAmount']?.toDouble(), isEmi: json['isEmi'] ?? json['is_emi'] ?? false,
emiCycle: json['emiCycle'], emiAmount: (json['emiAmount'] ?? json['emi_amount'] as num?)?.toDouble(),
emiCycle: json['emiCycle'] ?? json['emi_cycle'],
emiStartDate: json['emiStartDate'] != null emiStartDate: json['emiStartDate'] != null
? DateTime.parse(json['emiStartDate']) ? DateTime.parse(json['emiStartDate'])
: null, : (json['emi_start_date'] != null ? DateTime.parse(json['emi_start_date']) : null),
items: json['items'] != null items: json['items'] != null
? (json['items'] as List).map((i) => InvoiceItem.fromJson(i)).toList() ? (json['items'] as List).map((i) => InvoiceItem.fromJson(i)).toList()
: [], : [],
@@ -194,29 +271,35 @@ class Invoice {
if (customerId != null) data['customerId'] = customerId; if (customerId != null) data['customerId'] = customerId;
data['invoiceNumber'] = invoiceNumber; data['invoiceNumber'] = invoiceNumber;
data['issueDate'] = issueDate.toIso8601String().split('T')[0]; data['issueDate'] = issueDate.toIso8601String().split('T')[0];
if (dueDate != null) if (dueDate != null) {
data['dueDate'] = dueDate!.toIso8601String().split('T')[0]; data['dueDate'] = dueDate!.toIso8601String().split('T')[0];
}
data['subtotal'] = subtotal; data['subtotal'] = subtotal;
data['taxTotal'] = taxTotal; data['taxTotal'] = taxTotal;
data['cgstTotal'] = cgstTotal;
data['sgstTotal'] = sgstTotal;
data['igstTotal'] = igstTotal;
data['discountTotal'] = discountTotal; data['discountTotal'] = discountTotal;
data['totalAmount'] = totalAmount; data['totalAmount'] = totalAmount;
if (amountPaid > 0) data['amountPaid'] = amountPaid; if (amountPaid > 0) data['amountPaid'] = amountPaid;
if (nextPaymentDate != null) if (nextPaymentDate != null) {
data['nextPaymentDate'] = nextPaymentDate!.toIso8601String().split( data['nextPaymentDate'] = nextPaymentDate!.toIso8601String().split('T')[0];
'T', }
)[0];
if (paymentMethod != null) data['paymentMethod'] = paymentMethod; if (paymentMethod != null) data['paymentMethod'] = paymentMethod;
if (paymentWalletId != null) data['paymentWalletId'] = paymentWalletId; if (paymentWalletId != null) data['paymentWalletId'] = paymentWalletId;
data['status'] = status; data['status'] = status;
if (notes != null) data['notes'] = notes; if (notes != null) data['notes'] = notes;
if (invoiceUrl != null) data['invoiceUrl'] = invoiceUrl;
data['isEmi'] = isEmi; data['isEmi'] = isEmi;
if (emiAmount != null) data['emiAmount'] = emiAmount; if (emiAmount != null) data['emiAmount'] = emiAmount;
if (emiCycle != null) data['emiCycle'] = emiCycle; if (emiCycle != null) data['emiCycle'] = emiCycle;
if (emiStartDate != null) if (emiStartDate != null) {
data['emiStartDate'] = emiStartDate!.toIso8601String().split('T')[0]; data['emiStartDate'] = emiStartDate!.toIso8601String().split('T')[0];
}
data['items'] = items.map((i) => i.toJson()).toList(); data['items'] = items.map((i) => i.toJson()).toList();
if (payments != null) if (payments != null) {
data['payments'] = payments!.map((i) => i.toJson()).toList(); data['payments'] = payments!.map((i) => i.toJson()).toList();
}
return data; return data;
} }
} }
@@ -243,14 +326,14 @@ class InvoicePayment {
factory InvoicePayment.fromJson(Map<String, dynamic> json) { factory InvoicePayment.fromJson(Map<String, dynamic> json) {
return InvoicePayment( return InvoicePayment(
id: json['id'], id: json['id'],
invoiceId: json['invoiceId'], invoiceId: json['invoiceId'] ?? json['invoice_id'],
amount: json['amount'].toDouble(), amount: (json['amount'] as num?)?.toDouble() ?? 0.0,
paymentDate: json['paymentDate'] != null paymentDate: json['paymentDate'] != null
? DateTime.parse(json['paymentDate']) ? DateTime.parse(json['paymentDate'])
: null, : (json['payment_date'] != null ? DateTime.parse(json['payment_date']) : null),
paymentMethod: json['paymentMethod'] ?? 'Cash', paymentMethod: json['paymentMethod'] ?? json['payment_method'] ?? 'Cash',
emiInstallmentNumber: json['emiInstallmentNumber'], emiInstallmentNumber: json['emiInstallmentNumber'] ?? json['emi_installment_number'],
walletId: json['walletId'], walletId: json['walletId'] ?? json['wallet_id'],
); );
} }
@@ -259,11 +342,13 @@ class InvoicePayment {
if (id != null) data['id'] = id; if (id != null) data['id'] = id;
if (invoiceId != null) data['invoiceId'] = invoiceId; if (invoiceId != null) data['invoiceId'] = invoiceId;
data['amount'] = amount; data['amount'] = amount;
if (paymentDate != null) if (paymentDate != null) {
data['paymentDate'] = paymentDate!.toIso8601String().split('T')[0]; data['paymentDate'] = paymentDate!.toIso8601String().split('T')[0];
}
data['paymentMethod'] = paymentMethod; data['paymentMethod'] = paymentMethod;
if (emiInstallmentNumber != null) if (emiInstallmentNumber != null) {
data['emiInstallmentNumber'] = emiInstallmentNumber; data['emiInstallmentNumber'] = emiInstallmentNumber;
}
if (walletId != null) data['walletId'] = walletId; if (walletId != null) data['walletId'] = walletId;
return data; return data;
} }

View File

@@ -2,14 +2,12 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons/lucide_icons.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../../../core/widgets/shimmer_loading.dart';
import '../providers/invoices_provider.dart'; import '../providers/invoices_provider.dart';
import '../domain/invoice.dart'; import '../domain/invoice.dart';
import '../providers/customers_provider.dart';
import '../domain/customer.dart';
import 'invoice_builder_screen.dart'; import 'invoice_builder_screen.dart';
import 'invoice_details_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 { class InvoicesListScreen extends ConsumerStatefulWidget {
const InvoicesListScreen({super.key}); const InvoicesListScreen({super.key});
@@ -24,277 +22,115 @@ class _InvoicesListScreenState extends ConsumerState<InvoicesListScreen> {
String _getStatusLabel(Invoice invoice) { String _getStatusLabel(Invoice invoice) {
if (invoice.isEmi && invoice.status != 'PAID') return 'EMI'; if (invoice.isEmi && invoice.status != 'PAID') return 'EMI';
if (invoice.status == 'PAID') return 'Fully Paid'; if (invoice.status == 'PAID' || invoice.amountPaid >= invoice.totalAmount && invoice.totalAmount > 0) return 'PAID';
if (invoice.status == 'PARTIAL') return 'Partially Paid'; if (invoice.amountPaid > 0) return 'PARTIAL';
return invoice.status; return invoice.status;
} }
Color _getStatusColor(Invoice invoice) { Color _getStatusColor(Invoice invoice) {
if (invoice.isEmi && invoice.status != 'PAID') return Colors.purple; if (invoice.isEmi && invoice.status != 'PAID') return Colors.purple;
switch (invoice.status) { final label = _getStatusLabel(invoice);
case 'DRAFT': switch (label) {
return Colors.grey;
case 'FINALIZED':
return Colors.orange;
case 'PAID': case 'PAID':
return Colors.green; return Colors.green;
case 'PARTIAL': case 'PARTIAL':
return Colors.blue; return Colors.blue;
case 'FINALIZED':
return Colors.orange;
case 'OVERDUE': case 'OVERDUE':
return Colors.red; return Colors.red;
case 'CANCELLED': case 'DRAFT':
return Colors.black;
default: default:
return Colors.grey; return Colors.grey;
} }
} }
void _showPaymentHistory( @override
BuildContext context, void dispose() {
WidgetRef ref, _searchController.dispose();
Invoice invoice, super.dispose();
) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (ctx) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Payment History: ${invoice.invoiceNumber}',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
FutureBuilder<List<InvoicePayment>>(
future: ref
.read(invoicesProvider.notifier)
.fetchPaymentsForInvoice(invoice.id!),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(child: Text('Error: ${snapshot.error}'));
}
final payments = snapshot.data;
if (payments == null || payments.isEmpty) {
return const Padding(
padding: EdgeInsets.all(24.0),
child: Text('No payments recorded yet.'),
);
}
final wallets = ref.read(walletProvider).value ?? [];
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: DataTable(
headingRowColor: WidgetStateProperty.resolveWith(
(states) => Colors.grey.shade100,
),
columnSpacing: 24,
columns: const [
DataColumn(
label: Text(
'Date',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
DataColumn(
label: Text(
'Wallet',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
DataColumn(
label: Text(
'Method',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
DataColumn(
label: Text(
'Amount',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
],
rows: payments.map((p) {
final walletName = wallets
.firstWhere(
(w) => w.id == p.walletId,
orElse: () => wallets.first,
)
.name;
return DataRow(
cells: [
DataCell(
Text(
p.paymentDate != null
? DateFormat(
'dd MMM yyyy',
).format(p.paymentDate!)
: '-',
),
),
DataCell(
Text(p.walletId != null ? walletName : '-'),
),
DataCell(Text(p.paymentMethod ?? '-')),
DataCell(
Text(
'${p.amount.toStringAsFixed(2)}',
style: TextStyle(
color: Colors.green.shade700,
fontWeight: FontWeight.bold,
),
),
),
],
);
}).toList(),
),
);
},
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Close'),
),
),
],
),
);
},
);
}
void _showReceivePaymentSheet(BuildContext context, Invoice invoice) async {
final result = await showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => ReceivePaymentSheet(invoice: invoice),
);
if (result == true) {
ref.read(invoicesProvider.notifier).refresh();
}
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final invoicesState = ref.watch(invoicesProvider); final invoicesState = ref.watch(invoicesProvider);
final customersState = ref.watch(customersProvider); final customersState = ref.watch(customersProvider);
final customers = customersState.value ?? []; final isDark = Theme.of(context).brightness == Brightness.dark;
final formatCurrency = NumberFormat.currency(symbol: ''); final formatCurrency = NumberFormat.currency(symbol: '');
final formatDate = DateFormat('MMM dd, yyyy'); final formatDate = DateFormat('MMM dd, yyyy');
return Scaffold( return Scaffold(
backgroundColor: Colors.grey[100], backgroundColor: isDark ? const Color(0xFF0F172A) : const Color(0xFFF8FAFC),
appBar: AppBar( appBar: AppBar(
title: const Text( title: const Text(
'Invoices', 'Sales Invoices',
style: TextStyle(fontWeight: FontWeight.bold), style: TextStyle(fontWeight: FontWeight.bold),
), ),
elevation: 0, elevation: 0,
backgroundColor: Colors.white, backgroundColor: isDark ? const Color(0xFF1E293B) : Colors.white,
foregroundColor: Colors.black, foregroundColor: isDark ? Colors.white : Colors.black,
centerTitle: true, centerTitle: true,
), ),
body: Column( body: Column(
children: [ children: [
// Unified Search Header
Container( Container(
color: Colors.white, color: isDark ? const Color(0xFF1E293B) : Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: TextField( child: TextField(
controller: _searchController, controller: _searchController,
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Search by Invoice # or Customer', hintText: 'Search Invoice # or Customer...',
prefixIcon: const Icon(LucideIcons.search, color: Colors.grey), prefixIcon: const Icon(LucideIcons.search, color: Colors.grey),
contentPadding: const EdgeInsets.symmetric(vertical: 14), 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 suffixIcon: _searchQuery.isNotEmpty
? IconButton( ? IconButton(
icon: const Icon(LucideIcons.x, size: 20), icon: const Icon(LucideIcons.xCircle, size: 20),
onPressed: () { onPressed: () {
_searchController.clear(); _searchController.clear();
setState(() { setState(() => _searchQuery = '');
_searchQuery = '';
});
}, },
) )
: null, : null,
), ),
onChanged: (value) { onChanged: (val) => setState(() => _searchQuery = val.trim()),
setState(() {
_searchQuery = value.toLowerCase();
});
},
), ),
), ),
Expanded( Expanded(
child: invoicesState.when( child: invoicesState.when(
loading: () => ListView.builder( data: (invoices) {
padding: const EdgeInsets.all(16), final customers = customersState.value ?? [];
itemCount: 5, final filtered = invoices.where((inv) {
itemBuilder: (context, index) => const Padding( final matchesInv = inv.invoiceNumber.toLowerCase().contains(_searchQuery.toLowerCase());
padding: EdgeInsets.only(bottom: 12), final customer = customers.where((c) => c.id == inv.customerId).firstOrNull;
child: ShimmerCard(), final matchesCust = customer != null && customer.name.toLowerCase().contains(_searchQuery.toLowerCase());
), return matchesInv || matchesCust;
),
error: (err, stack) => Center(child: Text('Error: $err')),
data: (allInvoices) {
final invoices = allInvoices.where((invoice) {
final matchesInvoice = invoice.invoiceNumber
.toLowerCase()
.contains(_searchQuery);
final customer = customers.firstWhere(
(c) => c.id == invoice.customerId,
orElse: () => customers.first,
);
final customerName = invoice.customerId != null
? customer.name.toLowerCase()
: '';
final matchesCustomer = customerName.contains(_searchQuery);
return matchesInvoice || matchesCustomer;
}).toList(); }).toList();
if (invoices.isEmpty) { if (filtered.isEmpty) {
return RefreshIndicator( return RefreshIndicator(
onRefresh: () async { onRefresh: () => ref.refresh(invoicesProvider.future),
await ref.read(invoicesProvider.notifier).refresh();
},
child: ListView( child: ListView(
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
children: const [ children: const [
SizedBox(height: 100), SizedBox(height: 120),
Center( Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon( Icon(LucideIcons.fileText, size: 64, color: Colors.grey),
LucideIcons.receipt,
size: 64,
color: Colors.grey,
),
SizedBox(height: 16), SizedBox(height: 16),
Text( Text(
'No invoices found.', 'No sales invoices found.',
style: TextStyle( style: TextStyle(color: Colors.grey, fontSize: 16),
color: Colors.grey,
fontSize: 16,
),
), ),
], ],
), ),
@@ -305,32 +141,25 @@ class _InvoicesListScreenState extends ConsumerState<InvoicesListScreen> {
} }
return RefreshIndicator( return RefreshIndicator(
onRefresh: () async { onRefresh: () => ref.refresh(invoicesProvider.future),
await ref.read(invoicesProvider.notifier).refresh();
},
child: ListView.builder( child: ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
itemCount: invoices.length, itemCount: filtered.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final invoice = invoices[index]; final invoice = filtered[index];
double remaining = final customer = customers.where((c) => c.id == invoice.customerId).firstOrNull;
invoice.totalAmount - invoice.amountPaid; final statusColor = _getStatusColor(invoice);
final customer = invoice.customerId != null final statusLabel = _getStatusLabel(invoice);
? customers.firstWhere(
(c) => c.id == invoice.customerId,
orElse: () => customers.first,
)
: null;
return Container( return Container(
margin: const EdgeInsets.only(bottom: 16), margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: isDark ? const Color(0xFF1E293B) : Colors.white,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: Colors.grey.withOpacity(0.08), color: Colors.black.withValues(alpha: isDark ? 0.2 : 0.04),
blurRadius: 10, blurRadius: 10,
offset: const Offset(0, 4), offset: const Offset(0, 4),
), ),
@@ -341,13 +170,21 @@ class _InvoicesListScreenState extends ConsumerState<InvoicesListScreen> {
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
child: InkWell( child: InkWell(
onTap: () { onTap: () {
if (invoice.status == 'DRAFT') {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (_) => builder: (_) => InvoiceBuilderScreen(existingInvoice: invoice),
InvoiceDetailsScreen(invoice: invoice),
), ),
); );
} else {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => InvoiceDetailsScreen(invoice: invoice),
),
);
}
}, },
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
child: Padding( child: Padding(
@@ -355,13 +192,9 @@ class _InvoicesListScreenState extends ConsumerState<InvoicesListScreen> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Row 1: Invoice Number + Status Pill
Row( Row(
mainAxisAlignment: mainAxisAlignment: MainAxisAlignment.spaceBetween,
MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [ children: [
Text( Text(
invoice.invoiceNumber, invoice.invoiceNumber,
@@ -370,34 +203,17 @@ class _InvoicesListScreenState extends ConsumerState<InvoicesListScreen> {
fontSize: 16, fontSize: 16,
), ),
), ),
if (customer != null)
Text(
customer.name,
style: TextStyle(
color: Colors.grey[700],
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
],
),
Container( Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: _getStatusColor( color: statusColor.withValues(alpha: 0.12),
invoice, borderRadius: BorderRadius.circular(20),
).withOpacity(0.1), border: Border.all(color: statusColor.withValues(alpha: 0.3), width: 0.8),
borderRadius: BorderRadius.circular(
12,
),
), ),
child: Text( child: Text(
_getStatusLabel(invoice), statusLabel,
style: TextStyle( style: TextStyle(
color: _getStatusColor(invoice), color: statusColor,
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
@@ -406,172 +222,48 @@ class _InvoicesListScreenState extends ConsumerState<InvoicesListScreen> {
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Row(
mainAxisAlignment: // Row 2: Customer Name
MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
'Issued: ${formatDate.format(invoice.issueDate)}',
style: TextStyle(
color: Colors.grey[600],
fontSize: 13,
),
),
if (invoice.dueDate != null)
Text(
'Due: ${formatDate.format(invoice.dueDate!)}',
style: TextStyle(
color: Colors.grey[600],
fontSize: 13,
),
),
if (invoice.nextPaymentDate != null &&
remaining > 0)
Text(
'Next Pmt: ${formatDate.format(invoice.nextPaymentDate!)}',
style: TextStyle(
color: Colors.orange.shade700,
fontSize: 13,
fontWeight: FontWeight.bold,
),
),
],
),
Column(
crossAxisAlignment:
CrossAxisAlignment.end,
children: [
Text(
formatCurrency.format(
invoice.totalAmount,
),
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18,
),
),
if (invoice.amountPaid > 0)
Text(
'Paid: ${formatCurrency.format(invoice.amountPaid)}',
style: TextStyle(
color: Colors.green.shade700,
fontSize: 12,
),
),
if (remaining > 0 &&
invoice.status != 'DRAFT')
Text(
'Bal: ${formatCurrency.format(remaining)}',
style: TextStyle(
color: Colors.red.shade700,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
],
),
],
),
if (invoice.isEmi &&
invoice.emiAmount != null) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.purple.withOpacity(0.05),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Colors.purple.withOpacity(0.2),
),
),
child: Row(
children: [
const Icon(
LucideIcons.calendarClock,
size: 16,
color: Colors.purple,
),
const SizedBox(width: 8),
Text(
'EMI: ${formatCurrency.format(invoice.emiAmount)} / ${invoice.emiCycle}',
style: const TextStyle(
color: Colors.purple,
fontSize: 12,
),
),
],
),
),
],
const Divider(height: 24),
Row( Row(
children: [ children: [
if (invoice.amountPaid > 0) const Icon(LucideIcons.user, size: 16, color: Colors.grey),
Expanded(
child: OutlinedButton.icon(
onPressed: () =>
_showPaymentHistory(
context,
ref,
invoice,
),
icon: const Icon(
LucideIcons.history,
size: 14,
),
label: const Text(
'History',
style: TextStyle(fontSize: 13),
),
style: OutlinedButton.styleFrom(
foregroundColor:
Colors.blue.shade700,
side: BorderSide(
color: Colors.blue.shade200,
),
padding:
const EdgeInsets.symmetric(
horizontal: 8,
vertical: 8,
),
),
),
),
if (invoice.amountPaid > 0 &&
remaining > 0)
const SizedBox(width: 8), const SizedBox(width: 8),
if (remaining > 0)
Expanded( Expanded(
child: ElevatedButton.icon( child: Text(
onPressed: () => customer?.name ?? 'Walk-in Customer',
_showReceivePaymentSheet( style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14),
context, maxLines: 1,
invoice, overflow: TextOverflow.ellipsis,
),
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,
), ),
), ),
if (invoice.items.isNotEmpty)
Text(
'${invoice.items.length} ${invoice.items.length == 1 ? "item" : "items"}',
style: TextStyle(fontSize: 12, color: Colors.grey.shade500),
),
],
),
const SizedBox(height: 10),
// Row 3: Date & Total Amount
Row(
children: [
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,
),
),
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<InvoicesListScreen> {
), ),
); );
}, },
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, stack) => Center(child: Text('Error: $e')),
), ),
), ),
], ],
@@ -598,8 +292,9 @@ class _InvoicesListScreenState extends ConsumerState<InvoicesListScreen> {
); );
}, },
icon: const Icon(LucideIcons.plus), icon: const Icon(LucideIcons.plus),
label: const Text('Create Invoice'), label: const Text('Create Invoice', style: TextStyle(fontWeight: FontWeight.bold)),
backgroundColor: Colors.blue, backgroundColor: Colors.blue,
foregroundColor: Colors.white,
), ),
); );
} }

View File

@@ -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<QuickAddCustomerSheet> createState() => _QuickAddCustomerSheetState();
}
class _QuickAddCustomerSheetState extends ConsumerState<QuickAddCustomerSheet> {
final _formKey = GlobalKey<FormState>();
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<void> _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<int>(
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)),
),
],
),
),
),
),
],
),
);
}
}

View File

@@ -42,7 +42,7 @@ class CustomersNotifier extends AsyncNotifier<List<Customer>> {
} }
} }
Future<void> addCustomer(Customer customer, {XFile? photo}) async { Future<Customer?> addCustomer(Customer customer, {XFile? photo}) async {
try { try {
final response = await DioClient().dio.post( final response = await DioClient().dio.post(
'/customers', '/customers',
@@ -55,14 +55,18 @@ class CustomersNotifier extends AsyncNotifier<List<Customer>> {
} }
await refresh(); await refresh();
if (response.data != null) {
return Customer.fromJson(response.data);
}
} catch (e) { } catch (e) {
if (e is DioException) { if (e is DioException) {
throw Exception( 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<void> updateCustomer(int id, Customer customer, {XFile? photo}) async { Future<void> updateCustomer(int id, Customer customer, {XFile? photo}) async {

View File

@@ -34,10 +34,14 @@ class InvoicesNotifier extends AsyncNotifier<List<Invoice>> {
} }
} }
Future<void> createInvoice(Invoice invoice) async { Future<Invoice?> createInvoice(Invoice invoice) async {
try { try {
await DioClient().dio.post('/invoices', data: invoice.toJson()); final response = await DioClient().dio.post('/invoices', data: invoice.toJson());
await refresh(); await refresh();
if (response.data != null) {
return Invoice.fromJson(response.data);
}
return null;
} catch (e) { } catch (e) {
if (e is DioException) { if (e is DioException) {
throw Exception( throw Exception(

View File

@@ -1,6 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../data/repository.dart';
import '../data/models.dart'; import '../data/models.dart';
import 'providers.dart'; import 'providers.dart';
@@ -98,9 +97,8 @@ class PaginatedTransactionNotifier extends Notifier<PaginatedTransactionState> {
isLoading: false, isLoading: false,
hasMore: _currentPage < totalPages, hasMore: _currentPage < totalPages,
); );
} catch (e) { } catch (_) {
state = state.copyWith(isLoading: false); state = state.copyWith(isLoading: false);
print("Error loading paginated transactions: $e");
} }
} }

View File

@@ -21,6 +21,8 @@ import '../domain/vendor.dart';
import '../../inventory/domain/product.dart'; import '../../inventory/domain/product.dart';
import 'pay_vendor_sheet.dart' as import_pay; import 'pay_vendor_sheet.dart' as import_pay;
import 'widgets/quick_add_vendor_sheet.dart'; import 'widgets/quick_add_vendor_sheet.dart';
import '../../../core/widgets/barcode_scanner_screen.dart';
import '../../../core/utils/purity_utils.dart';
String _resolveImageUrl(String path) { String _resolveImageUrl(String path) {
if (path.startsWith('http://') || path.startsWith('https://')) { if (path.startsWith('http://') || path.startsWith('https://')) {
@@ -47,6 +49,7 @@ class _PurchaseOrderBuilderScreenState
DateTime? _dueDate; DateTime? _dueDate;
final TextEditingController _poNumberCtrl = TextEditingController(); final TextEditingController _poNumberCtrl = TextEditingController();
final TextEditingController _notesCtrl = TextEditingController(); final TextEditingController _notesCtrl = TextEditingController();
final TextEditingController _discountCtrl = TextEditingController(text: '0.0');
List<PurchaseOrderItem> _items = []; List<PurchaseOrderItem> _items = [];
bool _isLoading = false; bool _isLoading = false;
@@ -64,6 +67,9 @@ class _PurchaseOrderBuilderScreenState
_notesCtrl.text = widget.existingPo!.notes ?? ''; _notesCtrl.text = widget.existingPo!.notes ?? '';
_items = List.from(widget.existingPo!.items); _items = List.from(widget.existingPo!.items);
_invoiceUrl = widget.existingPo!.vendorInvoiceUrl; _invoiceUrl = widget.existingPo!.vendorInvoiceUrl;
if (widget.existingPo!.discountTotal > 0) {
_discountCtrl.text = widget.existingPo!.discountTotal.toStringAsFixed(2);
}
if (_items.isEmpty && widget.existingPo!.id != null) { if (_items.isEmpty && widget.existingPo!.id != null) {
_loadExistingItems(widget.existingPo!.id!); _loadExistingItems(widget.existingPo!.id!);
} }
@@ -90,22 +96,10 @@ class _PurchaseOrderBuilderScreenState
void dispose() { void dispose() {
_poNumberCtrl.dispose(); _poNumberCtrl.dispose();
_notesCtrl.dispose(); _notesCtrl.dispose();
_discountCtrl.dispose();
super.dispose(); super.dispose();
} }
double get _subtotal {
return _items.fold(
0,
(sum, item) => sum + (item.total),
);
}
double get _totalAmount {
return _subtotal;
}
Future<void> _pickInvoiceFile() async { Future<void> _pickInvoiceFile() async {
final picker = ImagePicker(); final picker = ImagePicker();
final picked = await picker.pickImage( 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( final po = PurchaseOrder(
id: widget.existingPo?.id, id: widget.existingPo?.id,
vendorId: _selectedVendorId, vendorId: _selectedVendorId,
@@ -237,18 +234,17 @@ class _PurchaseOrderBuilderScreenState
issueDate: _issueDate, issueDate: _issueDate,
dueDate: _dueDate, dueDate: _dueDate,
subtotal: totalSubtotal, subtotal: totalSubtotal,
discountTotal: discountAmount,
taxTotal: totalTax, taxTotal: totalTax,
cgstTotal: totalCgst, cgstTotal: totalCgst,
sgstTotal: totalSgst, sgstTotal: totalSgst,
igstTotal: totalIgst, igstTotal: totalIgst,
totalAmount: totalSubtotal + totalTax, totalAmount: grandTotal,
notes: _notesCtrl.text, notes: _notesCtrl.text,
vendorInvoiceUrl: finalInvoiceUrl, vendorInvoiceUrl: finalInvoiceUrl,
items: processedItems, items: processedItems,
); );
print('--- SAVING PO ---');
print(po.toJson());
if (widget.existingPo == null) { if (widget.existingPo == null) {
await ref.read(purchaseOrdersProvider.notifier).createPurchaseOrder(po); await ref.read(purchaseOrdersProvider.notifier).createPurchaseOrder(po);
} else { } else {
@@ -319,18 +315,72 @@ class _PurchaseOrderBuilderScreenState
}); });
} }
Future<void> _scanBarcode() async {
final scannedCode = await Navigator.push<String>(
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final vendorsState = ref.watch(vendorsProvider); final vendorsState = ref.watch(vendorsProvider);
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text( title: Text(
widget.existingPo == null widget.existingPo == null
? 'New Invoice' ? 'New Purchase Invoice'
: 'Edit Invoice ${_poNumberCtrl.text}', : 'Edit Invoice ${_poNumberCtrl.text}',
), ),
actions: [ actions: [
IconButton(
icon: const Icon(LucideIcons.qrCode),
tooltip: 'Scan Barcode/SKU',
onPressed: _scanBarcode,
),
if (_isLoading) if (_isLoading)
const Padding( const Padding(
padding: EdgeInsets.all(16.0), padding: EdgeInsets.all(16.0),
@@ -384,44 +434,112 @@ class _PurchaseOrderBuilderScreenState
], ],
], ],
), ),
body: SingleChildScrollView( body: GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
behavior: HitTestBehavior.translucent,
child: SingleChildScrollView(
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
vendorsState.when( // Vendor Selection Row + Quick Add Vendor
data: (vendors) => SmartSearchDropdown<int>( Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: vendorsState.when(
data: (vendors) => SmartSearchDropdown<Vendor>(
labelText: 'Vendor *', labelText: 'Vendor *',
hintText: 'Select Vendor', hintText: 'Search Vendor by Name / Phone / Company / GSTIN',
value: _selectedVendorId, value: vendors.where((v) => v.id == _selectedVendorId).firstOrNull,
items: vendors.map((v) => v.id!).toList(), items: vendors,
itemAsString: (id) => itemAsString: (v) => v.name,
vendors.where((v) => v.id == id).firstOrNull?.name ?? 'Unknown', filterFn: (v, query) {
onChanged: (val) => setState(() => _selectedVendorId = val), final q = query.toLowerCase();
emptyActionText: '+ Quick Add Vendor', return v.name.toLowerCase().contains(q) ||
onEmptyActionPressed: () { (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: 8),
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: IconButton.filledTonal(
onPressed: () {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (ctx) => const QuickAddVendorSheet(), builder: (ctx) => const QuickAddVendorSheet(),
); );
}, },
icon: const Icon(LucideIcons.userPlus, size: 20),
tooltip: 'Quick Add Vendor',
), ),
loading: () => const CircularProgressIndicator(), ),
error: (e, stack) => Text('Error: $e'), ],
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Row( PremiumTextField(
children: [
Expanded(
child: PremiumTextField(
controller: _poNumberCtrl, controller: _poNumberCtrl,
labelText: 'Invoice Number *', labelText: 'Invoice Number *',
prefixIcon: const Icon(LucideIcons.fileText),
), ),
), const SizedBox(height: 16),
const SizedBox(width: 16), InkWell(
Expanded(
child: InkWell(
onTap: () async { onTap: () async {
final d = await showDatePicker( final d = await showDatePicker(
context: context, context: context,
@@ -432,41 +550,65 @@ class _PurchaseOrderBuilderScreenState
if (d != null) setState(() => _issueDate = d); if (d != null) setState(() => _issueDate = d);
}, },
child: InputDecorator( child: InputDecorator(
decoration: const InputDecoration( decoration: InputDecoration(
labelText: 'Issue Date', labelText: 'Issue Date',
prefixIcon: const Icon(LucideIcons.calendar),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
), ),
child: Text(DateFormat('dd MMM yyyy').format(_issueDate)), child: Text(
DateFormat('dd MMM yyyy').format(_issueDate),
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14),
), ),
), ),
), ),
],
),
const SizedBox(height: 24), const SizedBox(height: 24),
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text( Text(
'Items', 'Invoice Items (${_items.length})',
style: Theme.of( style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
), ),
TextButton.icon( Row(
children: [
IconButton.filledTonal(
onPressed: _scanBarcode,
icon: const Icon(LucideIcons.qrCode, size: 18),
tooltip: 'Scan Barcode',
),
const SizedBox(width: 8),
FilledButton.tonalIcon(
onPressed: _showAddItemSheet, onPressed: _showAddItemSheet,
icon: const Icon(LucideIcons.plus), icon: const Icon(LucideIcons.plus, size: 16),
label: const Text('Add Item'), label: const Text('Add Item'),
), ),
], ],
), ),
const Divider(), ],
if (_items.isEmpty)
const Padding(
padding: EdgeInsets.all(24.0),
child: Center(
child: Text(
'No items added yet',
style: TextStyle(color: Colors.grey),
), ),
const SizedBox(height: 12),
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.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 else
@@ -544,14 +686,14 @@ class _PurchaseOrderBuilderScreenState
}, },
onDelete: () => setState(() => _items.removeAt(itemIndex)), onDelete: () => setState(() => _items.removeAt(itemIndex)),
); );
}).toList(), }),
Container( Container(
margin: const EdgeInsets.only(bottom: 16), margin: const EdgeInsets.only(bottom: 16),
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.blue.withOpacity(0.05), color: Colors.blue.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(8), 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( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -612,45 +754,124 @@ class _PurchaseOrderBuilderScreenState
); );
}, },
), ),
const SizedBox(height: 24),
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( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
const Text( const Text('Items Subtotal:'),
'Total Amount', Text('${rawSubtotal.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.w600)),
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ],
), ),
Builder( if (discountAmount > 0) ...[
builder: (context) { const SizedBox(height: 8),
final productsState = ref.watch(productsProvider); Row(
final products = productsState.value ?? []; mainAxisAlignment: MainAxisAlignment.spaceBetween,
double grandTotal = 0; children: [
const Text('Discount:', style: TextStyle(color: Colors.red, fontWeight: FontWeight.w600)),
final Map<int, double> groupSubtotals = {}; Text('- ₹${discountAmount.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.red)),
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: 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), const SizedBox(height: 24),
PremiumTextField( 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> { class _AddItemSheetState extends ConsumerState<_AddItemSheet> {
int? _selectedProductId; final _searchCtrl = TextEditingController();
final TextEditingController _qtyCtrl = TextEditingController(text: '1'); final Map<int, TextEditingController> _qtyControllers = {};
void _save() {
if (_selectedProductId == null) return;
final qty = int.tryParse(_qtyCtrl.text) ?? 1;
final products = ref.read(productsProvider).value ?? []; @override
final categories = ref.read(productCategoriesProvider).value ?? []; void dispose() {
final commodityRates = ref.read(commodityRatesProvider).value ?? []; _searchCtrl.dispose();
for (final ctrl in _qtyControllers.values) {
ctrl.dispose();
}
super.dispose();
}
final selectedProduct = products.where((p) => p.id == _selectedProductId).firstOrNull; TextEditingController _getQtyController(int productId) {
final selectedCat = categories.where((c) => c.id == selectedProduct?.categoryId).firstOrNull; return _qtyControllers.putIfAbsent(productId, () => TextEditingController(text: '1'));
}
double initialRate = 0.0; Future<void> _scanBarcodeInSheet() async {
if (selectedCat?.commodityCode != null) { final scanned = await Navigator.push<String>(
final matchRate = commodityRates.where( context,
(r) => r.commodityCode.toUpperCase() == selectedCat!.commodityCode!.toUpperCase() MaterialPageRoute(builder: (_) => const BarcodeScannerScreen()),
).firstOrNull; );
if (matchRate != null && matchRate.rate > 0) { if (scanned != null && scanned.isNotEmpty) {
final purity = selectedCat?.purityFactor ?? (selectedProduct?.purityFactor ?? 1.0); _searchCtrl.text = scanned;
initialRate = matchRate.rate * purity; setState(() {});
} }
} }
if (initialRate == 0.0) {
initialRate = selectedProduct?.sellingPrice ?? 0.0; void _addItemsFromCard(Product product, ProductCategory? category, int qty) {
} if (qty <= 0) return;
final gstRate = product.gstRate ?? (category?.defaultGst ?? 3.0);
final List<PurchaseOrderItem> items = []; final List<PurchaseOrderItem> items = [];
for (int i = 0; i < qty; i++) { for (int i = 0; i < qty; i++) {
items.add(PurchaseOrderItem( items.add(PurchaseOrderItem(
productId: _selectedProductId, productId: product.id,
quantity: 1.0, quantity: 1.0,
unitPrice: initialRate, weight: 0.0,
total: initialRate > 0 ? initialRate : 0.0, unitPrice: 0.0,
taxRate: gstRate,
makingCharge: 0.0,
total: 0.0,
)); ));
} }
Navigator.pop(context, items); Navigator.pop(context, items);
@@ -771,62 +1015,252 @@ class _AddItemSheetState extends ConsumerState<_AddItemSheet> {
@override @override
Widget build(BuildContext context) { 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( final query = _searchCtrl.text.trim().toLowerCase();
padding: EdgeInsets.only( final filteredProducts = query.isEmpty
bottom: MediaQuery.of(context).viewInsets.bottom, ? products
left: 24, : products.where((p) =>
right: 24, p.name.toLowerCase().contains(query) ||
top: 24, (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)),
), ),
child: Column( 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 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, 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),
),
],
),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Text( Row(
'Add PO Item', crossAxisAlignment: CrossAxisAlignment.center,
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
), ),
const SizedBox(height: 16), child: const Icon(
productsState.when( LucideIcons.tag,
data: (products) => SmartSearchDropdown<int>( size: 20,
labelText: 'Product', color: Colors.blue,
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), const SizedBox(width: 10),
PremiumTextField( Expanded(
controller: _qtyCtrl, child: Column(
labelText: 'Number of Items', 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, 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 SizedBox(height: 24), ),
),
],
),
const Divider(height: 20),
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: ElevatedButton( child: ElevatedButton.icon(
onPressed: _save, onPressed: () {
final qty = int.tryParse(qtyCtrl.text.trim()) ?? 1;
_addItemsFromCard(product, category, qty);
},
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
backgroundColor: Colors.blue, backgroundColor: Colors.blue,
foregroundColor: Colors.white, foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 10),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
), ),
child: const Text('Add Item'), icon: const Icon(LucideIcons.plus, size: 16),
label: const Text('Add to Invoice', style: TextStyle(fontWeight: FontWeight.bold)),
), ),
), ),
const SizedBox(height: 24),
], ],
), ),
),
); );
} }
} }
@@ -866,10 +1300,10 @@ class _POItemRowState extends State<_POItemRow> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_weightCtrl = TextEditingController(text: widget.item.weight?.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() : ''); _rateCtrl = TextEditingController(text: widget.item.unitPrice > 0 ? widget.item.unitPrice.toString() : '0');
_huidCtrl = TextEditingController(text: widget.item.huid ?? ''); _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 @override
@@ -882,12 +1316,13 @@ class _POItemRowState extends State<_POItemRow> {
} }
void _updateItem() { 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 rate = double.tryParse(_rateCtrl.text) ?? 0.0;
final total = (weight ?? 0.0) * rate; final total = weight * rate;
if (_totalCtrl.text != total.toStringAsFixed(2)) { final totalFormatted = total.toStringAsFixed(2);
_totalCtrl.text = total.toStringAsFixed(2); if (_totalCtrl.text != totalFormatted) {
_totalCtrl.text = totalFormatted;
} }
final updated = widget.item.copyWith( final updated = widget.item.copyWith(
@@ -1029,6 +1464,18 @@ class _POItemRowState extends State<_POItemRow> {
style: TextStyle(fontSize: 10, color: Colors.grey[700]), 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', labelText: 'Weight/Pcs',
keyboardType: const TextInputType.numberWithOptions(decimal: true), keyboardType: const TextInputType.numberWithOptions(decimal: true),
onChanged: (_) => _updateItem(), onChanged: (_) => _updateItem(),
suffixIcon: widget.category?.baseUnit != null && widget.category!.baseUnit!.isNotEmpty suffixIcon: (widget.category?.baseUnit != null && widget.category!.baseUnit.isNotEmpty)
? Container( ? Container(
padding: const EdgeInsets.symmetric(horizontal: 12), padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -1071,7 +1518,7 @@ class _POItemRowState extends State<_POItemRow> {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min, 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, : null,
@@ -1089,7 +1536,7 @@ class _POItemRowState extends State<_POItemRow> {
labelText: 'Rate', labelText: 'Rate',
keyboardType: const TextInputType.numberWithOptions(decimal: true), keyboardType: const TextInputType.numberWithOptions(decimal: true),
onChanged: (_) => _updateItem(), onChanged: (_) => _updateItem(),
suffixIcon: widget.category?.baseUnit != null && widget.category!.baseUnit!.isNotEmpty suffixIcon: (widget.category?.baseUnit != null && widget.category!.baseUnit.isNotEmpty)
? Container( ? Container(
padding: const EdgeInsets.symmetric(horizontal: 12), padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -1100,7 +1547,7 @@ class _POItemRowState extends State<_POItemRow> {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min, 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, : null,

View File

@@ -1,5 +1,4 @@
import 'dart:io'; import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons/lucide_icons.dart';
@@ -1143,11 +1142,11 @@ class _PurchaseOrderDetailsScreenState
], ],
), ),
); );
}).toList(), }),
Container( Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.blue.shade50.withOpacity(0.5), color: Colors.blue.shade50.withValues(alpha: 0.5),
borderRadius: const BorderRadius.only( borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(16), bottomLeft: Radius.circular(16),
bottomRight: Radius.circular(16), bottomRight: Radius.circular(16),

View File

@@ -56,34 +56,44 @@ class _PurchaseOrdersListScreenState
Widget build(BuildContext context) { Widget build(BuildContext context) {
final poState = ref.watch(purchaseOrdersProvider); final poState = ref.watch(purchaseOrdersProvider);
final vendorsState = ref.watch(vendorsProvider); 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( return Scaffold(
backgroundColor: Colors.grey[100], backgroundColor: isDark ? const Color(0xFF0F172A) : const Color(0xFFF8FAFC),
appBar: AppBar( appBar: AppBar(
title: const Text( title: const Text(
'Purchase Invoices', 'Purchase Invoices',
style: TextStyle(fontWeight: FontWeight.bold), style: TextStyle(fontWeight: FontWeight.bold),
), ),
elevation: 0, elevation: 0,
backgroundColor: Colors.white, backgroundColor: isDark ? const Color(0xFF1E293B) : Colors.white,
foregroundColor: Colors.black, foregroundColor: isDark ? Colors.white : Colors.black,
centerTitle: true, centerTitle: true,
), ),
body: Column( body: Column(
children: [ children: [
Container( Container(
color: Colors.white, color: isDark ? const Color(0xFF1E293B) : Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: TextField( child: TextField(
controller: _searchController, controller: _searchController,
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Search PO Number...', hintText: 'Search PO Number or Vendor...',
prefixIcon: const Icon(LucideIcons.search, color: Colors.grey), prefixIcon: const Icon(LucideIcons.search, color: Colors.grey),
contentPadding: const EdgeInsets.symmetric(vertical: 14), 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 suffixIcon: _searchQuery.isNotEmpty
? IconButton( ? IconButton(
icon: const Icon(LucideIcons.xCircle), icon: const Icon(LucideIcons.xCircle, size: 20),
onPressed: () { onPressed: () {
_searchController.clear(); _searchController.clear();
setState(() => _searchQuery = ''); setState(() => _searchQuery = '');
@@ -91,20 +101,28 @@ class _PurchaseOrdersListScreenState
) )
: null, : null,
), ),
onChanged: (val) => setState(() => _searchQuery = val), onChanged: (val) => setState(() => _searchQuery = val.trim()),
), ),
), ),
Expanded( Expanded(
child: poState.when( child: poState.when(
data: (pos) { data: (pos) {
final vendors = vendorsState.value ?? [];
final filtered = pos.where((p) { final filtered = pos.where((p) {
return p.poNumber.toLowerCase().contains( final matchesPo = p.poNumber.toLowerCase().contains(_searchQuery.toLowerCase());
_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(); }).toList();
if (filtered.isEmpty) { if (filtered.isEmpty) {
return const Center( return RefreshIndicator(
onRefresh: () => ref.refresh(purchaseOrdersProvider.future),
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: const [
SizedBox(height: 120),
Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
@@ -120,6 +138,9 @@ class _PurchaseOrdersListScreenState
), ),
], ],
), ),
),
],
),
); );
} }
@@ -130,20 +151,19 @@ class _PurchaseOrdersListScreenState
itemCount: filtered.length, itemCount: filtered.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final po = filtered[index]; final po = filtered[index];
final vendors = vendorsState.value ?? []; final vendor = vendors.where((v) => v.id == po.vendorId).firstOrNull;
final vendor = vendors.firstWhere( final statusColor = _getStatusColor(po);
(v) => v.id == po.vendorId, final statusLabel = _getStatusLabel(po);
orElse: () => po.vendor!,
);
return Container( return Container(
margin: const EdgeInsets.only(bottom: 16), margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: isDark ? const Color(0xFF1E293B) : Colors.white,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: Colors.grey.withOpacity(0.08), color: Colors.black.withValues(alpha: isDark ? 0.2 : 0.04),
blurRadius: 10, blurRadius: 10,
offset: const Offset(0, 4), offset: const Offset(0, 4),
), ),
@@ -193,20 +213,17 @@ class _PurchaseOrdersListScreenState
Container( Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 12, horizontal: 12,
vertical: 6, vertical: 5,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: _getStatusColor( color: statusColor.withValues(alpha: 0.12),
po, borderRadius: BorderRadius.circular(20),
).withOpacity(0.1), border: Border.all(color: statusColor.withValues(alpha: 0.3), width: 0.8),
borderRadius: BorderRadius.circular(
20,
),
), ),
child: Text( child: Text(
_getStatusLabel(po), statusLabel,
style: TextStyle( style: TextStyle(
color: _getStatusColor(po), color: statusColor,
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
@@ -218,24 +235,30 @@ class _PurchaseOrdersListScreenState
Row( Row(
children: [ children: [
const Icon( const Icon(
LucideIcons.user, LucideIcons.truck,
size: 16, size: 16,
color: Colors.grey, color: Colors.grey,
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Text( child: Text(
vendor.name, vendor?.name ?? po.vendor?.name ?? 'Vendor',
style: const TextStyle( style: const TextStyle(
fontWeight: FontWeight.w500, fontWeight: FontWeight.w600,
fontSize: 14,
), ),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, 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( Row(
children: [ children: [
const Icon( const Icon(
@@ -245,20 +268,19 @@ class _PurchaseOrdersListScreenState
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
DateFormat( formatDate.format(po.issueDate),
'MMM dd, yyyy', style: TextStyle(
).format(po.issueDate), color: Colors.grey.shade600,
style: const TextStyle( fontSize: 13,
color: Colors.grey,
fontSize: 14,
), ),
), ),
const Spacer(), const Spacer(),
Text( Text(
'${po.totalAmount.toStringAsFixed(2)}', formatCurrency.format(po.totalAmount),
style: const TextStyle( style: const TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 16, fontSize: 16,
color: Colors.green,
), ),
), ),
], ],
@@ -289,7 +311,9 @@ class _PurchaseOrdersListScreenState
); );
}, },
icon: const Icon(LucideIcons.plus), 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,
), ),
); );
} }

View File

@@ -17,7 +17,6 @@ class _VendorsListScreenState extends ConsumerState<VendorsListScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final vendorsState = ref.watch(vendorsProvider); final vendorsState = ref.watch(vendorsProvider);
final darkTheme = Theme.of(context).brightness == Brightness.dark;
return Scaffold( return Scaffold(
backgroundColor: Colors.grey[100], backgroundColor: Colors.grey[100],
@@ -82,7 +81,7 @@ class _VendorsListScreenState extends ConsumerState<VendorsListScreen> {
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: Colors.grey.withOpacity(0.08), color: Colors.grey.withValues(alpha: 0.08),
blurRadius: 10, blurRadius: 10,
offset: const Offset(0, 4), offset: const Offset(0, 4),
), ),
@@ -107,8 +106,8 @@ class _VendorsListScreenState extends ConsumerState<VendorsListScreen> {
children: [ children: [
CircleAvatar( CircleAvatar(
radius: 24, radius: 24,
backgroundColor: Colors.orange.withOpacity( backgroundColor: Colors.orange.withValues(
0.1, alpha: 0.1,
), ),
child: vendor.photoUrl != null child: vendor.photoUrl != null
? ClipOval( ? ClipOval(

View File

@@ -1,4 +1,3 @@
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/network/dio_client.dart'; import '../../../core/network/dio_client.dart';
import '../domain/purchase_order.dart'; import '../domain/purchase_order.dart';

View File

@@ -1,4 +1,3 @@
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/network/dio_client.dart'; import '../../../core/network/dio_client.dart';
import '../domain/vendor.dart'; import '../domain/vendor.dart';

View File

@@ -5,6 +5,7 @@ import 'package:local_auth/local_auth.dart';
import 'core/theme/app_theme.dart'; import 'core/theme/app_theme.dart';
import 'core/theme/theme_provider.dart'; import 'core/theme/theme_provider.dart';
import 'features/auth/presentation/auth_screen.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/dashboard/presentation/dashboard_screen.dart';
import 'features/onboarding/presentation/onboarding_screen.dart'; import 'features/onboarding/presentation/onboarding_screen.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
@@ -31,6 +32,7 @@ class KifiApp extends ConsumerStatefulWidget {
class _KifiAppState extends ConsumerState<KifiApp> { class _KifiAppState extends ConsumerState<KifiApp> {
bool _isLoading = true; bool _isLoading = true;
bool _isAuthenticated = false; bool _isAuthenticated = false;
bool _isSetupCompleted = true;
bool _hasSeenOnboarding = false; bool _hasSeenOnboarding = false;
final LocalAuthentication _localAuth = LocalAuthentication(); final LocalAuthentication _localAuth = LocalAuthentication();
String? _startupError; String? _startupError;
@@ -43,11 +45,11 @@ class _KifiAppState extends ConsumerState<KifiApp> {
Future<bool> _authenticateWithBiometrics() async { Future<bool> _authenticateWithBiometrics() async {
try { try {
final canCheckBiometrics = await _localAuth.canCheckBiometrics; final isAvailable = await _localAuth.canCheckBiometrics;
final isDeviceSupported = await _localAuth.isDeviceSupported(); final isDeviceSupported = await _localAuth.isDeviceSupported();
if (!canCheckBiometrics && !isDeviceSupported) { if (!isAvailable || !isDeviceSupported) {
return true; // Pass if device doesn't support biometrics to avoid locking users out return true; // If device does not support biometrics, allow access via stored token
} }
return await _localAuth.authenticate( return await _localAuth.authenticate(
@@ -79,11 +81,27 @@ class _KifiAppState extends ConsumerState<KifiApp> {
return; // User failed biometrics, leave them on login screen return; // User failed biometrics, leave them on login screen
} }
// 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(() { setState(() {
_isAuthenticated = true; _isAuthenticated = true;
_isSetupCompleted = status == 'COMPLETED';
_hasSeenOnboarding = hasSeenOnboarding; _hasSeenOnboarding = hasSeenOnboarding;
_isLoading = false; _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 { } else {
setState(() { setState(() {
_isAuthenticated = false; _isAuthenticated = false;
@@ -91,7 +109,7 @@ class _KifiAppState extends ConsumerState<KifiApp> {
_isLoading = false; _isLoading = false;
}); });
} }
} catch (e, stacktrace) { } catch (e) {
setState(() { setState(() {
_startupError = e.toString(); _startupError = e.toString();
_isLoading = false; _isLoading = false;
@@ -116,7 +134,9 @@ class _KifiAppState extends ConsumerState<KifiApp> {
? const Scaffold(body: Center(child: CircularProgressIndicator())) ? const Scaffold(body: Center(child: CircularProgressIndicator()))
: (!_hasSeenOnboarding : (!_hasSeenOnboarding
? const OnboardingScreen() ? const OnboardingScreen()
: (_isAuthenticated ? const DashboardScreen() : const AuthScreen())), : (_isAuthenticated
? (_isSetupCompleted ? const DashboardScreen() : const SetupWizardScreen())
: const AuthScreen())),
); );
} }
} }

View File

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