Fixed UI issue
This commit is contained in:
@@ -3,6 +3,7 @@ class BusinessFeature {
|
||||
final int? userId;
|
||||
final bool inventoryManagement;
|
||||
final bool salesManagement;
|
||||
final bool purchaseManagement;
|
||||
final bool multiLocation;
|
||||
final String bomReductionStrategy;
|
||||
final bool stockDeductionOnInvoice;
|
||||
@@ -14,6 +15,7 @@ class BusinessFeature {
|
||||
this.userId,
|
||||
this.inventoryManagement = false,
|
||||
this.salesManagement = false,
|
||||
this.purchaseManagement = false,
|
||||
this.multiLocation = false,
|
||||
this.bomReductionStrategy = 'COMPONENTS_ONLY',
|
||||
this.stockDeductionOnInvoice = true,
|
||||
@@ -27,6 +29,7 @@ class BusinessFeature {
|
||||
userId: json['userId'],
|
||||
inventoryManagement: json['inventoryManagement'] ?? false,
|
||||
salesManagement: json['salesManagement'] ?? false,
|
||||
purchaseManagement: json['purchaseManagement'] ?? false,
|
||||
multiLocation: json['multiLocation'] ?? false,
|
||||
bomReductionStrategy: json['bomReductionStrategy'] ?? 'COMPONENTS_ONLY',
|
||||
stockDeductionOnInvoice: json['stockDeductionOnInvoice'] ?? true,
|
||||
@@ -41,6 +44,7 @@ class BusinessFeature {
|
||||
'userId': userId,
|
||||
'inventoryManagement': inventoryManagement,
|
||||
'salesManagement': salesManagement,
|
||||
'purchaseManagement': purchaseManagement,
|
||||
'multiLocation': multiLocation,
|
||||
'bomReductionStrategy': bomReductionStrategy,
|
||||
'stockDeductionOnInvoice': stockDeductionOnInvoice,
|
||||
@@ -49,18 +53,22 @@ class BusinessFeature {
|
||||
}
|
||||
|
||||
BusinessFeature copyWith({
|
||||
int? id,
|
||||
int? userId,
|
||||
bool? inventoryManagement,
|
||||
bool? salesManagement,
|
||||
bool? purchaseManagement,
|
||||
bool? multiLocation,
|
||||
String? bomReductionStrategy,
|
||||
bool? stockDeductionOnInvoice,
|
||||
String? barcodeSource,
|
||||
}) {
|
||||
return BusinessFeature(
|
||||
id: id,
|
||||
userId: userId,
|
||||
id: id ?? this.id,
|
||||
userId: userId ?? this.userId,
|
||||
inventoryManagement: inventoryManagement ?? this.inventoryManagement,
|
||||
salesManagement: salesManagement ?? this.salesManagement,
|
||||
purchaseManagement: purchaseManagement ?? this.purchaseManagement,
|
||||
multiLocation: multiLocation ?? this.multiLocation,
|
||||
bomReductionStrategy: bomReductionStrategy ?? this.bomReductionStrategy,
|
||||
stockDeductionOnInvoice: stockDeductionOnInvoice ?? this.stockDeductionOnInvoice,
|
||||
|
||||
@@ -10,6 +10,8 @@ import '../../../sales/presentation/invoices_list_screen.dart';
|
||||
import '../../providers/business_provider.dart';
|
||||
import '../widgets/business_profile_form_sheet.dart';
|
||||
import '../../../inventory/providers/products_provider.dart';
|
||||
import '../../../vendor/presentation/vendors_list_screen.dart';
|
||||
import '../../../vendor/presentation/purchase_orders_list_screen.dart';
|
||||
|
||||
class BusinessHubScreen extends ConsumerWidget {
|
||||
const BusinessHubScreen({super.key});
|
||||
@@ -29,6 +31,7 @@ class BusinessHubScreen extends ConsumerWidget {
|
||||
final featureState = ref.watch(businessFeatureProvider).value;
|
||||
final bool showInventory = featureState?.inventoryManagement ?? false;
|
||||
final bool showSales = featureState?.salesManagement ?? false;
|
||||
final bool showPurchase = featureState?.purchaseManagement ?? false;
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
@@ -122,6 +125,24 @@ class BusinessHubScreen extends ConsumerWidget {
|
||||
() => Navigator.push(context, MaterialPageRoute(builder: (_) => const CustomersListScreen())),
|
||||
),
|
||||
],
|
||||
if (showPurchase) ...[
|
||||
_buildActionCard(
|
||||
context,
|
||||
'Vendors',
|
||||
'Manage suppliers',
|
||||
LucideIcons.truck,
|
||||
Colors.orange,
|
||||
() => Navigator.push(context, MaterialPageRoute(builder: (_) => const VendorsListScreen())),
|
||||
),
|
||||
_buildActionCard(
|
||||
context,
|
||||
'Purchase Orders',
|
||||
'Manage inward stock',
|
||||
LucideIcons.clipboardList,
|
||||
Colors.deepOrange,
|
||||
() => Navigator.push(context, MaterialPageRoute(builder: (_) => const PurchaseOrdersListScreen())),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (showInventory) ...[
|
||||
|
||||
@@ -14,6 +14,7 @@ class _BusinessSettingsScreenState extends ConsumerState<BusinessSettingsScreen>
|
||||
bool _taxIncludedInPrice = false;
|
||||
bool _salesEnabled = true;
|
||||
bool _inventoryEnabled = true;
|
||||
bool _purchaseEnabled = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -30,6 +31,7 @@ class _BusinessSettingsScreenState extends ConsumerState<BusinessSettingsScreen>
|
||||
setState(() {
|
||||
_inventoryEnabled = feature.inventoryManagement;
|
||||
_salesEnabled = feature.salesManagement;
|
||||
_purchaseEnabled = feature.purchaseManagement;
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -68,6 +70,16 @@ class _BusinessSettingsScreenState extends ConsumerState<BusinessSettingsScreen>
|
||||
},
|
||||
secondary: const Icon(LucideIcons.shoppingCart),
|
||||
),
|
||||
SwitchListTile(
|
||||
title: const Text('Vendor & Purchases'),
|
||||
subtitle: const Text('Manage vendors, purchase orders, and inward stock'),
|
||||
value: _purchaseEnabled,
|
||||
onChanged: (val) {
|
||||
setState(() => _purchaseEnabled = val);
|
||||
_saveFeatures();
|
||||
},
|
||||
secondary: const Icon(LucideIcons.truck),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
const Text('Inventory Strategy', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.grey)),
|
||||
const SizedBox(height: 16),
|
||||
@@ -153,6 +165,7 @@ class _BusinessSettingsScreenState extends ConsumerState<BusinessSettingsScreen>
|
||||
final updated = featureState.copyWith(
|
||||
inventoryManagement: _inventoryEnabled,
|
||||
salesManagement: _salesEnabled,
|
||||
purchaseManagement: _purchaseEnabled,
|
||||
);
|
||||
ref.read(businessFeatureProvider.notifier).updateFeatures(updated);
|
||||
}
|
||||
|
||||
@@ -312,7 +312,8 @@ class _AccountsScreenState extends ConsumerState<AccountsScreen> {
|
||||
final newWallet = await ref.read(walletProvider.notifier).createWallet(
|
||||
name: ctrl.text,
|
||||
nature: selectedNature,
|
||||
initialBalance: 0.0,
|
||||
initialBalance: amt,
|
||||
initialBalanceDate: openingDate,
|
||||
subNature: selectedNature == 'PAYABLES' ? selectedSubNature : null,
|
||||
creditLimit: cl,
|
||||
fixedAmount: fa,
|
||||
@@ -320,19 +321,6 @@ class _AccountsScreenState extends ConsumerState<AccountsScreen> {
|
||||
cycleDate: cd,
|
||||
);
|
||||
|
||||
if (amt != 0) {
|
||||
final tx = Transaction(
|
||||
id: 0,
|
||||
type: amt > 0 ? 'INCOME' : 'EXPENSE',
|
||||
amount: amt.abs(),
|
||||
date: openingDate,
|
||||
description: 'Opening Balance',
|
||||
fromWalletId: amt < 0 ? newWallet.id : null,
|
||||
toWalletId: amt > 0 ? newWallet.id : null,
|
||||
);
|
||||
await ref.read(transactionProvider.notifier).addTransaction(tx);
|
||||
}
|
||||
|
||||
if (context.mounted) Navigator.pop(ctx);
|
||||
}
|
||||
},
|
||||
@@ -522,6 +510,7 @@ class _AccountsScreenState extends ConsumerState<AccountsScreen> {
|
||||
error: (e, st) => Center(child: Text('Error: $e')),
|
||||
data: (allWallets) {
|
||||
final wallets = allWallets.where((w) {
|
||||
if (w.nature == 'EQUITY') return false;
|
||||
final matchSearch = _searchQuery.isEmpty ||
|
||||
w.name.toLowerCase().contains(_searchQuery.toLowerCase());
|
||||
final matchNature = _filterNature == null || w.nature == _filterNature;
|
||||
@@ -601,6 +590,14 @@ class _AccountsScreenState extends ConsumerState<AccountsScreen> {
|
||||
final editCreditLimitCtrl = TextEditingController(text: w.creditLimit?.toString() ?? '');
|
||||
final editFixedAmountCtrl = TextEditingController(text: w.fixedAmount?.toString() ?? '');
|
||||
final editCycleDateCtrl = TextEditingController(text: w.cycleDate?.toString() ?? '');
|
||||
|
||||
final allTxs = ref.read(transactionProvider).value ?? [];
|
||||
final walletTxs = allTxs.where((t) => t.fromWalletId == w.id || t.toWalletId == w.id).toList();
|
||||
final obTxs = walletTxs.where((t) => t.description == 'Opening Balance');
|
||||
final hasRealTx = walletTxs.any((t) => t.description != 'Opening Balance');
|
||||
final editInitialBalanceCtrl = TextEditingController(text: w.balance != 0 ? w.balance.toString() : '');
|
||||
DateTime editOpeningDate = obTxs.isNotEmpty ? obTxs.first.date : DateTime.now();
|
||||
|
||||
String? editPaymentCycle = w.paymentCycle;
|
||||
|
||||
final natures = ['CASH', 'SAVINGS', 'EXPENSE', 'INVESTMENTS', 'LOAN', 'LENDING', 'PAYABLES', 'RECEIVABLES', 'INCOME'];
|
||||
@@ -663,6 +660,58 @@ class _AccountsScreenState extends ConsumerState<AccountsScreen> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
Tooltip(
|
||||
message: hasRealTx ? 'Opening balance cannot be edited after transactions are recorded' : '',
|
||||
child: TextField(
|
||||
controller: editInitialBalanceCtrl,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: true),
|
||||
enabled: !hasRealTx,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Opening Balance (Optional)',
|
||||
prefixText: 'Rs. ',
|
||||
filled: true,
|
||||
fillColor: hasRealTx ? Colors.grey.shade200 : Colors.grey.shade100,
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
Tooltip(
|
||||
message: hasRealTx ? 'Opening balance date cannot be edited after transactions are recorded' : '',
|
||||
child: InkWell(
|
||||
onTap: hasRealTx ? null : () async {
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: editOpeningDate,
|
||||
firstDate: DateTime.now().subtract(const Duration(days: 365 * 10)),
|
||||
lastDate: DateTime.now(),
|
||||
);
|
||||
if (picked != null) {
|
||||
setStateDialog(() => editOpeningDate = picked);
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: hasRealTx ? Colors.grey.shade200 : Colors.grey.shade100,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(LucideIcons.calendar, color: Colors.black54, size: 20),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'Opening Date: ${editOpeningDate.day.toString().padLeft(2, '0')}/${editOpeningDate.month.toString().padLeft(2, '0')}/${editOpeningDate.year}',
|
||||
style: TextStyle(fontWeight: FontWeight.w500, fontSize: 15, color: hasRealTx ? Colors.black38 : Colors.black87),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
if (editNature == 'PAYABLES') ...[
|
||||
DropdownButtonFormField<String>(
|
||||
value: editSubNature,
|
||||
@@ -758,15 +807,26 @@ class _AccountsScreenState extends ConsumerState<AccountsScreen> {
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
if (editCtrl.text.isNotEmpty) {
|
||||
final double? cl = double.tryParse(editCreditLimitCtrl.text);
|
||||
final double? fa = double.tryParse(editFixedAmountCtrl.text);
|
||||
final double? cl = double.tryParse(editCreditLimitCtrl.text.replaceAll(RegExp(r'[^0-9.]'), ''));
|
||||
final double? fa = double.tryParse(editFixedAmountCtrl.text.replaceAll(RegExp(r'[^0-9.]'), ''));
|
||||
final int? cd = int.tryParse(editCycleDateCtrl.text);
|
||||
|
||||
// Make parsing super robust by stripping everything except digits and dot
|
||||
final String rawText = editInitialBalanceCtrl.text.replaceAll(RegExp(r'[^0-9.]'), '');
|
||||
final double? ib = (!hasRealTx && rawText.isNotEmpty) ? double.tryParse(rawText) : null;
|
||||
|
||||
print("DEBUG KIFI: editWallet called.");
|
||||
print("DEBUG KIFI: hasRealTx = $hasRealTx");
|
||||
print("DEBUG KIFI: rawText = '$rawText'");
|
||||
print("DEBUG KIFI: parsed ib = $ib");
|
||||
|
||||
try {
|
||||
await ref.read(walletProvider.notifier).editWallet(
|
||||
w.id,
|
||||
name: editCtrl.text.trim(),
|
||||
nature: editNature,
|
||||
initialBalance: ib,
|
||||
initialBalanceDate: ib != null ? editOpeningDate : null,
|
||||
subNature: editNature == 'PAYABLES' ? editSubNature : null,
|
||||
creditLimit: cl,
|
||||
fixedAmount: fa,
|
||||
|
||||
@@ -177,16 +177,17 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(LucideIcons.trendingUp),
|
||||
tooltip: 'Daily Commodity Rates',
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const DailyRatesScreen()),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (isBusinessMode)
|
||||
IconButton(
|
||||
icon: const Icon(LucideIcons.trendingUp),
|
||||
tooltip: 'Daily Commodity Rates',
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const DailyRatesScreen()),
|
||||
);
|
||||
},
|
||||
),
|
||||
Consumer(
|
||||
builder: (context, ref, child) {
|
||||
final invitationsState = ref.watch(invitationProvider);
|
||||
|
||||
@@ -3,6 +3,8 @@ class Product {
|
||||
final int? userId;
|
||||
final int? categoryId;
|
||||
final int? uomId;
|
||||
final String? hsnCode;
|
||||
|
||||
final String name;
|
||||
final String? sku;
|
||||
final String? barcode;
|
||||
@@ -33,6 +35,7 @@ class Product {
|
||||
this.categoryId,
|
||||
this.uomId,
|
||||
required this.name,
|
||||
this.hsnCode,
|
||||
this.sku,
|
||||
this.barcode,
|
||||
this.description,
|
||||
@@ -64,6 +67,7 @@ class Product {
|
||||
categoryId: json['categoryId'] ?? json['category_id'],
|
||||
uomId: json['uomId'] ?? json['uom_id'],
|
||||
name: json['name'],
|
||||
hsnCode: json['hsnCode'] ?? json['hsn_code'],
|
||||
sku: json['sku'],
|
||||
barcode: json['barcode'],
|
||||
description: json['description'],
|
||||
@@ -98,6 +102,7 @@ class Product {
|
||||
'categoryId': categoryId,
|
||||
'uomId': uomId,
|
||||
'name': name,
|
||||
'hsnCode': hsnCode,
|
||||
'sku': sku,
|
||||
'barcode': barcode,
|
||||
'description': description,
|
||||
|
||||
@@ -32,6 +32,7 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
||||
// Basic
|
||||
String _name = '';
|
||||
String _sku = '';
|
||||
String _hsnCode = '';
|
||||
ProductCategory? _selectedCategory;
|
||||
int? _uomId;
|
||||
|
||||
@@ -71,6 +72,7 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
||||
final p = widget.product!;
|
||||
_name = p.name;
|
||||
_sku = p.sku ?? '';
|
||||
_hsnCode = p.hsnCode ?? '';
|
||||
_uomId = p.uomId;
|
||||
_color = p.color ?? '';
|
||||
_size = p.size ?? '';
|
||||
@@ -269,6 +271,7 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
||||
final product = Product(
|
||||
id: widget.product?.id,
|
||||
name: _name,
|
||||
hsnCode: _hsnCode.isNotEmpty ? _hsnCode : null,
|
||||
sku: _sku.isNotEmpty ? _sku : null,
|
||||
categoryId: _selectedCategory?.id,
|
||||
uomId: _uomId,
|
||||
@@ -701,12 +704,26 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
|
||||
_buildPremiumTextField(
|
||||
label: 'GST Rate (%)',
|
||||
initialValue: _gstRate == 0 ? '' : _gstRate.toString(),
|
||||
suffixText: '%',
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
onChanged: (val) => _gstRate = double.tryParse(val) ?? 0,
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildPremiumTextField(
|
||||
label: 'HSN Code',
|
||||
initialValue: _hsnCode,
|
||||
onChanged: (val) => _hsnCode = val,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: _buildPremiumTextField(
|
||||
label: 'GST Rate (%)',
|
||||
initialValue: _gstRate == 0 ? '' : _gstRate.toString(),
|
||||
suffixText: '%',
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
onChanged: (val) => _gstRate = double.tryParse(val) ?? 0,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
|
||||
@@ -60,10 +60,22 @@ class _AdjustStockSheetState extends ConsumerState<AdjustStockSheet> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final inputDecoration = InputDecoration(
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).brightness == Brightness.dark ? Colors.black26 : Colors.grey[100],
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
|
||||
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide(color: Theme.of(context).colorScheme.primary, width: 2)
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||
);
|
||||
|
||||
return Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom + 24,
|
||||
@@ -86,10 +98,7 @@ class _AdjustStockSheetState extends ConsumerState<AdjustStockSheet> {
|
||||
|
||||
DropdownButtonFormField<String>(
|
||||
value: _selectedType,
|
||||
decoration: InputDecoration(
|
||||
labelText: "Movement Type",
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
decoration: inputDecoration.copyWith(labelText: "Movement Type"),
|
||||
items: _types.map((t) => DropdownMenuItem(value: t, child: Text(t))).toList(),
|
||||
onChanged: (val) {
|
||||
if (val != null) setState(() => _selectedType = val);
|
||||
@@ -100,9 +109,8 @@ class _AdjustStockSheetState extends ConsumerState<AdjustStockSheet> {
|
||||
TextFormField(
|
||||
controller: _qtyController,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
decoration: InputDecoration(
|
||||
decoration: inputDecoration.copyWith(
|
||||
labelText: "Quantity",
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||
prefixIcon: const Icon(Icons.inventory_2),
|
||||
),
|
||||
),
|
||||
@@ -110,9 +118,8 @@ class _AdjustStockSheetState extends ConsumerState<AdjustStockSheet> {
|
||||
|
||||
TextFormField(
|
||||
controller: _notesController,
|
||||
decoration: InputDecoration(
|
||||
decoration: inputDecoration.copyWith(
|
||||
labelText: "Notes (Optional)",
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||
prefixIcon: const Icon(Icons.note),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -3,6 +3,8 @@ class InvoiceItem {
|
||||
final int? invoiceId;
|
||||
final int? productId;
|
||||
final String? sku;
|
||||
final String? hsnCode;
|
||||
|
||||
final String? description;
|
||||
final double quantity;
|
||||
final double unitPrice;
|
||||
@@ -16,6 +18,7 @@ class InvoiceItem {
|
||||
this.id,
|
||||
this.invoiceId,
|
||||
this.productId,
|
||||
this.hsnCode,
|
||||
this.sku,
|
||||
this.description,
|
||||
required this.quantity,
|
||||
@@ -31,6 +34,7 @@ class InvoiceItem {
|
||||
int? id,
|
||||
int? invoiceId,
|
||||
int? productId,
|
||||
String? hsnCode,
|
||||
String? sku,
|
||||
String? description,
|
||||
double? quantity,
|
||||
@@ -45,6 +49,7 @@ class InvoiceItem {
|
||||
id: id ?? this.id,
|
||||
invoiceId: invoiceId ?? this.invoiceId,
|
||||
productId: productId ?? this.productId,
|
||||
hsnCode: hsnCode ?? this.hsnCode,
|
||||
sku: sku ?? this.sku,
|
||||
description: description ?? this.description,
|
||||
quantity: quantity ?? this.quantity,
|
||||
@@ -62,6 +67,7 @@ class InvoiceItem {
|
||||
id: json['id'],
|
||||
invoiceId: json['invoiceId'],
|
||||
productId: json['productId'],
|
||||
hsnCode: json['hsnCode'] ?? json['hsn_code'],
|
||||
sku: json['sku'],
|
||||
description: json['description'],
|
||||
quantity: json['quantity'].toDouble(),
|
||||
@@ -79,6 +85,7 @@ class InvoiceItem {
|
||||
if (id != null) data['id'] = id;
|
||||
if (invoiceId != null) data['invoiceId'] = invoiceId;
|
||||
if (productId != null) data['productId'] = productId;
|
||||
if (hsnCode != null) data['hsnCode'] = hsnCode;
|
||||
if (sku != null) data['sku'] = sku;
|
||||
if (description != null) data['description'] = description;
|
||||
data['quantity'] = quantity;
|
||||
|
||||
@@ -293,6 +293,16 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text('SKU: $skuToDisplay', style: TextStyle(fontSize: 12, color: Colors.grey.shade600, fontWeight: FontWeight.w600)),
|
||||
),
|
||||
if (item.hsnCode != null && item.hsnCode!.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text('HSN: ${item.hsnCode}', style: TextStyle(fontSize: 12, color: Colors.grey.shade600, fontWeight: FontWeight.w600)),
|
||||
)
|
||||
else if (product?.hsnCode != null && product!.hsnCode!.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text('HSN: ${product.hsnCode}', style: TextStyle(fontSize: 12, color: Colors.grey.shade600, fontWeight: FontWeight.w600)),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
@@ -654,8 +664,10 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
|
||||
...(latestInvoice.items ?? []).map((item) {
|
||||
final product = item.productId != null ? ref.read(productsProvider).value?.where((p) => p.id == item.productId).firstOrNull : null;
|
||||
final skuToDisplay = item.sku ?? product?.sku;
|
||||
final hsnToDisplay = item.hsnCode ?? product?.hsnCode;
|
||||
String itemDesc = item.description ?? 'Item';
|
||||
if (skuToDisplay != null && skuToDisplay.isNotEmpty) itemDesc += '\nSKU: $skuToDisplay';
|
||||
if (hsnToDisplay != null && hsnToDisplay.isNotEmpty) itemDesc += '\nHSN: $hsnToDisplay';
|
||||
if (item.makingCharge > 0) itemDesc += '\n+ Making Charges: ${formatCurrency.format(item.makingCharge)}';
|
||||
if (item.otherCharges > 0) itemDesc += '\n+ Other Charges: ${formatCurrency.format(item.otherCharges)}';
|
||||
if (item.discount > 0) itemDesc += '\n- Discount: ${formatCurrency.format(item.discount)}';
|
||||
|
||||
@@ -147,6 +147,7 @@ class ApiRepository {
|
||||
required String name,
|
||||
String nature = 'CASH',
|
||||
double initialBalance = 0.0,
|
||||
String? initialBalanceDate,
|
||||
String currency = 'INR',
|
||||
String? icon,
|
||||
String? color,
|
||||
@@ -160,6 +161,7 @@ class ApiRepository {
|
||||
'name': name,
|
||||
'nature': nature,
|
||||
'initialBalance': initialBalance,
|
||||
if (initialBalanceDate != null) 'initialBalanceDate': initialBalanceDate,
|
||||
'currency': currency,
|
||||
'icon': icon,
|
||||
'color': color,
|
||||
@@ -177,6 +179,8 @@ class ApiRepository {
|
||||
String? nature,
|
||||
String? icon,
|
||||
String? color,
|
||||
double? initialBalance,
|
||||
String? initialBalanceDate,
|
||||
String? subNature,
|
||||
double? creditLimit,
|
||||
double? fixedAmount,
|
||||
@@ -188,6 +192,8 @@ class ApiRepository {
|
||||
if (nature != null) 'nature': nature,
|
||||
if (icon != null) 'icon': icon,
|
||||
if (color != null) 'color': color,
|
||||
if (initialBalance != null) 'initialBalance': initialBalance,
|
||||
if (initialBalanceDate != null) 'initialBalanceDate': initialBalanceDate,
|
||||
if (subNature != null) 'subNature': subNature,
|
||||
if (creditLimit != null) 'creditLimit': creditLimit,
|
||||
if (fixedAmount != null) 'fixedAmount': fixedAmount,
|
||||
|
||||
@@ -423,7 +423,8 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen> {
|
||||
child: walletsState.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, st) => Center(child: Text('Error: $e')),
|
||||
data: (wallets) {
|
||||
data: (walletsData) {
|
||||
final wallets = walletsData.where((w) => w.nature != 'EQUITY').toList();
|
||||
return ListView.builder(
|
||||
itemCount: wallets.length,
|
||||
itemBuilder: (context, index) {
|
||||
|
||||
@@ -154,6 +154,7 @@ class WalletNotifier extends AsyncNotifier<List<Wallet>> {
|
||||
required String name,
|
||||
String nature = 'CASH',
|
||||
double initialBalance = 0.0,
|
||||
DateTime? initialBalanceDate,
|
||||
String currency = 'INR',
|
||||
String? icon,
|
||||
String? color,
|
||||
@@ -167,6 +168,7 @@ class WalletNotifier extends AsyncNotifier<List<Wallet>> {
|
||||
name: name,
|
||||
nature: nature,
|
||||
initialBalance: initialBalance,
|
||||
initialBalanceDate: initialBalanceDate != null ? "${initialBalanceDate.year}-${initialBalanceDate.month.toString().padLeft(2, '0')}-${initialBalanceDate.day.toString().padLeft(2, '0')}" : null,
|
||||
currency: currency,
|
||||
icon: icon,
|
||||
color: color,
|
||||
@@ -188,13 +190,15 @@ class WalletNotifier extends AsyncNotifier<List<Wallet>> {
|
||||
await ref.read(apiRepositoryProvider).inviteUserToWallet(walletId, email);
|
||||
}
|
||||
|
||||
Future<void> editWallet(int id, {String? name, String? nature, String? icon, String? color, String? subNature, double? creditLimit, double? fixedAmount, String? paymentCycle, int? cycleDate}) async {
|
||||
Future<void> editWallet(int id, {String? name, String? nature, String? icon, String? color, double? initialBalance, DateTime? initialBalanceDate, String? subNature, double? creditLimit, double? fixedAmount, String? paymentCycle, int? cycleDate}) async {
|
||||
final updated = await ref.read(apiRepositoryProvider).editWallet(
|
||||
id: id,
|
||||
name: name,
|
||||
nature: nature,
|
||||
icon: icon,
|
||||
color: color,
|
||||
initialBalance: initialBalance,
|
||||
initialBalanceDate: initialBalanceDate != null ? "${initialBalanceDate.year}-${initialBalanceDate.month.toString().padLeft(2, '0')}-${initialBalanceDate.day.toString().padLeft(2, '0')}" : null,
|
||||
subNature: subNature,
|
||||
creditLimit: creditLimit,
|
||||
fixedAmount: fixedAmount,
|
||||
|
||||
139
kifi-app/lib/features/vendor/domain/purchase_order.dart
vendored
Normal file
139
kifi-app/lib/features/vendor/domain/purchase_order.dart
vendored
Normal file
@@ -0,0 +1,139 @@
|
||||
import 'purchase_order_item.dart';
|
||||
import 'vendor.dart';
|
||||
|
||||
class PurchaseOrder {
|
||||
final int? id;
|
||||
final int? userId;
|
||||
final int? vendorId;
|
||||
final String poNumber;
|
||||
final DateTime issueDate;
|
||||
final DateTime? dueDate;
|
||||
final double subtotal;
|
||||
final double taxTotal;
|
||||
final double discountTotal;
|
||||
final double totalAmount;
|
||||
final String status;
|
||||
final String? notes;
|
||||
final DateTime? createdAt;
|
||||
final DateTime? updatedAt;
|
||||
final DateTime? poDate;
|
||||
final double amountPaid;
|
||||
final DateTime? nextPaymentDate;
|
||||
final String? vendorInvoiceUrl;
|
||||
final List<PurchaseOrderItem> items;
|
||||
final Vendor? vendor;
|
||||
|
||||
PurchaseOrder({
|
||||
this.id,
|
||||
this.userId,
|
||||
this.vendorId,
|
||||
required this.poNumber,
|
||||
required this.issueDate,
|
||||
this.dueDate,
|
||||
this.subtotal = 0.0,
|
||||
this.taxTotal = 0.0,
|
||||
this.discountTotal = 0.0,
|
||||
this.totalAmount = 0.0,
|
||||
this.status = 'PENDING',
|
||||
this.notes,
|
||||
this.createdAt,
|
||||
this.updatedAt,
|
||||
this.poDate,
|
||||
this.amountPaid = 0.0,
|
||||
this.nextPaymentDate,
|
||||
this.vendorInvoiceUrl,
|
||||
this.items = const [],
|
||||
this.vendor,
|
||||
});
|
||||
|
||||
factory PurchaseOrder.fromJson(Map<String, dynamic> json) {
|
||||
return PurchaseOrder(
|
||||
id: json['id'],
|
||||
userId: json['userId'],
|
||||
vendorId: json['vendorId'],
|
||||
poNumber: json['poNumber'] ?? '',
|
||||
issueDate: json['issueDate'] != null ? DateTime.parse(json['issueDate']) : DateTime.now(),
|
||||
dueDate: json['dueDate'] != null ? DateTime.parse(json['dueDate']) : null,
|
||||
subtotal: (json['subtotal'] ?? 0.0).toDouble(),
|
||||
taxTotal: (json['taxTotal'] ?? 0.0).toDouble(),
|
||||
discountTotal: (json['discountTotal'] ?? 0.0).toDouble(),
|
||||
totalAmount: (json['totalAmount'] ?? 0.0).toDouble(),
|
||||
status: json['status'] ?? 'PENDING',
|
||||
notes: json['notes'],
|
||||
createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt']) : null,
|
||||
updatedAt: json['updatedAt'] != null ? DateTime.parse(json['updatedAt']) : null,
|
||||
poDate: json['poDate'] != null ? DateTime.parse(json['poDate']) : null,
|
||||
amountPaid: (json['amountPaid'] ?? 0.0).toDouble(),
|
||||
nextPaymentDate: json['nextPaymentDate'] != null ? DateTime.parse(json['nextPaymentDate']) : null,
|
||||
vendorInvoiceUrl: json['vendorInvoiceUrl'],
|
||||
items: json['items'] != null ? (json['items'] as List).map((i) => PurchaseOrderItem.fromJson(i)).toList() : [],
|
||||
vendor: json['vendor'] != null ? Vendor.fromJson(json['vendor']) : null,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'userId': userId,
|
||||
'vendorId': vendorId,
|
||||
'poNumber': poNumber,
|
||||
'issueDate': issueDate.toIso8601String(),
|
||||
'dueDate': dueDate?.toIso8601String(),
|
||||
'subtotal': subtotal,
|
||||
'taxTotal': taxTotal,
|
||||
'discountTotal': discountTotal,
|
||||
'totalAmount': totalAmount,
|
||||
'status': status,
|
||||
'notes': notes,
|
||||
'poDate': poDate?.toIso8601String(),
|
||||
'amountPaid': amountPaid,
|
||||
'nextPaymentDate': nextPaymentDate?.toIso8601String(),
|
||||
'vendorInvoiceUrl': vendorInvoiceUrl,
|
||||
'items': items.map((i) => i.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
PurchaseOrder copyWith({
|
||||
int? id,
|
||||
int? userId,
|
||||
int? vendorId,
|
||||
String? poNumber,
|
||||
DateTime? issueDate,
|
||||
DateTime? dueDate,
|
||||
double? subtotal,
|
||||
double? taxTotal,
|
||||
double? discountTotal,
|
||||
double? totalAmount,
|
||||
String? status,
|
||||
String? notes,
|
||||
DateTime? poDate,
|
||||
double? amountPaid,
|
||||
DateTime? nextPaymentDate,
|
||||
String? vendorInvoiceUrl,
|
||||
List<PurchaseOrderItem>? items,
|
||||
Vendor? vendor,
|
||||
}) {
|
||||
return PurchaseOrder(
|
||||
id: id ?? this.id,
|
||||
userId: userId ?? this.userId,
|
||||
vendorId: vendorId ?? this.vendorId,
|
||||
poNumber: poNumber ?? this.poNumber,
|
||||
issueDate: issueDate ?? this.issueDate,
|
||||
dueDate: dueDate ?? this.dueDate,
|
||||
subtotal: subtotal ?? this.subtotal,
|
||||
taxTotal: taxTotal ?? this.taxTotal,
|
||||
discountTotal: discountTotal ?? this.discountTotal,
|
||||
totalAmount: totalAmount ?? this.totalAmount,
|
||||
status: status ?? this.status,
|
||||
notes: notes ?? this.notes,
|
||||
createdAt: createdAt,
|
||||
updatedAt: updatedAt,
|
||||
poDate: poDate ?? this.poDate,
|
||||
amountPaid: amountPaid ?? this.amountPaid,
|
||||
nextPaymentDate: nextPaymentDate ?? this.nextPaymentDate,
|
||||
vendorInvoiceUrl: vendorInvoiceUrl ?? this.vendorInvoiceUrl,
|
||||
items: items ?? this.items,
|
||||
vendor: vendor ?? this.vendor,
|
||||
);
|
||||
}
|
||||
}
|
||||
93
kifi-app/lib/features/vendor/domain/purchase_order_item.dart
vendored
Normal file
93
kifi-app/lib/features/vendor/domain/purchase_order_item.dart
vendored
Normal file
@@ -0,0 +1,93 @@
|
||||
class PurchaseOrderItem {
|
||||
final int? id;
|
||||
final int? poId;
|
||||
final int? productId;
|
||||
final double quantity;
|
||||
final double unitPrice;
|
||||
final double taxRate;
|
||||
final double discount;
|
||||
final double total;
|
||||
final String? description;
|
||||
final double makingCharge;
|
||||
final double otherCharges;
|
||||
final String? sku;
|
||||
|
||||
PurchaseOrderItem({
|
||||
this.id,
|
||||
this.poId,
|
||||
this.productId,
|
||||
this.quantity = 1.0,
|
||||
this.unitPrice = 0.0,
|
||||
this.taxRate = 0.0,
|
||||
this.discount = 0.0,
|
||||
this.total = 0.0,
|
||||
this.description,
|
||||
this.makingCharge = 0.0,
|
||||
this.otherCharges = 0.0,
|
||||
this.sku,
|
||||
});
|
||||
|
||||
factory PurchaseOrderItem.fromJson(Map<String, dynamic> json) {
|
||||
return PurchaseOrderItem(
|
||||
id: json['id'],
|
||||
poId: json['poId'],
|
||||
productId: json['productId'],
|
||||
quantity: (json['quantity'] ?? 1.0).toDouble(),
|
||||
unitPrice: (json['unitPrice'] ?? 0.0).toDouble(),
|
||||
taxRate: (json['taxRate'] ?? 0.0).toDouble(),
|
||||
discount: (json['discount'] ?? 0.0).toDouble(),
|
||||
total: (json['total'] ?? 0.0).toDouble(),
|
||||
description: json['description'],
|
||||
makingCharge: (json['makingCharge'] ?? 0.0).toDouble(),
|
||||
otherCharges: (json['otherCharges'] ?? 0.0).toDouble(),
|
||||
sku: json['sku'],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'poId': poId,
|
||||
'productId': productId,
|
||||
'quantity': quantity,
|
||||
'unitPrice': unitPrice,
|
||||
'taxRate': taxRate,
|
||||
'discount': discount,
|
||||
'total': total,
|
||||
'description': description,
|
||||
'makingCharge': makingCharge,
|
||||
'otherCharges': otherCharges,
|
||||
'sku': sku,
|
||||
};
|
||||
}
|
||||
|
||||
PurchaseOrderItem copyWith({
|
||||
int? id,
|
||||
int? poId,
|
||||
int? productId,
|
||||
double? quantity,
|
||||
double? unitPrice,
|
||||
double? taxRate,
|
||||
double? discount,
|
||||
double? total,
|
||||
String? description,
|
||||
double? makingCharge,
|
||||
double? otherCharges,
|
||||
String? sku,
|
||||
}) {
|
||||
return PurchaseOrderItem(
|
||||
id: id ?? this.id,
|
||||
poId: poId ?? this.poId,
|
||||
productId: productId ?? this.productId,
|
||||
quantity: quantity ?? this.quantity,
|
||||
unitPrice: unitPrice ?? this.unitPrice,
|
||||
taxRate: taxRate ?? this.taxRate,
|
||||
discount: discount ?? this.discount,
|
||||
total: total ?? this.total,
|
||||
description: description ?? this.description,
|
||||
makingCharge: makingCharge ?? this.makingCharge,
|
||||
otherCharges: otherCharges ?? this.otherCharges,
|
||||
sku: sku ?? this.sku,
|
||||
);
|
||||
}
|
||||
}
|
||||
62
kifi-app/lib/features/vendor/domain/purchase_payment.dart
vendored
Normal file
62
kifi-app/lib/features/vendor/domain/purchase_payment.dart
vendored
Normal file
@@ -0,0 +1,62 @@
|
||||
class PurchasePayment {
|
||||
final int? id;
|
||||
final int? poId;
|
||||
final int? transactionId;
|
||||
final double amount;
|
||||
final String? paymentMethod;
|
||||
final DateTime? paymentDate;
|
||||
final String? notes;
|
||||
|
||||
PurchasePayment({
|
||||
this.id,
|
||||
this.poId,
|
||||
this.transactionId,
|
||||
required this.amount,
|
||||
this.paymentMethod,
|
||||
this.paymentDate,
|
||||
this.notes,
|
||||
});
|
||||
|
||||
factory PurchasePayment.fromJson(Map<String, dynamic> json) {
|
||||
return PurchasePayment(
|
||||
id: json['id'],
|
||||
poId: json['poId'],
|
||||
transactionId: json['transactionId'],
|
||||
amount: (json['amount'] ?? 0.0).toDouble(),
|
||||
paymentMethod: json['paymentMethod'],
|
||||
paymentDate: json['paymentDate'] != null ? DateTime.parse(json['paymentDate']) : null,
|
||||
notes: json['notes'],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'poId': poId,
|
||||
'transactionId': transactionId,
|
||||
'amount': amount,
|
||||
'paymentMethod': paymentMethod,
|
||||
'notes': notes,
|
||||
};
|
||||
}
|
||||
|
||||
PurchasePayment copyWith({
|
||||
int? id,
|
||||
int? poId,
|
||||
int? transactionId,
|
||||
double? amount,
|
||||
String? paymentMethod,
|
||||
DateTime? paymentDate,
|
||||
String? notes,
|
||||
}) {
|
||||
return PurchasePayment(
|
||||
id: id ?? this.id,
|
||||
poId: poId ?? this.poId,
|
||||
transactionId: transactionId ?? this.transactionId,
|
||||
amount: amount ?? this.amount,
|
||||
paymentMethod: paymentMethod ?? this.paymentMethod,
|
||||
paymentDate: paymentDate ?? this.paymentDate,
|
||||
notes: notes ?? this.notes,
|
||||
);
|
||||
}
|
||||
}
|
||||
91
kifi-app/lib/features/vendor/domain/vendor.dart
vendored
Normal file
91
kifi-app/lib/features/vendor/domain/vendor.dart
vendored
Normal file
@@ -0,0 +1,91 @@
|
||||
class Vendor {
|
||||
final int? id;
|
||||
final int? userId;
|
||||
final String name;
|
||||
final String? email;
|
||||
final String? phone;
|
||||
final String? address;
|
||||
final String? gstin;
|
||||
final int? stateId;
|
||||
final String? idNumber;
|
||||
final String? photoUrl;
|
||||
final String? contactPerson;
|
||||
final DateTime? createdAt;
|
||||
|
||||
Vendor({
|
||||
this.id,
|
||||
this.userId,
|
||||
required this.name,
|
||||
this.email,
|
||||
this.phone,
|
||||
this.address,
|
||||
this.gstin,
|
||||
this.stateId,
|
||||
this.idNumber,
|
||||
this.photoUrl,
|
||||
this.contactPerson,
|
||||
this.createdAt,
|
||||
});
|
||||
|
||||
factory Vendor.fromJson(Map<String, dynamic> json) {
|
||||
return Vendor(
|
||||
id: json['id'],
|
||||
userId: json['userId'],
|
||||
name: json['name'] ?? '',
|
||||
email: json['email'],
|
||||
phone: json['phone'],
|
||||
address: json['address'],
|
||||
gstin: json['gstin'],
|
||||
stateId: json['stateId'],
|
||||
idNumber: json['idNumber'],
|
||||
photoUrl: json['photoUrl'],
|
||||
contactPerson: json['contactPerson'],
|
||||
createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt']) : null,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'userId': userId,
|
||||
'name': name,
|
||||
'email': email,
|
||||
'phone': phone,
|
||||
'address': address,
|
||||
'gstin': gstin,
|
||||
'stateId': stateId,
|
||||
'idNumber': idNumber,
|
||||
'photoUrl': photoUrl,
|
||||
'contactPerson': contactPerson,
|
||||
};
|
||||
}
|
||||
|
||||
Vendor copyWith({
|
||||
int? id,
|
||||
int? userId,
|
||||
String? name,
|
||||
String? email,
|
||||
String? phone,
|
||||
String? address,
|
||||
String? gstin,
|
||||
int? stateId,
|
||||
String? idNumber,
|
||||
String? photoUrl,
|
||||
String? contactPerson,
|
||||
}) {
|
||||
return Vendor(
|
||||
id: id ?? this.id,
|
||||
userId: userId ?? this.userId,
|
||||
name: name ?? this.name,
|
||||
email: email ?? this.email,
|
||||
phone: phone ?? this.phone,
|
||||
address: address ?? this.address,
|
||||
gstin: gstin ?? this.gstin,
|
||||
stateId: stateId ?? this.stateId,
|
||||
idNumber: idNumber ?? this.idNumber,
|
||||
photoUrl: photoUrl ?? this.photoUrl,
|
||||
contactPerson: contactPerson ?? this.contactPerson,
|
||||
createdAt: createdAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
322
kifi-app/lib/features/vendor/presentation/add_vendor_sheet.dart
vendored
Normal file
322
kifi-app/lib/features/vendor/presentation/add_vendor_sheet.dart
vendored
Normal file
@@ -0,0 +1,322 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import '../providers/vendors_provider.dart';
|
||||
import '../domain/vendor.dart';
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import '../../../core/network/dio_client.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart' as import_storage;
|
||||
import '../../../core/widgets/premium_text_field.dart';
|
||||
import '../../../core/widgets/smart_search_dropdown.dart';
|
||||
import '../../business/providers/indian_states_provider.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
class AddVendorSheet extends ConsumerStatefulWidget {
|
||||
final Vendor? vendor;
|
||||
|
||||
const AddVendorSheet({super.key, this.vendor});
|
||||
|
||||
@override
|
||||
ConsumerState<AddVendorSheet> createState() => _AddVendorSheetState();
|
||||
}
|
||||
|
||||
class _AddVendorSheetState extends ConsumerState<AddVendorSheet> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
late TextEditingController _nameCtrl;
|
||||
late TextEditingController _phoneCtrl;
|
||||
late TextEditingController _emailCtrl;
|
||||
late TextEditingController _addressCtrl;
|
||||
late TextEditingController _gstinCtrl;
|
||||
late TextEditingController _idNumberCtrl;
|
||||
late TextEditingController _contactPersonCtrl;
|
||||
int? _selectedStateId;
|
||||
XFile? _photo;
|
||||
String? _token;
|
||||
bool _isLoading = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_nameCtrl = TextEditingController(text: widget.vendor?.name ?? '');
|
||||
_phoneCtrl = TextEditingController(text: widget.vendor?.phone ?? '');
|
||||
_emailCtrl = TextEditingController(text: widget.vendor?.email ?? '');
|
||||
_addressCtrl = TextEditingController(text: widget.vendor?.address ?? '');
|
||||
_gstinCtrl = TextEditingController(text: widget.vendor?.gstin ?? '');
|
||||
_idNumberCtrl = TextEditingController(text: widget.vendor?.idNumber ?? '');
|
||||
_contactPersonCtrl = TextEditingController(text: widget.vendor?.contactPerson ?? '');
|
||||
_selectedStateId = widget.vendor?.stateId;
|
||||
import_storage.FlutterSecureStorage().read(key: 'jwt_token').then((val) {
|
||||
if (mounted) setState(() => _token = val);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameCtrl.dispose();
|
||||
_phoneCtrl.dispose();
|
||||
_emailCtrl.dispose();
|
||||
_addressCtrl.dispose();
|
||||
_gstinCtrl.dispose();
|
||||
_idNumberCtrl.dispose();
|
||||
_contactPersonCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _pickImage(ImageSource source) async {
|
||||
final picker = ImagePicker();
|
||||
final picked = await picker.pickImage(source: source, imageQuality: 80);
|
||||
if (picked != null) {
|
||||
setState(() => _photo = picked);
|
||||
}
|
||||
}
|
||||
|
||||
void _showImagePickerModal() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))),
|
||||
builder: (ctx) => SafeArea(
|
||||
child: Wrap(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.camera),
|
||||
title: const Text('Take a photo'),
|
||||
onTap: () {
|
||||
Navigator.pop(ctx);
|
||||
_pickImage(ImageSource.camera);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.image),
|
||||
title: const Text('Choose from gallery'),
|
||||
onTap: () {
|
||||
Navigator.pop(ctx);
|
||||
_pickImage(ImageSource.gallery);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
String? photoUrl = widget.vendor?.photoUrl;
|
||||
if (_photo != null && _token != null) {
|
||||
final formData = FormData.fromMap({
|
||||
'file': await MultipartFile.fromFile(_photo!.path, filename: _photo!.name),
|
||||
'type': 'VENDOR',
|
||||
});
|
||||
|
||||
final uploadResp = await DioClient().dio.post(
|
||||
'/upload',
|
||||
data: formData,
|
||||
);
|
||||
|
||||
if (uploadResp.statusCode == 200) {
|
||||
photoUrl = uploadResp.data['url'];
|
||||
}
|
||||
}
|
||||
|
||||
final vendor = Vendor(
|
||||
id: widget.vendor?.id,
|
||||
name: _nameCtrl.text,
|
||||
phone: _phoneCtrl.text.isEmpty ? null : _phoneCtrl.text,
|
||||
email: _emailCtrl.text.isEmpty ? null : _emailCtrl.text,
|
||||
address: _addressCtrl.text.isEmpty ? null : _addressCtrl.text,
|
||||
gstin: _gstinCtrl.text.isEmpty ? null : _gstinCtrl.text,
|
||||
idNumber: _idNumberCtrl.text.isEmpty ? null : _idNumberCtrl.text,
|
||||
stateId: _selectedStateId,
|
||||
photoUrl: photoUrl,
|
||||
contactPerson: _contactPersonCtrl.text.isEmpty ? null : _contactPersonCtrl.text,
|
||||
);
|
||||
|
||||
if (widget.vendor == null) {
|
||||
await ref.read(vendorsProvider.notifier).addVendor(vendor);
|
||||
} else {
|
||||
await ref.read(vendorsProvider.notifier).updateVendor(vendor);
|
||||
}
|
||||
|
||||
if (mounted) Navigator.pop(context);
|
||||
} 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(
|
||||
height: MediaQuery.of(context).size.height * 0.9,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 8, 8, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(LucideIcons.chevronLeft),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.vendor == null ? 'New Vendor' : 'Edit Vendor',
|
||||
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
children: [
|
||||
Center(
|
||||
child: Stack(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 50,
|
||||
backgroundColor: Colors.blue.withOpacity(0.1),
|
||||
backgroundImage: _photo != null
|
||||
? FileImage(File(_photo!.path)) as ImageProvider
|
||||
: (widget.vendor?.photoUrl != null
|
||||
? NetworkImage(widget.vendor!.photoUrl!)
|
||||
: null),
|
||||
child: (_photo == null && widget.vendor?.photoUrl == null)
|
||||
? const Icon(LucideIcons.truck, size: 50, color: Colors.blue)
|
||||
: null,
|
||||
),
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
child: GestureDetector(
|
||||
onTap: _showImagePickerModal,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.blue,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(LucideIcons.camera, color: Colors.white, size: 20),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
PremiumTextField(
|
||||
controller: _nameCtrl,
|
||||
labelText: 'Vendor Name *',
|
||||
prefixIcon: const Icon(LucideIcons.building),
|
||||
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),
|
||||
PremiumTextField(
|
||||
controller: _contactPersonCtrl,
|
||||
labelText: 'Contact Person',
|
||||
prefixIcon: const Icon(LucideIcons.user),
|
||||
textCapitalization: TextCapitalization.words,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
PremiumTextField(
|
||||
controller: _emailCtrl,
|
||||
labelText: 'Email Address',
|
||||
prefixIcon: const Icon(LucideIcons.mail),
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
PremiumTextField(
|
||||
controller: _gstinCtrl,
|
||||
labelText: 'GSTIN (Optional)',
|
||||
prefixIcon: const Icon(LucideIcons.fileText),
|
||||
textCapitalization: TextCapitalization.characters,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
PremiumTextField(
|
||||
controller: _idNumberCtrl,
|
||||
labelText: 'ID No. (PAN, etc.)',
|
||||
prefixIcon: const Icon(LucideIcons.creditCard),
|
||||
textCapitalization: TextCapitalization.characters,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Consumer(
|
||||
builder: (context, ref, _) {
|
||||
final statesState = ref.watch(indianStatesProvider);
|
||||
return statesState.when(
|
||||
data: (states) {
|
||||
return SmartSearchDropdown<int>(
|
||||
labelText: 'State',
|
||||
hintText: 'Select State',
|
||||
value: _selectedStateId,
|
||||
items: states.map((s) => s.id).toList(),
|
||||
itemAsString: (id) {
|
||||
final s = states.firstWhere((st) => st.id == id);
|
||||
return '${s.name} (${s.gstCode})';
|
||||
},
|
||||
onChanged: (val) => setState(() => _selectedStateId = val),
|
||||
);
|
||||
},
|
||||
loading: () => const CircularProgressIndicator(),
|
||||
error: (e, stack) => Text('Error loading states: $e'),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
PremiumTextField(
|
||||
controller: _addressCtrl,
|
||||
labelText: 'Billing Address',
|
||||
prefixIcon: const Icon(LucideIcons.mapPin),
|
||||
maxLines: 3,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: _isLoading ? null : _save,
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
backgroundColor: Colors.blue,
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
child: _isLoading
|
||||
? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
|
||||
: Text(widget.vendor == null ? 'Save Vendor' : 'Update Vendor'),
|
||||
),
|
||||
),
|
||||
SizedBox(height: MediaQuery.of(context).viewInsets.bottom),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
159
kifi-app/lib/features/vendor/presentation/pay_vendor_sheet.dart
vendored
Normal file
159
kifi-app/lib/features/vendor/presentation/pay_vendor_sheet.dart
vendored
Normal file
@@ -0,0 +1,159 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../../core/widgets/premium_text_field.dart';
|
||||
import '../../../core/widgets/smart_search_dropdown.dart';
|
||||
import '../domain/purchase_order.dart';
|
||||
import '../domain/purchase_payment.dart';
|
||||
import '../providers/purchase_orders_provider.dart';
|
||||
|
||||
class PayVendorSheet extends ConsumerStatefulWidget {
|
||||
final PurchaseOrder po;
|
||||
|
||||
const PayVendorSheet({super.key, required this.po});
|
||||
|
||||
@override
|
||||
ConsumerState<PayVendorSheet> createState() => _PayVendorSheetState();
|
||||
}
|
||||
|
||||
class _PayVendorSheetState extends ConsumerState<PayVendorSheet> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
late TextEditingController _amountCtrl;
|
||||
final TextEditingController _notesCtrl = TextEditingController();
|
||||
DateTime _paymentDate = DateTime.now();
|
||||
String? _selectedMethod;
|
||||
bool _isLoading = false;
|
||||
|
||||
final List<String> _paymentMethods = ['CASH', 'BANK_TRANSFER', 'UPI', 'CARD', 'CHEQUE'];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final balance = widget.po.totalAmount - widget.po.amountPaid;
|
||||
_amountCtrl = TextEditingController(text: balance > 0 ? balance.toStringAsFixed(2) : '0');
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_amountCtrl.dispose();
|
||||
_notesCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _savePayment() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
if (_selectedMethod == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Please select a payment method')));
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
final payment = PurchasePayment(
|
||||
amount: double.parse(_amountCtrl.text),
|
||||
paymentMethod: _selectedMethod,
|
||||
paymentDate: _paymentDate,
|
||||
notes: _notesCtrl.text.isEmpty ? null : _notesCtrl.text,
|
||||
);
|
||||
|
||||
await ref.read(purchaseOrdersProvider.notifier).addPayment(widget.po.id!, payment);
|
||||
|
||||
if (mounted) {
|
||||
Navigator.pop(context, true);
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Payment recorded successfully')));
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e')));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom, left: 24, right: 24, top: 24),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('Record Payment', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||
IconButton(
|
||||
icon: const Icon(LucideIcons.x),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
PremiumTextField(
|
||||
controller: _amountCtrl,
|
||||
labelText: 'Amount Paid',
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
prefixIcon: const Icon(LucideIcons.indianRupee),
|
||||
validator: (val) {
|
||||
if (val == null || val.isEmpty) return 'Required';
|
||||
if (double.tryParse(val) == null) return 'Invalid amount';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SmartSearchDropdown<String>(
|
||||
labelText: 'Payment Method',
|
||||
hintText: 'Select Method',
|
||||
value: _selectedMethod,
|
||||
items: _paymentMethods,
|
||||
itemAsString: (val) => val.replaceAll('_', ' '),
|
||||
onChanged: (val) => setState(() => _selectedMethod = val),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
InkWell(
|
||||
onTap: () async {
|
||||
final d = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _paymentDate,
|
||||
firstDate: DateTime(2000),
|
||||
lastDate: DateTime(2100),
|
||||
);
|
||||
if (d != null) setState(() => _paymentDate = d);
|
||||
},
|
||||
child: InputDecorator(
|
||||
decoration: const InputDecoration(labelText: 'Payment Date', border: OutlineInputBorder()),
|
||||
child: Text(DateFormat('dd MMM yyyy').format(_paymentDate)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
PremiumTextField(
|
||||
controller: _notesCtrl,
|
||||
labelText: 'Notes (Optional)',
|
||||
maxLines: 2,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: _isLoading ? null : _savePayment,
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
backgroundColor: Colors.green,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: _isLoading
|
||||
? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
|
||||
: const Text('Save Payment'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
456
kifi-app/lib/features/vendor/presentation/purchase_order_builder_screen.dart
vendored
Normal file
456
kifi-app/lib/features/vendor/presentation/purchase_order_builder_screen.dart
vendored
Normal file
@@ -0,0 +1,456 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'dart:io';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import '../../../core/network/dio_client.dart';
|
||||
import '../../../core/widgets/premium_text_field.dart';
|
||||
import '../../../core/widgets/smart_search_dropdown.dart';
|
||||
import '../../inventory/providers/products_provider.dart';
|
||||
import '../providers/vendors_provider.dart';
|
||||
import '../providers/purchase_orders_provider.dart';
|
||||
import '../domain/purchase_order.dart';
|
||||
import '../domain/purchase_order_item.dart';
|
||||
import '../domain/vendor.dart';
|
||||
import '../../inventory/domain/product.dart';
|
||||
import 'pay_vendor_sheet.dart' as import_pay;
|
||||
|
||||
class PurchaseOrderBuilderScreen extends ConsumerStatefulWidget {
|
||||
final PurchaseOrder? existingPo;
|
||||
|
||||
const PurchaseOrderBuilderScreen({super.key, this.existingPo});
|
||||
|
||||
@override
|
||||
ConsumerState<PurchaseOrderBuilderScreen> createState() => _PurchaseOrderBuilderScreenState();
|
||||
}
|
||||
|
||||
class _PurchaseOrderBuilderScreenState extends ConsumerState<PurchaseOrderBuilderScreen> {
|
||||
int? _selectedVendorId;
|
||||
DateTime _issueDate = DateTime.now();
|
||||
DateTime? _dueDate;
|
||||
final TextEditingController _poNumberCtrl = TextEditingController();
|
||||
final TextEditingController _notesCtrl = TextEditingController();
|
||||
|
||||
List<PurchaseOrderItem> _items = [];
|
||||
bool _isLoading = false;
|
||||
XFile? _invoiceFile;
|
||||
String? _invoiceUrl;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.existingPo != null) {
|
||||
_selectedVendorId = widget.existingPo!.vendorId;
|
||||
_issueDate = widget.existingPo!.issueDate;
|
||||
_dueDate = widget.existingPo!.dueDate;
|
||||
_poNumberCtrl.text = widget.existingPo!.poNumber;
|
||||
_notesCtrl.text = widget.existingPo!.notes ?? '';
|
||||
_items = List.from(widget.existingPo!.items);
|
||||
_invoiceUrl = widget.existingPo!.vendorInvoiceUrl;
|
||||
} else {
|
||||
_poNumberCtrl.text = 'PO-${DateTime.now().millisecondsSinceEpoch.toString().substring(5)}';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_poNumberCtrl.dispose();
|
||||
_notesCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
double get _subtotal {
|
||||
return _items.fold(0, (sum, item) => sum + (item.quantity * item.unitPrice));
|
||||
}
|
||||
|
||||
double get _totalAmount {
|
||||
return _subtotal;
|
||||
}
|
||||
|
||||
Future<void> _pickInvoiceFile() async {
|
||||
final picker = ImagePicker();
|
||||
final picked = await picker.pickImage(source: ImageSource.gallery, imageQuality: 80);
|
||||
if (picked != null) {
|
||||
setState(() => _invoiceFile = picked);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _savePo() async {
|
||||
if (_selectedVendorId == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Please select a vendor')));
|
||||
return;
|
||||
}
|
||||
if (_items.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Please add at least one item')));
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
String? finalInvoiceUrl = _invoiceUrl;
|
||||
|
||||
if (_invoiceFile != null) {
|
||||
final formData = FormData.fromMap({
|
||||
'file': await MultipartFile.fromFile(_invoiceFile!.path, filename: _invoiceFile!.name),
|
||||
'type': 'PO_INVOICE',
|
||||
});
|
||||
final uploadResp = await DioClient().dio.post('/upload', data: formData);
|
||||
if (uploadResp.statusCode == 200) {
|
||||
finalInvoiceUrl = uploadResp.data['url'];
|
||||
}
|
||||
}
|
||||
|
||||
final po = PurchaseOrder(
|
||||
id: widget.existingPo?.id,
|
||||
vendorId: _selectedVendorId,
|
||||
poNumber: _poNumberCtrl.text,
|
||||
issueDate: _issueDate,
|
||||
dueDate: _dueDate,
|
||||
subtotal: _subtotal,
|
||||
totalAmount: _totalAmount,
|
||||
notes: _notesCtrl.text,
|
||||
vendorInvoiceUrl: finalInvoiceUrl,
|
||||
items: _items,
|
||||
);
|
||||
|
||||
if (widget.existingPo == null) {
|
||||
await ref.read(purchaseOrdersProvider.notifier).createPurchaseOrder(po);
|
||||
} else {
|
||||
await ref.read(purchaseOrdersProvider.notifier).updatePurchaseOrder(po);
|
||||
}
|
||||
|
||||
if (mounted) Navigator.pop(context);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error saving PO: $e')));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _receivePo() async {
|
||||
if (widget.existingPo == null) return;
|
||||
|
||||
// We update the items in the existing PO to what's currently in _items (user might have edited quantities)
|
||||
final po = widget.existingPo!.copyWith(items: _items);
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
await ref.read(purchaseOrdersProvider.notifier).markAsReceived(po);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('PO Received and Stock Updated')));
|
||||
Navigator.pop(context);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error receiving PO: $e')));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _showAddItemSheet() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))),
|
||||
builder: (ctx) {
|
||||
return const _AddItemSheet();
|
||||
},
|
||||
).then((result) {
|
||||
if (result != null && result is PurchaseOrderItem) {
|
||||
setState(() => _items.add(result));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final vendorsState = ref.watch(vendorsProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.existingPo == null ? 'New Purchase Order' : 'Edit PO ${_poNumberCtrl.text}'),
|
||||
actions: [
|
||||
if (_isLoading)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)),
|
||||
)
|
||||
else ...[
|
||||
if (widget.existingPo != null && widget.existingPo!.status == 'RECEIVED' && widget.existingPo!.amountPaid < widget.existingPo!.totalAmount)
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))),
|
||||
builder: (_) => import_pay.PayVendorSheet(po: widget.existingPo!),
|
||||
).then((val) {
|
||||
if (val == true && mounted) {
|
||||
Navigator.pop(context); // Close the builder screen to refresh the list
|
||||
}
|
||||
});
|
||||
},
|
||||
icon: const Icon(LucideIcons.indianRupee, size: 18),
|
||||
label: const Text('Pay'),
|
||||
),
|
||||
if (widget.existingPo != null && widget.existingPo!.status == 'PENDING')
|
||||
TextButton.icon(
|
||||
onPressed: _receivePo,
|
||||
icon: const Icon(LucideIcons.packageCheck, size: 18),
|
||||
label: const Text('Receive'),
|
||||
),
|
||||
if (widget.existingPo == null || widget.existingPo!.status == 'PENDING')
|
||||
TextButton.icon(
|
||||
onPressed: _savePo,
|
||||
icon: const Icon(LucideIcons.save, size: 18),
|
||||
label: const Text('Save'),
|
||||
),
|
||||
]
|
||||
],
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
vendorsState.when(
|
||||
data: (vendors) => SmartSearchDropdown<int>(
|
||||
labelText: 'Vendor *',
|
||||
hintText: 'Select Vendor',
|
||||
value: _selectedVendorId,
|
||||
items: vendors.map((v) => v.id!).toList(),
|
||||
itemAsString: (id) => vendors.firstWhere((v) => v.id == id).name,
|
||||
onChanged: (val) => setState(() => _selectedVendorId = val),
|
||||
),
|
||||
loading: () => const CircularProgressIndicator(),
|
||||
error: (e, stack) => Text('Error: $e'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: PremiumTextField(
|
||||
controller: _poNumberCtrl,
|
||||
labelText: 'PO Number *',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: InkWell(
|
||||
onTap: () async {
|
||||
final d = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _issueDate,
|
||||
firstDate: DateTime(2000),
|
||||
lastDate: DateTime(2100),
|
||||
);
|
||||
if (d != null) setState(() => _issueDate = d);
|
||||
},
|
||||
child: InputDecorator(
|
||||
decoration: const InputDecoration(labelText: 'Issue Date', border: OutlineInputBorder()),
|
||||
child: Text(DateFormat('dd MMM yyyy').format(_issueDate)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('Items', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
|
||||
TextButton.icon(
|
||||
onPressed: _showAddItemSheet,
|
||||
icon: const Icon(LucideIcons.plus),
|
||||
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))),
|
||||
)
|
||||
else
|
||||
ListView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: _items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = _items[index];
|
||||
final productsState = ref.watch(productsProvider);
|
||||
final product = productsState.value?.firstWhere(
|
||||
(p) => p.id == item.productId,
|
||||
orElse: () => Product(name: 'Unknown')
|
||||
);
|
||||
final productName = product?.name ?? 'Unknown';
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: ListTile(
|
||||
title: Text(productName),
|
||||
subtitle: Text('${item.quantity} x ₹${item.unitPrice}'),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('₹${(item.quantity * item.unitPrice).toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
IconButton(
|
||||
icon: const Icon(LucideIcons.trash, color: Colors.red),
|
||||
onPressed: () => setState(() => _items.removeAt(index)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('Total Amount', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
||||
Text('₹${_totalAmount.toStringAsFixed(2)}', style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Colors.green)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
PremiumTextField(
|
||||
controller: _notesCtrl,
|
||||
labelText: 'Notes',
|
||||
maxLines: 3,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text('Attachments', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 8),
|
||||
if (_invoiceFile != null)
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.image),
|
||||
title: Text(_invoiceFile!.name),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(LucideIcons.x),
|
||||
onPressed: () => setState(() => _invoiceFile = null),
|
||||
),
|
||||
)
|
||||
else if (_invoiceUrl != null)
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.link),
|
||||
title: const Text('View Attached Invoice'),
|
||||
onTap: () {
|
||||
// Open link (TBD)
|
||||
},
|
||||
)
|
||||
else
|
||||
OutlinedButton.icon(
|
||||
onPressed: _pickInvoiceFile,
|
||||
icon: const Icon(LucideIcons.upload),
|
||||
label: const Text('Attach Vendor Invoice'),
|
||||
),
|
||||
const SizedBox(height: 100),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AddItemSheet extends ConsumerStatefulWidget {
|
||||
const _AddItemSheet();
|
||||
|
||||
@override
|
||||
ConsumerState<_AddItemSheet> createState() => _AddItemSheetState();
|
||||
}
|
||||
|
||||
class _AddItemSheetState extends ConsumerState<_AddItemSheet> {
|
||||
int? _selectedProductId;
|
||||
final TextEditingController _qtyCtrl = TextEditingController(text: '1');
|
||||
final TextEditingController _priceCtrl = TextEditingController();
|
||||
|
||||
void _save() {
|
||||
if (_selectedProductId == null) return;
|
||||
final qty = double.tryParse(_qtyCtrl.text) ?? 1;
|
||||
final price = double.tryParse(_priceCtrl.text) ?? 0;
|
||||
|
||||
final item = PurchaseOrderItem(
|
||||
productId: _selectedProductId,
|
||||
quantity: qty,
|
||||
unitPrice: price,
|
||||
total: qty * price,
|
||||
);
|
||||
Navigator.pop(context, item);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final productsState = ref.watch(productsProvider);
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom, left: 24, right: 24, top: 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Add PO Item', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 16),
|
||||
productsState.when(
|
||||
data: (products) => SmartSearchDropdown<int>(
|
||||
labelText: 'Product',
|
||||
hintText: 'Select Product',
|
||||
value: _selectedProductId,
|
||||
items: products.map((p) => p.id!).toList(),
|
||||
itemAsString: (id) => products.firstWhere((p) => p.id == id).name,
|
||||
onChanged: (val) {
|
||||
setState(() {
|
||||
_selectedProductId = val;
|
||||
final p = products.firstWhere((prod) => prod.id == val);
|
||||
_priceCtrl.text = p.sellingPrice?.toString() ?? '0';
|
||||
});
|
||||
},
|
||||
),
|
||||
loading: () => const CircularProgressIndicator(),
|
||||
error: (e, stack) => Text('Error: $e'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: PremiumTextField(
|
||||
controller: _qtyCtrl,
|
||||
labelText: 'Quantity',
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: PremiumTextField(
|
||||
controller: _priceCtrl,
|
||||
labelText: 'Unit Price',
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: _save,
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
backgroundColor: Colors.blue,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: const Text('Add Item'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
579
kifi-app/lib/features/vendor/presentation/purchase_order_details_screen.dart
vendored
Normal file
579
kifi-app/lib/features/vendor/presentation/purchase_order_details_screen.dart
vendored
Normal file
@@ -0,0 +1,579 @@
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:pdf/pdf.dart';
|
||||
import 'package:pdf/widgets.dart' as pw;
|
||||
import '../../inventory/providers/products_provider.dart';
|
||||
import '../../inventory/domain/product.dart';
|
||||
import '../domain/purchase_order.dart';
|
||||
import '../domain/purchase_payment.dart';
|
||||
import '../providers/purchase_orders_provider.dart';
|
||||
import '../providers/vendors_provider.dart';
|
||||
import '../../business/providers/business_provider.dart';
|
||||
import 'pay_vendor_sheet.dart' as import_pay;
|
||||
|
||||
class PurchaseOrderDetailsScreen extends ConsumerStatefulWidget {
|
||||
final PurchaseOrder po;
|
||||
|
||||
const PurchaseOrderDetailsScreen({super.key, required this.po});
|
||||
|
||||
@override
|
||||
ConsumerState<PurchaseOrderDetailsScreen> createState() => _PurchaseOrderDetailsScreenState();
|
||||
}
|
||||
|
||||
class _PurchaseOrderDetailsScreenState extends ConsumerState<PurchaseOrderDetailsScreen> {
|
||||
|
||||
void _showPaymentSheet(BuildContext context, WidgetRef ref, PurchaseOrder latestPo) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => import_pay.PayVendorSheet(po: latestPo),
|
||||
).then((result) {
|
||||
if (result == true) {
|
||||
ref.invalidate(purchaseOrdersProvider);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _showPaymentHistory(BuildContext context, WidgetRef ref, PurchaseOrder po) {
|
||||
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: ${po.poNumber}', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 16),
|
||||
FutureBuilder<List<PurchasePayment>>(
|
||||
future: ref.read(purchaseOrdersProvider.notifier).fetchPaymentsForPurchaseOrder(po.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.'),
|
||||
);
|
||||
}
|
||||
|
||||
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('Method', style: TextStyle(fontWeight: FontWeight.bold))),
|
||||
DataColumn(label: Text('Amount', style: TextStyle(fontWeight: FontWeight.bold))),
|
||||
],
|
||||
rows: payments.map((p) {
|
||||
return DataRow(
|
||||
cells: [
|
||||
DataCell(Text(p.paymentDate != null ? DateFormat('dd MMM yyyy').format(p.paymentDate!) : '-')),
|
||||
DataCell(Text(p.paymentMethod ?? '-')),
|
||||
DataCell(Text('₹${p.amount.toStringAsFixed(2)}', style: TextStyle(color: Colors.green.shade700, fontWeight: FontWeight.bold))),
|
||||
],
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _shareAsPdf(String poNumber, PurchaseOrder po, dynamic vendor, dynamic business, List<dynamic> products) async {
|
||||
try {
|
||||
final formatCurrency = NumberFormat.currency(symbol: '₹');
|
||||
final formatDate = DateFormat('dd MMM yyyy');
|
||||
final pdf = pw.Document();
|
||||
|
||||
pdf.addPage(
|
||||
pw.MultiPage(
|
||||
pageFormat: PdfPageFormat.a4,
|
||||
margin: const pw.EdgeInsets.all(32),
|
||||
build: (pw.Context context) {
|
||||
return [
|
||||
pw.Row(
|
||||
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
pw.Text(business?.businessName ?? 'Your Company Name', style: pw.TextStyle(fontSize: 24, fontWeight: pw.FontWeight.bold, color: PdfColors.deepOrange800)),
|
||||
pw.SizedBox(height: 4),
|
||||
if (business?.address != null) pw.Text(business!.address!, style: const pw.TextStyle(fontSize: 10, color: PdfColors.grey700)),
|
||||
if (business?.contactNumber != null) pw.Text('Ph: ${business!.contactNumber}', style: const pw.TextStyle(fontSize: 10, color: PdfColors.grey700)),
|
||||
],
|
||||
),
|
||||
pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.end,
|
||||
children: [
|
||||
pw.Text('PURCHASE ORDER', style: pw.TextStyle(fontSize: 24, fontWeight: pw.FontWeight.bold, color: PdfColors.deepOrange800)),
|
||||
pw.SizedBox(height: 8),
|
||||
pw.Container(
|
||||
padding: const pw.EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: pw.BoxDecoration(color: PdfColors.grey200, borderRadius: const pw.BorderRadius.all(pw.Radius.circular(4))),
|
||||
child: pw.Text(po.status, style: pw.TextStyle(fontSize: 10, fontWeight: pw.FontWeight.bold)),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
pw.SizedBox(height: 30),
|
||||
pw.Row(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
pw.Expanded(
|
||||
child: pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
pw.Text('VENDOR:', style: pw.TextStyle(fontSize: 10, fontWeight: pw.FontWeight.bold, color: PdfColors.grey600)),
|
||||
pw.SizedBox(height: 4),
|
||||
pw.Text(vendor?.name ?? 'Unknown Vendor', style: pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold)),
|
||||
if (vendor?.address != null) pw.Text(vendor!.address!, style: const pw.TextStyle(fontSize: 10)),
|
||||
if (vendor?.phone != null) pw.Text('Ph: ${vendor!.phone}', style: const pw.TextStyle(fontSize: 10)),
|
||||
if (vendor?.gstin != null) pw.Text('GSTIN: ${vendor!.gstin}', style: const pw.TextStyle(fontSize: 10)),
|
||||
],
|
||||
),
|
||||
),
|
||||
pw.Expanded(
|
||||
child: pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.end,
|
||||
children: [
|
||||
pw.Text('PO NUMBER:', style: pw.TextStyle(fontSize: 10, fontWeight: pw.FontWeight.bold, color: PdfColors.grey600)),
|
||||
pw.Text(po.poNumber, style: pw.TextStyle(fontSize: 12, fontWeight: pw.FontWeight.bold)),
|
||||
pw.SizedBox(height: 8),
|
||||
pw.Text('ISSUE DATE:', style: pw.TextStyle(fontSize: 10, fontWeight: pw.FontWeight.bold, color: PdfColors.grey600)),
|
||||
pw.Text(formatDate.format(po.issueDate), style: pw.TextStyle(fontSize: 12, fontWeight: pw.FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
pw.SizedBox(height: 30),
|
||||
pw.Table(
|
||||
border: const pw.TableBorder(
|
||||
bottom: pw.BorderSide(color: PdfColors.grey300, width: .5),
|
||||
horizontalInside: pw.BorderSide(color: PdfColors.grey300, width: .5),
|
||||
),
|
||||
columnWidths: {
|
||||
0: const pw.FlexColumnWidth(3),
|
||||
1: const pw.FlexColumnWidth(1),
|
||||
2: const pw.FlexColumnWidth(1.5),
|
||||
3: const pw.FlexColumnWidth(1.5),
|
||||
},
|
||||
children: [
|
||||
pw.TableRow(
|
||||
decoration: const pw.BoxDecoration(color: PdfColors.deepOrange800),
|
||||
children: [
|
||||
pw.Padding(padding: const pw.EdgeInsets.all(8), child: pw.Text('Item Description', style: pw.TextStyle(color: PdfColors.white, fontWeight: pw.FontWeight.bold))),
|
||||
pw.Padding(padding: const pw.EdgeInsets.all(8), child: pw.Text('Qty', textAlign: pw.TextAlign.center, style: pw.TextStyle(color: PdfColors.white, fontWeight: pw.FontWeight.bold))),
|
||||
pw.Padding(padding: const pw.EdgeInsets.all(8), child: pw.Text('Rate', textAlign: pw.TextAlign.right, style: pw.TextStyle(color: PdfColors.white, fontWeight: pw.FontWeight.bold))),
|
||||
pw.Padding(padding: const pw.EdgeInsets.all(8), child: pw.Text('Amount', textAlign: pw.TextAlign.right, style: pw.TextStyle(color: PdfColors.white, fontWeight: pw.FontWeight.bold))),
|
||||
],
|
||||
),
|
||||
...po.items.map((item) {
|
||||
final product = products.firstWhere((p) => p.id == item.productId, orElse: () => Product(name: 'Item ${item.productId}'));
|
||||
final pName = product.name;
|
||||
return pw.TableRow(
|
||||
children: [
|
||||
pw.Padding(padding: const pw.EdgeInsets.all(8), child: pw.Text(pName, style: const pw.TextStyle(fontSize: 10))),
|
||||
pw.Padding(padding: const pw.EdgeInsets.all(8), child: pw.Text(item.quantity.toString(), textAlign: pw.TextAlign.center, style: const pw.TextStyle(fontSize: 10))),
|
||||
pw.Padding(padding: const pw.EdgeInsets.all(8), child: pw.Text(formatCurrency.format(item.unitPrice), textAlign: pw.TextAlign.right, style: const pw.TextStyle(fontSize: 10))),
|
||||
pw.Padding(padding: const pw.EdgeInsets.all(8), child: pw.Text(formatCurrency.format(item.quantity * item.unitPrice), textAlign: pw.TextAlign.right, style: const pw.TextStyle(fontSize: 10))),
|
||||
],
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
pw.SizedBox(height: 20),
|
||||
pw.Row(
|
||||
mainAxisAlignment: pw.MainAxisAlignment.end,
|
||||
children: [
|
||||
pw.Container(
|
||||
width: 250,
|
||||
child: pw.Column(
|
||||
children: [
|
||||
pw.Row(
|
||||
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
pw.Text('Subtotal:', style: pw.TextStyle(fontWeight: pw.FontWeight.bold, color: PdfColors.grey700)),
|
||||
pw.Text(formatCurrency.format(po.subtotal), style: pw.TextStyle(fontWeight: pw.FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
pw.SizedBox(height: 8),
|
||||
pw.Divider(color: PdfColors.grey400),
|
||||
pw.SizedBox(height: 8),
|
||||
pw.Row(
|
||||
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
pw.Text('Total Amount:', style: pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold, color: PdfColors.deepOrange800)),
|
||||
pw.Text(formatCurrency.format(po.totalAmount), style: pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold, color: PdfColors.deepOrange800)),
|
||||
],
|
||||
),
|
||||
if (po.status == 'RECEIVED') ...[
|
||||
pw.SizedBox(height: 8),
|
||||
pw.Row(
|
||||
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
pw.Text('Amount Paid:', style: pw.TextStyle(color: PdfColors.green700)),
|
||||
pw.Text(formatCurrency.format(po.amountPaid), style: pw.TextStyle(color: PdfColors.green700)),
|
||||
],
|
||||
),
|
||||
pw.SizedBox(height: 4),
|
||||
pw.Row(
|
||||
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
pw.Text('Balance Due:', style: pw.TextStyle(fontWeight: pw.FontWeight.bold, color: PdfColors.red700)),
|
||||
pw.Text(formatCurrency.format(po.totalAmount - po.amountPaid), style: pw.TextStyle(fontWeight: pw.FontWeight.bold, color: PdfColors.red700)),
|
||||
],
|
||||
),
|
||||
]
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final directory = await getApplicationDocumentsDirectory();
|
||||
final pdfPath = await File('${directory.path}/PO_$poNumber.pdf').create();
|
||||
await pdfPath.writeAsBytes(await pdf.save());
|
||||
|
||||
await Share.shareXFiles([XFile(pdfPath.path)], text: 'Purchase Order $poNumber');
|
||||
} catch (e) {
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error sharing PDF: $e')));
|
||||
}
|
||||
}
|
||||
|
||||
void _showShareOptions(BuildContext context, PurchaseOrder po, dynamic vendor, dynamic business, List<dynamic> products) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))),
|
||||
builder: (ctx) {
|
||||
return SafeArea(
|
||||
child: Wrap(
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Text('Share Purchase Order', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.fileText, color: Colors.red),
|
||||
title: const Text('Share as PDF'),
|
||||
onTap: () {
|
||||
Navigator.pop(ctx);
|
||||
_shareAsPdf(po.poNumber, po, vendor, business, products);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final posState = ref.watch(purchaseOrdersProvider);
|
||||
final latestPo = posState.value?.firstWhere(
|
||||
(p) => p.id == widget.po.id,
|
||||
orElse: () => widget.po,
|
||||
) ?? widget.po;
|
||||
|
||||
final vendorsState = ref.watch(vendorsProvider);
|
||||
final vendor = vendorsState.value?.firstWhere(
|
||||
(v) => v.id == latestPo.vendorId,
|
||||
orElse: () => null as dynamic,
|
||||
);
|
||||
|
||||
final businessState = ref.watch(businessProfileProvider);
|
||||
final business = businessState.value;
|
||||
|
||||
final productsState = ref.watch(productsProvider);
|
||||
final products = productsState.value ?? [];
|
||||
|
||||
final formatCurrency = NumberFormat.currency(symbol: '₹');
|
||||
final formatDate = DateFormat('dd MMM yyyy');
|
||||
|
||||
double remaining = latestPo.totalAmount - latestPo.amountPaid;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.grey[50],
|
||||
appBar: AppBar(
|
||||
title: Text('PO #${latestPo.poNumber}', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.black,
|
||||
elevation: 0,
|
||||
centerTitle: true,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(LucideIcons.share2, size: 20),
|
||||
onPressed: () => _showShareOptions(context, latestPo, vendor, business, products),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Header Card
|
||||
Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [Colors.deepOrange.shade900, Colors.deepOrange.shade800],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(color: Colors.deepOrange.withOpacity(0.2), blurRadius: 15, offset: const Offset(0, 5)),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
business?.businessName ?? 'Your Company Name',
|
||||
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.white),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
latestPo.status,
|
||||
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 12, letterSpacing: 1),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// PO Details
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: Colors.grey.shade200),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('PO NUMBER', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.grey, letterSpacing: 1)),
|
||||
const SizedBox(height: 4),
|
||||
Text(latestPo.poNumber, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Colors.deepOrange)),
|
||||
],
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
const Text('ISSUE DATE', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.grey, letterSpacing: 1)),
|
||||
const SizedBox(height: 4),
|
||||
Text(formatDate.format(latestPo.issueDate), style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(height: 32),
|
||||
const Text('VENDOR', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.grey, letterSpacing: 1)),
|
||||
const SizedBox(height: 8),
|
||||
Text(vendor?.name ?? 'Unknown', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
if (vendor?.phone != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(LucideIcons.phone, size: 14, color: Colors.grey),
|
||||
const SizedBox(width: 8),
|
||||
Text(vendor!.phone!, style: const TextStyle(color: Colors.black87)),
|
||||
],
|
||||
),
|
||||
]
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Items List
|
||||
const Text('ITEMS', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: Colors.grey, letterSpacing: 1)),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: Colors.grey.shade200),
|
||||
),
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: latestPo.items.length,
|
||||
separatorBuilder: (context, index) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
final item = latestPo.items[index];
|
||||
final product = products.firstWhere((p) => p.id == item.productId, orElse: () => Product(name: 'Item ${item.productId}'));
|
||||
final pName = product.name;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(pName, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
|
||||
const SizedBox(height: 4),
|
||||
Text('${item.quantity} x ${formatCurrency.format(item.unitPrice)}', style: TextStyle(color: Colors.grey.shade600, fontSize: 13)),
|
||||
],
|
||||
),
|
||||
),
|
||||
Text(formatCurrency.format(item.quantity * item.unitPrice), style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15)),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Summary Card
|
||||
Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: Colors.grey.shade200),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('Subtotal', style: TextStyle(color: Colors.grey.shade600, fontSize: 15)),
|
||||
Text(formatCurrency.format(latestPo.subtotal), style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 15)),
|
||||
],
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 12),
|
||||
child: Divider(height: 1),
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('Total Amount', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
||||
Text(formatCurrency.format(latestPo.totalAmount), style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 20, color: Colors.deepOrange)),
|
||||
],
|
||||
),
|
||||
if (latestPo.status == 'RECEIVED') ...[
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('Amount Paid', style: TextStyle(color: Colors.green.shade700, fontWeight: FontWeight.w600)),
|
||||
Text(formatCurrency.format(latestPo.amountPaid), style: TextStyle(color: Colors.green.shade700, fontWeight: FontWeight.bold, fontSize: 16)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('Balance Due', style: TextStyle(color: Colors.red.shade700, fontWeight: FontWeight.w600)),
|
||||
Text(formatCurrency.format(remaining), style: TextStyle(color: Colors.red.shade700, fontWeight: FontWeight.bold, fontSize: 16)),
|
||||
],
|
||||
),
|
||||
]
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => _showPaymentHistory(context, ref, latestPo),
|
||||
icon: const Icon(LucideIcons.history, size: 18),
|
||||
label: const Text('Payment History'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (latestPo.status == 'RECEIVED' && remaining > 0) ...[
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () => _showPaymentSheet(context, ref, latestPo),
|
||||
icon: const Icon(LucideIcons.indianRupee, size: 18),
|
||||
label: const Text('Record Payment'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.green,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
210
kifi-app/lib/features/vendor/presentation/purchase_orders_list_screen.dart
vendored
Normal file
210
kifi-app/lib/features/vendor/presentation/purchase_orders_list_screen.dart
vendored
Normal file
@@ -0,0 +1,210 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../providers/purchase_orders_provider.dart';
|
||||
import '../providers/vendors_provider.dart';
|
||||
import '../domain/purchase_order.dart';
|
||||
import 'purchase_order_builder_screen.dart';
|
||||
import 'purchase_order_details_screen.dart';
|
||||
|
||||
class PurchaseOrdersListScreen extends ConsumerStatefulWidget {
|
||||
const PurchaseOrdersListScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<PurchaseOrdersListScreen> createState() => _PurchaseOrdersListScreenState();
|
||||
}
|
||||
|
||||
class _PurchaseOrdersListScreenState extends ConsumerState<PurchaseOrdersListScreen> {
|
||||
String _searchQuery = '';
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
|
||||
String _getStatusLabel(PurchaseOrder po) {
|
||||
if (po.status == 'RECEIVED') {
|
||||
if (po.amountPaid <= 0) return 'UNPAID';
|
||||
if (po.amountPaid < po.totalAmount) return 'PARTIAL';
|
||||
return 'PAID';
|
||||
}
|
||||
return po.status;
|
||||
}
|
||||
|
||||
Color _getStatusColor(PurchaseOrder po) {
|
||||
if (po.status == 'RECEIVED') {
|
||||
if (po.amountPaid <= 0) return Colors.orange;
|
||||
if (po.amountPaid < po.totalAmount) return Colors.blue;
|
||||
return Colors.green;
|
||||
}
|
||||
switch (po.status) {
|
||||
case 'PENDING': return Colors.grey;
|
||||
case 'CANCELLED': return Colors.red;
|
||||
default: return Colors.blue;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final poState = ref.watch(purchaseOrdersProvider);
|
||||
final vendorsState = ref.watch(vendorsProvider);
|
||||
final darkTheme = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Purchase Orders'),
|
||||
elevation: 0,
|
||||
backgroundColor: Colors.transparent,
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 8.0),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search PO Number...',
|
||||
prefixIcon: const Icon(LucideIcons.search),
|
||||
filled: true,
|
||||
fillColor: darkTheme ? Colors.grey[900] : Colors.grey[100],
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
suffixIcon: _searchQuery.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(LucideIcons.xCircle),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
setState(() => _searchQuery = '');
|
||||
},
|
||||
)
|
||||
: null,
|
||||
),
|
||||
onChanged: (val) => setState(() => _searchQuery = val),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: poState.when(
|
||||
data: (pos) {
|
||||
final filtered = pos.where((p) {
|
||||
return p.poNumber.toLowerCase().contains(_searchQuery.toLowerCase());
|
||||
}).toList();
|
||||
|
||||
if (filtered.isEmpty) {
|
||||
return const Center(child: Text('No purchase orders found', style: TextStyle(color: Colors.grey)));
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () => ref.refresh(purchaseOrdersProvider.future),
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: filtered.length,
|
||||
itemBuilder: (context, index) {
|
||||
final po = filtered[index];
|
||||
final vendors = vendorsState.value ?? [];
|
||||
final vendor = vendors.firstWhere((v) => v.id == po.vendorId, orElse: () => po.vendor!);
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
if (po.status == 'PENDING') {
|
||||
Navigator.push(context, MaterialPageRoute(builder: (_) => PurchaseOrderBuilderScreen(existingPo: po)));
|
||||
} else {
|
||||
Navigator.push(context, MaterialPageRoute(builder: (_) => PurchaseOrderDetailsScreen(po: po)));
|
||||
}
|
||||
},
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
po.poNumber,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: _getStatusColor(po).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
_getStatusLabel(po),
|
||||
style: TextStyle(
|
||||
color: _getStatusColor(po),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(LucideIcons.user, size: 16, color: Colors.grey),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
vendor.name,
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(LucideIcons.calendar, size: 16, color: Colors.grey),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
DateFormat('MMM dd, yyyy').format(po.issueDate),
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 14),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'₹${po.totalAmount.toStringAsFixed(2)}',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, stack) => Center(child: Text('Error: $e')),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: () {
|
||||
Navigator.push(context, MaterialPageRoute(builder: (_) => const PurchaseOrderBuilderScreen()));
|
||||
},
|
||||
icon: const Icon(LucideIcons.plus),
|
||||
label: const Text('New PO'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
106
kifi-app/lib/features/vendor/presentation/vendors_list_screen.dart
vendored
Normal file
106
kifi-app/lib/features/vendor/presentation/vendors_list_screen.dart
vendored
Normal file
@@ -0,0 +1,106 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import '../providers/vendors_provider.dart';
|
||||
import 'add_vendor_sheet.dart';
|
||||
|
||||
class VendorsListScreen extends ConsumerStatefulWidget {
|
||||
const VendorsListScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<VendorsListScreen> createState() => _VendorsListScreenState();
|
||||
}
|
||||
|
||||
class _VendorsListScreenState extends ConsumerState<VendorsListScreen> {
|
||||
String _searchQuery = '';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final vendorsState = ref.watch(vendorsProvider);
|
||||
final darkTheme = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Vendors'),
|
||||
elevation: 0,
|
||||
backgroundColor: Colors.transparent,
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 8.0),
|
||||
child: TextField(
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search vendors...',
|
||||
prefixIcon: const Icon(LucideIcons.search),
|
||||
filled: true,
|
||||
fillColor: darkTheme ? Colors.grey[900] : Colors.grey[100],
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
onChanged: (val) => setState(() => _searchQuery = val),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: vendorsState.when(
|
||||
data: (vendors) {
|
||||
final filtered = vendors.where((v) {
|
||||
final search = _searchQuery.toLowerCase();
|
||||
return v.name.toLowerCase().contains(search) ||
|
||||
(v.phone != null && v.phone!.contains(search));
|
||||
}).toList();
|
||||
|
||||
if (filtered.isEmpty) {
|
||||
return const Center(child: Text('No vendors found', style: TextStyle(color: Colors.grey)));
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: filtered.length,
|
||||
itemBuilder: (context, index) {
|
||||
final vendor = filtered[index];
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: ListTile(
|
||||
contentPadding: const EdgeInsets.all(16),
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: Colors.blue.withOpacity(0.1),
|
||||
child: vendor.photoUrl != null
|
||||
? ClipOval(child: Image.network(vendor.photoUrl!, width: 40, height: 40, fit: BoxFit.cover))
|
||||
: const Icon(LucideIcons.truck, color: Colors.blue),
|
||||
),
|
||||
title: Text(vendor.name, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
subtitle: Text(vendor.phone ?? 'No phone number'),
|
||||
trailing: const Icon(LucideIcons.chevronRight, color: Colors.grey),
|
||||
onTap: () {
|
||||
// View vendor details (TBD)
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, stack) => Center(child: Text('Error: $e')),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => const AddVendorSheet(),
|
||||
);
|
||||
},
|
||||
icon: const Icon(LucideIcons.plus),
|
||||
label: const Text('New Vendor'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
129
kifi-app/lib/features/vendor/providers/purchase_orders_provider.dart
vendored
Normal file
129
kifi-app/lib/features/vendor/providers/purchase_orders_provider.dart
vendored
Normal file
@@ -0,0 +1,129 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/network/dio_client.dart';
|
||||
import '../domain/purchase_order.dart';
|
||||
import '../domain/purchase_payment.dart';
|
||||
|
||||
class PurchaseOrdersNotifier extends AsyncNotifier<List<PurchaseOrder>> {
|
||||
@override
|
||||
Future<List<PurchaseOrder>> build() async {
|
||||
return _fetchPurchaseOrders();
|
||||
}
|
||||
|
||||
Future<List<PurchaseOrder>> _fetchPurchaseOrders() async {
|
||||
try {
|
||||
final response = await DioClient().dio.get('/purchase-orders');
|
||||
if (response.statusCode == 200) {
|
||||
final List<dynamic> data = response.data;
|
||||
return data.map((json) => PurchaseOrder.fromJson(json)).toList();
|
||||
}
|
||||
return [];
|
||||
} catch (e) {
|
||||
throw Exception('Failed to load purchase orders: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<PurchaseOrder> getPurchaseOrder(int id) async {
|
||||
try {
|
||||
final response = await DioClient().dio.get('/purchase-orders/$id');
|
||||
if (response.statusCode == 200) {
|
||||
return PurchaseOrder.fromJson(response.data);
|
||||
}
|
||||
throw Exception('Purchase order not found');
|
||||
} catch (e) {
|
||||
throw Exception('Failed to load purchase order: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<PurchaseOrder> createPurchaseOrder(PurchaseOrder po) async {
|
||||
try {
|
||||
final response = await DioClient().dio.post(
|
||||
'/purchase-orders',
|
||||
data: po.toJson(),
|
||||
);
|
||||
if (response.statusCode == 200) {
|
||||
final newPo = PurchaseOrder.fromJson(response.data);
|
||||
final current = state.value ?? [];
|
||||
state = AsyncValue.data([newPo, ...current]);
|
||||
return newPo;
|
||||
}
|
||||
throw Exception('Failed to create purchase order');
|
||||
} catch (e) {
|
||||
throw Exception('Error creating purchase order: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<PurchaseOrder> updatePurchaseOrder(PurchaseOrder po) async {
|
||||
try {
|
||||
final response = await DioClient().dio.put(
|
||||
'/purchase-orders/${po.id}',
|
||||
data: po.toJson(),
|
||||
);
|
||||
if (response.statusCode == 200) {
|
||||
final updatedPo = PurchaseOrder.fromJson(response.data);
|
||||
final current = state.value ?? [];
|
||||
state = AsyncValue.data(
|
||||
current.map((c) => c.id == updatedPo.id ? updatedPo : c).toList(),
|
||||
);
|
||||
return updatedPo;
|
||||
}
|
||||
throw Exception('Failed to update purchase order');
|
||||
} catch (e) {
|
||||
throw Exception('Error updating purchase order: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<PurchaseOrder> markAsReceived(PurchaseOrder po) async {
|
||||
try {
|
||||
final response = await DioClient().dio.put(
|
||||
'/purchase-orders/${po.id}/receive',
|
||||
data: po.toJson(),
|
||||
);
|
||||
if (response.statusCode == 200) {
|
||||
final updatedPo = PurchaseOrder.fromJson(response.data);
|
||||
final current = state.value ?? [];
|
||||
state = AsyncValue.data(
|
||||
current.map((c) => c.id == updatedPo.id ? updatedPo : c).toList(),
|
||||
);
|
||||
return updatedPo;
|
||||
}
|
||||
throw Exception('Failed to receive purchase order');
|
||||
} catch (e) {
|
||||
throw Exception('Error receiving purchase order: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<PurchasePayment> addPayment(int poId, PurchasePayment payment) async {
|
||||
try {
|
||||
final response = await DioClient().dio.post(
|
||||
'/purchase-orders/$poId/payments',
|
||||
data: payment.toJson(),
|
||||
);
|
||||
if (response.statusCode == 200) {
|
||||
// Refresh POs to update amount paid
|
||||
ref.invalidateSelf();
|
||||
return PurchasePayment.fromJson(response.data);
|
||||
}
|
||||
throw Exception('Failed to add payment');
|
||||
} catch (e) {
|
||||
throw Exception('Error adding payment: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<PurchasePayment>> fetchPaymentsForPurchaseOrder(int poId) async {
|
||||
try {
|
||||
final response = await DioClient().dio.get('/purchase-orders/$poId/payments');
|
||||
if (response.statusCode == 200) {
|
||||
final List<dynamic> data = response.data;
|
||||
return data.map((json) => PurchasePayment.fromJson(json)).toList();
|
||||
}
|
||||
return [];
|
||||
} catch (e) {
|
||||
throw Exception('Failed to load payments: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final purchaseOrdersProvider = AsyncNotifierProvider<PurchaseOrdersNotifier, List<PurchaseOrder>>(() {
|
||||
return PurchaseOrdersNotifier();
|
||||
});
|
||||
88
kifi-app/lib/features/vendor/providers/vendors_provider.dart
vendored
Normal file
88
kifi-app/lib/features/vendor/providers/vendors_provider.dart
vendored
Normal file
@@ -0,0 +1,88 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/network/dio_client.dart';
|
||||
import '../domain/vendor.dart';
|
||||
|
||||
class VendorsNotifier extends AsyncNotifier<List<Vendor>> {
|
||||
@override
|
||||
Future<List<Vendor>> build() async {
|
||||
return _fetchVendors();
|
||||
}
|
||||
|
||||
Future<List<Vendor>> _fetchVendors() async {
|
||||
try {
|
||||
final response = await DioClient().dio.get('/vendors');
|
||||
if (response.statusCode == 200) {
|
||||
final List<dynamic> data = response.data;
|
||||
return data.map((json) => Vendor.fromJson(json)).toList();
|
||||
}
|
||||
return [];
|
||||
} catch (e) {
|
||||
throw Exception('Failed to load vendors: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<Vendor> getVendor(int id) async {
|
||||
try {
|
||||
final response = await DioClient().dio.get('/vendors/$id');
|
||||
if (response.statusCode == 200) {
|
||||
return Vendor.fromJson(response.data);
|
||||
}
|
||||
throw Exception('Vendor not found');
|
||||
} catch (e) {
|
||||
throw Exception('Failed to load vendor: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<Vendor> addVendor(Vendor vendor) async {
|
||||
try {
|
||||
final response = await DioClient().dio.post(
|
||||
'/vendors',
|
||||
data: vendor.toJson(),
|
||||
);
|
||||
if (response.statusCode == 200) {
|
||||
final newVendor = Vendor.fromJson(response.data);
|
||||
final current = state.value ?? [];
|
||||
state = AsyncValue.data([...current, newVendor]);
|
||||
return newVendor;
|
||||
}
|
||||
throw Exception('Failed to add vendor');
|
||||
} catch (e) {
|
||||
throw Exception('Error adding vendor: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<Vendor> updateVendor(Vendor vendor) async {
|
||||
try {
|
||||
final response = await DioClient().dio.put(
|
||||
'/vendors/${vendor.id}',
|
||||
data: vendor.toJson(),
|
||||
);
|
||||
if (response.statusCode == 200) {
|
||||
final updatedVendor = Vendor.fromJson(response.data);
|
||||
final current = state.value ?? [];
|
||||
state = AsyncValue.data(
|
||||
current.map((c) => c.id == updatedVendor.id ? updatedVendor : c).toList(),
|
||||
);
|
||||
return updatedVendor;
|
||||
}
|
||||
throw Exception('Failed to update vendor');
|
||||
} catch (e) {
|
||||
throw Exception('Error updating vendor: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteVendor(int id) async {
|
||||
try {
|
||||
await DioClient().dio.delete('/vendors/$id');
|
||||
final current = state.value ?? [];
|
||||
state = AsyncValue.data(current.where((c) => c.id != id).toList());
|
||||
} catch (e) {
|
||||
throw Exception('Error deleting vendor: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final vendorsProvider = AsyncNotifierProvider<VendorsNotifier, List<Vendor>>(() {
|
||||
return VendorsNotifier();
|
||||
});
|
||||
Reference in New Issue
Block a user