purchase and vendor and GRN

This commit is contained in:
Surendiran 2026-06-24 11:02:13 +05:30
parent f496be0c7f
commit 1fc8abaee7
90 changed files with 15742 additions and 900 deletions

View File

@ -1,3 +1,4 @@
# Dev API (demo server). Override only if needed:
API_BASE_URL=https://demo.venbait.in/api/v1
API_TIMEOUT_SECONDS=30
DEV_BYPASS_AUTH=false

View File

@ -39,24 +39,59 @@ flutter pub get
# Generate Freezed/JSON models
dart run build_runner build --delete-conflicting-outputs
# Run with Development
flutter run -d chrome --dart-define=ENV=development
# Run with UAT
flutter run --dart-define=ENV=uat
# Run with Production
flutter run --dart-define=ENV=production
```
### Environment config
Each environment has its own entry point in `lib/config/` that sets `Environment.flavor`.
API URL and `.env` file are picked automatically (see `lib/core/config/environment.dart`).
| Flavor | Entry point | API URL (auto) | Web URL | Web base-href |
|--------|-------------|----------------|---------|---------------|
| **dev** | `lib/config/main_dev.dart` | `https://demo.venbait.in/api/v1` | `https://bharatconsumerproducts.com/erp/login` | `/erp/` |
| **uat** | `lib/config/main_uat.dart` | `https://uat-api.bharaterp.com/api/v1` | — | `/` |
| **prod** | `lib/config/main_prod.dart` | `https://api.bharaterp.com/api/v1` | — | `/app/` |
Dev web uses **path URLs** (no `index.html#`). Build with `--base-href /erp/` and deploy `web/.htaccess` for Apache SPA fallback.
### Web builds
```bash
# Release builds
flutter build apk --release --dart-define=ENV=production
flutter build ios --release --dart-define=ENV=production
flutter build web --release --dart-define=ENV=production
# Dev
flutter build web -t lib/config/main_dev.dart --release --base-href /erp/
# UAT
flutter build web -t lib/config/main_uat.dart --release --base-href /
# Prod
flutter build web -t lib/config/main_prod.dart --release --base-href /app/
```
### Android APK / AAB
```bash
flutter build apk --flavor dev -t lib/config/main_dev.dart --release
flutter build apk --flavor uat -t lib/config/main_uat.dart --release
flutter build apk --flavor prod -t lib/config/main_prod.dart --release
flutter build appbundle --flavor prod -t lib/config/main_prod.dart --release
```
### iOS
```bash
flutter build ios -t lib/config/main_prod.dart --release
```
### Local dev run
```bash
flutter run -d chrome -t lib/config/main_dev.dart --web-port=8080
flutter run --flavor dev -t lib/config/main_dev.dart
```
Output (web): `build/web/`
## Project Structure
See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for full architecture documentation.

View File

@ -10,6 +10,26 @@ android {
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
flavorDimensions += "environment"
productFlavors {
create("dev") {
dimension = "environment"
applicationIdSuffix = ".dev"
versionNameSuffix = "-dev"
resValue("string", "app_name", "Bharat ERP Dev")
}
create("uat") {
dimension = "environment"
applicationIdSuffix = ".uat"
versionNameSuffix = "-uat"
resValue("string", "app_name", "Bharat ERP UAT")
}
create("prod") {
dimension = "environment"
resValue("string", "app_name", "Bharat ERP")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11

View File

@ -1,6 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="bharat_erp"
android:label="@string/app_name"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Bharat ERP</string>
</resources>

7
lib/config/main_dev.dart Normal file
View File

@ -0,0 +1,7 @@
import '../core/config/app_bootstrap.dart';
import '../core/config/environment.dart';
Future<void> main() async {
Environment.flavor = Flavor.dev;
await startApp();
}

View File

@ -0,0 +1,7 @@
import '../core/config/app_bootstrap.dart';
import '../core/config/environment.dart';
Future<void> main() async {
Environment.flavor = Flavor.prod;
await startApp();
}

7
lib/config/main_uat.dart Normal file
View File

@ -0,0 +1,7 @@
import '../core/config/app_bootstrap.dart';
import '../core/config/environment.dart';
Future<void> main() async {
Environment.flavor = Flavor.uat;
await startApp();
}

View File

@ -0,0 +1,28 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_web_plugins/url_strategy.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../app.dart';
import '../theme/theme_provider.dart';
import 'environment.dart';
Future<void> startApp() async {
WidgetsFlutterBinding.ensureInitialized();
if (kIsWeb) {
usePathUrlStrategy();
}
await dotenv.load(fileName: Environment.envFileName);
final prefs = await SharedPreferences.getInstance();
runApp(
ProviderScope(
overrides: [
sharedPreferencesProvider.overrideWithValue(prefs),
],
child: const BharatErpApp(),
),
);
}

View File

@ -1,26 +1,16 @@
import 'package:flutter/foundation.dart';
import 'environment.dart';
/// Back-compat alias prefer [Environment] for new code.
class AppEnv {
AppEnv._();
static const String development = 'development';
static const String uat = 'uat';
static const String production = 'production';
static String get development => Environment.name;
static String get uat => 'uat';
static String get production => 'production';
static String get current {
const env = String.fromEnvironment('APP_ENV', defaultValue: development);
return env;
}
static String get current => Environment.name;
static String get envFileName {
switch (current) {
case uat:
return '.env.uat';
case production:
return '.env.production';
case development:
default:
return kReleaseMode ? '.env.production' : '.env.development';
}
}
static String get apiBaseUrl => Environment.apiBaseUrl;
static String get envFileName => Environment.envFileName;
}

View File

@ -5,14 +5,18 @@ class DevConfig {
DevConfig._();
/// True when dev bypass is enabled via .env or running in debug mode.
/// Never enabled in release builds.
static bool get bypassAuth {
if (kReleaseMode) return false;
final env = dotenv.env['DEV_BYPASS_AUTH'];
if (env != null) return env.toLowerCase() == 'true';
return kDebugMode;
}
/// UI preview without a working login API (screen gallery, explore mode).
/// Never enabled in release builds.
static bool get screenPreviewEnabled {
if (kReleaseMode) return false;
if (bypassAuth) return true;
final env = dotenv.env['DEV_SCREEN_PREVIEW'];
if (env != null) return env.toLowerCase() == 'true';

View File

@ -0,0 +1,54 @@
import 'package:flutter_dotenv/flutter_dotenv.dart';
enum Flavor { dev, uat, prod }
/// Environment config set [flavor] in each `lib/config/main_*.dart` entry point.
class Environment {
Environment._();
/// Overwritten by each `main_*.dart` before [startApp] runs.
static Flavor flavor = Flavor.dev;
static bool get isProd => flavor == Flavor.prod;
static String get name => switch (flavor) {
Flavor.dev => 'development',
Flavor.uat => 'uat',
Flavor.prod => 'production',
};
/// Web deploy path (`--base-href` should match this value).
static String get baseHref => switch (flavor) {
Flavor.dev => '/erp/',
Flavor.uat => '/',
Flavor.prod => '/app/',
};
/// Public web app URL (path-based routing no `index.html#`).
static String get webAppUrl => switch (flavor) {
Flavor.dev => 'https://bharatconsumerproducts.com/erp',
Flavor.uat => 'https://uat.bharaterp.com',
Flavor.prod => 'https://app.bharaterp.com',
};
static String get envFileName => switch (flavor) {
Flavor.dev => '.env.development',
Flavor.uat => '.env.uat',
Flavor.prod => '.env.production',
};
/// Priority: `--dart-define=API_BASE_URL` `.env` file flavor default.
static String get apiBaseUrl {
const fromDefine = String.fromEnvironment('API_BASE_URL');
if (fromDefine.isNotEmpty) return fromDefine;
final fromEnvFile = dotenv.env['API_BASE_URL'];
if (fromEnvFile != null && fromEnvFile.isNotEmpty) return fromEnvFile;
return switch (flavor) {
Flavor.dev => 'https://demo.venbait.in/api/v1',
Flavor.uat => 'https://uat-api.bharaterp.com/api/v1',
Flavor.prod => 'https://api.bharaterp.com/api/v1',
};
}
}

View File

@ -70,6 +70,37 @@ class ApiEndpoints {
static const String warehouses = '/masters/warehouses';
static String warehouseById(String id) => '/masters/warehouses/$id';
// Vendors
static const String vendors = '/vendors';
static String vendorById(String id) => '/vendors/$id';
static String vendorStatus(String id) => '/vendors/$id/status';
static String vendorAddresses(String vendorId) => '/vendors/$vendorId/addresses';
static String vendorAddressById(String vendorId, String addressId) =>
'/vendors/$vendorId/addresses/$addressId';
static String vendorContacts(String vendorId) => '/vendors/$vendorId/contacts';
static String vendorContactById(String vendorId, String contactId) =>
'/vendors/$vendorId/contacts/$contactId';
static String vendorBankDetails(String vendorId) =>
'/vendors/$vendorId/bank-details';
static String vendorBankDetailById(String vendorId, String bankDetailId) =>
'/vendors/$vendorId/bank-details/$bankDetailId';
// Purchase Orders
static const String purchaseOrders = '/purchase-orders';
static String purchaseOrderById(String id) => '/purchase-orders/$id';
static String purchaseOrderSubmit(String id) => '/purchase-orders/$id/submit';
static String purchaseOrderApprove(String id) => '/purchase-orders/$id/approve';
static String purchaseOrderReject(String id) => '/purchase-orders/$id/reject';
static String purchaseOrderAmend(String id) => '/purchase-orders/$id/amend';
static String purchaseOrderCancel(String id) => '/purchase-orders/$id/cancel';
static String purchaseOrderPdf(String id) => '/purchase-orders/$id/pdf';
// GRN
static const String grn = '/grn';
static String grnById(String id) => '/grn/$id';
static String grnCancel(String id) => '/grn/$id/cancel';
static String grnPdf(String id) => '/grn/$id/pdf';
// Assets
static const String assets = '/assets';
static String assetById(String id) => '/assets/$id';

View File

@ -39,6 +39,24 @@ class RouteConstants {
// Profile
static const String profile = '/profile';
// Vendors
static const String vendors = '/vendors';
static const String vendorAdd = '/vendors/add';
static const String vendorEdit = '/vendors/:id/edit';
static const String vendorDetail = '/vendors/:id';
// Purchase Orders
static const String purchaseOrders = '/purchase-orders';
static const String purchaseOrderAdd = '/purchase-orders/add';
static const String purchaseOrderEdit = '/purchase-orders/:id/edit';
static const String purchaseOrderDetail = '/purchase-orders/:id';
// GRN
static const String grn = '/grn';
static const String grnAdd = '/grn/add';
static const String grnEdit = '/grn/:id/edit';
static const String grnDetail = '/grn/:id';
// Assets
static const String assets = '/assets';
static const String assetAdd = '/assets/add';

View File

@ -3,13 +3,14 @@ import 'package:flutter/foundation.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../config/environment.dart';
import 'auth_interceptor.dart';
import 'error_interceptor.dart';
final dioProvider = Provider<Dio>((ref) {
final dio = Dio(
BaseOptions(
baseUrl: dotenv.env['API_BASE_URL'] ?? 'https://api.bharaterp.example.com/v1',
baseUrl: Environment.apiBaseUrl,
connectTimeout: Duration(
seconds: int.tryParse(dotenv.env['API_TIMEOUT_SECONDS'] ?? '30') ?? 30,
),

View File

@ -1,7 +1,7 @@
import 'package:dio/dio.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../config/environment.dart';
import '../constants/api_endpoints.dart';
import '../errors/exceptions.dart';
import 'api_envelope.dart';
@ -33,8 +33,7 @@ class TokenRefreshService {
final dio = Dio(
BaseOptions(
baseUrl: dotenv.env['API_BASE_URL'] ??
'https://demo.venbait.in/api/v1',
baseUrl: Environment.apiBaseUrl,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',

View File

@ -27,6 +27,21 @@ class DateFormatter {
if (value == null || value.isEmpty) return null;
return DateTime.tryParse(value);
}
static String formatUserLastLogin(DateTime? value) {
if (value == null) return '';
final local = value.toLocal();
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final date = DateTime(local.year, local.month, local.day);
final dayDiff = today.difference(date).inDays;
final time = DateFormat('hh:mm a').format(local);
if (dayDiff == 0) return 'Today $time';
if (dayDiff == 1) return 'Yesterday';
if (dayDiff < 7) return '$dayDiff days ago';
return displayDateTime(local);
}
}
class CurrencyFormatter {

View File

@ -15,6 +15,8 @@ const Map<String, String> permissionModuleAliases = {
'asset_maintenance': 'ASSET',
'asset_disposal': 'ASSET',
'vendor': 'VENDOR',
'vendors': 'VENDOR',
'purchase_orders': 'PURCHASE_ORDER',
'purchase_order': 'PURCHASE_ORDER',
'grn': 'GRN',
};

View File

@ -1,6 +1,17 @@
import 'package:flutter/services.dart';
class Validators {
Validators._();
static final RegExp _gstinPattern =
RegExp(r'^\d{2}[A-Z]{5}\d{4}[A-Z][A-Z\d]Z[A-Z\d]$');
static final RegExp _panPattern = RegExp(r'^[A-Z]{5}\d{4}[A-Z]$');
static final RegExp _emailPattern = RegExp(
r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$',
);
static final RegExp _ifscPattern = RegExp(r'^[A-Z]{4}0[A-Z0-9]{6}$');
static final RegExp _pincodePattern = RegExp(r'^[1-9]\d{5}$');
static String? required(String? value, {String fieldName = 'This field'}) {
if (value == null || value.trim().isEmpty) {
return '$fieldName is required';
@ -10,15 +21,119 @@ class Validators {
static String? email(String? value) {
if (value == null || value.trim().isEmpty) return 'Email is required';
final regex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');
if (!regex.hasMatch(value.trim())) return 'Enter a valid email address';
return _validateEmail(value.trim());
}
/// Validates email only when a value is entered.
static String? optionalEmail(String? value) {
if (value == null || value.trim().isEmpty) return null;
return _validateEmail(value.trim());
}
static String? _validateEmail(String raw) {
if (!_emailPattern.hasMatch(raw)) {
return 'Enter a valid email address';
}
return null;
}
static String? phone(String? value) {
if (value == null || value.trim().isEmpty) return 'Phone is required';
final regex = RegExp(r'^[6-9]\d{9}$');
if (!regex.hasMatch(value.trim())) return 'Enter a valid 10-digit mobile number';
/// Required 918 digit bank account number.
static String? accountNumber(String? value) {
if (value == null || value.trim().isEmpty) {
return 'Account number is required';
}
return _validateAccountNumber(value.trim());
}
/// Validates account number only when a value is entered.
static String? optionalAccountNumber(String? value) {
if (value == null || value.trim().isEmpty) return null;
return _validateAccountNumber(value.trim());
}
static String? _validateAccountNumber(String digits) {
if (!RegExp(r'^\d+$').hasMatch(digits)) {
return 'Account number must contain digits only';
}
if (digits.length < 9 || digits.length > 18) {
return 'Account number must be 9 to 18 digits';
}
return null;
}
/// Required 11-character IFSC code (e.g. SBIN0001234).
static String? ifsc(String? value) {
if (value == null || value.trim().isEmpty) {
return 'IFSC is required';
}
return _validateIfsc(value.trim());
}
/// Validates IFSC only when a value is entered.
static String? optionalIfsc(String? value) {
if (value == null || value.trim().isEmpty) return null;
return _validateIfsc(value.trim());
}
static String? _validateIfsc(String raw) {
final normalized = raw.toUpperCase();
if (normalized.length != 11) {
return 'IFSC must be exactly 11 characters';
}
if (!_ifscPattern.hasMatch(normalized)) {
return 'Enter a valid IFSC code (e.g. SBIN0001234)';
}
return null;
}
/// Required 6-digit Indian pincode.
static String? pincode(String? value) {
if (value == null || value.trim().isEmpty) {
return 'Pincode is required';
}
return _validatePincode(value.trim());
}
/// Validates pincode only when a value is entered.
static String? optionalPincode(String? value) {
if (value == null || value.trim().isEmpty) return null;
return _validatePincode(value.trim());
}
static String? _validatePincode(String digits) {
if (!RegExp(r'^\d+$').hasMatch(digits)) {
return 'Pincode must contain digits only';
}
if (!_pincodePattern.hasMatch(digits)) {
return 'Enter a valid 6-digit pincode';
}
return null;
}
/// Required 10-digit mobile digits only.
static String? mobile(String? value) {
if (value == null || value.trim().isEmpty) {
return 'Mobile number is required';
}
return _validateMobileDigits(value.trim());
}
/// Alias for [mobile].
static String? phone(String? value) => mobile(value);
/// Validates mobile only when a value is entered.
static String? optionalMobile(String? value) {
if (value == null || value.trim().isEmpty) return null;
return _validateMobileDigits(value.trim());
}
static String? _validateMobileDigits(String digits) {
if (!RegExp(r'^\d+$').hasMatch(digits)) {
return 'Mobile number must contain digits only';
}
if (digits.length != 10) {
return 'Mobile number must be exactly 10 digits';
}
return null;
}
@ -31,10 +146,56 @@ class Validators {
return null;
}
static String? gstNumber(String? value) {
/// Required 15-character GSTIN pattern.
static String? gstin(String? value) {
if (value == null || value.trim().isEmpty) {
return 'GSTIN is required';
}
return _validateGstin(value.trim());
}
/// Validates GSTIN only when a value is entered.
static String? optionalGstin(String? value) {
if (value == null || value.trim().isEmpty) return null;
final regex = RegExp(r'^\d{2}[A-Z]{5}\d{4}[A-Z]{1}[A-Z\d]{1}Z[A-Z\d]{1}$');
if (!regex.hasMatch(value.trim().toUpperCase())) return 'Enter a valid GST number';
return _validateGstin(value.trim());
}
/// Alias for [optionalGstin] (legacy company forms).
static String? gstNumber(String? value) => optionalGstin(value);
static String? _validateGstin(String raw) {
final normalized = raw.toUpperCase();
if (normalized.length != 15) {
return 'GSTIN must be exactly 15 characters';
}
if (!_gstinPattern.hasMatch(normalized)) {
return 'Enter a valid GSTIN (e.g. 27ABCDE1234F1Z5)';
}
return null;
}
/// Required 10-character PAN pattern (e.g. ABCDE1234F).
static String? pan(String? value) {
if (value == null || value.trim().isEmpty) {
return 'PAN is required';
}
return _validatePan(value.trim());
}
/// Validates PAN only when a value is entered.
static String? optionalPan(String? value) {
if (value == null || value.trim().isEmpty) return null;
return _validatePan(value.trim());
}
static String? _validatePan(String raw) {
final normalized = raw.toUpperCase();
if (normalized.length != 10) {
return 'PAN must be exactly 10 characters';
}
if (!_panPattern.hasMatch(normalized)) {
return 'Enter a valid PAN (e.g. ABCDE1234F)';
}
return null;
}
@ -44,4 +205,114 @@ class Validators {
}
return null;
}
/// Resolves validators for master-data and dynamic form fields by key.
static String? forFieldKey(
String key,
String? value, {
required bool required,
String fieldName = 'Field',
}) {
final normalizedKey = key.trim().toLowerCase();
if (normalizedKey == 'phone' || normalizedKey == 'mobile') {
return required ? mobile(value) : optionalMobile(value);
}
if (normalizedKey == 'email') {
return required ? email(value) : optionalEmail(value);
}
if (normalizedKey == 'gstin' ||
normalizedKey == 'gst_number' ||
normalizedKey == 'gst') {
return required ? gstin(value) : optionalGstin(value);
}
if (normalizedKey == 'pan') {
return required ? pan(value) : optionalPan(value);
}
if (normalizedKey == 'pincode' ||
normalizedKey == 'postal_code' ||
normalizedKey == 'zip') {
return required ? pincode(value) : optionalPincode(value);
}
if (normalizedKey == 'ifsc') {
return required ? ifsc(value) : optionalIfsc(value);
}
if (normalizedKey == 'account_number' || normalizedKey == 'account_no') {
return required ? accountNumber(value) : optionalAccountNumber(value);
}
if (required) {
return Validators.required(value, fieldName: fieldName);
}
return null;
}
static List<TextInputFormatter> inputFormattersForFieldKey(String key) {
final normalizedKey = key.trim().toLowerCase();
if (normalizedKey == 'phone' || normalizedKey == 'mobile') {
return mobileInput;
}
if (normalizedKey == 'pincode' ||
normalizedKey == 'postal_code' ||
normalizedKey == 'zip') {
return pincodeInput;
}
if (normalizedKey == 'ifsc') {
return ifscInput;
}
if (normalizedKey == 'account_number' || normalizedKey == 'account_no') {
return accountNumberInput;
}
if (normalizedKey == 'gstin' ||
normalizedKey == 'gst_number' ||
normalizedKey == 'gst') {
return gstinInput;
}
if (normalizedKey == 'pan') {
return panInput;
}
return const [];
}
static List<TextInputFormatter> get mobileInput => [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10),
];
static List<TextInputFormatter> get pincodeInput => [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(6),
];
static List<TextInputFormatter> get accountNumberInput => [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(18),
];
static List<TextInputFormatter> get ifscInput => [
FilteringTextInputFormatter.allow(RegExp(r'[A-Za-z0-9]')),
LengthLimitingTextInputFormatter(11),
_upperCaseFormatter,
];
static List<TextInputFormatter> get gstinInput => [
FilteringTextInputFormatter.allow(RegExp(r'[A-Za-z0-9]')),
LengthLimitingTextInputFormatter(15),
_upperCaseFormatter,
];
static List<TextInputFormatter> get panInput => [
FilteringTextInputFormatter.allow(RegExp(r'[A-Za-z0-9]')),
LengthLimitingTextInputFormatter(10),
_upperCaseFormatter,
];
static final TextInputFormatter _upperCaseFormatter =
TextInputFormatter.withFunction(
(oldValue, newValue) => TextEditingValue(
text: newValue.text.toUpperCase(),
selection: newValue.selection,
),
);
}

View File

@ -1,23 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'app.dart';
import 'core/config/app_env.dart';
import 'core/theme/theme_provider.dart';
import 'core/config/app_bootstrap.dart';
import 'core/config/environment.dart';
/// Default entry point development flavor.
/// For UAT/Prod use `lib/config/main_uat.dart` or `lib/config/main_prod.dart`.
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await dotenv.load(fileName: AppEnv.envFileName);
final prefs = await SharedPreferences.getInstance();
runApp(
ProviderScope(
overrides: [
sharedPreferencesProvider.overrideWithValue(prefs),
],
child: const BharatErpApp(),
),
);
Environment.flavor = Flavor.dev;
await startApp();
}

View File

@ -11,14 +11,16 @@ import '../../../../shared/models/asset_model.dart';
import '../../../../shared/providers/permissions_provider.dart';
import '../../../../shared/widgets/app_card.dart';
import '../../../../shared/widgets/app_confirmation_dialog.dart';
import '../../../../shared/widgets/app_data_table.dart';
import '../../../../shared/widgets/app_dropdown.dart';
import '../../../../shared/widgets/app_empty_state.dart';
import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/app_status_chip.dart';
import '../../../../shared/widgets/app_table_action_icon.dart';
import '../../../../shared/widgets/can_permission.dart';
import '../../../../shared/widgets/error_view.dart';
import '../../../../shared/widgets/page_header.dart';
import '../../../rbac/presentation/widgets/rbac_widgets.dart';
import '../providers/asset_categories_provider.dart';
import '../providers/assets_provider.dart';
import '../widgets/asset_form_panel.dart';
@ -31,8 +33,6 @@ class AssetListScreen extends ConsumerStatefulWidget {
}
class _AssetListScreenState extends ConsumerState<AssetListScreen> {
static const _tableMinWidth = 1040.0;
String? _selectedCategory;
String? _selectedPlant;
String? _selectedStatus;
@ -182,119 +182,13 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
)
else
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
final tableWidth =
constraints.maxWidth < _tableMinWidth
? _tableMinWidth
: constraints.maxWidth;
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding:
const EdgeInsets.symmetric(horizontal: 4),
child: ConstrainedBox(
constraints:
BoxConstraints(minWidth: tableWidth),
child: DataTable(
horizontalMargin: 20,
columnSpacing: 16,
headingRowColor: WidgetStateProperty.all(
Theme.of(context)
.colorScheme
.surfaceContainerHighest
.withValues(alpha: 0.4),
),
columns: const [
DataColumn(label: Text('ASSET')),
DataColumn(label: Text('CATEGORY')),
DataColumn(label: Text('PLANT')),
DataColumn(label: Text('WARRANTY')),
DataColumn(label: Text('STATUS')),
DataColumn(
label: _AssetTableActionsHeader(),
),
],
rows: filteredAssets.map((asset) {
final status =
assetStatusDisplay(asset.status);
return DataRow(
cells: [
DataCell(
Row(
children: [
UserAvatarChip(
name: asset.assetName,
initials:
assetInitials(asset),
),
const SizedBox(width: 10),
Column(
crossAxisAlignment:
CrossAxisAlignment.start,
mainAxisAlignment:
MainAxisAlignment.center,
children: [
Text(
asset.assetName,
style: const TextStyle(
fontWeight:
FontWeight.w600,
),
),
Text(
asset.assetCode ?? '',
style: Theme.of(context)
.textTheme
.bodySmall,
),
],
),
],
),
),
DataCell(
RoleBadge(
label: asset.assetCategoryName ??
'',
),
),
DataCell(
Text(asset.plantName ?? ''),
),
DataCell(
Text(
DateFormatter.displayDate(
asset.warrantyExpiryDate,
),
),
),
DataCell(
StatusBadge(
label: status.label,
color: status.color,
),
),
DataCell(
_AssetTableActionsCell(
child: _AssetTableActions(
canView: true,
canEdit: canEdit,
canDelete: canDelete,
onView: () => _viewAsset(asset),
onEdit: () => _editAsset(asset),
onDelete: () =>
_deleteAsset(asset),
),
),
),
],
);
}).toList(),
),
),
);
},
child: _AssetDataTable(
assets: filteredAssets,
canEdit: canEdit,
canDelete: canDelete,
onView: _viewAsset,
onEdit: _editAsset,
onDelete: _deleteAsset,
),
),
const Divider(height: 1),
@ -452,21 +346,6 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
}
}
String assetInitials(AssetModel asset) {
final code = asset.assetCode;
if (code != null && code.isNotEmpty) {
return code.length >= 2 ? code.substring(0, 2).toUpperCase() : code.toUpperCase();
}
if (asset.assetName.isNotEmpty) {
final parts = asset.assetName.trim().split(RegExp(r'\s+'));
if (parts.length >= 2) {
return '${parts[0][0]}${parts[1][0]}'.toUpperCase();
}
return asset.assetName[0].toUpperCase();
}
return 'A';
}
({String label, Color color}) assetStatusDisplay(String? status) {
return switch (status?.toUpperCase().replaceAll(' ', '_')) {
'IN_USE' => (label: 'In Use', color: const Color(0xFF16A34A)),
@ -599,119 +478,90 @@ class _AssetFilterDropdown extends StatelessWidget {
}
}
class _AssetTableActions extends StatelessWidget {
const _AssetTableActions({
class _AssetDataTable extends StatelessWidget {
const _AssetDataTable({
required this.assets,
required this.canEdit,
required this.canDelete,
required this.onView,
required this.onEdit,
required this.onDelete,
this.canView = true,
this.canEdit = true,
this.canDelete = true,
});
static const columnWidth = 108.0;
final VoidCallback onView;
final VoidCallback onEdit;
final VoidCallback onDelete;
final bool canView;
final List<AssetModel> assets;
final bool canEdit;
final bool canDelete;
final void Function(AssetModel asset) onView;
final void Function(AssetModel asset) onEdit;
final Future<void> Function(AssetModel asset) onDelete;
@override
Widget build(BuildContext context) {
final muted = Theme.of(context).colorScheme.onSurfaceVariant;
return Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (canView)
_AssetActionIcon(
tooltip: 'View asset',
icon: Icons.visibility_outlined,
color: muted,
onPressed: onView,
return AppDataTable<AssetModel>(
wrapInCard: false,
columns: [
AppDataColumn(
label: 'Asset Code',
flex: 1,
cellBuilder: (_, asset) => Text(asset.assetCode ?? ''),
),
AppDataColumn(
label: 'Asset Name',
flex: 2,
cellBuilder: (_, asset) => Text(asset.assetName),
),
AppDataColumn(
label: 'Category',
flex: 2,
cellBuilder: (_, asset) => Text(asset.assetCategoryName ?? ''),
),
AppDataColumn(
label: 'Plant',
flex: 1,
cellBuilder: (_, asset) => Text(asset.plantName ?? ''),
),
AppDataColumn(
label: 'Warranty',
flex: 1,
cellBuilder: (_, asset) => Text(
DateFormatter.displayDate(asset.warrantyExpiryDate),
),
if (canEdit)
_AssetActionIcon(
tooltip: 'Edit asset',
icon: Icons.edit_outlined,
color: muted,
onPressed: onEdit,
),
AppDataColumn(
label: 'Status',
flex: 1,
cellBuilder: (_, asset) => AppStatusChip(
status: asset.status?.toLowerCase().replaceAll(' ', '_') ?? 'active',
),
if (canDelete)
_AssetActionIcon(
tooltip: 'Delete asset',
icon: Icons.delete_outline,
color: muted,
onPressed: onDelete,
),
AppDataColumn(
label: 'Actions',
flex: 1,
alignment: Alignment.centerRight,
cellBuilder: (_, asset) => AppTableActions(
children: [
AppTableActionIcon(
tooltip: 'View',
icon: Icons.visibility_outlined,
onPressed: () => onView(asset),
),
if (canEdit)
AppTableActionIcon(
tooltip: 'Edit',
icon: Icons.edit_outlined,
onPressed: () => onEdit(asset),
),
if (canDelete)
AppTableActionIcon(
tooltip: 'Delete',
icon: Icons.delete_outline,
onPressed: () => onDelete(asset),
),
],
),
),
],
);
}
}
class _AssetTableActionsHeader extends StatelessWidget {
const _AssetTableActionsHeader();
@override
Widget build(BuildContext context) {
return const SizedBox(
width: _AssetTableActions.columnWidth,
child: Align(
alignment: Alignment.centerRight,
child: Text('ACTIONS'),
),
);
}
}
class _AssetTableActionsCell extends StatelessWidget {
const _AssetTableActionsCell({required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
return SizedBox(
width: _AssetTableActions.columnWidth,
child: Align(
alignment: Alignment.centerRight,
child: Padding(
padding: const EdgeInsets.only(right: 4),
child: child,
),
),
);
}
}
class _AssetActionIcon extends StatelessWidget {
const _AssetActionIcon({
required this.tooltip,
required this.icon,
required this.color,
required this.onPressed,
});
final String tooltip;
final IconData icon;
final Color color;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return Tooltip(
message: tooltip,
child: InkWell(
onTap: onPressed,
borderRadius: BorderRadius.circular(6),
child: Padding(
padding: const EdgeInsets.all(6),
child: Icon(icon, size: 18, color: color),
),
),
rows: assets,
);
}
}
@ -748,50 +598,54 @@ class _AssetMobileList extends StatelessWidget {
separatorBuilder: (_, __) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final asset = assets[index];
final status = assetStatusDisplay(asset.status);
return AppCard(
child: ListTile(
onTap: () => onView(asset),
leading: UserAvatarChip(
name: asset.assetName,
initials: assetInitials(asset),
),
title: Text(
asset.assetName,
style: const TextStyle(fontWeight: FontWeight.w600),
),
subtitle: Column(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(asset.assetCode ?? ''),
const SizedBox(height: 4),
Row(
children: [
Flexible(
child: RoleBadge(
label: asset.assetCategoryName ?? '',
Expanded(
child: Text(
asset.assetName,
style: Theme.of(context).textTheme.titleMedium,
),
),
const SizedBox(width: 8),
StatusBadge(label: status.label, color: status.color),
AppStatusChip(
status: asset.status?.toLowerCase().replaceAll(' ', '_') ??
'active',
compact: true,
),
],
),
const SizedBox(height: 4),
Text(asset.assetCode ?? ''),
Text('${asset.assetCategoryName ?? ''} · ${asset.plantName ?? ''}'),
const SizedBox(height: 8),
Row(
mainAxisSize: MainAxisSize.min,
children: [
AppTableActionIcon(
tooltip: 'View',
icon: Icons.visibility_outlined,
onPressed: () => onView(asset),
),
if (onEdit != null)
AppTableActionIcon(
tooltip: 'Edit',
icon: Icons.edit_outlined,
onPressed: () => onEdit!(asset),
),
if (onDelete != null)
AppTableActionIcon(
tooltip: 'Delete',
icon: Icons.delete_outline,
onPressed: () => onDelete!(asset),
),
],
),
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (onEdit != null)
IconButton(
icon: const Icon(Icons.edit_outlined),
onPressed: () => onEdit!(asset),
),
if (onDelete != null)
IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: () => onDelete!(asset),
),
],
),
),

View File

@ -8,6 +8,8 @@ import '../../../../core/utils/validators.dart';
import '../../../../shared/models/asset_model.dart';
import '../../../../shared/models/user_management_models.dart' show FilterOptionModel;
import '../../../../shared/widgets/app_button.dart';
import '../../../../shared/widgets/app_dropdown.dart';
import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_side_panel.dart';
import '../../../../shared/widgets/app_text_field.dart';
@ -250,18 +252,18 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
.map((c) => int.tryParse(c.id))
.whereType<int>()
.toList();
return DropdownButtonFormField<int>(
return AppSearchableDropdown<int>(
label: 'Category *',
value: _dropdownValue(_categoryId, categoryIds),
isExpanded: true,
decoration: const InputDecoration(labelText: 'Category *'),
items: categories
searchHint: 'Search category...',
options: categories
.map(
(c) => DropdownMenuItem(
value: int.tryParse(c.id),
child: Text('${c.code}${c.name}'),
(c) => AppDropdownOption(
value: int.tryParse(c.id) ?? 0,
label: '${c.code}${c.name}',
),
)
.where((item) => item.value != null)
.where((option) => option.value != 0)
.toList(),
onChanged: (v) => setState(() => _categoryId = v),
validator: (v) => v == null ? 'Category is required' : null,
@ -273,18 +275,18 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
.map((p) => int.tryParse(p.id))
.whereType<int>()
.toList();
return DropdownButtonFormField<int>(
return AppSearchableDropdown<int>(
label: 'Plant *',
value: _dropdownValue(_plantId, plantIds),
isExpanded: true,
decoration: const InputDecoration(labelText: 'Plant *'),
items: plants
searchHint: 'Search plant...',
options: plants
.map(
(p) => DropdownMenuItem(
value: int.tryParse(p.id),
child: Text(p.name),
(p) => AppDropdownOption(
value: int.tryParse(p.id) ?? 0,
label: p.name,
),
)
.where((item) => item.value != null)
.where((option) => option.value != 0)
.toList(),
onChanged: (v) => setState(() => _plantId = v),
validator: (v) => v == null ? 'Plant is required' : null,

View File

@ -4,6 +4,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/utils/formatters.dart';
import '../../../../core/utils/validators.dart';
import '../../../../shared/widgets/app_button.dart';
import '../../../../shared/widgets/app_dropdown.dart';
import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/app_side_panel.dart';
import '../../../../shared/widgets/app_text_field.dart';
import '../providers/assets_provider.dart';
@ -119,18 +121,16 @@ class _AddAmcPanelState extends ConsumerState<AddAmcPanel> {
label: 'Contract No',
),
),
DropdownButtonFormField<String>(
AppSearchableDropdown<String>(
label: 'Contract Type',
value: _contractType,
isExpanded: true,
decoration: const InputDecoration(labelText: 'Contract Type'),
items: const [
searchHint: 'Search contract type...',
options: stringDropdownOptions(const [
'COMPREHENSIVE',
'LABOUR_ONLY',
'PARTS_ONLY',
'PREVENTIVE_ONLY',
]
.map((t) => DropdownMenuItem(value: t, child: Text(t)))
.toList(),
]),
onChanged: (v) {
if (v != null) setState(() => _contractType = v);
},
@ -238,37 +238,33 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SidePanelFormRow(
left: DropdownButtonFormField<String>(
left: AppSearchableDropdown<String>(
label: 'Visit Type *',
value: _visitType,
isExpanded: true,
decoration: const InputDecoration(labelText: 'Visit Type *'),
items: const [
searchHint: 'Search visit type...',
options: stringDropdownOptions(const [
'PREVENTIVE',
'BREAKDOWN',
'INSPECTION',
'INSTALLATION',
'CALIBRATION',
'OTHER',
]
.map((t) => DropdownMenuItem(value: t, child: Text(t)))
.toList(),
]),
onChanged: (v) {
if (v != null) setState(() => _visitType = v);
},
),
right: DropdownButtonFormField<String>(
right: AppSearchableDropdown<String>(
label: 'Status',
value: _status,
isExpanded: true,
decoration: const InputDecoration(labelText: 'Status'),
items: const [
searchHint: 'Search status...',
options: stringDropdownOptions(const [
'SCHEDULED',
'IN_PROGRESS',
'COMPLETED',
'CANCELLED',
'PENDING_PARTS',
]
.map((t) => DropdownMenuItem(value: t, child: Text(t)))
.toList(),
]),
onChanged: (v) {
if (v != null) setState(() => _status = v);
},
@ -396,20 +392,18 @@ class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
),
),
SidePanelFormRow(
left: DropdownButtonFormField<String>(
left: AppSearchableDropdown<String>(
label: 'Policy Type',
value: _policyType,
isExpanded: true,
decoration: const InputDecoration(labelText: 'Policy Type'),
items: const [
searchHint: 'Search policy type...',
options: stringDropdownOptions(const [
'FIRE_AND_ALLIED',
'MACHINERY_BREAKDOWN',
'COMPREHENSIVE',
'THIRD_PARTY',
'VEHICLE',
'OTHER',
]
.map((t) => DropdownMenuItem(value: t, child: Text(t)))
.toList(),
]),
onChanged: (v) {
if (v != null) setState(() => _policyType = v);
},

View File

@ -63,7 +63,8 @@ class _CompanyFormScreenState extends State<CompanyFormScreen> {
AppTextField(
controller: _gstController,
label: 'GST Number',
validator: Validators.gstNumber,
validator: Validators.optionalGstin,
inputFormatters: Validators.gstinInput,
),
const SizedBox(height: 16),
AppTextField(
@ -83,7 +84,8 @@ class _CompanyFormScreenState extends State<CompanyFormScreen> {
controller: _phoneController,
label: 'Phone',
keyboardType: TextInputType.phone,
validator: Validators.phone,
validator: Validators.mobile,
inputFormatters: Validators.mobileInput,
),
const SizedBox(height: 24),
AppButton(

View File

@ -0,0 +1,121 @@
import 'package:dio/dio.dart';
import '../../../../core/constants/api_endpoints.dart';
import '../../../../shared/models/api_response.dart';
import '../../../../shared/models/grn_model.dart';
class GrnRemoteDataSource {
GrnRemoteDataSource({required this.dio});
final Dio dio;
Future<PaginatedResponse<GrnModel>> getGrns(GrnListQuery query) async {
final response = await dio.get(
ApiEndpoints.grn,
queryParameters: _queryToMap(query),
);
return _parsePaginated(response.data, GrnModel.fromJson);
}
Future<GrnModel> getGrnById(String id) async {
final response = await dio.get(ApiEndpoints.grnById(id));
return GrnModel.fromJson(response.data['data'] as Map<String, dynamic>);
}
Future<GrnModel> createGrn(Map<String, dynamic> data) async {
final response = await dio.post(ApiEndpoints.grn, data: data);
return GrnModel.fromJson(response.data['data'] as Map<String, dynamic>);
}
Future<GrnModel> updateGrn(String id, Map<String, dynamic> data) async {
final response = await dio.put(ApiEndpoints.grnById(id), data: data);
return GrnModel.fromJson(response.data['data'] as Map<String, dynamic>);
}
Future<GrnModel> cancelGrn(String id, {required String cancellationReason}) async {
final response = await dio.post(
ApiEndpoints.grnCancel(id),
data: {'cancellation_reason': cancellationReason},
);
return GrnModel.fromJson(response.data['data'] as Map<String, dynamic>);
}
Future<List<int>> downloadGrnPdf(String id) async {
final response = await dio.get<List<int>>(
ApiEndpoints.grnPdf(id),
options: Options(responseType: ResponseType.bytes),
);
return response.data ?? [];
}
Map<String, dynamic> _queryToMap(GrnListQuery query) {
return {
'page': query.page,
'limit': query.limit,
if (query.search != null && query.search!.isNotEmpty) 'search': query.search,
if (query.status != null) 'status': query.status,
if (query.poId != null) 'po_id': query.poId,
if (query.vendorId != null) 'vendor_id': query.vendorId,
if (query.warehouseId != null) 'warehouse_id': query.warehouseId,
if (query.dateFrom != null) 'date_from': query.dateFrom,
if (query.dateTo != null) 'date_to': query.dateTo,
};
}
PaginatedResponse<T> _parsePaginated<T>(
dynamic body,
T Function(Map<String, dynamic>) fromJson,
) {
if (body is! Map<String, dynamic>) {
return const PaginatedResponse(
items: [],
page: 1,
limit: 20,
total: 0,
totalPages: 1,
);
}
final raw = body['data'];
final meta = body['meta'] as Map<String, dynamic>? ?? {};
if (raw is List) {
final items = raw.whereType<Map<String, dynamic>>().map(fromJson).toList();
final limit = (meta['limit'] as num?)?.toInt() ?? items.length;
final total = (meta['total'] as num?)?.toInt() ?? items.length;
return PaginatedResponse(
items: items,
page: (meta['page'] as num?)?.toInt() ?? 1,
limit: limit,
total: total,
totalPages:
limit > 0 ? ((total + limit - 1) / limit).ceil().clamp(1, 999999) : 1,
);
}
if (raw is Map<String, dynamic>) {
final list = raw['items'];
if (list is List) {
final items = list.whereType<Map<String, dynamic>>().map(fromJson).toList();
final limit = (meta['limit'] as num?)?.toInt() ?? 20;
final total = (meta['total'] as num?)?.toInt() ?? items.length;
return PaginatedResponse(
items: items,
page: (meta['page'] as num?)?.toInt() ?? 1,
limit: limit,
total: total,
totalPages:
limit > 0 ? ((total + limit - 1) / limit).ceil().clamp(1, 999999) : 1,
);
}
}
return const PaginatedResponse(
items: [],
page: 1,
limit: 20,
total: 0,
totalPages: 1,
);
}
}

View File

@ -0,0 +1,57 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/network/api_handler.dart';
import '../../../../core/network/dio_client.dart';
import '../../../../shared/models/api_response.dart';
import '../../../../shared/models/grn_model.dart';
import '../../domain/repositories/grn_repository.dart';
import '../datasources/grn_remote_data_source.dart';
final grnRemoteDataSourceProvider = Provider<GrnRemoteDataSource>((ref) {
return GrnRemoteDataSource(dio: ref.watch(dioProvider));
});
final grnRepositoryProvider = Provider<GrnRepository>((ref) {
return GrnRepositoryImpl(dataSource: ref.watch(grnRemoteDataSourceProvider));
});
class GrnRepositoryImpl implements GrnRepository {
GrnRepositoryImpl({required this.dataSource});
final GrnRemoteDataSource dataSource;
@override
Future<Result<PaginatedResponse<GrnModel>>> getGrns(GrnListQuery query) {
return safeApiCall(() => dataSource.getGrns(query));
}
@override
Future<Result<GrnModel>> getGrnById(String id) {
return safeApiCall(() => dataSource.getGrnById(id));
}
@override
Future<Result<GrnModel>> createGrn(Map<String, dynamic> data) {
return safeApiCall(() => dataSource.createGrn(data));
}
@override
Future<Result<GrnModel>> updateGrn(String id, Map<String, dynamic> data) {
return safeApiCall(() => dataSource.updateGrn(id, data));
}
@override
Future<Result<GrnModel>> cancelGrn(
String id, {
required String cancellationReason,
}) {
return safeApiCall(
() => dataSource.cancelGrn(id, cancellationReason: cancellationReason),
);
}
@override
Future<Result<List<int>>> downloadGrnPdf(String id) {
return safeApiCall(() => dataSource.downloadGrnPdf(id));
}
}

View File

@ -0,0 +1,12 @@
import '../../../../core/network/api_handler.dart';
import '../../../../shared/models/api_response.dart';
import '../../../../shared/models/grn_model.dart';
abstract class GrnRepository {
Future<Result<PaginatedResponse<GrnModel>>> getGrns(GrnListQuery query);
Future<Result<GrnModel>> getGrnById(String id);
Future<Result<GrnModel>> createGrn(Map<String, dynamic> data);
Future<Result<GrnModel>> updateGrn(String id, Map<String, dynamic> data);
Future<Result<GrnModel>> cancelGrn(String id, {required String cancellationReason});
Future<Result<List<int>>> downloadGrnPdf(String id);
}

View File

@ -0,0 +1,65 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/constants/app_constants.dart';
import '../../../../shared/models/purchase_order_model.dart';
import '../../../../shared/models/user_management_models.dart';
import '../../../masters/data/datasources/master_remote_data_source.dart';
import '../../../purchase_orders/data/repositories/purchase_order_repository_impl.dart';
class GrnLookups {
const GrnLookups({
this.warehouses = const [],
this.receivablePurchaseOrders = const [],
this.assetCategories = const [],
});
final List<FilterOptionModel> warehouses;
final List<PurchaseOrderModel> receivablePurchaseOrders;
final List<FilterOptionModel> assetCategories;
}
final grnLookupsProvider = FutureProvider.autoDispose<GrnLookups>((ref) async {
final master = ref.watch(masterRemoteDataSourceProvider);
final poRepo = ref.watch(purchaseOrderRepositoryProvider);
final warehouses = await _safeOptions(master.listWarehouses);
final assetCategories = await _safeOptions(master.listAssetCategories);
final receivablePos = <PurchaseOrderModel>[];
for (final status in ['APPROVED', 'PARTIALLY_RECEIVED']) {
final result = await poRepo.getPurchaseOrders(
PurchaseOrderListQuery(
page: 1,
limit: AppConstants.maxPageSize,
status: status,
),
);
if (result.failure == null && result.data != null) {
receivablePos.addAll(result.data!.items);
}
}
return GrnLookups(
warehouses: warehouses,
receivablePurchaseOrders: receivablePos,
assetCategories: assetCategories,
);
});
Future<List<FilterOptionModel>> _safeOptions(
Future<List<FilterOptionModel>> Function() load,
) async {
try {
return await load();
} catch (_) {
return const [];
}
}
final grnPurchaseOrderProvider =
FutureProvider.autoDispose.family<PurchaseOrderModel?, String>((ref, poId) async {
final result =
await ref.read(purchaseOrderRepositoryProvider).getPurchaseOrderById(poId);
if (result.failure != null) throw result.failure!;
return result.data;
});

View File

@ -0,0 +1,188 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../shared/models/grn_model.dart';
import '../../data/repositories/grn_repository_impl.dart';
class GrnListState {
const GrnListState({
this.grns = const [],
this.query = const GrnListQuery(limit: 20),
this.total = 0,
this.totalPages = 1,
this.isRefreshing = false,
this.actionError,
this.actionSuccess,
});
final List<GrnModel> grns;
final GrnListQuery query;
final int total;
final int totalPages;
final bool isRefreshing;
final String? actionError;
final String? actionSuccess;
GrnListState copyWith({
List<GrnModel>? grns,
GrnListQuery? query,
int? total,
int? totalPages,
bool? isRefreshing,
String? actionError,
String? actionSuccess,
bool clearMessages = false,
}) {
return GrnListState(
grns: grns ?? this.grns,
query: query ?? this.query,
total: total ?? this.total,
totalPages: totalPages ?? this.totalPages,
isRefreshing: isRefreshing ?? this.isRefreshing,
actionError: clearMessages ? null : actionError ?? this.actionError,
actionSuccess: clearMessages ? null : actionSuccess ?? this.actionSuccess,
);
}
}
final grnListProvider =
AsyncNotifierProvider.autoDispose<GrnListNotifier, GrnListState>(
GrnListNotifier.new,
);
class GrnListNotifier extends AutoDisposeAsyncNotifier<GrnListState> {
@override
Future<GrnListState> build() async {
return _load(const GrnListQuery(limit: 20));
}
Future<GrnListState> _load(GrnListQuery query) async {
final repository = ref.read(grnRepositoryProvider);
final result = await repository.getGrns(query);
if (result.failure != null) throw result.failure!;
final page = result.data!;
return GrnListState(
grns: page.items,
query: query,
total: page.total,
totalPages: page.totalPages,
);
}
Future<void> refresh() async {
final current = state.valueOrNull ?? const GrnListState();
state = AsyncData(current.copyWith(isRefreshing: true, clearMessages: true));
try {
state = AsyncData(await _load(current.query));
} catch (e, st) {
state = AsyncError(e, st);
}
}
Future<void> applyQuery(GrnListQuery query) async {
final previous = state.valueOrNull;
if (previous == null) {
state = const AsyncLoading();
}
try {
state = AsyncData(await _load(query));
} catch (e, st) {
state = AsyncError(e, st);
}
}
void setSearch(String search) {
final current = state.valueOrNull;
if (current == null) return;
applyQuery(current.query.copyWith(search: search, page: 1));
}
void setStatusFilter(String? status) {
final current = state.valueOrNull;
if (current == null) return;
applyQuery(current.query.copyWith(status: status, page: 1));
}
void setPage(int page) {
final current = state.valueOrNull;
if (current == null) return;
applyQuery(current.query.copyWith(page: page));
}
void setPageSize(int limit) {
final current = state.valueOrNull;
if (current == null) return;
applyQuery(current.query.copyWith(limit: limit, page: 1));
}
}
final grnDetailProvider =
AsyncNotifierProvider.family<GrnDetailNotifier, GrnModel, String>(
GrnDetailNotifier.new,
);
class GrnDetailNotifier extends FamilyAsyncNotifier<GrnModel, String> {
@override
Future<GrnModel> build(String arg) async {
final repository = ref.read(grnRepositoryProvider);
final result = await repository.getGrnById(arg);
if (result.failure != null) throw result.failure!;
return result.data!;
}
Future<void> reload() async {
state = const AsyncLoading();
state = AsyncData(await build(arg));
}
Future<GrnModel> cancel({required String cancellationReason}) async {
final repository = ref.read(grnRepositoryProvider);
final result = await repository.cancelGrn(
arg,
cancellationReason: cancellationReason,
);
if (result.failure != null) throw result.failure!;
state = AsyncData(result.data!);
ref.invalidate(grnListProvider);
return result.data!;
}
Future<List<int>> downloadPdf() async {
final repository = ref.read(grnRepositoryProvider);
final result = await repository.downloadGrnPdf(arg);
if (result.failure != null) throw result.failure!;
return result.data ?? [];
}
}
final grnFormProvider =
AsyncNotifierProvider.family<GrnFormNotifier, GrnModel?, String?>(
GrnFormNotifier.new,
);
class GrnFormNotifier extends FamilyAsyncNotifier<GrnModel?, String?> {
@override
Future<GrnModel?> build(String? arg) async {
if (arg == null) return null;
final repository = ref.read(grnRepositoryProvider);
final result = await repository.getGrnById(arg);
if (result.failure != null) throw result.failure!;
return result.data;
}
Future<GrnModel> submitCreate(Map<String, dynamic> data) async {
final repository = ref.read(grnRepositoryProvider);
final result = await repository.createGrn(data);
if (result.failure != null) throw result.failure!;
ref.invalidate(grnListProvider);
return result.data!;
}
Future<GrnModel> submitUpdate(String id, Map<String, dynamic> data) async {
final repository = ref.read(grnRepositoryProvider);
final result = await repository.updateGrn(id, data);
if (result.failure != null) throw result.failure!;
ref.invalidate(grnListProvider);
ref.invalidate(grnDetailProvider(id));
return result.data!;
}
}

View File

@ -0,0 +1,276 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/constants/enums.dart';
import '../../../../core/constants/route_constants.dart';
import '../../../../core/errors/failure.dart';
import '../../../../core/utils/formatters.dart';
import '../../../../shared/models/grn_model.dart';
import '../../../../shared/providers/permissions_provider.dart';
import '../../../../shared/utils/file_download_helper.dart';
import '../../../../shared/widgets/app_card.dart';
import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_text_field.dart';
import '../../../../shared/widgets/error_view.dart';
import '../../../../shared/widgets/page_header.dart';
import '../providers/grn_provider.dart';
import '../widgets/grn_line_items_editor.dart';
import '../widgets/grn_status_chip.dart';
class GrnDetailScreen extends ConsumerStatefulWidget {
const GrnDetailScreen({super.key, required this.grnId});
final String grnId;
@override
ConsumerState<GrnDetailScreen> createState() => _GrnDetailScreenState();
}
class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> {
bool _isWorking = false;
@override
Widget build(BuildContext context) {
final detailAsync = ref.watch(grnDetailProvider(widget.grnId));
final canEdit = ref.can('grn', PermissionAction.update);
final canExport = ref.can('grn', PermissionAction.export);
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface,
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.go(RouteConstants.grn),
),
title: detailAsync.maybeWhen(
data: (grn) => Text(grn.grnNumber ?? 'GRN #${grn.id}'),
orElse: () => const Text('GRN'),
),
),
body: detailAsync.when(
loading: () => const AppLoadingView(message: 'Loading GRN...'),
error: (e, _) => ErrorView.fromFailure(
e is Failure ? e : Failure.unknown(message: e.toString()),
onRetry: () => ref.invalidate(grnDetailProvider(widget.grnId)),
),
data: (grn) => SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
PageHeader(
title: grn.grnNumber ?? 'GRN #${grn.id}',
subtitle:
'PO ${grn.poNumber ?? ''} · ${grn.vendorName ?? ''}',
actions: [
if (canExport)
OutlinedButton.icon(
onPressed: _isWorking ? null : () => _downloadPdf(grn),
icon: const Icon(Icons.picture_as_pdf_outlined),
label: const Text('PDF'),
),
if (canEdit && grn.canEdit) ...[
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: _isWorking
? null
: () => context.push(
'${RouteConstants.grn}/${grn.id}/edit',
),
icon: const Icon(Icons.edit_outlined),
label: const Text('Edit'),
),
],
if (canEdit && grn.canCancel) ...[
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: _isWorking ? null : () => _cancel(grn),
icon: const Icon(Icons.block_outlined),
label: const Text('Cancel'),
),
],
],
),
const SizedBox(height: 16),
GrnStatusChip(status: grn.status),
if (grn.cancellationReason != null &&
grn.cancellationReason!.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
'Cancellation reason: ${grn.cancellationReason}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.error,
),
),
],
const SizedBox(height: 16),
_OverviewCard(grn: grn),
const SizedBox(height: 16),
AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Line Items',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 12),
GrnItemsTable(items: grn.items),
],
),
),
],
),
),
),
);
}
Future<void> _runWorkflow(Future<void> Function() action, String success) async {
setState(() => _isWorking = true);
try {
await action();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(success)));
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString())));
}
} finally {
if (mounted) setState(() => _isWorking = false);
}
}
Future<void> _cancel(GrnModel grn) async {
final reasonController = TextEditingController();
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Cancel GRN'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Cancel ${grn.grnNumber ?? grn.id}? This will reverse PO receipts.'),
const SizedBox(height: 16),
AppTextField(
label: 'Cancellation reason *',
controller: reasonController,
maxLines: 3,
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Close'),
),
FilledButton(
onPressed: () {
if (reasonController.text.trim().isEmpty) return;
Navigator.pop(context, true);
},
child: const Text('Cancel GRN'),
),
],
),
);
if (confirmed != true || !mounted) return;
await _runWorkflow(
() => ref.read(grnDetailProvider(widget.grnId).notifier).cancel(
cancellationReason: reasonController.text.trim(),
),
'GRN cancelled',
);
reasonController.dispose();
}
Future<void> _downloadPdf(GrnModel grn) async {
await _runWorkflow(() async {
final bytes =
await ref.read(grnDetailProvider(widget.grnId).notifier).downloadPdf();
await downloadFile(
bytes: bytes,
fileName: '${grn.grnNumber ?? 'GRN-${grn.id}'}.pdf',
);
}, 'PDF downloaded');
}
}
class _OverviewCard extends StatelessWidget {
const _OverviewCard({required this.grn});
final GrnModel grn;
@override
Widget build(BuildContext context) {
return AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Overview',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 12),
_DetailRow(label: 'GRN Date', value: DateFormatter.displayDate(grn.grnDate)),
_DetailRow(label: 'PO Number', value: grn.poNumber ?? ''),
_DetailRow(label: 'Vendor', value: grn.vendorName ?? ''),
_DetailRow(label: 'Warehouse', value: grn.warehouseName ?? ''),
_DetailRow(label: 'Vendor Invoice No', value: grn.vendorInvoiceNo ?? ''),
_DetailRow(
label: 'Vendor Invoice Date',
value: DateFormatter.displayDate(grn.vendorInvoiceDate),
),
_DetailRow(
label: 'Vendor Invoice Amount',
value: grn.vendorInvoiceAmount != null
? CurrencyFormatter.format(grn.vendorInvoiceAmount!)
: '',
),
_DetailRow(label: 'Vehicle No', value: grn.vehicleNo ?? ''),
_DetailRow(label: 'LR No', value: grn.lrNo ?? ''),
_DetailRow(label: 'LR Date', value: DateFormatter.displayDate(grn.lrDate)),
_DetailRow(label: 'Remarks', value: grn.remarks ?? ''),
],
),
);
}
}
class _DetailRow extends StatelessWidget {
const _DetailRow({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 180,
child: Text(
label,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
Expanded(child: Text(value)),
],
),
);
}
}

View File

@ -0,0 +1,562 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/constants/route_constants.dart';
import '../../../../core/errors/failure.dart';
import '../../../../core/network/api_handler.dart';
import '../../../../core/utils/formatters.dart';
import '../../../../shared/models/grn_model.dart';
import '../../../../shared/models/purchase_order_model.dart';
import '../../../../shared/models/user_management_models.dart';
import '../../../../shared/utils/navigation_utils.dart';
import '../../../../shared/widgets/app_button.dart';
import '../../../../shared/widgets/app_dropdown.dart';
import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/app_text_field.dart';
import '../../../../shared/widgets/error_view.dart';
import '../../../../shared/widgets/app_side_panel.dart';
import '../../../../shared/widgets/page_header.dart';
import '../providers/grn_lookups_provider.dart';
import '../providers/grn_provider.dart';
import '../widgets/grn_line_items_editor.dart';
class GrnFormScreen extends ConsumerStatefulWidget {
const GrnFormScreen({super.key, this.grnId});
final String? grnId;
bool get isEditing => grnId != null;
@override
ConsumerState<GrnFormScreen> createState() => _GrnFormScreenState();
}
class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
final _formKey = GlobalKey<FormState>();
final _scrollController = ScrollController();
final _vendorInvoiceNoController = TextEditingController();
final _vendorInvoiceAmountController = TextEditingController();
final _vehicleNoController = TextEditingController();
final _lrNoController = TextEditingController();
final _remarksController = TextEditingController();
DateTime? _grnDate;
DateTime? _vendorInvoiceDate;
DateTime? _lrDate;
String? _selectedPoId;
int? _warehouseId;
final List<GrnLineItemDraft> _lines = [];
bool _isSubmitting = false;
String? _populatedSignature;
@override
void initState() {
super.initState();
if (!widget.isEditing) {
_grnDate = DateTime.now();
}
}
@override
void dispose() {
_scrollController.dispose();
_vendorInvoiceNoController.dispose();
_vendorInvoiceAmountController.dispose();
_vehicleNoController.dispose();
_lrNoController.dispose();
_remarksController.dispose();
for (final line in _lines) {
line.dispose();
}
super.dispose();
}
String _grnSignature(GrnModel grn) =>
'${grn.id}:${grn.updatedAt?.toIso8601String()}';
void _populateFromGrn(GrnModel grn) {
setState(() {
_grnDate = grn.grnDate ?? DateTime.now();
_selectedPoId = grn.poId?.toString();
_warehouseId = grn.warehouseId;
_vendorInvoiceNoController.text = grn.vendorInvoiceNo ?? '';
_vendorInvoiceDate = grn.vendorInvoiceDate;
_vendorInvoiceAmountController.text =
grn.vendorInvoiceAmount?.toString() ?? '';
_vehicleNoController.text = grn.vehicleNo ?? '';
_lrNoController.text = grn.lrNo ?? '';
_lrDate = grn.lrDate;
_remarksController.text = grn.remarks ?? '';
});
}
void _loadLinesFromPo(PurchaseOrderModel po) {
for (final line in _lines) {
line.dispose();
}
setState(() {
_lines
..clear()
..addAll(draftsFromPurchaseOrder(po));
if (_warehouseId == null && po.warehouseId != null) {
_warehouseId = po.warehouseId;
}
});
}
int? _parseId(String value) => int.tryParse(value.trim());
int? _dropdownValue(int? selected, Iterable<int> validIds) {
if (selected == null) return null;
return validIds.contains(selected) ? selected : null;
}
List<AppDropdownOption<int>> _intOptions(List<FilterOptionModel> options) {
return options
.map((e) {
final id = _parseId(e.id);
if (id == null) return null;
return AppDropdownOption(value: id, label: e.name);
})
.whereType<AppDropdownOption<int>>()
.toList();
}
List<AppDropdownOption<String>> _assetCategoryOptions(
List<FilterOptionModel> categories,
) {
return categories
.map((e) => AppDropdownOption(value: e.id, label: e.name))
.toList();
}
Map<String, dynamic> _buildCreatePayload() {
final poId = int.tryParse(_selectedPoId ?? '');
return {
'grn_date': DateFormatter.toApiDate(_grnDate ?? DateTime.now()),
'po_id': poId,
'warehouse_id': _warehouseId,
if (_vendorInvoiceNoController.text.trim().isNotEmpty)
'vendor_invoice_no': _vendorInvoiceNoController.text.trim(),
if (_vendorInvoiceDate != null)
'vendor_invoice_date': DateFormatter.toApiDate(_vendorInvoiceDate!),
if (_vendorInvoiceAmountController.text.trim().isNotEmpty)
'vendor_invoice_amount':
double.tryParse(_vendorInvoiceAmountController.text.trim()),
if (_vehicleNoController.text.trim().isNotEmpty)
'vehicle_no': _vehicleNoController.text.trim(),
if (_lrNoController.text.trim().isNotEmpty) 'lr_no': _lrNoController.text.trim(),
if (_lrDate != null) 'lr_date': DateFormatter.toApiDate(_lrDate!),
if (_remarksController.text.trim().isNotEmpty)
'remarks': _remarksController.text.trim(),
'items': _lines.map((line) => line.toPayload()).toList(),
};
}
Map<String, dynamic> _buildUpdatePayload() {
return {
if (_vendorInvoiceNoController.text.trim().isNotEmpty)
'vendor_invoice_no': _vendorInvoiceNoController.text.trim(),
if (_vendorInvoiceDate != null)
'vendor_invoice_date': DateFormatter.toApiDate(_vendorInvoiceDate!),
if (_vendorInvoiceAmountController.text.trim().isNotEmpty)
'vendor_invoice_amount':
double.tryParse(_vendorInvoiceAmountController.text.trim()),
if (_vehicleNoController.text.trim().isNotEmpty)
'vehicle_no': _vehicleNoController.text.trim(),
if (_lrNoController.text.trim().isNotEmpty) 'lr_no': _lrNoController.text.trim(),
if (_lrDate != null) 'lr_date': DateFormatter.toApiDate(_lrDate!),
if (_remarksController.text.trim().isNotEmpty)
'remarks': _remarksController.text.trim(),
};
}
String? _lineItemsError() {
if (_lines.isEmpty) return 'Add at least one line item with quantity';
for (final line in _lines) {
if (line.currentQty <= 0) {
return 'Enter quantity for line ${line.lineNo}';
}
if (line.acceptedQty < 0) {
return 'Accepted quantity must be zero or more for line ${line.lineNo}';
}
}
return null;
}
Future<void> _submit() async {
final formState = _formKey.currentState;
if (formState == null) return;
if (!formState.validate()) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Please fix the highlighted errors before saving'),
),
);
return;
}
if (!widget.isEditing) {
if (_selectedPoId == null || _warehouseId == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please select PO and warehouse')),
);
return;
}
final lineError = _lineItemsError();
if (lineError != null) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(lineError)));
return;
}
}
setState(() => _isSubmitting = true);
try {
final GrnModel saved;
if (widget.isEditing) {
saved = await ref
.read(grnFormProvider(widget.grnId).notifier)
.submitUpdate(widget.grnId!, _buildUpdatePayload());
} else {
saved = await ref
.read(grnFormProvider(null).notifier)
.submitCreate(_buildCreatePayload());
}
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(widget.isEditing ? 'GRN updated' : 'GRN created'),
),
);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final destination = widget.isEditing
? '${RouteConstants.grn}/${saved.id}'
: RouteConstants.grn;
goAndDismissOverlays(context, destination);
});
} catch (e) {
if (!mounted) return;
final message =
e is Failure ? validationErrorMessage(e) : e.toString();
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
} finally {
if (mounted) setState(() => _isSubmitting = false);
}
}
Future<void> _pickDate({
required DateTime? current,
required ValueChanged<DateTime?> onPicked,
}) async {
final picked = await showDatePicker(
context: context,
initialDate: current ?? DateTime.now(),
firstDate: DateTime(2020),
lastDate: DateTime(2100),
);
if (picked != null) onPicked(picked);
}
@override
Widget build(BuildContext context) {
final lookupsAsync = ref.watch(grnLookupsProvider);
final existingAsync = widget.isEditing
? ref.watch(grnFormProvider(widget.grnId))
: const AsyncData<GrnModel?>(null);
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface,
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.go(
widget.isEditing
? '${RouteConstants.grn}/${widget.grnId}'
: RouteConstants.grn,
),
),
title: Text(widget.isEditing ? 'Edit GRN' : 'Create GRN'),
),
body: lookupsAsync.when(
loading: () => const AppLoadingView(message: 'Loading form...'),
error: (e, _) => ErrorView.fromFailure(
e is Failure ? e : Failure.unknown(message: e.toString()),
onRetry: () => ref.invalidate(grnLookupsProvider),
),
data: (lookups) => existingAsync.when(
loading: () => const AppLoadingView(message: 'Loading GRN...'),
error: (e, _) => ErrorView.fromFailure(
e is Failure ? e : Failure.unknown(message: e.toString()),
onRetry: () => ref.invalidate(grnFormProvider(widget.grnId)),
),
data: (existing) => _buildFormBody(lookups: lookups, existing: existing),
),
),
);
}
Widget _buildFormBody({
required GrnLookups lookups,
GrnModel? existing,
}) {
if (widget.isEditing && existing != null) {
final signature = _grnSignature(existing);
if (_populatedSignature != signature) {
_populatedSignature = signature;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _populateFromGrn(existing);
});
}
}
if (widget.isEditing && existing != null && !existing.canEdit) {
return ErrorView.fromFailure(
const Failure.validation(message: 'This GRN cannot be edited'),
onRetry: () => context.go('${RouteConstants.grn}/${existing.id}'),
);
}
final warehouseIds =
lookups.warehouses.map((e) => _parseId(e.id)).whereType<int>();
final poOptions = lookups.receivablePurchaseOrders
.map(
(po) => AppDropdownOption(
value: po.id,
label: po.poNo ?? 'PO #${po.id}',
),
)
.toList();
return SingleChildScrollView(
controller: _scrollController,
padding: const EdgeInsets.all(24),
child: Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1200),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (!widget.isEditing)
const PageHeader(
title: 'Create Goods Received Note',
subtitle: 'Receive items against an approved purchase order',
)
else if (existing?.grnNumber != null)
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Text(
existing!.grnNumber!,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
FormRowThree(
children: [
_DateField(
label: 'GRN Date *',
value: _grnDate,
enabled: !widget.isEditing,
onTap: widget.isEditing
? null
: () => _pickDate(
current: _grnDate,
onPicked: (d) => setState(() => _grnDate = d),
),
),
if (!widget.isEditing)
AppSearchableDropdown<String>(
label: 'Purchase Order *',
value: _selectedPoId,
searchHint: 'Search PO...',
options: poOptions,
onChanged: (v) async {
setState(() => _selectedPoId = v);
if (v == null) {
for (final line in _lines) {
line.dispose();
}
setState(() => _lines.clear());
return;
}
try {
final po = await ref.read(
grnPurchaseOrderProvider(v).future,
);
if (mounted && po != null) _loadLinesFromPo(po);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString())),
);
}
},
validator: (v) =>
v == null ? 'Purchase order is required' : null,
)
else
InputDecorator(
decoration: const InputDecoration(
labelText: 'Purchase Order',
),
child: Text(existing?.poNumber ?? ''),
),
AppSearchableDropdown<int>(
label: 'Warehouse *',
value: _dropdownValue(_warehouseId, warehouseIds),
searchHint: 'Search warehouse...',
options: _intOptions(lookups.warehouses),
onChanged: widget.isEditing
? (_) {}
: (v) => setState(() => _warehouseId = v),
validator: widget.isEditing
? null
: (v) => v == null ? 'Warehouse is required' : null,
enabled: !widget.isEditing,
),
],
),
FormRowThree(
children: [
AppTextField(
label: 'Vendor Invoice No',
controller: _vendorInvoiceNoController,
),
_DateField(
label: 'Vendor Invoice Date',
value: _vendorInvoiceDate,
onTap: () => _pickDate(
current: _vendorInvoiceDate,
onPicked: (d) =>
setState(() => _vendorInvoiceDate = d),
),
),
AppTextField(
label: 'Vendor Invoice Amount',
controller: _vendorInvoiceAmountController,
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
),
],
),
FormRowThree(
children: [
AppTextField(
label: 'Vehicle No',
controller: _vehicleNoController,
),
AppTextField(
label: 'LR No',
controller: _lrNoController,
),
_DateField(
label: 'LR Date',
value: _lrDate,
onTap: () => _pickDate(
current: _lrDate,
onPicked: (d) => setState(() => _lrDate = d),
),
),
],
),
AppTextField(
label: 'Remarks',
controller: _remarksController,
maxLines: 3,
),
if (!widget.isEditing) ...[
const SizedBox(height: 24),
Text(
'Line Items',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 12),
GrnLineItemsEditor(
items: _lines,
assetCategoryOptions:
_assetCategoryOptions(lookups.assetCategories),
onChanged: () => setState(() {}),
),
] else ...[
const SizedBox(height: 16),
Text(
'Line items cannot be changed after posting.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: AppButton(
label: widget.isEditing ? 'Save Changes' : 'Create GRN',
onPressed: _isSubmitting ? null : _submit,
isLoading: _isSubmitting,
),
),
],
),
],
),
),
],
),
),
),
);
}
}
class _DateField extends StatelessWidget {
const _DateField({
required this.label,
required this.value,
this.onTap,
this.enabled = true,
});
final String label;
final DateTime? value;
final VoidCallback? onTap;
final bool enabled;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: enabled ? onTap : null,
borderRadius: BorderRadius.circular(8),
child: InputDecorator(
decoration: InputDecoration(
labelText: label,
suffixIcon: const Icon(Icons.calendar_today_outlined, size: 18),
enabled: enabled,
),
child: Text(
value != null ? DateFormatter.displayDate(value) : 'Select date',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: value != null
? null
: Theme.of(context).hintColor,
),
),
),
);
}
}

View File

@ -0,0 +1,349 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/constants/enums.dart';
import '../../../../core/constants/route_constants.dart';
import '../../../../core/errors/failure.dart';
import '../../../../core/utils/formatters.dart';
import '../../../../core/utils/responsive_utils.dart';
import '../../../../shared/models/grn_model.dart';
import '../../../../shared/providers/permissions_provider.dart';
import '../../../../shared/widgets/app_card.dart';
import '../../../../shared/widgets/app_data_table.dart';
import '../../../../shared/widgets/app_dropdown.dart';
import '../../../../shared/widgets/app_empty_state.dart';
import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_pagination.dart';
import '../../../../shared/widgets/app_search_field.dart';
import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/can_permission.dart';
import '../../../../shared/widgets/app_table_action_icon.dart';
import '../../../../shared/widgets/app_table_shell.dart';
import '../../../../shared/widgets/error_view.dart';
import '../../../../shared/widgets/page_header.dart';
import '../providers/grn_provider.dart';
import '../widgets/grn_status_chip.dart';
class GrnListScreen extends ConsumerStatefulWidget {
const GrnListScreen({super.key});
@override
ConsumerState<GrnListScreen> createState() => _GrnListScreenState();
}
class _GrnListScreenState extends ConsumerState<GrnListScreen> {
final _searchController = TextEditingController();
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final listAsync = ref.watch(grnListProvider);
final canEdit = ref.can('grn', PermissionAction.update);
return Padding(
padding: const EdgeInsets.all(24),
child: listAsync.when(
loading: () => const AppLoadingView(message: 'Loading GRNs...'),
error: (error, _) => ErrorView.fromFailure(
error is Failure ? error : Failure.unknown(message: error.toString()),
onRetry: () => ref.invalidate(grnListProvider),
),
data: (state) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
PageHeader(
title: 'Goods Received Notes',
subtitle: 'Record and track purchase order receipts',
actions: [
CanPermission(
module: 'grn',
action: PermissionAction.create,
child: ElevatedButton.icon(
onPressed: () => context.go(RouteConstants.grnAdd),
icon: const Icon(Icons.add),
label: const Text('Create GRN'),
),
),
],
),
const SizedBox(height: 16),
Expanded(
child: AppTableShell(
toolbar: LayoutBuilder(
builder: (context, constraints) {
return _FiltersBar(
searchController: _searchController,
query: state.query,
wrapped: constraints.maxWidth < 900,
onSearch: ref.read(grnListProvider.notifier).setSearch,
onStatusChanged:
ref.read(grnListProvider.notifier).setStatusFilter,
);
},
),
footer: AppPagination(
currentPage: state.query.page,
totalPages: state.totalPages,
totalItems: state.total,
pageSize: state.query.limit,
onPageChanged: ref.read(grnListProvider.notifier).setPage,
onPageSizeChanged: ref.read(grnListProvider.notifier).setPageSize,
),
child: RefreshIndicator(
onRefresh: () => ref.read(grnListProvider.notifier).refresh(),
child: state.grns.isEmpty
? ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: const [
SizedBox(
height: 240,
child: AppEmptyState(
title: 'No GRNs found',
description:
'Try adjusting filters or create a new goods received note.',
icon: Icons.inventory_2_outlined,
),
),
],
)
: context.isMobile
? _GrnCardList(
grns: state.grns,
onView: _viewGrn,
onEdit: canEdit ? _editGrn : null,
)
: _GrnDataTable(
grns: state.grns,
onView: _viewGrn,
onEdit: canEdit ? _editGrn : null,
),
),
),
),
],
),
),
);
}
void _viewGrn(GrnModel grn) {
context.push('${RouteConstants.grn}/${grn.id}');
}
void _editGrn(GrnModel grn) {
if (!grn.canEdit) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Only posted GRNs can be edited')),
);
return;
}
context.push('${RouteConstants.grn}/${grn.id}/edit');
}
}
class _FiltersBar extends StatelessWidget {
const _FiltersBar({
required this.searchController,
required this.query,
required this.wrapped,
required this.onSearch,
required this.onStatusChanged,
});
final TextEditingController searchController;
final GrnListQuery query;
final bool wrapped;
final ValueChanged<String> onSearch;
final ValueChanged<String?> onStatusChanged;
@override
Widget build(BuildContext context) {
final searchField = SizedBox(
width: wrapped ? double.infinity : null,
child: AppSearchField(
controller: searchController,
hint: 'Search GRN number, PO, vendor...',
onChanged: onSearch,
),
);
final statusFilter = SizedBox(
width: wrapped ? double.infinity : 180,
child: AppSearchableDropdown<String?>(
label: 'Status',
value: query.status,
searchHint: 'Search status...',
isDense: true,
options: [
const AppDropdownOption(value: null, label: 'All statuses'),
...grnStatusOptions.map(
(e) => AppDropdownOption(value: e.$1, label: e.$2),
),
],
onChanged: onStatusChanged,
),
);
if (wrapped) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
searchField,
const SizedBox(height: 12),
statusFilter,
],
);
}
return Row(
children: [
Expanded(flex: 3, child: searchField),
const SizedBox(width: 12),
Expanded(flex: 2, child: statusFilter),
],
);
}
}
class _GrnDataTable extends StatelessWidget {
const _GrnDataTable({
required this.grns,
required this.onView,
this.onEdit,
});
final List<GrnModel> grns;
final ValueChanged<GrnModel> onView;
final ValueChanged<GrnModel>? onEdit;
@override
Widget build(BuildContext context) {
return AppDataTable<GrnModel>(
wrapInCard: false,
columns: [
AppDataColumn(
label: 'GRN Number',
flex: 2,
cellBuilder: (_, grn) => Text(grn.grnNumber ?? ''),
),
AppDataColumn(
label: 'Date',
flex: 1,
cellBuilder: (_, grn) => Text(DateFormatter.displayDate(grn.grnDate)),
),
AppDataColumn(
label: 'PO Number',
flex: 2,
cellBuilder: (_, grn) => Text(grn.poNumber ?? ''),
),
AppDataColumn(
label: 'Vendor',
flex: 2,
cellBuilder: (_, grn) => Text(grn.vendorName ?? ''),
),
AppDataColumn(
label: 'Warehouse',
flex: 2,
cellBuilder: (_, grn) => Text(grn.warehouseName ?? ''),
),
AppDataColumn(
label: 'Status',
flex: 1,
cellBuilder: (_, grn) => GrnStatusChip(status: grn.status, compact: true),
),
AppDataColumn(
label: 'Actions',
flex: 1,
cellBuilder: (_, grn) => AppTableActions(
children: [
AppTableActionIcon(
icon: Icons.visibility_outlined,
tooltip: 'View',
onPressed: () => onView(grn),
),
if (onEdit != null && grn.canEdit)
AppTableActionIcon(
icon: Icons.edit_outlined,
tooltip: 'Edit',
onPressed: () => onEdit!(grn),
),
],
),
),
],
rows: grns,
);
}
}
class _GrnCardList extends StatelessWidget {
const _GrnCardList({
required this.grns,
required this.onView,
this.onEdit,
});
final List<GrnModel> grns;
final ValueChanged<GrnModel> onView;
final ValueChanged<GrnModel>? onEdit;
@override
Widget build(BuildContext context) {
return ListView.separated(
physics: const AlwaysScrollableScrollPhysics(),
itemCount: grns.length,
separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (context, index) {
final grn = grns[index];
return AppCard(
onTap: () => onView(grn),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
grn.grnNumber ?? 'GRN #${grn.id}',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
GrnStatusChip(status: grn.status, compact: true),
],
),
const SizedBox(height: 8),
Text('PO: ${grn.poNumber ?? ''}'),
Text('Vendor: ${grn.vendorName ?? ''}'),
Text('Date: ${DateFormatter.displayDate(grn.grnDate)}'),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
AppTableActionIcon(
icon: Icons.visibility_outlined,
tooltip: 'View',
onPressed: () => onView(grn),
),
if (onEdit != null && grn.canEdit)
AppTableActionIcon(
icon: Icons.edit_outlined,
tooltip: 'Edit',
onPressed: () => onEdit!(grn),
),
],
),
],
),
);
},
);
}
}

View File

@ -0,0 +1,358 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../shared/models/grn_model.dart';
import '../../../../shared/models/purchase_order_model.dart';
import '../../../../shared/widgets/app_dropdown.dart';
import '../../../../shared/widgets/app_text_field.dart';
String _formatQty(double value) {
if (value % 1 == 0) return value.toInt().toString();
return value.toStringAsFixed(2);
}
class GrnLineItemDraft {
GrnLineItemDraft({
required this.poItemId,
required this.lineNo,
required this.itemName,
required this.orderedQty,
required this.receivedQty,
required this.remainingQty,
TextEditingController? acceptedQtyController,
TextEditingController? damagedQtyController,
TextEditingController? shortQtyController,
TextEditingController? excessQtyController,
TextEditingController? batchNoController,
TextEditingController? expiryDateController,
TextEditingController? remarksController,
this.assetCategoryId,
}) : acceptedQtyController = acceptedQtyController ??
TextEditingController(
text: remainingQty > 0 ? _formatQty(remainingQty) : '',
),
damagedQtyController = damagedQtyController ?? TextEditingController(),
shortQtyController = shortQtyController ?? TextEditingController(),
excessQtyController = excessQtyController ?? TextEditingController(),
batchNoController = batchNoController ?? TextEditingController(),
expiryDateController = expiryDateController ?? TextEditingController(),
remarksController = remarksController ?? TextEditingController();
final String poItemId;
final int lineNo;
final String itemName;
final double orderedQty;
final double receivedQty;
final double remainingQty;
final TextEditingController acceptedQtyController;
final TextEditingController damagedQtyController;
final TextEditingController shortQtyController;
final TextEditingController excessQtyController;
final TextEditingController batchNoController;
final TextEditingController expiryDateController;
final TextEditingController remarksController;
String? assetCategoryId;
double get acceptedQty => double.tryParse(acceptedQtyController.text.trim()) ?? 0;
double get damagedQty => double.tryParse(damagedQtyController.text.trim()) ?? 0;
double get shortQty => double.tryParse(shortQtyController.text.trim()) ?? 0;
double get excessQty => double.tryParse(excessQtyController.text.trim()) ?? 0;
double get currentQty => acceptedQty + damagedQty + shortQty + excessQty;
void dispose() {
acceptedQtyController.dispose();
damagedQtyController.dispose();
shortQtyController.dispose();
excessQtyController.dispose();
batchNoController.dispose();
expiryDateController.dispose();
remarksController.dispose();
}
Map<String, dynamic> toPayload() {
final poItemIdValue = int.tryParse(poItemId) ?? poItemId;
return {
'po_item_id': poItemIdValue,
'line_no': lineNo,
'current_qty': currentQty,
'accepted_qty': acceptedQty,
if (damagedQty > 0) 'damaged_qty': damagedQty,
if (shortQty > 0) 'short_qty': shortQty,
if (excessQty > 0) 'excess_qty': excessQty,
if (batchNoController.text.trim().isNotEmpty)
'batch_no': batchNoController.text.trim(),
if (expiryDateController.text.trim().isNotEmpty)
'expiry_date': expiryDateController.text.trim(),
if (assetCategoryId != null && assetCategoryId!.isNotEmpty)
'asset_category_id': int.tryParse(assetCategoryId!) ?? assetCategoryId,
if (remarksController.text.trim().isNotEmpty)
'remarks': remarksController.text.trim(),
};
}
}
List<GrnLineItemDraft> draftsFromPurchaseOrder(PurchaseOrderModel po) {
return po.items.map((item) {
final ordered = item.orderedQty ?? 0;
final received = item.receivedQty ?? 0;
final remaining = (ordered - received).clamp(0.0, double.infinity);
return GrnLineItemDraft(
poItemId: item.id,
lineNo: item.lineNo ?? 1,
itemName: item.itemName ?? 'Item ${item.lineNo}',
orderedQty: ordered,
receivedQty: received,
remainingQty: remaining,
);
}).toList();
}
class GrnLineItemsEditor extends StatelessWidget {
const GrnLineItemsEditor({
super.key,
required this.items,
required this.onChanged,
this.assetCategoryOptions = const [],
this.readOnly = false,
});
final List<GrnLineItemDraft> items;
final VoidCallback onChanged;
final List<AppDropdownOption<String>> assetCategoryOptions;
final bool readOnly;
@override
Widget build(BuildContext context) {
if (items.isEmpty) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: AppColors.lightSurface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.textSecondary.withValues(alpha: 0.2)),
),
child: Text(
readOnly
? 'No line items.'
: 'Select a purchase order to load receivable line items.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: AppColors.textSecondary,
),
),
);
}
return Column(
children: [
for (var i = 0; i < items.length; i++)
Padding(
padding: EdgeInsets.only(bottom: i == items.length - 1 ? 0 : 12),
child: _GrnLineItemCard(
item: items[i],
assetCategoryOptions: assetCategoryOptions,
readOnly: readOnly,
onChanged: onChanged,
),
),
],
);
}
}
class _GrnLineItemCard extends StatelessWidget {
const _GrnLineItemCard({
required this.item,
required this.onChanged,
required this.assetCategoryOptions,
required this.readOnly,
});
final GrnLineItemDraft item;
final VoidCallback onChanged;
final List<AppDropdownOption<String>> assetCategoryOptions;
final bool readOnly;
static final _qtyFormatters = [
FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,4}')),
];
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.lightSurface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.textSecondary.withValues(alpha: 0.2)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'Line ${item.lineNo}',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
item.itemName,
style: Theme.of(context).textTheme.bodyMedium,
),
),
],
),
const SizedBox(height: 8),
Text(
'Ordered: ${_formatQty(item.orderedQty)} · '
'Already received: ${_formatQty(item.receivedQty)} · '
'Remaining: ${_formatQty(item.remainingQty)}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: AppColors.textSecondary,
),
),
if (!readOnly) ...[
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: AppTextField(
label: 'Accepted Qty *',
controller: item.acceptedQtyController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: _qtyFormatters,
onChanged: (_) => onChanged(),
),
),
const SizedBox(width: 12),
Expanded(
child: AppTextField(
label: 'Damaged Qty',
controller: item.damagedQtyController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: _qtyFormatters,
onChanged: (_) => onChanged(),
),
),
const SizedBox(width: 12),
Expanded(
child: AppTextField(
label: 'Short Qty',
controller: item.shortQtyController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: _qtyFormatters,
onChanged: (_) => onChanged(),
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: AppTextField(
label: 'Excess Qty',
controller: item.excessQtyController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: _qtyFormatters,
onChanged: (_) => onChanged(),
),
),
const SizedBox(width: 12),
Expanded(
child: AppTextField(
label: 'Batch No',
controller: item.batchNoController,
onChanged: (_) => onChanged(),
),
),
const SizedBox(width: 12),
Expanded(
child: AppTextField(
label: 'Expiry Date',
controller: item.expiryDateController,
hint: 'YYYY-MM-DD',
onChanged: (_) => onChanged(),
),
),
],
),
if (assetCategoryOptions.isNotEmpty) ...[
const SizedBox(height: 12),
AppDropdown<String>(
label: 'Asset Category',
value: item.assetCategoryId,
options: assetCategoryOptions,
onChanged: (v) {
item.assetCategoryId = v;
onChanged();
},
),
],
const SizedBox(height: 12),
AppTextField(
label: 'Remarks',
controller: item.remarksController,
maxLines: 2,
onChanged: (_) => onChanged(),
),
] else ...[
const SizedBox(height: 8),
Text(
'Accepted: ${_formatQty(item.acceptedQty)} · '
'Current: ${_formatQty(item.currentQty)}',
style: Theme.of(context).textTheme.bodySmall,
),
],
],
),
);
}
}
class GrnItemsTable extends StatelessWidget {
const GrnItemsTable({super.key, required this.items});
final List<GrnItemModel> items;
@override
Widget build(BuildContext context) {
if (items.isEmpty) {
return const SizedBox.shrink();
}
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: DataTable(
headingRowColor: WidgetStateProperty.all(AppColors.lightSurface),
columns: const [
DataColumn(label: Text('Line')),
DataColumn(label: Text('Item')),
DataColumn(label: Text('Accepted')),
DataColumn(label: Text('Damaged')),
DataColumn(label: Text('Short')),
DataColumn(label: Text('Excess')),
DataColumn(label: Text('Batch')),
],
rows: items.map((item) {
return DataRow(
cells: [
DataCell(Text('${item.lineNo}')),
DataCell(Text(item.itemName ?? '')),
DataCell(Text(_formatQty(item.acceptedQty ?? 0))),
DataCell(Text(_formatQty(item.damagedQty ?? 0))),
DataCell(Text(_formatQty(item.shortQty ?? 0))),
DataCell(Text(_formatQty(item.excessQty ?? 0))),
DataCell(Text(item.batchNo ?? '')),
],
);
}).toList(),
),
);
}
}

View File

@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import '../../../../shared/models/grn_model.dart';
class GrnStatusChip extends StatelessWidget {
const GrnStatusChip({
super.key,
required this.status,
this.compact = false,
});
final String status;
final bool compact;
@override
Widget build(BuildContext context) {
final (color, label) = _resolveStatus(status);
return Chip(
label: Text(
label,
style: TextStyle(
color: color,
fontSize: compact ? 11 : 12,
fontWeight: FontWeight.w600,
),
),
backgroundColor: color.withValues(alpha: 0.12),
side: BorderSide(color: color.withValues(alpha: 0.3)),
visualDensity: compact ? VisualDensity.compact : VisualDensity.standard,
padding: compact ? EdgeInsets.zero : null,
);
}
(Color, String) _resolveStatus(String raw) {
switch (raw.toUpperCase()) {
case 'POSTED':
return (Colors.green.shade700, grnStatusLabel(raw));
case 'CANCELLED':
return (Colors.grey.shade700, grnStatusLabel(raw));
default:
return (Colors.blueGrey, grnStatusLabel(raw));
}
}
}

View File

@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
enum MasterFieldType { text, number, boolean, dropdown }
const brandTypes = ['OWN', 'OEM', 'THIRD_PARTY'];
class MasterFieldDef {
const MasterFieldDef({
required this.key,
@ -10,6 +12,7 @@ class MasterFieldDef {
this.required = false,
this.showInList = false,
this.optionsMasterKey,
this.staticOptions,
this.multiline = false,
});
@ -20,6 +23,8 @@ class MasterFieldDef {
final bool showInList;
/// Master key used to populate dropdown options (e.g. `plants` for plant_id).
final String? optionsMasterKey;
/// Fixed dropdown choices (e.g. brand type) no API lookup.
final List<String>? staticOptions;
final bool multiline;
}
@ -209,7 +214,14 @@ const masterDefinitions = <MasterDefinition>[
fields: [
MasterFieldDef(key: 'code', label: 'Code', required: true, showInList: true),
MasterFieldDef(key: 'name', label: 'Name', required: true, showInList: true),
MasterFieldDef(key: 'brand_type', label: 'Brand Type', required: true, showInList: true),
MasterFieldDef(
key: 'brand_type',
label: 'Brand Type',
type: MasterFieldType.dropdown,
required: true,
showInList: true,
staticOptions: brandTypes,
),
MasterFieldDef(key: 'contact_person', label: 'Contact Person'),
MasterFieldDef(key: 'phone', label: 'Phone'),
MasterFieldDef(key: 'email', label: 'Email'),
@ -412,6 +424,16 @@ List<String> get masterCategories =>
masterDefinitions.map((def) => def.category).toSet().toList();
String masterRecordLabel(Map<String, dynamic> row) {
final ratePct = row['rate_pct'];
if (ratePct != null) {
final rate = ratePct is num
? ratePct.toDouble()
: double.tryParse(ratePct.toString());
if (rate != null) {
return rate % 1 == 0 ? '${rate.toInt()}%' : '$rate%';
}
}
for (final key in ['name', 'item_name', 'code', 'item_code', 'description']) {
final value = row[key];
if (value != null && value.toString().trim().isNotEmpty) {
@ -432,6 +454,9 @@ String masterCellValue(Map<String, dynamic> row, MasterFieldDef field) {
if (field.type == MasterFieldType.dropdown) {
final label = row['${field.key}_label'];
if (label != null && label.toString().isNotEmpty) return label.toString();
if (field.staticOptions != null) {
return value.toString().replaceAll('_', ' ');
}
}
return value.toString();

View File

@ -9,6 +9,7 @@ import '../../../../shared/utils/file_download_helper.dart';
import '../../../../shared/providers/permissions_provider.dart';
import '../../../../shared/widgets/app_card.dart';
import '../../../../shared/widgets/app_confirmation_dialog.dart';
import '../../../../shared/widgets/app_data_table.dart';
import '../../../../shared/widgets/app_empty_state.dart';
import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_search_export_bar.dart';
@ -327,181 +328,67 @@ class _MasterListTable extends StatelessWidget {
final ValueChanged<String> onEdit;
final ValueChanged<Map<String, dynamic>> onDelete;
static const double _horizontalPadding = 16;
static const double _columnSpacing = 16;
static const double _statusWidth = 110;
static const double _actionsWidth = 96;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AppDataTable<Map<String, dynamic>>(
wrapInCard: false,
columns: [
...definition.listFields.map(
(field) => AppDataColumn<Map<String, dynamic>>(
label: field.label,
flex: _columnFlex(field),
cellBuilder: (_, row) => Text(masterCellValue(row, field)),
),
),
AppDataColumn(
label: 'Status',
flex: 1,
cellBuilder: (_, row) => AppStatusChip(
status: masterStatusValue(row),
compact: true,
),
),
AppDataColumn(
label: 'Actions',
flex: 1,
alignment: Alignment.centerRight,
cellBuilder: (_, row) {
final id = row['id']?.toString();
return Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (canEdit)
IconButton(
tooltip: 'Edit',
icon: const Icon(Icons.edit_outlined),
onPressed: id == null ? null : () => onEdit(id),
),
if (canDelete)
IconButton(
tooltip: 'Delete',
icon: Icon(
Icons.delete_outline,
color: theme.colorScheme.error,
),
onPressed: isDeleting ? null : () => onDelete(row),
),
],
);
},
),
],
rows: items,
);
}
int _columnFlex(MasterFieldDef field) {
return switch (field.key) {
'code' || 'item_code' || 'series_code' => 1,
'name' ||
'item_name' ||
'description' ||
'term_name' =>
3,
'name' || 'item_name' || 'description' || 'term_name' => 3,
_ => 2,
};
}
TextStyle? _headerStyle(BuildContext context) {
return Theme.of(context).textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.w700,
letterSpacing: 0.6,
color: Theme.of(context).colorScheme.onSurfaceVariant,
);
}
Widget _tableRow({
required BuildContext context,
required List<Widget> fieldCells,
required Widget statusCell,
required Widget actionsCell,
Color? backgroundColor,
BoxDecoration? decoration,
EdgeInsetsGeometry padding = const EdgeInsets.symmetric(
horizontal: _horizontalPadding,
vertical: 12,
),
}) {
final fields = definition.listFields;
final children = <Widget>[];
for (var i = 0; i < fields.length; i++) {
if (i > 0) {
children.add(const SizedBox(width: _columnSpacing));
}
children.add(
Expanded(
flex: _columnFlex(fields[i]),
child: Align(
alignment: Alignment.centerLeft,
child: fieldCells[i],
),
),
);
}
children.addAll([
const SizedBox(width: _columnSpacing),
SizedBox(
width: _statusWidth,
child: Align(
alignment: Alignment.centerLeft,
child: statusCell,
),
),
const SizedBox(width: _columnSpacing),
SizedBox(
width: _actionsWidth,
child: Align(
alignment: Alignment.centerLeft,
child: actionsCell,
),
),
]);
return Container(
width: double.infinity,
padding: padding,
color: backgroundColor,
decoration: decoration,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: children,
),
);
}
Widget _headerRow(BuildContext context, Color headerColor) {
return _tableRow(
context: context,
backgroundColor: headerColor,
fieldCells: definition.listFields
.map(
(field) => Text(
field.label.toUpperCase(),
style: _headerStyle(context),
),
)
.toList(),
statusCell: Text('STATUS', style: _headerStyle(context)),
actionsCell: Text('ACTIONS', style: _headerStyle(context)),
);
}
Widget _dataRow(BuildContext context, Map<String, dynamic> row, ThemeData theme) {
final id = row['id']?.toString();
return _tableRow(
context: context,
padding: const EdgeInsets.symmetric(
horizontal: _horizontalPadding,
vertical: 4,
),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: theme.colorScheme.outline.withValues(alpha: 0.08),
),
),
),
fieldCells: definition.listFields
.map(
(field) => Text(masterCellValue(row, field)),
)
.toList(),
statusCell: AppStatusChip(
status: masterStatusValue(row),
compact: true,
),
actionsCell: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (canEdit)
IconButton(
tooltip: 'Edit',
icon: const Icon(Icons.edit_outlined, size: 20),
onPressed: id == null ? null : () => onEdit(id),
),
if (canDelete)
IconButton(
tooltip: 'Delete',
icon: Icon(
Icons.delete_outline,
size: 20,
color: theme.colorScheme.error,
),
onPressed: isDeleting ? null : () => onDelete(row),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final headerColor = theme.colorScheme.surfaceContainerHighest
.withValues(alpha: 0.4);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_headerRow(context, headerColor),
const Divider(height: 1),
Expanded(
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: items
.map((row) => _dataRow(context, row, theme))
.toList(),
),
),
),
],
);
}
}

View File

@ -95,15 +95,20 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
);
case MasterFieldType.dropdown:
final options = formState.dropdownOptions[field.optionsMasterKey] ??
const <Map<String, dynamic>>[];
final dropdownOptions = <AppDropdownOption<String>>[];
for (final item in options) {
final id = item['id']?.toString();
if (id == null || id.isEmpty) continue;
dropdownOptions.add(
AppDropdownOption(value: id, label: masterRecordLabel(item)),
);
final List<AppDropdownOption<String>> dropdownOptions;
if (field.staticOptions != null) {
dropdownOptions = stringDropdownOptions(field.staticOptions!);
} else {
final options = formState.dropdownOptions[field.optionsMasterKey] ??
const <Map<String, dynamic>>[];
dropdownOptions = <AppDropdownOption<String>>[];
for (final item in options) {
final id = item['id']?.toString();
if (id == null || id.isEmpty) continue;
dropdownOptions.add(
AppDropdownOption(value: id, label: masterRecordLabel(item)),
);
}
}
return AppSearchableDropdown<String>(
@ -123,7 +128,7 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
case MasterFieldType.number:
return TextFormField(
key: ValueKey('${field.key}-${value ?? ''}'),
key: ValueKey(field.key),
initialValue: value?.toString(),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: InputDecoration(labelText: _fieldLabel(field)),
@ -134,19 +139,44 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
);
case MasterFieldType.text:
final formatters = Validators.inputFormattersForFieldKey(field.key);
return TextFormField(
key: ValueKey('${field.key}-${value ?? ''}'),
key: ValueKey(field.key),
initialValue: value?.toString(),
maxLines: field.multiline ? 3 : 1,
keyboardType: _keyboardTypeForFieldKey(field.key),
inputFormatters: formatters.isEmpty ? null : formatters,
decoration: InputDecoration(labelText: _fieldLabel(field)),
validator: field.required
? (v) => Validators.required(v, fieldName: field.label)
: null,
validator: (v) => Validators.forFieldKey(
field.key,
v,
required: field.required,
fieldName: field.label,
),
onChanged: (text) => notifier.updateValue(field.key, text),
);
}
}
TextInputType? _keyboardTypeForFieldKey(String key) {
final normalizedKey = key.trim().toLowerCase();
if (normalizedKey == 'phone' || normalizedKey == 'mobile') {
return TextInputType.phone;
}
if (normalizedKey == 'email') {
return TextInputType.emailAddress;
}
if (normalizedKey == 'pincode' ||
normalizedKey == 'postal_code' ||
normalizedKey == 'zip') {
return TextInputType.number;
}
if (normalizedKey == 'account_number' || normalizedKey == 'account_no') {
return TextInputType.number;
}
return null;
}
List<Widget> _buildFieldLayout(
BuildContext context,
MasterFormState formState,

View File

@ -2,6 +2,7 @@ import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/constants/api_endpoints.dart';
import '../../../../core/constants/app_constants.dart';
import '../../../../core/network/dio_client.dart';
import '../../../../shared/models/user_management_models.dart';
@ -24,10 +25,35 @@ class MasterRemoteDataSource {
Future<List<FilterOptionModel>> listDesignations() =>
_listOptions(ApiEndpoints.designations);
Future<List<FilterOptionModel>> listPaymentTerms() =>
_listOptions(ApiEndpoints.paymentTerms);
Future<List<FilterOptionModel>> listDeliveryTerms() =>
_listOptions(ApiEndpoints.deliveryTerms);
Future<List<FilterOptionModel>> listWarehouses() =>
_listOptions(ApiEndpoints.warehouses);
Future<List<FilterOptionModel>> listBrands() =>
_listOptions(ApiEndpoints.brands);
Future<List<FilterOptionModel>> listUom() => _listOptions(ApiEndpoints.uom);
Future<List<FilterOptionModel>> listItems() => _listOptions(ApiEndpoints.items);
Future<List<FilterOptionModel>> listGstRates() =>
_listOptions(ApiEndpoints.gstRates);
Future<List<FilterOptionModel>> listAssetCategories() =>
_listOptions(ApiEndpoints.assetCategories);
Future<List<FilterOptionModel>> _listOptions(String endpoint) async {
final response = await dio.get(
endpoint,
queryParameters: const {'limit': 100, 'is_active': true},
queryParameters: {
'limit': AppConstants.maxPageSize,
'is_active': true,
},
);
final body = response.data as Map<String, dynamic>;
final raw = body['data'];
@ -44,12 +70,45 @@ class MasterRemoteDataSource {
.map(
(item) => FilterOptionModel(
id: item['id']?.toString() ?? '',
name: item['name'] as String? ?? '',
name: _optionLabel(item),
),
)
.where((item) => item.id.isNotEmpty && item.name.isNotEmpty)
.toList();
}
String _optionLabel(Map<String, dynamic> item) {
final ratePct = item['rate_pct'];
if (ratePct != null) {
final rate = ratePct is num
? ratePct.toDouble()
: double.tryParse(ratePct.toString());
if (rate != null) {
final rateLabel =
rate % 1 == 0 ? '${rate.toInt()}%' : '$rate%';
final desc = item['description'];
if (desc is String && desc.trim().isNotEmpty) {
return '$rateLabel${desc.trim()}';
}
return rateLabel;
}
}
for (final key in [
'name',
'item_name',
'term_name',
'vendor_name',
'code',
'description',
]) {
final value = item[key];
if (value is String && value.trim().isNotEmpty) {
return value.trim();
}
}
return '';
}
}
/// Users whose role name is exactly "Manager" (for reporting_to dropdown).

View File

@ -0,0 +1,193 @@
import 'package:dio/dio.dart';
import '../../../../core/constants/api_endpoints.dart';
import '../../../../shared/models/api_response.dart';
import '../../../../shared/models/purchase_order_model.dart';
class PurchaseOrderRemoteDataSource {
PurchaseOrderRemoteDataSource({required this.dio});
final Dio dio;
Future<PaginatedResponse<PurchaseOrderModel>> getPurchaseOrders(
PurchaseOrderListQuery query,
) async {
final response = await dio.get(
ApiEndpoints.purchaseOrders,
queryParameters: _queryToMap(query),
);
return _parsePaginated(response.data, PurchaseOrderModel.fromJson);
}
Future<PurchaseOrderModel> getPurchaseOrderById(String id) async {
final response = await dio.get(ApiEndpoints.purchaseOrderById(id));
return PurchaseOrderModel.fromJson(
response.data['data'] as Map<String, dynamic>,
);
}
Future<PurchaseOrderModel> createPurchaseOrder(Map<String, dynamic> data) async {
final response = await dio.post(ApiEndpoints.purchaseOrders, data: data);
return PurchaseOrderModel.fromJson(
response.data['data'] as Map<String, dynamic>,
);
}
Future<PurchaseOrderModel> updatePurchaseOrder(
String id,
Map<String, dynamic> data,
) async {
final response = await dio.put(ApiEndpoints.purchaseOrderById(id), data: data);
return PurchaseOrderModel.fromJson(
response.data['data'] as Map<String, dynamic>,
);
}
Future<void> deletePurchaseOrder(String id) async {
await dio.delete(ApiEndpoints.purchaseOrderById(id));
}
Future<PurchaseOrderModel> submitPurchaseOrder(
String id, {
String? remarks,
}) async {
final response = await dio.post(
ApiEndpoints.purchaseOrderSubmit(id),
data: remarks != null ? {'remarks': remarks} : {},
);
return PurchaseOrderModel.fromJson(
response.data['data'] as Map<String, dynamic>,
);
}
Future<PurchaseOrderModel> approvePurchaseOrder(
String id, {
String? remarks,
}) async {
final response = await dio.post(
ApiEndpoints.purchaseOrderApprove(id),
data: remarks != null ? {'remarks': remarks} : {},
);
return PurchaseOrderModel.fromJson(
response.data['data'] as Map<String, dynamic>,
);
}
Future<PurchaseOrderModel> rejectPurchaseOrder(
String id, {
required String remarks,
}) async {
final response = await dio.post(
ApiEndpoints.purchaseOrderReject(id),
data: {'remarks': remarks},
);
return PurchaseOrderModel.fromJson(
response.data['data'] as Map<String, dynamic>,
);
}
Future<PurchaseOrderModel> amendPurchaseOrder(
String id, {
Map<String, dynamic>? data,
}) async {
final response = await dio.post(
ApiEndpoints.purchaseOrderAmend(id),
data: data ?? {},
);
return PurchaseOrderModel.fromJson(
response.data['data'] as Map<String, dynamic>,
);
}
Future<PurchaseOrderModel> cancelPurchaseOrder(
String id, {
String? remarks,
}) async {
final response = await dio.post(
ApiEndpoints.purchaseOrderCancel(id),
data: remarks != null ? {'remarks': remarks} : {},
);
return PurchaseOrderModel.fromJson(
response.data['data'] as Map<String, dynamic>,
);
}
Future<List<int>> downloadPurchaseOrderPdf(String id) async {
final response = await dio.get<List<int>>(
ApiEndpoints.purchaseOrderPdf(id),
options: Options(responseType: ResponseType.bytes),
);
return response.data ?? [];
}
Map<String, dynamic> _queryToMap(PurchaseOrderListQuery query) {
return {
'page': query.page,
'limit': query.limit,
if (query.search != null && query.search!.isNotEmpty) 'search': query.search,
if (query.status != null) 'status': query.status,
if (query.poType != null) 'po_type': query.poType,
if (query.vendorId != null) 'vendor_id': query.vendorId,
if (query.plantId != null) 'plant_id': query.plantId,
if (query.dateFrom != null) 'date_from': query.dateFrom,
if (query.dateTo != null) 'date_to': query.dateTo,
};
}
PaginatedResponse<T> _parsePaginated<T>(
dynamic body,
T Function(Map<String, dynamic>) fromJson,
) {
if (body is! Map<String, dynamic>) {
return const PaginatedResponse(
items: [],
page: 1,
limit: 20,
total: 0,
totalPages: 1,
);
}
final raw = body['data'];
final meta = body['meta'] as Map<String, dynamic>? ?? {};
if (raw is List) {
final items = raw.whereType<Map<String, dynamic>>().map(fromJson).toList();
final limit = (meta['limit'] as num?)?.toInt() ?? items.length;
final total = (meta['total'] as num?)?.toInt() ?? items.length;
return PaginatedResponse(
items: items,
page: (meta['page'] as num?)?.toInt() ?? 1,
limit: limit,
total: total,
totalPages:
limit > 0 ? ((total + limit - 1) / limit).ceil().clamp(1, 999999) : 1,
);
}
if (raw is Map<String, dynamic>) {
final list = raw['items'];
if (list is List) {
final items = list.whereType<Map<String, dynamic>>().map(fromJson).toList();
final limit = (meta['limit'] as num?)?.toInt() ?? 20;
final total = (meta['total'] as num?)?.toInt() ?? items.length;
return PaginatedResponse(
items: items,
page: (meta['page'] as num?)?.toInt() ?? 1,
limit: limit,
total: total,
totalPages:
limit > 0 ? ((total + limit - 1) / limit).ceil().clamp(1, 999999) : 1,
);
}
}
return const PaginatedResponse(
items: [],
page: 1,
limit: 20,
total: 0,
totalPages: 1,
);
}
}

View File

@ -0,0 +1,102 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/network/api_handler.dart';
import '../../../../core/network/dio_client.dart';
import '../../../../shared/models/api_response.dart';
import '../../../../shared/models/purchase_order_model.dart';
import '../../domain/repositories/purchase_order_repository.dart';
import '../datasources/purchase_order_remote_data_source.dart';
final purchaseOrderRemoteDataSourceProvider =
Provider<PurchaseOrderRemoteDataSource>((ref) {
return PurchaseOrderRemoteDataSource(dio: ref.watch(dioProvider));
});
final purchaseOrderRepositoryProvider = Provider<PurchaseOrderRepository>((ref) {
return PurchaseOrderRepositoryImpl(
dataSource: ref.watch(purchaseOrderRemoteDataSourceProvider),
);
});
class PurchaseOrderRepositoryImpl implements PurchaseOrderRepository {
PurchaseOrderRepositoryImpl({required this.dataSource});
final PurchaseOrderRemoteDataSource dataSource;
@override
Future<Result<PaginatedResponse<PurchaseOrderModel>>> getPurchaseOrders(
PurchaseOrderListQuery query,
) {
return safeApiCall(() => dataSource.getPurchaseOrders(query));
}
@override
Future<Result<PurchaseOrderModel>> getPurchaseOrderById(String id) {
return safeApiCall(() => dataSource.getPurchaseOrderById(id));
}
@override
Future<Result<PurchaseOrderModel>> createPurchaseOrder(Map<String, dynamic> data) {
return safeApiCall(() => dataSource.createPurchaseOrder(data));
}
@override
Future<Result<PurchaseOrderModel>> updatePurchaseOrder(
String id,
Map<String, dynamic> data,
) {
return safeApiCall(() => dataSource.updatePurchaseOrder(id, data));
}
@override
Future<Result<void>> deletePurchaseOrder(String id) {
return safeApiCall(() => dataSource.deletePurchaseOrder(id));
}
@override
Future<Result<PurchaseOrderModel>> submitPurchaseOrder(
String id, {
String? remarks,
}) {
return safeApiCall(() => dataSource.submitPurchaseOrder(id, remarks: remarks));
}
@override
Future<Result<PurchaseOrderModel>> approvePurchaseOrder(
String id, {
String? remarks,
}) {
return safeApiCall(() => dataSource.approvePurchaseOrder(id, remarks: remarks));
}
@override
Future<Result<PurchaseOrderModel>> rejectPurchaseOrder(
String id, {
required String remarks,
}) {
return safeApiCall(
() => dataSource.rejectPurchaseOrder(id, remarks: remarks),
);
}
@override
Future<Result<PurchaseOrderModel>> amendPurchaseOrder(
String id, {
Map<String, dynamic>? data,
}) {
return safeApiCall(() => dataSource.amendPurchaseOrder(id, data: data));
}
@override
Future<Result<PurchaseOrderModel>> cancelPurchaseOrder(
String id, {
String? remarks,
}) {
return safeApiCall(() => dataSource.cancelPurchaseOrder(id, remarks: remarks));
}
@override
Future<Result<List<int>>> downloadPurchaseOrderPdf(String id) {
return safeApiCall(() => dataSource.downloadPurchaseOrderPdf(id));
}
}

View File

@ -0,0 +1,28 @@
import '../../../../core/network/api_handler.dart';
import '../../../../shared/models/api_response.dart';
import '../../../../shared/models/purchase_order_model.dart';
abstract class PurchaseOrderRepository {
Future<Result<PaginatedResponse<PurchaseOrderModel>>> getPurchaseOrders(
PurchaseOrderListQuery query,
);
Future<Result<PurchaseOrderModel>> getPurchaseOrderById(String id);
Future<Result<PurchaseOrderModel>> createPurchaseOrder(Map<String, dynamic> data);
Future<Result<PurchaseOrderModel>> updatePurchaseOrder(
String id,
Map<String, dynamic> data,
);
Future<Result<void>> deletePurchaseOrder(String id);
Future<Result<PurchaseOrderModel>> submitPurchaseOrder(String id, {String? remarks});
Future<Result<PurchaseOrderModel>> approvePurchaseOrder(String id, {String? remarks});
Future<Result<PurchaseOrderModel>> rejectPurchaseOrder(
String id, {
required String remarks,
});
Future<Result<PurchaseOrderModel>> amendPurchaseOrder(
String id, {
Map<String, dynamic>? data,
});
Future<Result<PurchaseOrderModel>> cancelPurchaseOrder(String id, {String? remarks});
Future<Result<List<int>>> downloadPurchaseOrderPdf(String id);
}

View File

@ -0,0 +1,105 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/constants/app_constants.dart';
import '../../../../shared/models/user_management_models.dart';
import '../../../../shared/models/vendor_model.dart';
import '../../../masters/data/datasources/master_remote_data_source.dart';
import '../../../vendors/data/repositories/vendor_repository_impl.dart';
import '../../../vendors/domain/repositories/vendor_repository.dart';
class PurchaseOrderLookups {
const PurchaseOrderLookups({
this.vendors = const [],
this.plants = const [],
this.warehouses = const [],
this.brands = const [],
this.paymentTerms = const [],
this.deliveryTerms = const [],
this.items = const [],
this.uom = const [],
this.gstRates = const [],
});
final List<FilterOptionModel> vendors;
final List<FilterOptionModel> plants;
final List<FilterOptionModel> warehouses;
final List<FilterOptionModel> brands;
final List<FilterOptionModel> paymentTerms;
final List<FilterOptionModel> deliveryTerms;
final List<FilterOptionModel> items;
final List<FilterOptionModel> uom;
final List<FilterOptionModel> gstRates;
}
final purchaseOrderLookupsProvider =
FutureProvider.autoDispose<PurchaseOrderLookups>((ref) async {
final master = ref.watch(masterRemoteDataSourceProvider);
final vendorRepo = ref.watch(vendorRepositoryProvider);
final vendors = await _safeOptions(() => _fetchActiveVendors(vendorRepo));
final results = await Future.wait([
_safeOptions(master.listPlants),
_safeOptions(master.listWarehouses),
_safeOptions(master.listBrands),
_safeOptions(master.listPaymentTerms),
_safeOptions(master.listDeliveryTerms),
_safeOptions(master.listItems),
_safeOptions(master.listUom),
_safeOptions(master.listGstRates),
]);
return PurchaseOrderLookups(
vendors: vendors,
plants: results[0],
warehouses: results[1],
brands: results[2],
paymentTerms: results[3],
deliveryTerms: results[4],
items: results[5],
uom: results[6],
gstRates: results[7],
);
});
Future<List<FilterOptionModel>> _safeOptions(
Future<List<FilterOptionModel>> Function() load,
) async {
try {
return await load();
} catch (_) {
return const [];
}
}
Future<List<FilterOptionModel>> _fetchActiveVendors(
VendorRepository vendorRepo,
) async {
final vendors = <FilterOptionModel>[];
var page = 1;
var totalPages = 1;
while (page <= totalPages) {
final result = await vendorRepo.getVendors(
VendorListQuery(
page: page,
limit: AppConstants.maxPageSize,
isActive: true,
),
);
if (result.failure != null) {
throw result.failure!;
}
final data = result.data!;
vendors.addAll(
data.items.map(
(vendor) => FilterOptionModel(id: vendor.id, name: vendor.vendorName),
),
);
totalPages = data.totalPages;
page++;
}
return vendors;
}

View File

@ -0,0 +1,246 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../shared/models/purchase_order_model.dart';
import '../../data/repositories/purchase_order_repository_impl.dart';
class PurchaseOrdersListState {
const PurchaseOrdersListState({
this.orders = const [],
this.query = const PurchaseOrderListQuery(limit: 20),
this.total = 0,
this.totalPages = 1,
this.isRefreshing = false,
this.actionError,
this.actionSuccess,
});
final List<PurchaseOrderModel> orders;
final PurchaseOrderListQuery query;
final int total;
final int totalPages;
final bool isRefreshing;
final String? actionError;
final String? actionSuccess;
PurchaseOrdersListState copyWith({
List<PurchaseOrderModel>? orders,
PurchaseOrderListQuery? query,
int? total,
int? totalPages,
bool? isRefreshing,
String? actionError,
String? actionSuccess,
bool clearMessages = false,
}) {
return PurchaseOrdersListState(
orders: orders ?? this.orders,
query: query ?? this.query,
total: total ?? this.total,
totalPages: totalPages ?? this.totalPages,
isRefreshing: isRefreshing ?? this.isRefreshing,
actionError: clearMessages ? null : actionError ?? this.actionError,
actionSuccess: clearMessages ? null : actionSuccess ?? this.actionSuccess,
);
}
}
final purchaseOrdersListProvider = AsyncNotifierProvider.autoDispose<
PurchaseOrdersListNotifier, PurchaseOrdersListState>(
PurchaseOrdersListNotifier.new,
);
class PurchaseOrdersListNotifier
extends AutoDisposeAsyncNotifier<PurchaseOrdersListState> {
@override
Future<PurchaseOrdersListState> build() async {
return _load(const PurchaseOrderListQuery(limit: 20));
}
Future<PurchaseOrdersListState> _load(PurchaseOrderListQuery query) async {
final repository = ref.read(purchaseOrderRepositoryProvider);
final result = await repository.getPurchaseOrders(query);
if (result.failure != null) throw result.failure!;
final page = result.data!;
return PurchaseOrdersListState(
orders: page.items,
query: query,
total: page.total,
totalPages: page.totalPages,
);
}
Future<void> refresh() async {
final current = state.valueOrNull ?? const PurchaseOrdersListState();
state = AsyncData(current.copyWith(isRefreshing: true, clearMessages: true));
try {
state = AsyncData(await _load(current.query));
} catch (e, st) {
state = AsyncError(e, st);
}
}
Future<void> applyQuery(PurchaseOrderListQuery query) async {
final previous = state.valueOrNull;
if (previous == null) {
state = const AsyncLoading();
}
try {
state = AsyncData(await _load(query));
} catch (e, st) {
state = AsyncError(e, st);
}
}
void setSearch(String search) {
final current = state.valueOrNull;
if (current == null) return;
applyQuery(current.query.copyWith(search: search, page: 1));
}
void setStatusFilter(String? status) {
final current = state.valueOrNull;
if (current == null) return;
applyQuery(current.query.copyWith(status: status, page: 1));
}
void setPoTypeFilter(String? poType) {
final current = state.valueOrNull;
if (current == null) return;
applyQuery(current.query.copyWith(poType: poType, page: 1));
}
void setPage(int page) {
final current = state.valueOrNull;
if (current == null) return;
applyQuery(current.query.copyWith(page: page));
}
void setPageSize(int limit) {
final current = state.valueOrNull;
if (current == null) return;
applyQuery(current.query.copyWith(limit: limit, page: 1));
}
Future<bool> deletePurchaseOrder(String id) async {
final repository = ref.read(purchaseOrderRepositoryProvider);
final result = await repository.deletePurchaseOrder(id);
if (result.failure != null) {
final current = state.valueOrNull;
if (current != null) {
state = AsyncData(current.copyWith(actionError: result.failure!.message));
}
return false;
}
await refresh();
final current = state.valueOrNull;
if (current != null) {
state = AsyncData(current.copyWith(actionSuccess: 'Purchase order deleted'));
}
return true;
}
}
final purchaseOrderDetailProvider = AsyncNotifierProvider.family<
PurchaseOrderDetailNotifier, PurchaseOrderModel, String>(
PurchaseOrderDetailNotifier.new,
);
class PurchaseOrderDetailNotifier
extends FamilyAsyncNotifier<PurchaseOrderModel, String> {
@override
Future<PurchaseOrderModel> build(String arg) async {
final repository = ref.read(purchaseOrderRepositoryProvider);
final result = await repository.getPurchaseOrderById(arg);
if (result.failure != null) throw result.failure!;
return result.data!;
}
Future<void> reload() async {
state = const AsyncLoading();
state = AsyncData(await build(arg));
}
Future<PurchaseOrderModel> submit({String? remarks}) async {
final repository = ref.read(purchaseOrderRepositoryProvider);
final result = await repository.submitPurchaseOrder(arg, remarks: remarks);
if (result.failure != null) throw result.failure!;
state = AsyncData(result.data!);
ref.invalidate(purchaseOrdersListProvider);
return result.data!;
}
Future<PurchaseOrderModel> approve({String? remarks}) async {
final repository = ref.read(purchaseOrderRepositoryProvider);
final result = await repository.approvePurchaseOrder(arg, remarks: remarks);
if (result.failure != null) throw result.failure!;
state = AsyncData(result.data!);
ref.invalidate(purchaseOrdersListProvider);
return result.data!;
}
Future<PurchaseOrderModel> reject({required String remarks}) async {
final repository = ref.read(purchaseOrderRepositoryProvider);
final result = await repository.rejectPurchaseOrder(arg, remarks: remarks);
if (result.failure != null) throw result.failure!;
state = AsyncData(result.data!);
ref.invalidate(purchaseOrdersListProvider);
return result.data!;
}
Future<PurchaseOrderModel> amend() async {
final repository = ref.read(purchaseOrderRepositoryProvider);
final result = await repository.amendPurchaseOrder(arg);
if (result.failure != null) throw result.failure!;
ref.invalidate(purchaseOrdersListProvider);
return result.data!;
}
Future<PurchaseOrderModel> cancel({String? remarks}) async {
final repository = ref.read(purchaseOrderRepositoryProvider);
final result = await repository.cancelPurchaseOrder(arg, remarks: remarks);
if (result.failure != null) throw result.failure!;
state = AsyncData(result.data!);
ref.invalidate(purchaseOrdersListProvider);
return result.data!;
}
Future<List<int>> downloadPdf() async {
final repository = ref.read(purchaseOrderRepositoryProvider);
final result = await repository.downloadPurchaseOrderPdf(arg);
if (result.failure != null) throw result.failure!;
return result.data ?? [];
}
}
final purchaseOrderFormProvider = AsyncNotifierProvider.family<
PurchaseOrderFormNotifier, PurchaseOrderModel?, String?>(
PurchaseOrderFormNotifier.new,
);
class PurchaseOrderFormNotifier extends FamilyAsyncNotifier<PurchaseOrderModel?, String?> {
@override
Future<PurchaseOrderModel?> build(String? arg) async {
if (arg == null) return null;
final repository = ref.read(purchaseOrderRepositoryProvider);
final result = await repository.getPurchaseOrderById(arg);
if (result.failure != null) throw result.failure!;
return result.data;
}
Future<PurchaseOrderModel> submitCreate(Map<String, dynamic> data) async {
final repository = ref.read(purchaseOrderRepositoryProvider);
final result = await repository.createPurchaseOrder(data);
if (result.failure != null) throw result.failure!;
ref.invalidate(purchaseOrdersListProvider);
return result.data!;
}
Future<PurchaseOrderModel> submitUpdate(String id, Map<String, dynamic> data) async {
final repository = ref.read(purchaseOrderRepositoryProvider);
final result = await repository.updatePurchaseOrder(id, data);
if (result.failure != null) throw result.failure!;
ref.invalidate(purchaseOrdersListProvider);
ref.invalidate(purchaseOrderDetailProvider(id));
return result.data!;
}
}

View File

@ -0,0 +1,492 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/constants/enums.dart';
import '../../../../core/constants/route_constants.dart';
import '../../../../core/errors/failure.dart';
import '../../../../core/utils/formatters.dart';
import '../../../../shared/models/purchase_order_model.dart';
import '../../../../shared/providers/permissions_provider.dart';
import '../../../../shared/utils/file_download_helper.dart';
import '../../../../shared/widgets/app_card.dart';
import '../../../../shared/widgets/app_confirmation_dialog.dart';
import '../../../../shared/widgets/app_data_table.dart';
import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_text_field.dart';
import '../../../../shared/widgets/error_view.dart';
import '../../../../shared/widgets/page_header.dart';
import '../providers/purchase_orders_provider.dart';
import '../../data/repositories/purchase_order_repository_impl.dart';
import '../widgets/po_status_chip.dart';
class PurchaseOrderDetailScreen extends ConsumerStatefulWidget {
const PurchaseOrderDetailScreen({super.key, required this.purchaseOrderId});
final String purchaseOrderId;
@override
ConsumerState<PurchaseOrderDetailScreen> createState() =>
_PurchaseOrderDetailScreenState();
}
class _PurchaseOrderDetailScreenState
extends ConsumerState<PurchaseOrderDetailScreen> {
bool _isWorking = false;
@override
Widget build(BuildContext context) {
final detailAsync = ref.watch(purchaseOrderDetailProvider(widget.purchaseOrderId));
final canEdit = ref.can('purchase_orders', PermissionAction.update);
final canDelete = ref.can('purchase_orders', PermissionAction.delete);
final canApprove = ref.can('purchase_orders', PermissionAction.approve);
final canExport = ref.can('purchase_orders', PermissionAction.export);
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface,
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.go(RouteConstants.purchaseOrders),
),
title: detailAsync.maybeWhen(
data: (order) => Text(order.poNo ?? 'Purchase Order #${order.id}'),
orElse: () => const Text('Purchase Order'),
),
),
body: detailAsync.when(
loading: () => const AppLoadingView(message: 'Loading purchase order...'),
error: (e, _) => ErrorView.fromFailure(
e is Failure ? e : Failure.unknown(message: e.toString()),
onRetry: () =>
ref.invalidate(purchaseOrderDetailProvider(widget.purchaseOrderId)),
),
data: (order) => SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
PageHeader(
title: order.poNo ?? 'Purchase Order #${order.id}',
subtitle:
'${poTypeLabel(order.poType)} · ${order.vendorName ?? ''}',
actions: [
if (canExport)
OutlinedButton.icon(
onPressed: _isWorking ? null : () => _downloadPdf(order),
icon: const Icon(Icons.picture_as_pdf_outlined),
label: const Text('PDF'),
),
if (canEdit && order.canEdit) ...[
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: _isWorking
? null
: () => context.push(
'${RouteConstants.purchaseOrders}/${order.id}/edit',
),
icon: const Icon(Icons.edit_outlined),
label: const Text('Edit'),
),
],
if (canEdit && order.canSubmit) ...[
const SizedBox(width: 8),
FilledButton.icon(
onPressed: _isWorking ? null : () => _submit(order),
icon: const Icon(Icons.send_outlined),
label: const Text('Submit'),
),
],
if (canApprove && order.canApprove) ...[
const SizedBox(width: 8),
FilledButton.icon(
onPressed: _isWorking ? null : () => _approve(order),
icon: const Icon(Icons.check_circle_outline),
label: const Text('Approve'),
),
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: _isWorking ? null : () => _reject(order),
icon: const Icon(Icons.cancel_outlined),
label: const Text('Reject'),
),
],
if (canEdit && order.canAmend) ...[
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: _isWorking ? null : () => _amend(order),
icon: const Icon(Icons.history_edu_outlined),
label: const Text('Amend'),
),
],
if (canEdit && order.canCancel) ...[
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: _isWorking ? null : () => _cancel(order),
icon: const Icon(Icons.block_outlined),
label: const Text('Cancel'),
),
],
if (canDelete && order.canDelete) ...[
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: _isWorking ? null : _delete,
icon: const Icon(Icons.delete_outline),
label: const Text('Delete'),
),
],
],
),
const SizedBox(height: 16),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
PoStatusChip(status: order.status),
if (order.revisionNo != null && order.revisionNo! > 0)
Chip(label: Text('Revision ${order.revisionNo}')),
],
),
const SizedBox(height: 16),
_OverviewCard(order: order),
const SizedBox(height: 16),
_LineItemsCard(items: order.items),
],
),
),
),
);
}
Future<void> _runWorkflow(Future<void> Function() action, String success) async {
setState(() => _isWorking = true);
try {
await action();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(success)));
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString())));
}
} finally {
if (mounted) setState(() => _isWorking = false);
}
}
Future<void> _submit(PurchaseOrderModel order) async {
await _runWorkflow(
() => ref
.read(purchaseOrderDetailProvider(widget.purchaseOrderId).notifier)
.submit(),
'Purchase order submitted for approval',
);
}
Future<void> _approve(PurchaseOrderModel order) async {
await _runWorkflow(
() => ref
.read(purchaseOrderDetailProvider(widget.purchaseOrderId).notifier)
.approve(),
'Purchase order approved',
);
}
Future<void> _reject(PurchaseOrderModel order) async {
final remarksController = TextEditingController();
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Reject Purchase Order'),
content: AppTextField(
controller: remarksController,
label: 'Remarks *',
maxLines: 3,
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Reject'),
),
],
),
);
if (confirmed != true || !mounted) return;
final remarks = remarksController.text.trim();
remarksController.dispose();
if (remarks.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Rejection remarks are required')),
);
return;
}
await _runWorkflow(
() => ref
.read(purchaseOrderDetailProvider(widget.purchaseOrderId).notifier)
.reject(remarks: remarks),
'Purchase order rejected',
);
}
Future<void> _amend(PurchaseOrderModel order) async {
final confirmed = await showAppConfirmationDialog(
context: context,
title: 'Amend Purchase Order',
message:
'This will create a new draft revision. Continue?',
confirmLabel: 'Amend',
);
if (confirmed != true || !mounted) return;
setState(() => _isWorking = true);
try {
final amended = await ref
.read(purchaseOrderDetailProvider(widget.purchaseOrderId).notifier)
.amend();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Amendment draft created')),
);
context.go('${RouteConstants.purchaseOrders}/${amended.id}');
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString())));
}
} finally {
if (mounted) setState(() => _isWorking = false);
}
}
Future<void> _cancel(PurchaseOrderModel order) async {
final confirmed = await showAppConfirmationDialog(
context: context,
title: 'Cancel Purchase Order',
message: 'Cancel ${order.poNo ?? order.id}?',
confirmLabel: 'Cancel PO',
isDestructive: true,
);
if (confirmed != true || !mounted) return;
await _runWorkflow(
() => ref
.read(purchaseOrderDetailProvider(widget.purchaseOrderId).notifier)
.cancel(),
'Purchase order cancelled',
);
}
Future<void> _delete() async {
final confirmed = await showAppConfirmationDialog(
context: context,
title: 'Delete Purchase Order',
message: 'Soft delete this purchase order?',
confirmLabel: 'Delete',
isDestructive: true,
);
if (confirmed != true || !mounted) return;
setState(() => _isWorking = true);
try {
final repository = ref.read(purchaseOrderRepositoryProvider);
final result = await repository.deletePurchaseOrder(widget.purchaseOrderId);
if (result.failure != null) throw result.failure!;
ref.invalidate(purchaseOrdersListProvider);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Purchase order deleted')),
);
context.go(RouteConstants.purchaseOrders);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString())));
}
} finally {
if (mounted) setState(() => _isWorking = false);
}
}
Future<void> _downloadPdf(PurchaseOrderModel order) async {
setState(() => _isWorking = true);
try {
final bytes = await ref
.read(purchaseOrderDetailProvider(widget.purchaseOrderId).notifier)
.downloadPdf();
if (bytes.isEmpty) throw Exception('Empty PDF response');
await downloadFile(
bytes: bytes,
fileName: '${order.poNo ?? 'PO-${order.id}'}.pdf',
);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString())));
}
} finally {
if (mounted) setState(() => _isWorking = false);
}
}
}
class _OverviewCard extends StatelessWidget {
const _OverviewCard({required this.order});
final PurchaseOrderModel order;
@override
Widget build(BuildContext context) {
return AppCard(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Overview', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 16),
_infoGrid([
_Info('PO Date', DateFormatter.displayDate(order.poDate)),
_Info('Expected Delivery',
DateFormatter.displayDate(order.expectedDeliveryDate)),
_Info('Vendor', order.vendorName ?? ''),
_Info('Plant', order.plantName ?? ''),
_Info('Warehouse', order.warehouseName ?? ''),
_Info('Type', poTypeLabel(order.poType)),
_Info('Taxable', CurrencyFormatter.format(order.taxableAmount)),
_Info('Tax', CurrencyFormatter.format(order.taxAmount)),
_Info('Freight', CurrencyFormatter.format(order.freightCharges)),
_Info('Other Charges', CurrencyFormatter.format(order.otherCharges)),
_Info('Discount', CurrencyFormatter.format(order.discountAmount)),
_Info('Total', CurrencyFormatter.format(order.totalAmount)),
]),
if (order.termsAndConditions?.isNotEmpty == true) ...[
const SizedBox(height: 16),
Text('Terms & Conditions',
style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 4),
Text(order.termsAndConditions!),
],
if (order.remarks?.isNotEmpty == true) ...[
const SizedBox(height: 16),
Text('Remarks', style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 4),
Text(order.remarks!),
],
],
),
),
);
}
Widget _infoGrid(List<_Info> rows) {
return LayoutBuilder(
builder: (context, constraints) {
final crossAxisCount = constraints.maxWidth > 900 ? 3 : 2;
return Wrap(
spacing: 24,
runSpacing: 12,
children: rows
.map(
(row) => SizedBox(
width: (constraints.maxWidth / crossAxisCount) - 24,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
row.label,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
Text(row.value),
],
),
),
)
.toList(),
);
},
);
}
}
class _Info {
const _Info(this.label, this.value);
final String label;
final String value;
}
class _LineItemsCard extends StatelessWidget {
const _LineItemsCard({required this.items});
final List<PurchaseOrderItemModel> items;
@override
Widget build(BuildContext context) {
return AppCard(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Line Items', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 16),
if (items.isEmpty)
Text(
'No line items',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
)
else
AppDataTable<PurchaseOrderItemModel>(
wrapInCard: false,
shrinkWrap: true,
columns: [
AppDataColumn(
label: '#',
flex: 1,
cellBuilder: (_, item) => Text('${item.lineNo ?? ''}'),
),
AppDataColumn(
label: 'Item',
flex: 3,
cellBuilder: (_, item) => Text(
item.itemName ?? item.itemCode ?? '',
),
),
AppDataColumn(
label: 'Qty',
flex: 1,
cellBuilder: (_, item) => Text('${item.orderedQty ?? ''}'),
),
AppDataColumn(
label: 'UOM',
flex: 1,
cellBuilder: (_, item) => Text(item.uomName ?? ''),
),
AppDataColumn(
label: 'Rate',
flex: 1,
cellBuilder: (_, item) =>
Text(CurrencyFormatter.format(item.rate)),
),
AppDataColumn(
label: 'Amount',
flex: 1,
cellBuilder: (_, item) =>
Text(CurrencyFormatter.format(item.lineAmount)),
),
],
rows: items,
),
],
),
),
);
}
}

View File

@ -0,0 +1,593 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/constants/route_constants.dart';
import '../../../../core/errors/failure.dart';
import '../../../../core/network/api_handler.dart';
import '../../../../core/utils/formatters.dart';
import '../../../../shared/models/purchase_order_model.dart';
import '../../../../shared/models/user_management_models.dart';
import '../../../../shared/utils/navigation_utils.dart';
import '../../../../shared/widgets/app_button.dart';
import '../../../../shared/widgets/app_dropdown.dart';
import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/app_text_field.dart';
import '../../../../shared/widgets/error_view.dart';
import '../../../../shared/widgets/app_side_panel.dart';
import '../../../../shared/widgets/page_header.dart';
import '../../data/repositories/purchase_order_repository_impl.dart';
import '../providers/purchase_order_lookups_provider.dart';
import '../providers/purchase_orders_provider.dart';
import '../widgets/purchase_order_line_items_editor.dart';
class PurchaseOrderFormScreen extends ConsumerStatefulWidget {
const PurchaseOrderFormScreen({super.key, this.purchaseOrderId});
final String? purchaseOrderId;
bool get isEditing => purchaseOrderId != null;
@override
ConsumerState<PurchaseOrderFormScreen> createState() =>
_PurchaseOrderFormScreenState();
}
class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScreen> {
final _formKey = GlobalKey<FormState>();
final _scrollController = ScrollController();
final _discountController = TextEditingController();
final _freightController = TextEditingController();
final _otherChargesController = TextEditingController();
final _termsController = TextEditingController();
final _remarksController = TextEditingController();
DateTime? _poDate;
DateTime? _expectedDeliveryDate;
String? _poType;
int? _vendorId;
int? _plantId;
int? _warehouseId;
int? _brandId;
int? _paymentTermId;
int? _deliveryTermId;
final List<PoLineItemDraft> _lines = [];
bool _isSubmitting = false;
String? _populatedSignature;
@override
void initState() {
super.initState();
if (!widget.isEditing) {
_poDate = DateTime.now();
_lines.add(PoLineItemDraft(lineNo: 1));
}
}
@override
void dispose() {
_scrollController.dispose();
_discountController.dispose();
_freightController.dispose();
_otherChargesController.dispose();
_termsController.dispose();
_remarksController.dispose();
for (final line in _lines) {
line.dispose();
}
super.dispose();
}
String _orderSignature(PurchaseOrderModel order) =>
'${order.id}:${order.updatedAt?.toIso8601String()}:${order.items.length}';
void _populateFromOrder(PurchaseOrderModel order) {
setState(() {
_poDate = order.poDate ?? DateTime.now();
_expectedDeliveryDate = order.expectedDeliveryDate;
_poType = order.poType;
_vendorId = order.vendorId;
_plantId = order.plantId;
_warehouseId = order.warehouseId;
_brandId = order.brandId;
_paymentTermId = order.paymentTermId;
_deliveryTermId = order.deliveryTermId;
_discountController.text = order.discountAmount?.toString() ?? '';
_freightController.text = order.freightCharges?.toString() ?? '';
_otherChargesController.text = order.otherCharges?.toString() ?? '';
_termsController.text = order.termsAndConditions ?? '';
_remarksController.text = order.remarks ?? '';
for (final line in _lines) {
line.dispose();
}
_lines
..clear()
..addAll(
order.items.isNotEmpty
? order.items.map(PoLineItemDraft.fromModel).toList()
: [PoLineItemDraft(lineNo: 1)],
);
});
}
void _addLine() {
setState(() {
_lines.add(PoLineItemDraft(lineNo: _lines.length + 1));
});
}
void _removeLine(int index) {
setState(() {
_lines[index].dispose();
_lines.removeAt(index);
for (var i = 0; i < _lines.length; i++) {
_lines[i].lineNo = i + 1;
}
});
}
int? _dropdownValue(int? selected, Iterable<int> validIds) {
if (selected == null) return null;
return validIds.contains(selected) ? selected : null;
}
int? _parseId(String value) => int.tryParse(value.trim());
List<AppDropdownOption<int>> _intOptions(List<FilterOptionModel> options) {
return options
.map((e) {
final id = _parseId(e.id);
if (id == null) return null;
return AppDropdownOption(value: id, label: e.name);
})
.whereType<AppDropdownOption<int>>()
.toList();
}
List<AppDropdownOption<int?>> _nullableIntOptions(List<FilterOptionModel> options) {
return [
const AppDropdownOption<int?>(value: null, label: 'None'),
...options.map(
(e) {
final id = _parseId(e.id);
if (id == null) return null;
return AppDropdownOption<int?>(value: id, label: e.name);
},
),
].whereType<AppDropdownOption<int?>>().toList();
}
Map<String, dynamic> _buildPayload() {
return {
'po_date': DateFormatter.toApiDate(_poDate ?? DateTime.now()),
'po_type': _poType,
'vendor_id': _vendorId,
'plant_id': _plantId,
if (_warehouseId != null) 'warehouse_id': _warehouseId,
if (_brandId != null) 'brand_id': _brandId,
if (_paymentTermId != null) 'payment_term_id': _paymentTermId,
if (_deliveryTermId != null) 'delivery_term_id': _deliveryTermId,
if (_expectedDeliveryDate != null)
'expected_delivery_date':
DateFormatter.toApiDate(_expectedDeliveryDate!),
if (_discountController.text.trim().isNotEmpty)
'discount_amount': double.tryParse(_discountController.text.trim()),
if (_freightController.text.trim().isNotEmpty)
'freight_charges': double.tryParse(_freightController.text.trim()),
if (_otherChargesController.text.trim().isNotEmpty)
'other_charges': double.tryParse(_otherChargesController.text.trim()),
if (_termsController.text.trim().isNotEmpty)
'terms_and_conditions': _termsController.text.trim(),
if (_remarksController.text.trim().isNotEmpty)
'remarks': _remarksController.text.trim(),
'items': _lines.map((line) => line.toPayload()).toList(),
};
}
String? _lineItemsError() {
for (final line in _lines) {
if (line.itemId == null) return 'Each line item must have an item selected';
if (line.uomId == null) return 'Each line item must have a UOM selected';
final qty = double.tryParse(line.qtyController.text.trim());
if (qty == null || qty <= 0) {
return 'Enter a valid quantity for line ${line.lineNo}';
}
final rate = double.tryParse(line.rateController.text.trim());
if (rate == null || rate < 0) {
return 'Enter a valid rate for line ${line.lineNo}';
}
}
return null;
}
Future<void> _submit() async {
final formState = _formKey.currentState;
if (formState == null) return;
final isValid = formState.validate();
if (!isValid) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Please fix the highlighted errors before saving'),
),
);
return;
}
if (_poType == null || _vendorId == null || _plantId == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please complete all required fields')),
);
return;
}
final lineItemsError = _lineItemsError();
if (lineItemsError != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(lineItemsError)),
);
return;
}
if (_lines.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Add at least one line item')),
);
return;
}
setState(() => _isSubmitting = true);
try {
final payload = _buildPayload();
final PurchaseOrderModel saved;
if (widget.isEditing) {
final notifier =
ref.read(purchaseOrderFormProvider(widget.purchaseOrderId).notifier);
saved = await notifier.submitUpdate(widget.purchaseOrderId!, payload);
} else {
final result =
await ref.read(purchaseOrderRepositoryProvider).createPurchaseOrder(payload);
if (result.failure != null) throw result.failure!;
saved = result.data!;
ref.invalidate(purchaseOrdersListProvider);
}
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
widget.isEditing ? 'Purchase order updated' : 'Purchase order created',
),
),
);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final destination = widget.isEditing
? '${RouteConstants.purchaseOrders}/${saved.id}'
: RouteConstants.purchaseOrders;
goAndDismissOverlays(context, destination);
});
} catch (e) {
if (!mounted) return;
final message = e is Failure
? validationErrorMessage(e)
: e.toString();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
} finally {
if (mounted) setState(() => _isSubmitting = false);
}
}
Future<void> _pickDate({
required DateTime? current,
required ValueChanged<DateTime?> onPicked,
}) async {
final picked = await showDatePicker(
context: context,
initialDate: current ?? DateTime.now(),
firstDate: DateTime(2020),
lastDate: DateTime(2100),
);
if (picked != null) onPicked(picked);
}
Widget _buildFormBody({
required PurchaseOrderLookups lookups,
PurchaseOrderModel? existing,
}) {
if (widget.isEditing && existing != null) {
final signature = _orderSignature(existing);
if (_populatedSignature != signature) {
_populatedSignature = signature;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _populateFromOrder(existing);
});
}
}
if (widget.isEditing && existing != null && !existing.canEdit) {
return ErrorView.fromFailure(
const Failure.validation(
message: 'This purchase order cannot be edited',
),
onRetry: () => context.go(
'${RouteConstants.purchaseOrders}/${existing.id}',
),
);
}
final vendorIds =
lookups.vendors.map((e) => _parseId(e.id)).whereType<int>();
final plantIds =
lookups.plants.map((e) => _parseId(e.id)).whereType<int>();
return SingleChildScrollView(
controller: _scrollController,
padding: const EdgeInsets.all(24),
child: Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1200),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (!widget.isEditing)
PageHeader(
title: 'Create Purchase Order',
subtitle: 'Fill header details and add line items',
)
else if (existing?.poNo != null)
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Text(
existing!.poNo!,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
FormRowThree(
children: [
_DateField(
label: 'PO Date *',
value: _poDate,
onTap: () => _pickDate(
current: _poDate,
onPicked: (d) => setState(() => _poDate = d),
),
),
AppSearchableDropdown<String>(
label: 'PO Type *',
value: _poType,
searchHint: 'Search type...',
options: poTypeOptions
.map(
(e) =>
AppDropdownOption(value: e.$1, label: e.$2),
)
.toList(),
onChanged: (v) => setState(() => _poType = v),
validator: (v) =>
v == null ? 'PO type is required' : null,
),
AppSearchableDropdown<int>(
label: 'Vendor *',
value: _dropdownValue(_vendorId, vendorIds),
searchHint: 'Search vendor...',
options: _intOptions(lookups.vendors),
onChanged: (v) => setState(() => _vendorId = v),
validator: (v) =>
v == null ? 'Vendor is required' : null,
),
],
),
FormRowThree(
children: [
AppSearchableDropdown<int>(
label: 'Plant *',
value: _dropdownValue(_plantId, plantIds),
searchHint: 'Search plant...',
options: _intOptions(lookups.plants),
onChanged: (v) => setState(() => _plantId = v),
validator: (v) =>
v == null ? 'Plant is required' : null,
),
AppSearchableDropdown<int?>(
label: 'Warehouse',
value: _warehouseId,
searchHint: 'Search warehouse...',
options: _nullableIntOptions(lookups.warehouses),
onChanged: (v) => setState(() => _warehouseId = v),
),
AppSearchableDropdown<int?>(
label: 'Brand',
value: _brandId,
searchHint: 'Search brand...',
options: _nullableIntOptions(lookups.brands),
onChanged: (v) => setState(() => _brandId = v),
),
],
),
FormRowThree(
children: [
AppSearchableDropdown<int?>(
label: 'Payment Term',
value: _paymentTermId,
searchHint: 'Search payment term...',
options: _nullableIntOptions(lookups.paymentTerms),
onChanged: (v) => setState(() => _paymentTermId = v),
),
AppSearchableDropdown<int?>(
label: 'Delivery Term',
value: _deliveryTermId,
searchHint: 'Search delivery term...',
options: _nullableIntOptions(lookups.deliveryTerms),
onChanged: (v) => setState(() => _deliveryTermId = v),
),
_DateField(
label: 'Expected Delivery',
value: _expectedDeliveryDate,
onTap: () => _pickDate(
current: _expectedDeliveryDate,
onPicked: (d) =>
setState(() => _expectedDeliveryDate = d),
),
),
],
),
FormRowThree(
children: [
AppTextField(
controller: _discountController,
label: 'Discount Amount',
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
),
AppTextField(
controller: _freightController,
label: 'Freight Charges',
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
),
AppTextField(
controller: _otherChargesController,
label: 'Other Charges',
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
),
],
),
const SizedBox(height: 4),
AppTextField(
controller: _termsController,
label: 'Terms & Conditions',
maxLines: 3,
),
const SizedBox(height: 12),
AppTextField(
controller: _remarksController,
label: 'Remarks',
maxLines: 2,
),
const SizedBox(height: 24),
PurchaseOrderLineItemsEditor(
lines: _lines,
items: lookups.items,
uom: lookups.uom,
gstRates: lookups.gstRates,
onAddLine: _addLine,
onRemoveLine: _removeLine,
),
const SizedBox(height: 24),
Row(
children: [
OutlinedButton(
onPressed: _isSubmitting
? null
: () => context.pop(),
child: const Text('Cancel'),
),
const SizedBox(width: 12),
Expanded(
child: AppButton(
label: widget.isEditing ? 'Update PO' : 'Create PO',
expand: false,
isLoading: _isSubmitting,
onPressed: _isSubmitting ? null : _submit,
),
),
],
),
],
),
),
],
),
),
),
);
}
@override
Widget build(BuildContext context) {
final lookupsAsync = ref.watch(purchaseOrderLookupsProvider);
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface,
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.surface,
surfaceTintColor: Colors.transparent,
title: Text(
widget.isEditing ? 'Edit Purchase Order' : 'Create Purchase Order',
),
),
body: lookupsAsync.when(
loading: () => const AppLoadingView(message: 'Loading form options...'),
error: (e, _) => ErrorView.fromFailure(
e is Failure ? e : Failure.unknown(message: e.toString()),
onRetry: () => ref.invalidate(purchaseOrderLookupsProvider),
),
data: (lookups) {
if (!widget.isEditing) {
return _buildFormBody(lookups: lookups);
}
final formAsync =
ref.watch(purchaseOrderFormProvider(widget.purchaseOrderId));
return formAsync.when(
loading: () =>
const AppLoadingView(message: 'Loading purchase order...'),
error: (e, _) => ErrorView.fromFailure(
e is Failure ? e : Failure.unknown(message: e.toString()),
onRetry: () => ref.invalidate(
purchaseOrderFormProvider(widget.purchaseOrderId),
),
),
data: (existing) =>
_buildFormBody(lookups: lookups, existing: existing),
);
},
),
);
}
}
class _DateField extends StatelessWidget {
const _DateField({
required this.label,
required this.value,
required this.onTap,
});
final String label;
final DateTime? value;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: InputDecorator(
decoration: InputDecoration(
labelText: label,
suffixIcon: const Icon(Icons.calendar_today_outlined),
),
child: Text(
value != null ? DateFormatter.displayDate(value) : 'Select date',
),
),
);
}
}

View File

@ -0,0 +1,398 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/constants/enums.dart';
import '../../../../core/constants/route_constants.dart';
import '../../../../core/errors/failure.dart';
import '../../../../core/utils/formatters.dart';
import '../../../../core/utils/responsive_utils.dart';
import '../../../../shared/models/purchase_order_model.dart';
import '../../../../shared/providers/permissions_provider.dart';
import '../../../../shared/widgets/app_card.dart';
import '../../../../shared/widgets/app_confirmation_dialog.dart';
import '../../../../shared/widgets/app_data_table.dart';
import '../../../../shared/widgets/app_dropdown.dart';
import '../../../../shared/widgets/app_empty_state.dart';
import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_pagination.dart';
import '../../../../shared/widgets/app_search_field.dart';
import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/can_permission.dart';
import '../../../../shared/widgets/app_table_action_icon.dart';
import '../../../../shared/widgets/app_table_shell.dart';
import '../../../../shared/widgets/error_view.dart';
import '../../../../shared/widgets/page_header.dart';
import '../providers/purchase_orders_provider.dart';
import '../widgets/po_status_chip.dart';
class PurchaseOrderListScreen extends ConsumerStatefulWidget {
const PurchaseOrderListScreen({super.key});
@override
ConsumerState<PurchaseOrderListScreen> createState() =>
_PurchaseOrderListScreenState();
}
class _PurchaseOrderListScreenState extends ConsumerState<PurchaseOrderListScreen> {
final _searchController = TextEditingController();
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final ordersAsync = ref.watch(purchaseOrdersListProvider);
final canEdit = ref.can('purchase_orders', PermissionAction.update);
final canDelete = ref.can('purchase_orders', PermissionAction.delete);
ref.listen(purchaseOrdersListProvider, (prev, next) {
final error = next.valueOrNull?.actionError;
final success = next.valueOrNull?.actionSuccess;
if (error != null && error != prev?.valueOrNull?.actionError) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(error)));
}
if (success != null && success != prev?.valueOrNull?.actionSuccess) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(success)));
}
});
return Padding(
padding: const EdgeInsets.all(24),
child: ordersAsync.when(
loading: () => const AppLoadingView(message: 'Loading purchase orders...'),
error: (error, _) => ErrorView.fromFailure(
error is Failure ? error : Failure.unknown(message: error.toString()),
onRetry: () => ref.invalidate(purchaseOrdersListProvider),
),
data: (state) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
PageHeader(
title: 'Purchase Orders',
subtitle: 'Create, approve and track procurement orders',
actions: [
CanPermission(
module: 'purchase_orders',
action: PermissionAction.create,
child: ElevatedButton.icon(
onPressed: () => context.go(RouteConstants.purchaseOrderAdd),
icon: const Icon(Icons.add),
label: const Text('Create PO'),
),
),
],
),
const SizedBox(height: 16),
Expanded(
child: AppTableShell(
toolbar: LayoutBuilder(
builder: (context, constraints) {
return _FiltersBar(
searchController: _searchController,
query: state.query,
wrapped: constraints.maxWidth < 900,
onSearch: ref.read(purchaseOrdersListProvider.notifier).setSearch,
onStatusChanged:
ref.read(purchaseOrdersListProvider.notifier).setStatusFilter,
onPoTypeChanged:
ref.read(purchaseOrdersListProvider.notifier).setPoTypeFilter,
);
},
),
footer: AppPagination(
currentPage: state.query.page,
totalPages: state.totalPages,
totalItems: state.total,
pageSize: state.query.limit,
onPageChanged:
ref.read(purchaseOrdersListProvider.notifier).setPage,
onPageSizeChanged:
ref.read(purchaseOrdersListProvider.notifier).setPageSize,
),
child: RefreshIndicator(
onRefresh: () =>
ref.read(purchaseOrdersListProvider.notifier).refresh(),
child: state.orders.isEmpty
? ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: const [
SizedBox(
height: 240,
child: AppEmptyState(
title: 'No purchase orders found',
description:
'Try adjusting filters or create a new purchase order.',
icon: Icons.receipt_long_outlined,
),
),
],
)
: context.isMobile
? _PoCardList(
orders: state.orders,
onView: _viewOrder,
onEdit: canEdit ? _editOrder : null,
onDelete: canDelete ? _deleteOrder : null,
)
: _PoDataTable(
orders: state.orders,
onView: _viewOrder,
onEdit: canEdit ? _editOrder : null,
onDelete: canDelete ? _deleteOrder : null,
),
),
),
),
],
),
),
);
}
void _viewOrder(PurchaseOrderModel order) {
context.push('${RouteConstants.purchaseOrders}/${order.id}');
}
void _editOrder(PurchaseOrderModel order) {
if (!order.canEdit) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Only draft or rejected orders can be edited'),
),
);
return;
}
context.push('${RouteConstants.purchaseOrders}/${order.id}/edit');
}
Future<void> _deleteOrder(PurchaseOrderModel order) async {
if (!order.canDelete) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Only draft or rejected orders can be deleted'),
),
);
return;
}
final confirmed = await showAppConfirmationDialog(
context: context,
title: 'Delete Purchase Order',
message: 'Delete ${order.poNo ?? order.id}?',
confirmLabel: 'Delete',
isDestructive: true,
);
if (confirmed != true || !mounted) return;
await ref.read(purchaseOrdersListProvider.notifier).deletePurchaseOrder(order.id);
}
}
class _FiltersBar extends StatelessWidget {
const _FiltersBar({
required this.searchController,
required this.query,
required this.wrapped,
required this.onSearch,
required this.onStatusChanged,
required this.onPoTypeChanged,
});
final TextEditingController searchController;
final PurchaseOrderListQuery query;
final bool wrapped;
final ValueChanged<String> onSearch;
final ValueChanged<String?> onStatusChanged;
final ValueChanged<String?> onPoTypeChanged;
@override
Widget build(BuildContext context) {
final searchField = SizedBox(
width: wrapped ? double.infinity : null,
child: AppSearchField(
controller: searchController,
hint: 'Search PO number, vendor...',
onChanged: onSearch,
),
);
final filters = [
SizedBox(
width: wrapped ? double.infinity : 180,
child: AppSearchableDropdown<String?>(
label: 'Status',
value: query.status,
searchHint: 'Search status...',
isDense: true,
options: [
const AppDropdownOption(value: null, label: 'All statuses'),
...poStatusOptions.map(
(e) => AppDropdownOption(value: e.$1, label: e.$2),
),
],
onChanged: onStatusChanged,
),
),
SizedBox(
width: wrapped ? double.infinity : 200,
child: AppSearchableDropdown<String?>(
label: 'PO Type',
value: query.poType,
searchHint: 'Search type...',
isDense: true,
options: [
const AppDropdownOption(value: null, label: 'All types'),
...poTypeOptions.map(
(e) => AppDropdownOption(value: e.$1, label: e.$2),
),
],
onChanged: onPoTypeChanged,
),
),
];
if (wrapped) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
searchField,
const SizedBox(height: 12),
...filters.expand((f) => [f, const SizedBox(height: 12)]).toList()..removeLast(),
],
);
}
return Row(
children: [
Expanded(flex: 3, child: searchField),
const SizedBox(width: 12),
Expanded(flex: 2, child: filters[0]),
const SizedBox(width: 12),
Expanded(flex: 2, child: filters[1]),
],
);
}
}
class _PoDataTable extends StatelessWidget {
const _PoDataTable({
required this.orders,
required this.onView,
this.onEdit,
this.onDelete,
});
final List<PurchaseOrderModel> orders;
final ValueChanged<PurchaseOrderModel> onView;
final ValueChanged<PurchaseOrderModel>? onEdit;
final ValueChanged<PurchaseOrderModel>? onDelete;
@override
Widget build(BuildContext context) {
return AppDataTable<PurchaseOrderModel>(
wrapInCard: false,
columns: [
AppDataColumn(
label: 'PO Number',
flex: 2,
cellBuilder: (_, order) => Text(order.poNo ?? ''),
),
AppDataColumn(
label: 'Date',
flex: 1,
cellBuilder: (_, order) => Text(DateFormatter.displayDate(order.poDate)),
),
AppDataColumn(
label: 'Vendor',
flex: 2,
cellBuilder: (_, order) => Text(order.vendorName ?? ''),
),
AppDataColumn(
label: 'Plant',
flex: 2,
cellBuilder: (_, order) => Text(order.plantName ?? ''),
),
AppDataColumn(
label: 'Type',
flex: 2,
cellBuilder: (_, order) => Text(poTypeLabel(order.poType)),
),
AppDataColumn(
label: 'Total',
flex: 1,
cellBuilder: (_, order) =>
Text(CurrencyFormatter.format(order.totalAmount)),
),
AppDataColumn(
label: 'Status',
flex: 1,
cellBuilder: (_, order) => PoStatusChip(status: order.status),
),
AppDataColumn(
label: 'Actions',
flex: 1,
alignment: Alignment.centerRight,
cellBuilder: (_, order) => AppTableActions(
children: [
AppTableActionIcon(
tooltip: 'View',
icon: Icons.visibility_outlined,
onPressed: () => onView(order),
),
if (onEdit != null && order.canEdit)
AppTableActionIcon(
tooltip: 'Edit',
icon: Icons.edit_outlined,
onPressed: () => onEdit!(order),
),
if (onDelete != null && order.canDelete)
AppTableActionIcon(
tooltip: 'Delete',
icon: Icons.delete_outline,
onPressed: () => onDelete!(order),
),
],
),
),
],
rows: orders,
);
}
}
class _PoCardList extends StatelessWidget {
const _PoCardList({
required this.orders,
required this.onView,
this.onEdit,
this.onDelete,
});
final List<PurchaseOrderModel> orders;
final ValueChanged<PurchaseOrderModel> onView;
final ValueChanged<PurchaseOrderModel>? onEdit;
final ValueChanged<PurchaseOrderModel>? onDelete;
@override
Widget build(BuildContext context) {
return ListView.separated(
itemCount: orders.length,
separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (context, index) {
final order = orders[index];
return AppCard(
child: ListTile(
title: Text(order.poNo ?? 'PO #${order.id}'),
subtitle: Text(
'${order.vendorName ?? ''} · ${poTypeLabel(order.poType)}',
),
trailing: PoStatusChip(status: order.status, compact: true),
onTap: () => onView(order),
),
);
},
);
}
}

View File

@ -0,0 +1,56 @@
import 'package:flutter/material.dart';
import '../../../../shared/models/purchase_order_model.dart';
class PoStatusChip extends StatelessWidget {
const PoStatusChip({
super.key,
required this.status,
this.compact = false,
});
final String status;
final bool compact;
@override
Widget build(BuildContext context) {
final (color, label) = _resolveStatus(status);
return Chip(
label: Text(
label,
style: TextStyle(
color: color,
fontSize: compact ? 11 : 12,
fontWeight: FontWeight.w600,
),
),
backgroundColor: color.withValues(alpha: 0.12),
side: BorderSide(color: color.withValues(alpha: 0.3)),
visualDensity: compact ? VisualDensity.compact : VisualDensity.standard,
padding: compact ? EdgeInsets.zero : null,
);
}
(Color, String) _resolveStatus(String raw) {
switch (raw.toUpperCase()) {
case 'DRAFT':
return (Colors.blueGrey.shade700, poStatusLabel(raw));
case 'SUBMITTED':
case 'PENDING_APPROVAL':
case 'PENDING':
return (Colors.orange.shade800, poStatusLabel(raw));
case 'APPROVED':
return (Colors.green.shade700, poStatusLabel(raw));
case 'REJECTED':
return (Colors.red.shade700, poStatusLabel(raw));
case 'CANCELLED':
return (Colors.grey.shade700, poStatusLabel(raw));
case 'PARTIALLY_RECEIVED':
return (Colors.teal.shade700, poStatusLabel(raw));
case 'FULLY_RECEIVED':
return (Colors.indigo.shade700, poStatusLabel(raw));
default:
return (Colors.blueGrey, poStatusLabel(raw));
}
}
}

View File

@ -0,0 +1,297 @@
import 'package:flutter/material.dart';
import '../../../../shared/models/purchase_order_model.dart';
import '../../../../shared/models/user_management_models.dart';
import '../../../../shared/widgets/app_dropdown.dart';
import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/app_side_panel.dart';
import '../../../../shared/widgets/app_text_field.dart';
class PoLineItemDraft {
PoLineItemDraft({
this.itemId,
required this.lineNo,
TextEditingController? qtyController,
this.uomId,
TextEditingController? rateController,
TextEditingController? discountController,
this.gstRateId,
TextEditingController? remarksController,
}) : qtyController = qtyController ?? TextEditingController(),
rateController = rateController ?? TextEditingController(),
discountController = discountController ?? TextEditingController(text: '0'),
remarksController = remarksController ?? TextEditingController();
int? itemId;
int lineNo;
final TextEditingController qtyController;
int? uomId;
final TextEditingController rateController;
final TextEditingController discountController;
int? gstRateId;
final TextEditingController remarksController;
factory PoLineItemDraft.fromModel(PurchaseOrderItemModel item) {
return PoLineItemDraft(
itemId: item.itemId,
lineNo: item.lineNo ?? 1,
qtyController: TextEditingController(text: item.orderedQty?.toString() ?? ''),
uomId: item.uomId,
rateController: TextEditingController(text: item.rate?.toString() ?? ''),
discountController:
TextEditingController(text: item.discountPct?.toString() ?? '0'),
gstRateId: item.gstRateId,
remarksController: TextEditingController(text: item.remarks ?? ''),
);
}
void dispose() {
qtyController.dispose();
rateController.dispose();
discountController.dispose();
remarksController.dispose();
}
Map<String, dynamic> toPayload() {
final qty = double.tryParse(qtyController.text.trim());
final rate = double.tryParse(rateController.text.trim());
if (qty == null || rate == null) {
throw const FormatException('Invalid line item quantity or rate');
}
return {
'item_id': itemId,
'line_no': lineNo,
'ordered_qty': qty,
'uom_id': uomId,
'rate': rate,
'discount_pct': double.tryParse(discountController.text.trim()) ?? 0,
if (gstRateId != null) 'gst_rate_id': gstRateId,
if (remarksController.text.trim().isNotEmpty)
'remarks': remarksController.text.trim(),
};
}
}
class PurchaseOrderLineItemsEditor extends StatefulWidget {
const PurchaseOrderLineItemsEditor({
super.key,
required this.lines,
required this.items,
required this.uom,
required this.gstRates,
required this.onAddLine,
required this.onRemoveLine,
});
final List<PoLineItemDraft> lines;
final List<FilterOptionModel> items;
final List<FilterOptionModel> uom;
final List<FilterOptionModel> gstRates;
final VoidCallback onAddLine;
final ValueChanged<int> onRemoveLine;
@override
State<PurchaseOrderLineItemsEditor> createState() =>
_PurchaseOrderLineItemsEditorState();
}
class _PurchaseOrderLineItemsEditorState extends State<PurchaseOrderLineItemsEditor> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text('Line Items', style: theme.textTheme.titleMedium),
const Spacer(),
TextButton.icon(
onPressed: widget.onAddLine,
icon: const Icon(Icons.add),
label: const Text('Add line'),
),
],
),
const SizedBox(height: 8),
if (widget.lines.isEmpty)
Container(
width: double.infinity,
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
border: Border.all(color: theme.dividerColor),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'Add at least one line item',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
)
else
...widget.lines.asMap().entries.map((entry) {
final index = entry.key;
final line = entry.value;
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _LineItemCard(
key: ValueKey('po-line-${line.lineNo}-$index'),
line: line,
items: widget.items,
uom: widget.uom,
gstRates: widget.gstRates,
onRemove: widget.lines.length > 1
? () => widget.onRemoveLine(index)
: null,
),
);
}),
],
);
}
}
class _LineItemCard extends StatelessWidget {
const _LineItemCard({
super.key,
required this.line,
required this.items,
required this.uom,
required this.gstRates,
this.onRemove,
});
final PoLineItemDraft line;
final List<FilterOptionModel> items;
final List<FilterOptionModel> uom;
final List<FilterOptionModel> gstRates;
final VoidCallback? onRemove;
int? _parseId(String value) => int.tryParse(value.trim());
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final itemOptions = items
.map((e) {
final id = _parseId(e.id);
if (id == null) return null;
return AppDropdownOption(value: id, label: e.name);
})
.whereType<AppDropdownOption<int>>()
.toList();
final uomOptions = uom
.map((e) {
final id = _parseId(e.id);
if (id == null) return null;
return AppDropdownOption(value: id, label: e.name);
})
.whereType<AppDropdownOption<int>>()
.toList();
final gstOptions = [
const AppDropdownOption<int?>(value: null, label: 'No GST'),
...gstRates.map((e) {
final id = _parseId(e.id);
if (id == null) return null;
return AppDropdownOption<int?>(value: id, label: e.name);
}),
].whereType<AppDropdownOption<int?>>().toList();
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(color: theme.dividerColor),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text('Line ${line.lineNo}', style: theme.textTheme.titleSmall),
const Spacer(),
if (onRemove != null)
IconButton(
tooltip: 'Remove line',
icon: const Icon(Icons.delete_outline),
onPressed: onRemove,
),
],
),
const SizedBox(height: 12),
FormRowThree(
children: [
AppSearchableDropdown<int>(
label: 'Item *',
value: line.itemId,
searchHint: 'Search item...',
options: itemOptions,
onChanged: (v) => line.itemId = v,
validator: (v) => v == null ? 'Item is required' : null,
),
AppTextField(
controller: line.qtyController,
label: 'Quantity *',
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
validator: (v) {
if (v == null || v.trim().isEmpty) {
return 'Quantity is required';
}
final qty = double.tryParse(v);
if (qty == null || qty <= 0) return 'Enter a valid quantity';
return null;
},
),
AppSearchableDropdown<int>(
label: 'UOM *',
value: line.uomId,
searchHint: 'Search UOM...',
options: uomOptions,
onChanged: (v) => line.uomId = v,
validator: (v) => v == null ? 'UOM is required' : null,
),
],
),
FormRowThree(
children: [
AppTextField(
controller: line.rateController,
label: 'Rate *',
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
validator: (v) {
if (v == null || v.trim().isEmpty) return 'Rate is required';
final rate = double.tryParse(v);
if (rate == null || rate < 0) return 'Enter a valid rate';
return null;
},
),
AppTextField(
controller: line.discountController,
label: 'Discount %',
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
),
AppSearchableDropdown<int?>(
label: 'GST Rate',
value: line.gstRateId,
searchHint: 'Search GST rate...',
options: gstOptions,
onChanged: (v) => line.gstRateId = v,
),
],
),
const SizedBox(height: 12),
AppTextField(
controller: line.remarksController,
label: 'Remarks',
),
],
),
);
}
}

View File

@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/utils/formatters.dart';
import '../../../../core/constants/enums.dart';
import '../../../../core/constants/route_constants.dart';
@ -12,6 +11,7 @@ import '../../../../shared/models/user_management_models.dart';
import '../../../../shared/utils/file_download_helper.dart';
import '../../../../shared/providers/permissions_provider.dart';
import '../../../../shared/widgets/app_confirmation_dialog.dart';
import '../../../users/presentation/widgets/user_rich_data_table.dart';
import '../../../../shared/widgets/app_dropdown.dart';
import '../../../../shared/widgets/app_card.dart';
import '../../../../shared/widgets/app_loading_view.dart';
@ -466,17 +466,10 @@ class _UsersTab extends ConsumerStatefulWidget {
}
class _UsersTabState extends ConsumerState<_UsersTab> {
static const _tableMinWidth = 1120.0;
String? _selectedRoleName;
String? _selectedDepartmentName;
String? _selectedStatusLabel;
String _formatLastLogin(DateTime? value) {
if (value == null) return '';
return DateFormatter.displayDateTime(value);
}
int? _roleIdForFilter(UserFiltersModel? filters) {
if (_selectedRoleName == null || filters == null) return null;
for (final role in filters.roles) {
@ -729,102 +722,18 @@ class _UsersTabState extends ConsumerState<_UsersTab> {
)
else
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
final tableWidth = constraints.maxWidth < _tableMinWidth
? _tableMinWidth
: constraints.maxWidth;
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 4),
child: ConstrainedBox(
constraints: BoxConstraints(minWidth: tableWidth),
child: DataTable(
horizontalMargin: 20,
columnSpacing: 16,
headingRowColor: WidgetStateProperty.all(
Theme.of(context)
.colorScheme
.surfaceContainerHighest
.withValues(alpha: 0.4),
),
columns: const [
DataColumn(label: Text('USER')),
DataColumn(label: Text('EMPLOYEE CODE')),
DataColumn(label: Text('ROLE')),
DataColumn(label: Text('DEPARTMENT')),
DataColumn(label: Text('PLANT')),
DataColumn(label: Text('LAST LOGIN')),
DataColumn(label: Text('STATUS')),
DataColumn(
label: UserTableActionsHeader(),
),
],
rows: usersState.users.map((user) {
final status = userStatusFromApi(user.status);
return DataRow(
cells: [
DataCell(
Row(
children: [
UserAvatarChip(
name: user.fullName,
initials: user.initialsDisplay,
),
const SizedBox(width: 10),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
user.fullName,
style: const TextStyle(
fontWeight: FontWeight.w600,
),
),
Text(
user.email,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
],
),
),
DataCell(Text(user.employeeCode)),
DataCell(RoleBadge(label: user.roleLabel)),
DataCell(Text(user.departmentLabel)),
DataCell(Text(user.plantLabel)),
DataCell(Text(_formatLastLogin(user.lastLoginAt))),
DataCell(
StatusBadge(
label: status.label,
color: status.color,
),
),
DataCell(
UserTableActionsCell(
child: UserTableActions(
user: user,
canEdit: canEditUser,
canResetPassword: canEditUser,
canDeactivate: canDeleteUser,
onEdit: () => _editUser(user),
onResetPassword: () =>
_resetPassword(user),
onDeactivate: () =>
_deactivateUser(user),
),
),
),
],
);
}).toList(),
),
),
);
},
child: UserRichDataTable(
wrapInCard: false,
users: usersState.users,
actionsBuilder: (_, user) => UserTableActions(
user: user,
canEdit: canEditUser,
canResetPassword: canEditUser,
canDeactivate: canDeleteUser,
onEdit: () => _editUser(user),
onResetPassword: () => _resetPassword(user),
onDeactivate: () => _deactivateUser(user),
),
),
),
const Divider(height: 1),

View File

@ -265,6 +265,8 @@ class _AddUserPanelState extends ConsumerState<AddUserPanel> {
label: 'Mobile',
hint: '9XXXXXXXXX',
keyboardType: TextInputType.phone,
validator: Validators.optionalMobile,
inputFormatters: Validators.mobileInput,
),
),
const SizedBox(height: 12),

View File

@ -151,6 +151,72 @@ class RoleBadge extends StatelessWidget {
}
}
class EmployeeCodeBadge extends StatelessWidget {
const EmployeeCodeBadge({super.key, required this.code});
final String code;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(20),
),
child: Text(
code,
style: theme.textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurfaceVariant,
),
),
);
}
}
class UserTableUserCell extends StatelessWidget {
const UserTableUserCell({super.key, required this.user});
final ManagedUserModel user;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
children: [
UserAvatarChip(
name: user.fullName,
initials: user.initialsDisplay,
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
user.fullName,
style: const TextStyle(fontWeight: FontWeight.w600),
overflow: TextOverflow.ellipsis,
),
Text(
user.email,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
overflow: TextOverflow.ellipsis,
),
],
),
),
],
);
}
}
class UserAvatarChip extends StatelessWidget {
const UserAvatarChip({super.key, required this.name, this.initials});

View File

@ -10,6 +10,7 @@ import '../../../../shared/widgets/app_data_table.dart';
import '../../../../shared/widgets/app_empty_state.dart';
import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_search_field.dart';
import '../../../../shared/widgets/app_table_shell.dart';
import '../../../../shared/widgets/error_view.dart';
import '../../../../shared/widgets/page_header.dart';
import '../providers/roles_provider.dart';
@ -52,31 +53,37 @@ class _RoleListScreenState extends ConsumerState<RoleListScreen> {
subtitle: 'Manage roles and permission assignments',
),
const SizedBox(height: 16),
SizedBox(
width: context.isMobile ? double.infinity : 320,
child: AppSearchField(
controller: _searchController,
hint: 'Search roles...',
onChanged: ref.read(rolesListProvider.notifier).setSearch,
),
),
const SizedBox(height: 16),
Expanded(
child: RefreshIndicator(
onRefresh: () => ref.read(rolesListProvider.notifier).refresh(),
child: roles.isEmpty
? ListView(
children: const [
AppEmptyState(
title: 'No roles found',
description: 'Roles from the API will appear here.',
icon: Icons.security_outlined,
),
],
)
: context.isMobile
? _RoleCardList(roles: roles, onOpen: _openRole)
: _RoleDataTable(roles: roles, onOpen: _openRole),
child: AppTableShell(
toolbar: SizedBox(
width: context.isMobile ? double.infinity : 320,
child: AppSearchField(
controller: _searchController,
hint: 'Search roles...',
onChanged: ref.read(rolesListProvider.notifier).setSearch,
),
),
child: RefreshIndicator(
onRefresh: () => ref.read(rolesListProvider.notifier).refresh(),
child: roles.isEmpty
? ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: const [
SizedBox(
height: 240,
child: AppEmptyState(
title: 'No roles found',
description:
'Roles from the API will appear here.',
icon: Icons.security_outlined,
),
),
],
)
: context.isMobile
? _RoleCardList(roles: roles, onOpen: _openRole)
: _RoleDataTable(roles: roles, onOpen: _openRole),
),
),
),
],
@ -100,21 +107,29 @@ class _RoleDataTable extends StatelessWidget {
@override
Widget build(BuildContext context) {
return AppDataTable<RoleCardModel>(
wrapInCard: false,
columns: [
AppDataColumn(label: 'Role Name', cellBuilder: (_, r) => Text(r.name)),
AppDataColumn(label: 'Role Name', flex: 2, cellBuilder: (_, r) => Text(r.name)),
AppDataColumn(
label: 'Description',
flex: 3,
cellBuilder: (_, r) => Text(r.description ?? ''),
),
AppDataColumn(
label: 'Users Count',
flex: 1,
cellBuilder: (_, r) => Text('${r.userCount}'),
),
AppDataColumn(
label: 'Actions',
cellBuilder: (_, r) => TextButton(
onPressed: () => onOpen(r),
child: const Text('View Matrix'),
flex: 1,
alignment: Alignment.centerRight,
cellBuilder: (_, r) => Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () => onOpen(r),
child: const Text('View Matrix'),
),
),
),
],

View File

@ -151,7 +151,8 @@ class _CompanyProfileSettingsScreenState
AppTextField(
controller: _gstController,
label: 'GST/VAT Number',
validator: Validators.gstNumber,
validator: Validators.optionalGstin,
inputFormatters: Validators.gstinInput,
),
const SizedBox(height: 16),
AppTextField(
@ -171,6 +172,8 @@ class _CompanyProfileSettingsScreenState
controller: _phoneController,
label: 'Phone',
keyboardType: TextInputType.phone,
validator: Validators.optionalMobile,
inputFormatters: Validators.mobileInput,
),
const SizedBox(height: 16),
AppTextField(

View File

@ -3,6 +3,8 @@ import 'package:go_router/go_router.dart';
import '../../../../core/utils/responsive_utils.dart';
import '../../../../shared/widgets/app_card.dart';
import '../../../../shared/widgets/app_dropdown.dart';
import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/page_header.dart';
import '../../domain/entities/app_settings.dart';
@ -138,14 +140,15 @@ class SettingsDropdownField<T> extends StatelessWidget {
@override
Widget build(BuildContext context) {
return DropdownButtonFormField<T>(
return AppSearchableDropdown<T>(
label: label,
value: value,
decoration: InputDecoration(labelText: label),
items: items
searchHint: 'Search ${label.toLowerCase()}...',
options: items
.map(
(item) => DropdownMenuItem(
(item) => AppDropdownOption(
value: item,
child: Text(itemLabel(item)),
label: itemLabel(item),
),
)
.toList(),

View File

@ -194,7 +194,8 @@ class _UserFormScreenState extends ConsumerState<UserFormScreen> {
controller: _mobileController,
label: 'Mobile',
keyboardType: TextInputType.phone,
validator: Validators.phone,
validator: Validators.mobile,
inputFormatters: Validators.mobileInput,
),
const SizedBox(height: 16),
if (!isEditing) ...[

View File

@ -8,7 +8,6 @@ import '../../../../core/errors/failure.dart';
import '../../../../core/utils/responsive_utils.dart';
import '../../../../shared/models/user_management_models.dart';
import '../../../../shared/widgets/app_confirmation_dialog.dart';
import '../../../../shared/widgets/app_data_table.dart';
import '../../../../shared/widgets/app_dropdown.dart';
import '../../../../shared/widgets/app_empty_state.dart';
import '../../../../shared/widgets/app_loading_view.dart';
@ -18,7 +17,9 @@ import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/app_status_chip.dart';
import '../../../../shared/widgets/error_view.dart';
import '../../../../shared/widgets/kpi_card.dart';
import '../../../../shared/widgets/app_table_shell.dart';
import '../../../../shared/widgets/page_header.dart';
import '../widgets/user_rich_data_table.dart';
import '../providers/users_provider.dart';
class UserListScreen extends ConsumerStatefulWidget {
@ -68,63 +69,70 @@ class _UserListScreenState extends ConsumerState<UserListScreen> {
_SummaryStrip(summary: state.summary!),
],
const SizedBox(height: 16),
_FiltersBar(
searchController: _searchController,
filters: state.filters,
query: state.query,
onSearch: ref.read(usersListProvider.notifier).setSearch,
onStatusChanged: (status) =>
ref.read(usersListProvider.notifier).setStatusFilter(status),
onRoleChanged: (roleId) =>
ref.read(usersListProvider.notifier).setRoleFilter(roleId),
onDepartmentChanged: (deptId) =>
ref.read(usersListProvider.notifier).setDepartmentFilter(deptId),
),
const SizedBox(height: 16),
Expanded(
child: RefreshIndicator(
onRefresh: () => ref.read(usersListProvider.notifier).refresh(),
child: state.users.isEmpty
? ListView(
children: const [
AppEmptyState(
title: 'No users found',
description: 'Try adjusting filters or add a new user.',
icon: Icons.people_outline,
),
],
)
: context.isMobile
? _UserCardList(
users: state.users,
onView: _viewUser,
onEdit: _editUser,
onToggleStatus: _toggleStatus,
onDeactivate: _deactivateUser,
)
: _UserDataTable(
users: state.users,
sortBy: state.query.sortBy,
sortOrder: state.query.sortOrder,
onSort: (column, ascending) => ref
.read(usersListProvider.notifier)
.setSort(column, ascending ? 'asc' : 'desc'),
onView: _viewUser,
onEdit: _editUser,
onToggleStatus: _toggleStatus,
onDeactivate: _deactivateUser,
),
child: AppTableShell(
toolbar: _FiltersBar(
searchController: _searchController,
filters: state.filters,
query: state.query,
onSearch: ref.read(usersListProvider.notifier).setSearch,
onStatusChanged: (status) =>
ref.read(usersListProvider.notifier).setStatusFilter(status),
onRoleChanged: (roleId) =>
ref.read(usersListProvider.notifier).setRoleFilter(roleId),
onDepartmentChanged: (deptId) => ref
.read(usersListProvider.notifier)
.setDepartmentFilter(deptId),
),
footer: AppPagination(
currentPage: state.query.page,
totalPages: state.totalPages,
totalItems: state.total,
pageSize: state.query.limit,
onPageChanged: ref.read(usersListProvider.notifier).setPage,
onPageSizeChanged:
ref.read(usersListProvider.notifier).setPageSize,
),
child: RefreshIndicator(
onRefresh: () => ref.read(usersListProvider.notifier).refresh(),
child: state.users.isEmpty
? ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: const [
SizedBox(
height: 240,
child: AppEmptyState(
title: 'No users found',
description:
'Try adjusting filters or add a new user.',
icon: Icons.people_outline,
),
),
],
)
: context.isMobile
? _UserCardList(
users: state.users,
onView: _viewUser,
onEdit: _editUser,
onToggleStatus: _toggleStatus,
onDeactivate: _deactivateUser,
)
: _UserDataTable(
users: state.users,
sortBy: state.query.sortBy,
sortOrder: state.query.sortOrder,
onSort: (column, ascending) => ref
.read(usersListProvider.notifier)
.setSort(column, ascending ? 'asc' : 'desc'),
onView: _viewUser,
onEdit: _editUser,
onToggleStatus: _toggleStatus,
onDeactivate: _deactivateUser,
),
),
),
),
const SizedBox(height: 12),
AppPagination(
currentPage: state.query.page,
totalPages: state.totalPages,
totalItems: state.total,
pageSize: state.query.limit,
onPageChanged: ref.read(usersListProvider.notifier).setPage,
onPageSizeChanged: ref.read(usersListProvider.notifier).setPageSize,
),
],
),
),
@ -209,72 +217,80 @@ class _FiltersBar extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 12,
runSpacing: 12,
crossAxisAlignment: WrapCrossAlignment.center,
final searchField = AppSearchField(
controller: searchController,
hint: 'Search users...',
onChanged: onSearch,
);
final dropdowns = [
AppSearchableDropdown<String?>(
label: 'Status',
value: query.status,
searchHint: 'Search status...',
isDense: true,
options: [
const AppDropdownOption(value: null, label: 'All statuses'),
...(filters?.statuses ?? [])
.map((s) => AppDropdownOption(value: s.id, label: s.name)),
],
onChanged: onStatusChanged,
),
AppSearchableDropdown<int?>(
label: 'Role',
value: query.roleId,
searchHint: 'Search role...',
isDense: true,
options: [
const AppDropdownOption(value: null, label: 'All roles'),
...(filters?.roles ?? []).map(
(r) => AppDropdownOption(
value: int.tryParse(r.id),
label: r.name,
),
),
],
onChanged: onRoleChanged,
),
AppSearchableDropdown<int?>(
label: 'Department',
value: query.departmentId,
searchHint: 'Search department...',
isDense: true,
options: [
const AppDropdownOption(value: null, label: 'All departments'),
...(filters?.departments ?? []).map(
(d) => AppDropdownOption(
value: int.tryParse(d.id),
label: d.name,
),
),
],
onChanged: onDepartmentChanged,
),
];
if (context.isMobile) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
searchField,
const SizedBox(height: 12),
...dropdowns.expand((f) => [f, const SizedBox(height: 12)]).toList()
..removeLast(),
],
);
}
return Row(
children: [
SizedBox(
width: context.isMobile ? double.infinity : 280,
child: AppSearchField(
controller: searchController,
hint: 'Search users...',
onChanged: onSearch,
),
),
SizedBox(
width: 180,
child: AppSearchableDropdown<String?>(
label: 'Status',
value: query.status,
searchHint: 'Search status...',
isDense: true,
options: [
const AppDropdownOption(value: null, label: 'All statuses'),
...(filters?.statuses ?? [])
.map((s) => AppDropdownOption(value: s.id, label: s.name)),
],
onChanged: onStatusChanged,
),
),
SizedBox(
width: 180,
child: AppSearchableDropdown<int?>(
label: 'Role',
value: query.roleId,
searchHint: 'Search role...',
isDense: true,
options: [
const AppDropdownOption(value: null, label: 'All roles'),
...(filters?.roles ?? []).map(
(r) => AppDropdownOption(
value: int.tryParse(r.id),
label: r.name,
),
),
],
onChanged: onRoleChanged,
),
),
SizedBox(
width: 180,
child: AppSearchableDropdown<int?>(
label: 'Department',
value: query.departmentId,
searchHint: 'Search department...',
isDense: true,
options: [
const AppDropdownOption(value: null, label: 'All departments'),
...(filters?.departments ?? []).map(
(d) => AppDropdownOption(
value: int.tryParse(d.id),
label: d.name,
),
),
],
onChanged: onDepartmentChanged,
),
),
Expanded(flex: 3, child: searchField),
const SizedBox(width: 12),
Expanded(flex: 2, child: dropdowns[0]),
const SizedBox(width: 12),
Expanded(flex: 2, child: dropdowns[1]),
const SizedBox(width: 12),
Expanded(flex: 2, child: dropdowns[2]),
],
);
}
@ -303,40 +319,19 @@ class _UserDataTable extends StatelessWidget {
@override
Widget build(BuildContext context) {
return AppDataTable<ManagedUserModel>(
return UserRichDataTable(
users: users,
sortColumn: sortBy,
sortAscending: sortOrder == 'asc',
onSort: onSort,
columns: [
AppDataColumn(
label: 'Employee ID',
sortKey: 'employee_code',
cellBuilder: (_, user) => Text(user.employeeCode),
),
AppDataColumn(
label: 'Name',
sortKey: 'full_name',
cellBuilder: (_, user) => Text(user.fullName),
),
AppDataColumn(label: 'Email', cellBuilder: (_, user) => Text(user.email)),
AppDataColumn(label: 'Mobile', cellBuilder: (_, user) => Text(user.mobile)),
AppDataColumn(label: 'Role', cellBuilder: (_, user) => Text(user.roleLabel)),
AppDataColumn(
label: 'Status',
cellBuilder: (_, user) => AppStatusChip(status: user.status),
),
AppDataColumn(
label: 'Actions',
cellBuilder: (_, user) => _UserActions(
user: user,
onView: onView,
onEdit: onEdit,
onToggleStatus: onToggleStatus,
onDeactivate: onDeactivate,
),
),
],
rows: users,
wrapInCard: false,
actionsBuilder: (_, user) => _UserActions(
user: user,
onView: onView,
onEdit: onEdit,
onToggleStatus: onToggleStatus,
onDeactivate: onDeactivate,
),
);
}
}

View File

@ -201,7 +201,8 @@ class _UserProfileScreenState extends ConsumerState<UserProfileScreen> {
controller: _mobileController,
label: 'Mobile',
keyboardType: TextInputType.phone,
validator: Validators.phone,
validator: Validators.mobile,
inputFormatters: Validators.mobileInput,
),
const SizedBox(height: 8),
Align(

View File

@ -0,0 +1,97 @@
import 'package:flutter/material.dart';
import '../../../../core/utils/formatters.dart';
import '../../../../shared/models/user_management_models.dart';
import '../../../../shared/widgets/app_data_table.dart';
import '../../../../shared/widgets/app_status_chip.dart';
import '../../../rbac/presentation/widgets/rbac_widgets.dart';
typedef UserTableActionsBuilder = Widget Function(
BuildContext context,
ManagedUserModel user,
);
class UserRichDataTable extends StatelessWidget {
const UserRichDataTable({
super.key,
required this.users,
required this.actionsBuilder,
this.sortColumn,
this.sortAscending = true,
this.onSort,
this.wrapInCard = false,
});
final List<ManagedUserModel> users;
final UserTableActionsBuilder actionsBuilder;
final String? sortColumn;
final bool sortAscending;
final void Function(String column, bool ascending)? onSort;
final bool wrapInCard;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AppDataTable<ManagedUserModel>(
wrapInCard: wrapInCard,
sortColumn: sortColumn,
sortAscending: sortAscending,
onSort: onSort,
columns: [
AppDataColumn(
label: 'User',
sortKey: 'full_name',
flex: 3,
cellBuilder: (_, user) => UserTableUserCell(user: user),
),
AppDataColumn(
label: 'Employee Code',
sortKey: 'employee_code',
flex: 1,
cellBuilder: (_, user) => EmployeeCodeBadge(code: user.employeeCode),
),
AppDataColumn(
label: 'Role',
flex: 2,
cellBuilder: (_, user) => RoleBadge(label: user.roleLabel),
),
AppDataColumn(
label: 'Department',
flex: 2,
cellBuilder: (_, user) => Text(user.departmentLabel),
),
AppDataColumn(
label: 'Plant',
flex: 2,
cellBuilder: (_, user) => Text(user.plantLabel),
),
AppDataColumn(
label: 'Last Login',
flex: 2,
cellBuilder: (_, user) => Text(
DateFormatter.formatUserLastLogin(user.lastLoginAt),
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
AppDataColumn(
label: 'Status',
flex: 1,
cellBuilder: (_, user) => AppStatusChip(status: user.status),
),
AppDataColumn(
label: 'Actions',
flex: 1,
alignment: Alignment.centerRight,
cellBuilder: (context, user) => Align(
alignment: Alignment.centerRight,
child: actionsBuilder(context, user),
),
),
],
rows: users,
);
}
}

View File

@ -0,0 +1,229 @@
import 'package:dio/dio.dart';
import '../../../../core/constants/api_endpoints.dart';
import '../../../../shared/models/api_response.dart';
import '../../../../shared/models/vendor_model.dart';
class VendorRemoteDataSource {
VendorRemoteDataSource({required this.dio});
final Dio dio;
Future<PaginatedResponse<VendorModel>> getVendors(VendorListQuery query) async {
final response = await dio.get(
ApiEndpoints.vendors,
queryParameters: _queryToMap(query),
);
return _parsePaginated(response.data, VendorModel.fromJson);
}
Future<VendorModel> getVendorById(String id) async {
final response = await dio.get(ApiEndpoints.vendorById(id));
return VendorModel.fromJson(response.data['data'] as Map<String, dynamic>);
}
Future<VendorModel> createVendor(Map<String, dynamic> data) async {
final response = await dio.post(ApiEndpoints.vendors, data: data);
return VendorModel.fromJson(response.data['data'] as Map<String, dynamic>);
}
Future<VendorModel> updateVendor(String id, Map<String, dynamic> data) async {
final response = await dio.put(ApiEndpoints.vendorById(id), data: data);
return VendorModel.fromJson(response.data['data'] as Map<String, dynamic>);
}
Future<void> deleteVendor(String id) async {
await dio.delete(ApiEndpoints.vendorById(id));
}
Future<VendorModel> updateVendorStatus(String id, String status) async {
final response = await dio.patch(
ApiEndpoints.vendorStatus(id),
data: {'status': status},
);
return VendorModel.fromJson(response.data['data'] as Map<String, dynamic>);
}
Future<List<VendorAddressModel>> getAddresses(String vendorId) async {
final response = await dio.get(ApiEndpoints.vendorAddresses(vendorId));
return _parseList(response.data, VendorAddressModel.fromJson);
}
Future<VendorAddressModel> createAddress(
String vendorId,
Map<String, dynamic> data,
) async {
final response = await dio.post(
ApiEndpoints.vendorAddresses(vendorId),
data: data,
);
return VendorAddressModel.fromJson(
response.data['data'] as Map<String, dynamic>,
);
}
Future<VendorAddressModel> updateAddress(
String vendorId,
String addressId,
Map<String, dynamic> data,
) async {
final response = await dio.put(
ApiEndpoints.vendorAddressById(vendorId, addressId),
data: data,
);
return VendorAddressModel.fromJson(
response.data['data'] as Map<String, dynamic>,
);
}
Future<void> deleteAddress(String vendorId, String addressId) async {
await dio.delete(ApiEndpoints.vendorAddressById(vendorId, addressId));
}
Future<List<VendorContactModel>> getContacts(String vendorId) async {
final response = await dio.get(ApiEndpoints.vendorContacts(vendorId));
return _parseList(response.data, VendorContactModel.fromJson);
}
Future<VendorContactModel> createContact(
String vendorId,
Map<String, dynamic> data,
) async {
final response = await dio.post(
ApiEndpoints.vendorContacts(vendorId),
data: data,
);
return VendorContactModel.fromJson(
response.data['data'] as Map<String, dynamic>,
);
}
Future<VendorContactModel> updateContact(
String vendorId,
String contactId,
Map<String, dynamic> data,
) async {
final response = await dio.put(
ApiEndpoints.vendorContactById(vendorId, contactId),
data: data,
);
return VendorContactModel.fromJson(
response.data['data'] as Map<String, dynamic>,
);
}
Future<void> deleteContact(String vendorId, String contactId) async {
await dio.delete(ApiEndpoints.vendorContactById(vendorId, contactId));
}
Future<List<VendorBankDetailModel>> getBankDetails(String vendorId) async {
final response = await dio.get(ApiEndpoints.vendorBankDetails(vendorId));
return _parseList(response.data, VendorBankDetailModel.fromJson);
}
Future<VendorBankDetailModel> createBankDetail(
String vendorId,
Map<String, dynamic> data,
) async {
final response = await dio.post(
ApiEndpoints.vendorBankDetails(vendorId),
data: data,
);
return VendorBankDetailModel.fromJson(
response.data['data'] as Map<String, dynamic>,
);
}
Future<VendorBankDetailModel> updateBankDetail(
String vendorId,
String bankDetailId,
Map<String, dynamic> data,
) async {
final response = await dio.put(
ApiEndpoints.vendorBankDetailById(vendorId, bankDetailId),
data: data,
);
return VendorBankDetailModel.fromJson(
response.data['data'] as Map<String, dynamic>,
);
}
Future<void> deleteBankDetail(String vendorId, String bankDetailId) async {
await dio.delete(ApiEndpoints.vendorBankDetailById(vendorId, bankDetailId));
}
Map<String, dynamic> _queryToMap(VendorListQuery query) {
return {
'page': query.page,
'limit': query.limit,
if (query.search != null && query.search!.isNotEmpty) 'search': query.search,
if (query.status != null) 'status': query.status,
if (query.vendorType != null) 'vendor_type': query.vendorType,
if (query.isActive != null) 'is_active': query.isActive,
};
}
List<T> _parseList<T>(
dynamic body,
T Function(Map<String, dynamic>) fromJson,
) {
if (body is! Map<String, dynamic>) return [];
final raw = body['data'];
if (raw is List) {
return raw.whereType<Map<String, dynamic>>().map(fromJson).toList();
}
if (raw is Map<String, dynamic>) {
final items = raw['items'];
if (items is List) {
return items.whereType<Map<String, dynamic>>().map(fromJson).toList();
}
}
return [];
}
PaginatedResponse<T> _parsePaginated<T>(
dynamic body,
T Function(Map<String, dynamic>) fromJson,
) {
if (body is! Map<String, dynamic>) {
return const PaginatedResponse(
items: [],
page: 1,
limit: 20,
total: 0,
totalPages: 1,
);
}
final raw = body['data'];
final meta = body['meta'] as Map<String, dynamic>? ?? {};
if (raw is List) {
final items = raw.whereType<Map<String, dynamic>>().map(fromJson).toList();
final limit = (meta['limit'] as num?)?.toInt() ?? items.length;
final total = (meta['total'] as num?)?.toInt() ?? items.length;
return PaginatedResponse(
items: items,
page: (meta['page'] as num?)?.toInt() ?? 1,
limit: limit,
total: total,
totalPages: limit > 0 ? ((total + limit - 1) / limit).ceil().clamp(1, 999999) : 1,
);
}
if (raw is Map<String, dynamic>) {
return PaginatedResponse.fromJson(
raw,
(json) => fromJson(json! as Map<String, dynamic>),
);
}
return const PaginatedResponse(
items: [],
page: 1,
limit: 20,
total: 0,
totalPages: 1,
);
}
}

View File

@ -0,0 +1,133 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/network/api_handler.dart';
import '../../../../core/network/dio_client.dart';
import '../../../../shared/models/api_response.dart';
import '../../../../shared/models/vendor_model.dart';
import '../../domain/repositories/vendor_repository.dart';
import '../datasources/vendor_remote_data_source.dart';
final vendorRemoteDataSourceProvider = Provider<VendorRemoteDataSource>((ref) {
return VendorRemoteDataSource(dio: ref.watch(dioProvider));
});
final vendorRepositoryProvider = Provider<VendorRepository>((ref) {
return VendorRepositoryImpl(dataSource: ref.watch(vendorRemoteDataSourceProvider));
});
class VendorRepositoryImpl implements VendorRepository {
VendorRepositoryImpl({required this.dataSource});
final VendorRemoteDataSource dataSource;
@override
Future<Result<PaginatedResponse<VendorModel>>> getVendors(VendorListQuery query) {
return safeApiCall(() => dataSource.getVendors(query));
}
@override
Future<Result<VendorModel>> getVendorById(String id) {
return safeApiCall(() => dataSource.getVendorById(id));
}
@override
Future<Result<VendorModel>> createVendor(Map<String, dynamic> data) {
return safeApiCall(() => dataSource.createVendor(data));
}
@override
Future<Result<VendorModel>> updateVendor(String id, Map<String, dynamic> data) {
return safeApiCall(() => dataSource.updateVendor(id, data));
}
@override
Future<Result<void>> deleteVendor(String id) {
return safeApiCall(() => dataSource.deleteVendor(id));
}
@override
Future<Result<VendorModel>> updateVendorStatus(String id, String status) {
return safeApiCall(() => dataSource.updateVendorStatus(id, status));
}
@override
Future<Result<List<VendorAddressModel>>> getAddresses(String vendorId) {
return safeApiCall(() => dataSource.getAddresses(vendorId));
}
@override
Future<Result<VendorAddressModel>> createAddress(
String vendorId,
Map<String, dynamic> data,
) {
return safeApiCall(() => dataSource.createAddress(vendorId, data));
}
@override
Future<Result<VendorAddressModel>> updateAddress(
String vendorId,
String addressId,
Map<String, dynamic> data,
) {
return safeApiCall(() => dataSource.updateAddress(vendorId, addressId, data));
}
@override
Future<Result<void>> deleteAddress(String vendorId, String addressId) {
return safeApiCall(() => dataSource.deleteAddress(vendorId, addressId));
}
@override
Future<Result<List<VendorContactModel>>> getContacts(String vendorId) {
return safeApiCall(() => dataSource.getContacts(vendorId));
}
@override
Future<Result<VendorContactModel>> createContact(
String vendorId,
Map<String, dynamic> data,
) {
return safeApiCall(() => dataSource.createContact(vendorId, data));
}
@override
Future<Result<VendorContactModel>> updateContact(
String vendorId,
String contactId,
Map<String, dynamic> data,
) {
return safeApiCall(() => dataSource.updateContact(vendorId, contactId, data));
}
@override
Future<Result<void>> deleteContact(String vendorId, String contactId) {
return safeApiCall(() => dataSource.deleteContact(vendorId, contactId));
}
@override
Future<Result<List<VendorBankDetailModel>>> getBankDetails(String vendorId) {
return safeApiCall(() => dataSource.getBankDetails(vendorId));
}
@override
Future<Result<VendorBankDetailModel>> createBankDetail(
String vendorId,
Map<String, dynamic> data,
) {
return safeApiCall(() => dataSource.createBankDetail(vendorId, data));
}
@override
Future<Result<VendorBankDetailModel>> updateBankDetail(
String vendorId,
String bankDetailId,
Map<String, dynamic> data,
) {
return safeApiCall(() => dataSource.updateBankDetail(vendorId, bankDetailId, data));
}
@override
Future<Result<void>> deleteBankDetail(String vendorId, String bankDetailId) {
return safeApiCall(() => dataSource.deleteBankDetail(vendorId, bankDetailId));
}
}

View File

@ -0,0 +1,45 @@
import '../../../../core/network/api_handler.dart';
import '../../../../shared/models/api_response.dart';
import '../../../../shared/models/vendor_model.dart';
abstract class VendorRepository {
Future<Result<PaginatedResponse<VendorModel>>> getVendors(VendorListQuery query);
Future<Result<VendorModel>> getVendorById(String id);
Future<Result<VendorModel>> createVendor(Map<String, dynamic> data);
Future<Result<VendorModel>> updateVendor(String id, Map<String, dynamic> data);
Future<Result<void>> deleteVendor(String id);
Future<Result<VendorModel>> updateVendorStatus(String id, String status);
Future<Result<List<VendorAddressModel>>> getAddresses(String vendorId);
Future<Result<VendorAddressModel>> createAddress(
String vendorId,
Map<String, dynamic> data,
);
Future<Result<VendorAddressModel>> updateAddress(
String vendorId,
String addressId,
Map<String, dynamic> data,
);
Future<Result<void>> deleteAddress(String vendorId, String addressId);
Future<Result<List<VendorContactModel>>> getContacts(String vendorId);
Future<Result<VendorContactModel>> createContact(
String vendorId,
Map<String, dynamic> data,
);
Future<Result<VendorContactModel>> updateContact(
String vendorId,
String contactId,
Map<String, dynamic> data,
);
Future<Result<void>> deleteContact(String vendorId, String contactId);
Future<Result<List<VendorBankDetailModel>>> getBankDetails(String vendorId);
Future<Result<VendorBankDetailModel>> createBankDetail(
String vendorId,
Map<String, dynamic> data,
);
Future<Result<VendorBankDetailModel>> updateBankDetail(
String vendorId,
String bankDetailId,
Map<String, dynamic> data,
);
Future<Result<void>> deleteBankDetail(String vendorId, String bankDetailId);
}

View File

@ -0,0 +1,329 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../shared/models/vendor_model.dart';
import '../../data/repositories/vendor_repository_impl.dart';
class VendorsListState {
const VendorsListState({
this.vendors = const [],
this.query = const VendorListQuery(limit: 20),
this.total = 0,
this.totalPages = 1,
this.isRefreshing = false,
this.actionError,
this.actionSuccess,
});
final List<VendorModel> vendors;
final VendorListQuery query;
final int total;
final int totalPages;
final bool isRefreshing;
final String? actionError;
final String? actionSuccess;
VendorsListState copyWith({
List<VendorModel>? vendors,
VendorListQuery? query,
int? total,
int? totalPages,
bool? isRefreshing,
String? actionError,
String? actionSuccess,
bool clearMessages = false,
}) {
return VendorsListState(
vendors: vendors ?? this.vendors,
query: query ?? this.query,
total: total ?? this.total,
totalPages: totalPages ?? this.totalPages,
isRefreshing: isRefreshing ?? this.isRefreshing,
actionError: clearMessages ? null : actionError ?? this.actionError,
actionSuccess: clearMessages ? null : actionSuccess ?? this.actionSuccess,
);
}
}
final vendorsListProvider =
AsyncNotifierProvider.autoDispose<VendorsListNotifier, VendorsListState>(
VendorsListNotifier.new,
);
class VendorsListNotifier extends AutoDisposeAsyncNotifier<VendorsListState> {
@override
Future<VendorsListState> build() async {
return _load(const VendorListQuery(limit: 20));
}
Future<VendorsListState> _load(VendorListQuery query) async {
final repository = ref.read(vendorRepositoryProvider);
final result = await repository.getVendors(query);
if (result.failure != null) throw result.failure!;
final page = result.data!;
return VendorsListState(
vendors: page.items,
query: query,
total: page.total,
totalPages: page.totalPages,
);
}
Future<void> refresh() async {
final current = state.valueOrNull ?? const VendorsListState();
state = AsyncData(current.copyWith(isRefreshing: true, clearMessages: true));
try {
state = AsyncData(await _load(current.query));
} catch (e, st) {
state = AsyncError(e, st);
}
}
Future<void> applyQuery(VendorListQuery query) async {
final previous = state.valueOrNull;
if (previous == null) {
state = const AsyncLoading();
}
try {
state = AsyncData(await _load(query));
} catch (e, st) {
state = AsyncError(e, st);
}
}
void setSearch(String search) {
final current = state.valueOrNull;
if (current == null) return;
applyQuery(current.query.copyWith(search: search, page: 1));
}
void setStatusFilter(String? status) {
final current = state.valueOrNull;
if (current == null) return;
applyQuery(current.query.copyWith(status: status, page: 1));
}
void setVendorTypeFilter(String? vendorType) {
final current = state.valueOrNull;
if (current == null) return;
applyQuery(current.query.copyWith(vendorType: vendorType, page: 1));
}
void setPage(int page) {
final current = state.valueOrNull;
if (current == null) return;
applyQuery(current.query.copyWith(page: page));
}
void setPageSize(int limit) {
final current = state.valueOrNull;
if (current == null) return;
applyQuery(current.query.copyWith(limit: limit, page: 1));
}
Future<bool> deleteVendor(String id) async {
final repository = ref.read(vendorRepositoryProvider);
final result = await repository.deleteVendor(id);
if (result.failure != null) {
final current = state.valueOrNull;
if (current != null) {
state = AsyncData(current.copyWith(actionError: result.failure!.message));
}
return false;
}
await refresh();
final current = state.valueOrNull;
if (current != null) {
state = AsyncData(current.copyWith(actionSuccess: 'Vendor deleted'));
}
return true;
}
}
final vendorDetailProvider =
AsyncNotifierProvider.family<VendorDetailNotifier, VendorDetailState, String>(
VendorDetailNotifier.new,
);
class VendorDetailState {
const VendorDetailState({
required this.vendor,
this.addresses = const [],
this.contacts = const [],
this.bankDetails = const [],
});
final VendorModel vendor;
final List<VendorAddressModel> addresses;
final List<VendorContactModel> contacts;
final List<VendorBankDetailModel> bankDetails;
VendorDetailState copyWith({
VendorModel? vendor,
List<VendorAddressModel>? addresses,
List<VendorContactModel>? contacts,
List<VendorBankDetailModel>? bankDetails,
}) {
return VendorDetailState(
vendor: vendor ?? this.vendor,
addresses: addresses ?? this.addresses,
contacts: contacts ?? this.contacts,
bankDetails: bankDetails ?? this.bankDetails,
);
}
}
class VendorDetailNotifier extends FamilyAsyncNotifier<VendorDetailState, String> {
@override
Future<VendorDetailState> build(String arg) async {
return _loadAll(arg);
}
Future<VendorDetailState> _loadAll(String vendorId) async {
final repository = ref.read(vendorRepositoryProvider);
final vendorResult = await repository.getVendorById(vendorId);
if (vendorResult.failure != null) throw vendorResult.failure!;
final vendor = vendorResult.data!;
final addresses = vendor.addresses.isNotEmpty
? vendor.addresses
: (await repository.getAddresses(vendorId)).data ?? [];
final contacts = vendor.contacts.isNotEmpty
? vendor.contacts
: (await repository.getContacts(vendorId)).data ?? [];
final bankDetails = vendor.bankDetails.isNotEmpty
? vendor.bankDetails
: (await repository.getBankDetails(vendorId)).data ?? [];
return VendorDetailState(
vendor: vendor,
addresses: addresses,
contacts: contacts,
bankDetails: bankDetails,
);
}
Future<void> reload() async {
state = const AsyncLoading();
state = AsyncData(await _loadAll(arg));
}
Future<VendorModel?> updateVendor(Map<String, dynamic> data) async {
final repository = ref.read(vendorRepositoryProvider);
final result = await repository.updateVendor(arg, data);
if (result.failure != null) throw result.failure!;
await reload();
ref.invalidate(vendorsListProvider);
return result.data;
}
Future<bool> deleteVendor() async {
final repository = ref.read(vendorRepositoryProvider);
final result = await repository.deleteVendor(arg);
if (result.failure != null) throw result.failure!;
ref.invalidate(vendorsListProvider);
return true;
}
Future<VendorModel?> updateStatus(String status) async {
final repository = ref.read(vendorRepositoryProvider);
final result = await repository.updateVendorStatus(arg, status);
if (result.failure != null) throw result.failure!;
await reload();
ref.invalidate(vendorsListProvider);
return result.data;
}
Future<void> createAddress(Map<String, dynamic> data) async {
final repository = ref.read(vendorRepositoryProvider);
final result = await repository.createAddress(arg, data);
if (result.failure != null) throw result.failure!;
await reload();
}
Future<void> updateAddress(String addressId, Map<String, dynamic> data) async {
final repository = ref.read(vendorRepositoryProvider);
final result = await repository.updateAddress(arg, addressId, data);
if (result.failure != null) throw result.failure!;
await reload();
}
Future<void> deleteAddress(String addressId) async {
final repository = ref.read(vendorRepositoryProvider);
final result = await repository.deleteAddress(arg, addressId);
if (result.failure != null) throw result.failure!;
await reload();
}
Future<void> createContact(Map<String, dynamic> data) async {
final repository = ref.read(vendorRepositoryProvider);
final result = await repository.createContact(arg, data);
if (result.failure != null) throw result.failure!;
await reload();
}
Future<void> updateContact(String contactId, Map<String, dynamic> data) async {
final repository = ref.read(vendorRepositoryProvider);
final result = await repository.updateContact(arg, contactId, data);
if (result.failure != null) throw result.failure!;
await reload();
}
Future<void> deleteContact(String contactId) async {
final repository = ref.read(vendorRepositoryProvider);
final result = await repository.deleteContact(arg, contactId);
if (result.failure != null) throw result.failure!;
await reload();
}
Future<void> createBankDetail(Map<String, dynamic> data) async {
final repository = ref.read(vendorRepositoryProvider);
final result = await repository.createBankDetail(arg, data);
if (result.failure != null) throw result.failure!;
await reload();
}
Future<void> updateBankDetail(String bankDetailId, Map<String, dynamic> data) async {
final repository = ref.read(vendorRepositoryProvider);
final result = await repository.updateBankDetail(arg, bankDetailId, data);
if (result.failure != null) throw result.failure!;
await reload();
}
Future<void> deleteBankDetail(String bankDetailId) async {
final repository = ref.read(vendorRepositoryProvider);
final result = await repository.deleteBankDetail(arg, bankDetailId);
if (result.failure != null) throw result.failure!;
await reload();
}
}
final vendorFormProvider = AsyncNotifierProvider.family<
VendorFormNotifier, VendorModel?, String?>(VendorFormNotifier.new);
class VendorFormNotifier extends FamilyAsyncNotifier<VendorModel?, String?> {
@override
Future<VendorModel?> build(String? arg) async {
if (arg == null) return null;
final repository = ref.read(vendorRepositoryProvider);
final result = await repository.getVendorById(arg);
if (result.failure != null) throw result.failure!;
return result.data;
}
Future<VendorModel> submitCreate(Map<String, dynamic> data) async {
final repository = ref.read(vendorRepositoryProvider);
final result = await repository.createVendor(data);
if (result.failure != null) throw result.failure!;
ref.invalidate(vendorsListProvider);
return result.data!;
}
Future<VendorModel> submitUpdate(String id, Map<String, dynamic> data) async {
final repository = ref.read(vendorRepositoryProvider);
final result = await repository.updateVendor(id, data);
if (result.failure != null) throw result.failure!;
ref.invalidate(vendorsListProvider);
ref.invalidate(vendorDetailProvider(id));
return result.data!;
}
}

View File

@ -0,0 +1,660 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/constants/enums.dart';
import '../../../../core/constants/route_constants.dart';
import '../../../../core/errors/failure.dart';
import '../../../../shared/models/vendor_model.dart';
import '../../../../shared/providers/permissions_provider.dart';
import '../../../../shared/widgets/app_card.dart';
import '../../../../shared/widgets/app_confirmation_dialog.dart';
import '../../../../shared/widgets/app_dropdown.dart';
import '../../../../shared/widgets/app_empty_state.dart';
import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_status_chip.dart';
import '../../../../shared/widgets/error_view.dart';
import '../../../../shared/widgets/page_header.dart';
import '../providers/vendors_provider.dart';
import '../widgets/vendor_form_panel.dart';
import '../widgets/vendor_sub_resource_panels.dart';
class VendorDetailScreen extends ConsumerStatefulWidget {
const VendorDetailScreen({super.key, required this.vendorId});
final String vendorId;
@override
ConsumerState<VendorDetailScreen> createState() => _VendorDetailScreenState();
}
class _VendorDetailScreenState extends ConsumerState<VendorDetailScreen>
with SingleTickerProviderStateMixin {
late final TabController _tabController;
@override
void initState() {
super.initState();
_tabController = TabController(length: 4, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final detailAsync = ref.watch(vendorDetailProvider(widget.vendorId));
final canEdit = ref.can('vendors', PermissionAction.update);
final canDelete = ref.can('vendors', PermissionAction.delete);
return detailAsync.when(
loading: () => const AppLoadingView(message: 'Loading vendor details...'),
error: (e, _) => ErrorView.fromFailure(
e is Failure ? e : Failure.unknown(message: e.toString()),
onRetry: () => ref.invalidate(vendorDetailProvider(widget.vendorId)),
),
data: (state) => Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
PageHeader(
title: state.vendor.vendorName,
subtitle: state.vendor.vendorCode ?? 'Vendor ID: ${state.vendor.id}',
actions: [
if (canEdit) ...[
OutlinedButton.icon(
onPressed: () => _changeStatus(state.vendor),
icon: const Icon(Icons.swap_horiz),
label: const Text('Change Status'),
),
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: () => openVendorFormPanel(
context,
ref,
vendorId: widget.vendorId,
),
icon: const Icon(Icons.edit_outlined),
label: const Text('Edit'),
),
],
if (canDelete) ...[
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: _deleteVendor,
icon: const Icon(Icons.delete_outline),
label: const Text('Delete'),
),
],
],
),
const SizedBox(height: 16),
TabBar(
controller: _tabController,
tabs: const [
Tab(text: 'Overview'),
Tab(text: 'Addresses'),
Tab(text: 'Contacts'),
Tab(text: 'Bank Details'),
],
),
const SizedBox(height: 16),
Expanded(
child: TabBarView(
controller: _tabController,
children: [
_OverviewTab(vendor: state.vendor),
_AddressesTab(
vendorId: widget.vendorId,
addresses: state.addresses,
canEdit: canEdit,
),
_ContactsTab(
vendorId: widget.vendorId,
contacts: state.contacts,
canEdit: canEdit,
),
_BankDetailsTab(
vendorId: widget.vendorId,
bankDetails: state.bankDetails,
canEdit: canEdit,
),
],
),
),
],
),
),
);
}
Future<void> _changeStatus(VendorModel vendor) async {
String? selected = vendor.status ?? 'active';
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setDialogState) => AlertDialog(
title: const Text('Change Vendor Status'),
content: AppDropdown<String>(
label: 'Status',
value: selected,
options: vendorStatusOptions
.map((e) => AppDropdownOption(value: e.$1, label: e.$2))
.toList(),
onChanged: (v) => setDialogState(() => selected = v),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Update'),
),
],
),
),
);
if (confirmed != true || selected == null || !mounted) return;
try {
await ref
.read(vendorDetailProvider(widget.vendorId).notifier)
.updateStatus(selected!);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Vendor status updated')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString())));
}
}
}
Future<void> _deleteVendor() async {
final confirmed = await showAppConfirmationDialog(
context: context,
title: 'Delete Vendor',
message: 'Soft delete this vendor?',
confirmLabel: 'Delete',
isDestructive: true,
);
if (confirmed != true || !mounted) return;
try {
await ref.read(vendorDetailProvider(widget.vendorId).notifier).deleteVendor();
if (mounted) context.go(RouteConstants.vendors);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString())));
}
}
}
}
class _OverviewTab extends StatelessWidget {
const _OverviewTab({required this.vendor});
final VendorModel vendor;
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
child: AppCard(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
_DetailRow(label: 'Vendor Code', value: vendor.vendorCode ?? ''),
_DetailRow(label: 'Vendor Name', value: vendor.vendorName),
_DetailRow(label: 'Type', value: vendorTypeLabel(vendor.vendorType)),
_DetailRow(label: 'GSTIN', value: vendor.gstin ?? ''),
_DetailRow(label: 'PAN', value: vendor.pan ?? ''),
_DetailRow(
label: 'Payment Term',
value: vendor.paymentTermName ?? '',
),
_DetailRow(
label: 'Credit Period',
value: vendor.creditPeriodDays != null
? '${vendor.creditPeriodDays} days'
: '',
),
_DetailRow(label: 'Remarks', value: vendor.remarks ?? ''),
_DetailRow(
label: 'Status',
value: vendorStatusLabel(vendor.status),
),
_DetailRow(
label: 'Active',
value: vendor.isActive ? 'Yes' : 'No',
),
],
),
),
),
);
}
}
class _AddressesTab extends ConsumerWidget {
const _AddressesTab({
required this.vendorId,
required this.addresses,
required this.canEdit,
});
final String vendorId;
final List<VendorAddressModel> addresses;
final bool canEdit;
@override
Widget build(BuildContext context, WidgetRef ref) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (canEdit)
Align(
alignment: Alignment.centerRight,
child: ElevatedButton.icon(
onPressed: () async {
final saved = await openVendorAddressPanel(
context,
vendorId: vendorId,
);
if (saved == true && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Address saved')),
);
}
},
icon: const Icon(Icons.add),
label: const Text('Add Address'),
),
),
const SizedBox(height: 12),
Expanded(
child: addresses.isEmpty
? const AppEmptyState(
title: 'No addresses',
description: 'Add registered, billing or dispatch addresses.',
icon: Icons.location_on_outlined,
)
: ListView.separated(
itemCount: addresses.length,
separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (context, index) {
final address = addresses[index];
return _SubResourceCard(
title: addressTypeOptions
.where((e) => e.$1 == address.addressType)
.map((e) => e.$2)
.firstOrNull ??
address.addressType ??
'Address',
subtitle: [
address.addressLine1,
address.addressLine2,
address.city,
address.state,
address.pincode,
].where((e) => e != null && e.isNotEmpty).join(', '),
trailing: canEdit
? _SubResourceActions(
onEdit: () => openVendorAddressPanel(
context,
vendorId: vendorId,
address: address,
),
onDelete: () => _deleteAddress(
context,
ref,
vendorId,
address.id,
),
)
: null,
chip: AppStatusChip(
status: address.isActive ? 'active' : 'inactive',
compact: true,
),
);
},
),
),
],
);
}
Future<void> _deleteAddress(
BuildContext context,
WidgetRef ref,
String vendorId,
String addressId,
) async {
final confirmed = await showAppConfirmationDialog(
context: context,
title: 'Delete Address',
message: 'Remove this address?',
confirmLabel: 'Delete',
isDestructive: true,
);
if (confirmed != true) return;
try {
await ref.read(vendorDetailProvider(vendorId).notifier).deleteAddress(addressId);
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString())));
}
}
}
}
class _ContactsTab extends ConsumerWidget {
const _ContactsTab({
required this.vendorId,
required this.contacts,
required this.canEdit,
});
final String vendorId;
final List<VendorContactModel> contacts;
final bool canEdit;
@override
Widget build(BuildContext context, WidgetRef ref) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (canEdit)
Align(
alignment: Alignment.centerRight,
child: ElevatedButton.icon(
onPressed: () async {
final saved = await openVendorContactPanel(
context,
vendorId: vendorId,
);
if (saved == true && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Contact saved')),
);
}
},
icon: const Icon(Icons.add),
label: const Text('Add Contact'),
),
),
const SizedBox(height: 12),
Expanded(
child: contacts.isEmpty
? const AppEmptyState(
title: 'No contacts',
description: 'Add contact persons for this vendor.',
icon: Icons.person_outline,
)
: ListView.separated(
itemCount: contacts.length,
separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (context, index) {
final contact = contacts[index];
return _SubResourceCard(
title: contact.contactName,
subtitle: [
contact.designation,
contact.phone,
contact.email,
].where((e) => e != null && e.isNotEmpty).join(' · '),
trailing: canEdit
? _SubResourceActions(
onEdit: () => openVendorContactPanel(
context,
vendorId: vendorId,
contact: contact,
),
onDelete: () => _deleteContact(
context,
ref,
vendorId,
contact.id,
),
)
: null,
chip: AppStatusChip(
status: contact.isActive ? 'active' : 'inactive',
compact: true,
),
);
},
),
),
],
);
}
Future<void> _deleteContact(
BuildContext context,
WidgetRef ref,
String vendorId,
String contactId,
) async {
final confirmed = await showAppConfirmationDialog(
context: context,
title: 'Delete Contact',
message: 'Remove this contact?',
confirmLabel: 'Delete',
isDestructive: true,
);
if (confirmed != true) return;
try {
await ref.read(vendorDetailProvider(vendorId).notifier).deleteContact(contactId);
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString())));
}
}
}
}
class _BankDetailsTab extends ConsumerWidget {
const _BankDetailsTab({
required this.vendorId,
required this.bankDetails,
required this.canEdit,
});
final String vendorId;
final List<VendorBankDetailModel> bankDetails;
final bool canEdit;
@override
Widget build(BuildContext context, WidgetRef ref) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (canEdit)
Align(
alignment: Alignment.centerRight,
child: ElevatedButton.icon(
onPressed: () async {
final saved = await openVendorBankDetailPanel(
context,
vendorId: vendorId,
);
if (saved == true && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Bank detail saved')),
);
}
},
icon: const Icon(Icons.add),
label: const Text('Add Bank Detail'),
),
),
const SizedBox(height: 12),
Expanded(
child: bankDetails.isEmpty
? const AppEmptyState(
title: 'No bank details',
description: 'Add bank accounts for payments.',
icon: Icons.account_balance_outlined,
)
: ListView.separated(
itemCount: bankDetails.length,
separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (context, index) {
final detail = bankDetails[index];
return _SubResourceCard(
title: detail.bankName ?? 'Bank Account',
subtitle: [
detail.branch,
detail.ifsc,
detail.accountHolderName,
if (detail.accountNumber != null)
'A/C: ${detail.accountNumber}',
].where((e) => e != null && e.isNotEmpty).join(' · '),
trailing: canEdit
? _SubResourceActions(
onEdit: () => openVendorBankDetailPanel(
context,
vendorId: vendorId,
bankDetail: detail,
),
onDelete: () => _deleteBankDetail(
context,
ref,
vendorId,
detail.id,
),
)
: null,
chip: AppStatusChip(
status: detail.isActive ? 'active' : 'inactive',
compact: true,
),
);
},
),
),
],
);
}
Future<void> _deleteBankDetail(
BuildContext context,
WidgetRef ref,
String vendorId,
String bankDetailId,
) async {
final confirmed = await showAppConfirmationDialog(
context: context,
title: 'Delete Bank Detail',
message: 'Remove this bank account?',
confirmLabel: 'Delete',
isDestructive: true,
);
if (confirmed != true) return;
try {
await ref
.read(vendorDetailProvider(vendorId).notifier)
.deleteBankDetail(bankDetailId);
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString())));
}
}
}
}
class _SubResourceCard extends StatelessWidget {
const _SubResourceCard({
required this.title,
required this.subtitle,
this.trailing,
this.chip,
});
final String title;
final String subtitle;
final Widget? trailing;
final Widget? chip;
@override
Widget build(BuildContext context) {
return AppCard(
child: ListTile(
title: Text(title),
subtitle: subtitle.isNotEmpty ? Text(subtitle) : null,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (chip != null) ...[chip!, const SizedBox(width: 8)],
if (trailing != null) trailing!,
],
),
),
);
}
}
class _SubResourceActions extends StatelessWidget {
const _SubResourceActions({required this.onEdit, required this.onDelete});
final VoidCallback onEdit;
final VoidCallback onDelete;
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'Edit',
icon: const Icon(Icons.edit_outlined, size: 20),
onPressed: onEdit,
),
IconButton(
tooltip: 'Delete',
icon: const Icon(Icons.delete_outline, size: 20),
onPressed: onDelete,
),
],
);
}
}
class _DetailRow extends StatelessWidget {
const _DetailRow({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 160,
child: Text(
label,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
Expanded(child: Text(value)),
],
),
);
}
}

View File

@ -0,0 +1,399 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/constants/enums.dart';
import '../../../../core/constants/route_constants.dart';
import '../../../../core/errors/failure.dart';
import '../../../../core/utils/responsive_utils.dart';
import '../../../../shared/models/vendor_model.dart';
import '../../../../shared/providers/permissions_provider.dart';
import '../../../../shared/widgets/app_card.dart';
import '../../../../shared/widgets/app_confirmation_dialog.dart';
import '../../../../shared/widgets/app_data_table.dart';
import '../../../../shared/widgets/app_dropdown.dart';
import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/app_empty_state.dart';
import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_pagination.dart';
import '../../../../shared/widgets/app_search_field.dart';
import '../../../../shared/widgets/app_status_chip.dart';
import '../../../../shared/widgets/app_table_action_icon.dart';
import '../../../../shared/widgets/can_permission.dart';
import '../../../../shared/widgets/app_table_shell.dart';
import '../../../../shared/widgets/error_view.dart';
import '../../../../shared/widgets/page_header.dart';
import '../providers/vendors_provider.dart';
import '../widgets/vendor_form_panel.dart';
class VendorListScreen extends ConsumerStatefulWidget {
const VendorListScreen({super.key});
@override
ConsumerState<VendorListScreen> createState() => _VendorListScreenState();
}
class _VendorListScreenState extends ConsumerState<VendorListScreen> {
final _searchController = TextEditingController();
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final vendorsAsync = ref.watch(vendorsListProvider);
final canEdit = ref.can('vendors', PermissionAction.update);
final canDelete = ref.can('vendors', PermissionAction.delete);
ref.listen(vendorsListProvider, (prev, next) {
final error = next.valueOrNull?.actionError;
final success = next.valueOrNull?.actionSuccess;
if (error != null && error != prev?.valueOrNull?.actionError) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(error)));
}
if (success != null && success != prev?.valueOrNull?.actionSuccess) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(success)));
}
});
return Padding(
padding: const EdgeInsets.all(24),
child: vendorsAsync.when(
loading: () => const AppLoadingView(message: 'Loading vendors...'),
error: (error, _) => ErrorView.fromFailure(
error is Failure ? error : Failure.unknown(message: error.toString()),
onRetry: () => ref.invalidate(vendorsListProvider),
),
data: (state) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
PageHeader(
title: 'Vendors',
subtitle: 'Manage suppliers, service providers and vendor master data',
actions: [
CanPermission(
module: 'vendors',
action: PermissionAction.create,
child: ElevatedButton.icon(
onPressed: () => openVendorFormPanel(context, ref),
icon: const Icon(Icons.add),
label: const Text('Add Vendor'),
),
),
],
),
const SizedBox(height: 16),
Expanded(
child: AppTableShell(
toolbar: LayoutBuilder(
builder: (context, constraints) {
return _FiltersBar(
searchController: _searchController,
query: state.query,
wrapped: constraints.maxWidth < 900,
onSearch: ref.read(vendorsListProvider.notifier).setSearch,
onStatusChanged:
ref.read(vendorsListProvider.notifier).setStatusFilter,
onVendorTypeChanged:
ref.read(vendorsListProvider.notifier).setVendorTypeFilter,
);
},
),
footer: AppPagination(
currentPage: state.query.page,
totalPages: state.totalPages,
totalItems: state.total,
pageSize: state.query.limit,
onPageChanged: ref.read(vendorsListProvider.notifier).setPage,
onPageSizeChanged:
ref.read(vendorsListProvider.notifier).setPageSize,
),
child: RefreshIndicator(
onRefresh: () => ref.read(vendorsListProvider.notifier).refresh(),
child: state.vendors.isEmpty
? ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: const [
SizedBox(
height: 240,
child: AppEmptyState(
title: 'No vendors found',
description:
'Try adjusting filters or add a new vendor.',
icon: Icons.store_outlined,
),
),
],
)
: context.isMobile
? _VendorCardList(
vendors: state.vendors,
onView: _viewVendor,
onEdit: canEdit ? _editVendor : null,
onDelete: canDelete ? _deleteVendor : null,
)
: _VendorDataTable(
vendors: state.vendors,
onView: _viewVendor,
onEdit: canEdit ? _editVendor : null,
onDelete: canDelete ? _deleteVendor : null,
),
),
),
),
],
),
),
);
}
void _viewVendor(VendorModel vendor) {
context.push('${RouteConstants.vendors}/${vendor.id}');
}
void _editVendor(VendorModel vendor) {
openVendorFormPanel(context, ref, vendorId: vendor.id);
}
Future<void> _deleteVendor(VendorModel vendor) async {
final confirmed = await showAppConfirmationDialog(
context: context,
title: 'Delete Vendor',
message: 'Soft delete "${vendor.vendorName}"?',
confirmLabel: 'Delete',
isDestructive: true,
);
if (confirmed != true || !mounted) return;
await ref.read(vendorsListProvider.notifier).deleteVendor(vendor.id);
}
}
class _FiltersBar extends StatelessWidget {
const _FiltersBar({
required this.searchController,
required this.query,
required this.wrapped,
required this.onSearch,
required this.onStatusChanged,
required this.onVendorTypeChanged,
});
final TextEditingController searchController;
final VendorListQuery query;
final bool wrapped;
final ValueChanged<String> onSearch;
final ValueChanged<String?> onStatusChanged;
final ValueChanged<String?> onVendorTypeChanged;
@override
Widget build(BuildContext context) {
final searchField = SizedBox(
width: wrapped ? double.infinity : null,
child: AppSearchField(
controller: searchController,
hint: 'Search vendors...',
onChanged: onSearch,
),
);
final filters = [
SizedBox(
width: wrapped ? double.infinity : 180,
child: AppSearchableDropdown<String?>(
label: 'Status',
value: query.status,
searchHint: 'Search status...',
isDense: true,
options: [
const AppDropdownOption(value: null, label: 'All statuses'),
...vendorStatusOptions.map(
(e) => AppDropdownOption(value: e.$1, label: e.$2),
),
],
onChanged: onStatusChanged,
),
),
SizedBox(
width: wrapped ? double.infinity : 200,
child: AppSearchableDropdown<String?>(
label: 'Vendor Type',
value: query.vendorType,
searchHint: 'Search vendor type...',
isDense: true,
options: [
const AppDropdownOption(value: null, label: 'All types'),
...vendorTypeOptions.map(
(e) => AppDropdownOption(value: e.$1, label: e.$2),
),
],
onChanged: onVendorTypeChanged,
),
),
];
if (wrapped) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
searchField,
const SizedBox(height: 12),
...filters.expand((filter) => [filter, const SizedBox(height: 12)]).toList()
..removeLast(),
],
);
}
return Row(
children: [
Expanded(flex: 3, child: searchField),
const SizedBox(width: 12),
Expanded(flex: 2, child: filters[0]),
const SizedBox(width: 12),
Expanded(flex: 2, child: filters[1]),
],
);
}
}
class _VendorDataTable extends StatelessWidget {
const _VendorDataTable({
required this.vendors,
required this.onView,
this.onEdit,
this.onDelete,
});
final List<VendorModel> vendors;
final ValueChanged<VendorModel> onView;
final ValueChanged<VendorModel>? onEdit;
final ValueChanged<VendorModel>? onDelete;
@override
Widget build(BuildContext context) {
return AppDataTable<VendorModel>(
wrapInCard: false,
columns: [
AppDataColumn(
label: 'Code',
flex: 1,
cellBuilder: (_, vendor) => Text(vendor.vendorCode ?? ''),
),
AppDataColumn(
label: 'Name',
flex: 2,
cellBuilder: (_, vendor) => Text(vendor.vendorName),
),
AppDataColumn(
label: 'Type',
flex: 2,
cellBuilder: (_, vendor) => Text(vendorTypeLabel(vendor.vendorType)),
),
AppDataColumn(
label: 'GSTIN',
flex: 2,
cellBuilder: (_, vendor) => Text(vendor.gstin ?? ''),
),
AppDataColumn(
label: 'Status',
flex: 1,
cellBuilder: (_, vendor) => AppStatusChip(
status: vendor.status ?? (vendor.isActive ? 'active' : 'inactive'),
),
),
AppDataColumn(
label: 'Actions',
flex: 1,
alignment: Alignment.centerRight,
cellBuilder: (_, vendor) => AppTableActions(
children: [
AppTableActionIcon(
tooltip: 'View',
icon: Icons.visibility_outlined,
onPressed: () => onView(vendor),
),
if (onEdit != null)
AppTableActionIcon(
tooltip: 'Edit',
icon: Icons.edit_outlined,
onPressed: () => onEdit!(vendor),
),
if (onDelete != null)
AppTableActionIcon(
tooltip: 'Delete',
icon: Icons.delete_outline,
onPressed: () => onDelete!(vendor),
),
],
),
),
],
rows: vendors,
);
}
}
class _VendorCardList extends StatelessWidget {
const _VendorCardList({
required this.vendors,
required this.onView,
this.onEdit,
this.onDelete,
});
final List<VendorModel> vendors;
final ValueChanged<VendorModel> onView;
final ValueChanged<VendorModel>? onEdit;
final ValueChanged<VendorModel>? onDelete;
@override
Widget build(BuildContext context) {
return ListView.separated(
itemCount: vendors.length,
separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (context, index) {
final vendor = vendors[index];
return AppCard(
child: ListTile(
title: Text(vendor.vendorName),
subtitle: Text(
'${vendor.vendorCode ?? ''} · ${vendorTypeLabel(vendor.vendorType)}',
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
AppStatusChip(
status: vendor.status ??
(vendor.isActive ? 'active' : 'inactive'),
compact: true,
),
PopupMenuButton<String>(
onSelected: (action) {
switch (action) {
case 'view':
onView(vendor);
case 'edit':
onEdit?.call(vendor);
case 'delete':
onDelete?.call(vendor);
}
},
itemBuilder: (context) => [
const PopupMenuItem(value: 'view', child: Text('View')),
if (onEdit != null)
const PopupMenuItem(value: 'edit', child: Text('Edit')),
if (onDelete != null)
const PopupMenuItem(value: 'delete', child: Text('Delete')),
],
),
],
),
onTap: () => onView(vendor),
),
);
},
);
}
}

View File

@ -0,0 +1,288 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/errors/failure.dart';
import '../../../../core/utils/validators.dart';
import '../../../../shared/models/user_management_models.dart';
import '../../../../shared/models/vendor_model.dart';
import '../../../../shared/widgets/app_button.dart';
import '../../../../shared/widgets/app_dropdown.dart';
import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_side_panel.dart';
import '../../../../shared/widgets/app_text_field.dart';
import '../../../../shared/widgets/error_view.dart';
import '../../../masters/data/datasources/master_remote_data_source.dart';
import '../providers/vendors_provider.dart';
Future<void> openVendorFormPanel(
BuildContext context,
WidgetRef ref, {
String? vendorId,
}) async {
ref.invalidate(vendorFormProvider(vendorId));
final saved = await showSidePanel<bool>(
context,
VendorFormPanel(vendorId: vendorId),
width: 560,
);
if (saved == true && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
vendorId == null
? 'Vendor created successfully'
: 'Vendor updated successfully',
),
),
);
}
}
class VendorFormPanel extends ConsumerStatefulWidget {
const VendorFormPanel({super.key, this.vendorId});
final String? vendorId;
bool get isEditing => vendorId != null;
@override
ConsumerState<VendorFormPanel> createState() => _VendorFormPanelState();
}
class _VendorFormPanelState extends ConsumerState<VendorFormPanel> {
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
final _gstinController = TextEditingController();
final _panController = TextEditingController();
final _creditDaysController = TextEditingController();
final _remarksController = TextEditingController();
String? _vendorType;
int? _paymentTermId;
bool _isActive = true;
bool _isSubmitting = false;
String? _populatedSignature;
@override
void dispose() {
_nameController.dispose();
_gstinController.dispose();
_panController.dispose();
_creditDaysController.dispose();
_remarksController.dispose();
super.dispose();
}
String _vendorSignature(VendorModel vendor) =>
'${vendor.id}:${vendor.vendorType}:${vendor.paymentTermId}:'
'${vendor.creditPeriodDays}:${vendor.isActive}:${vendor.vendorName}';
void _populateFromVendor(VendorModel vendor) {
setState(() {
_nameController.text = vendor.vendorName;
_vendorType = vendor.vendorType;
_gstinController.text = vendor.gstin ?? '';
_panController.text = vendor.pan ?? '';
_paymentTermId = vendor.paymentTermId;
_creditDaysController.text =
vendor.creditPeriodDays?.toString() ?? '';
_remarksController.text = vendor.remarks ?? '';
_isActive = vendor.isActive;
});
}
Map<String, dynamic> _buildPayload() {
return {
'vendor_name': _nameController.text.trim(),
'vendor_type': _vendorType,
if (_gstinController.text.trim().isNotEmpty)
'gstin': _gstinController.text.trim(),
if (_panController.text.trim().isNotEmpty) 'pan': _panController.text.trim(),
if (_paymentTermId != null) 'payment_term_id': _paymentTermId,
if (_creditDaysController.text.trim().isNotEmpty)
'credit_period_days': int.tryParse(_creditDaysController.text.trim()),
if (_remarksController.text.trim().isNotEmpty)
'remarks': _remarksController.text.trim(),
'is_active': _isActive,
};
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
if (_vendorType == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please select vendor type')),
);
return;
}
setState(() => _isSubmitting = true);
try {
final notifier = ref.read(vendorFormProvider(widget.vendorId).notifier);
final payload = _buildPayload();
if (widget.isEditing) {
await notifier.submitUpdate(widget.vendorId!, payload);
} else {
await notifier.submitCreate(payload);
}
if (mounted) Navigator.of(context, rootNavigator: true).pop(true);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString())),
);
}
} finally {
if (mounted) setState(() => _isSubmitting = false);
}
}
@override
Widget build(BuildContext context) {
final paymentTermsAsync = ref.watch(vendorPaymentTermsProvider);
if (widget.isEditing) {
ref.listen(vendorFormProvider(widget.vendorId), (prev, next) {
next.whenData((vendor) {
if (vendor == null || !mounted) return;
final signature = _vendorSignature(vendor);
if (_populatedSignature != signature) {
_populatedSignature = signature;
_populateFromVendor(vendor);
}
});
});
}
final formAsync =
widget.isEditing ? ref.watch(vendorFormProvider(widget.vendorId)) : null;
return SidePanelScaffold(
title: widget.isEditing ? 'Edit vendor' : 'Add vendor',
footer: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
OutlinedButton(
onPressed: _isSubmitting
? null
: () => Navigator.of(context, rootNavigator: true).pop(),
child: const Text('Cancel'),
),
const SizedBox(width: 12),
AppButton(
label: widget.isEditing ? 'Update vendor' : 'Save vendor',
expand: false,
icon: Icons.check,
isLoading: _isSubmitting,
onPressed: _isSubmitting ? null : _submit,
),
],
),
child: widget.isEditing && formAsync != null
? formAsync.when(
loading: () => const AppLoadingView(message: 'Loading vendor...'),
error: (e, _) => ErrorView.fromFailure(
e is Failure ? e : Failure.unknown(message: e.toString()),
onRetry: () => ref.invalidate(vendorFormProvider(widget.vendorId)),
),
data: (_) => _buildForm(paymentTermsAsync),
)
: _buildForm(paymentTermsAsync),
);
}
Widget _buildForm(AsyncValue<List<FilterOptionModel>> paymentTermsAsync) {
return Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
AppTextField(
controller: _nameController,
label: 'Vendor Name *',
validator: (v) => Validators.required(v, fieldName: 'Vendor name'),
),
const SizedBox(height: 12),
AppDropdown<String>(
label: 'Vendor Type *',
value: _vendorType,
options: vendorTypeOptions
.map((e) => AppDropdownOption(value: e.$1, label: e.$2))
.toList(),
onChanged: (v) => setState(() => _vendorType = v),
validator: (v) => v == null ? 'Vendor type is required' : null,
),
const SizedBox(height: 12),
SidePanelFormRow(
left: AppTextField(
controller: _gstinController,
label: 'GSTIN',
validator: Validators.optionalGstin,
inputFormatters: Validators.gstinInput,
),
right: AppTextField(
controller: _panController,
label: 'PAN',
validator: Validators.optionalPan,
inputFormatters: Validators.panInput,
),
),
const SizedBox(height: 12),
SidePanelFormRow(
left: paymentTermsAsync.when(
loading: () => const LinearProgressIndicator(),
error: (_, __) => const Text('Failed to load payment terms'),
data: (terms) => _paymentTermDropdown(terms),
),
right: AppTextField(
controller: _creditDaysController,
label: 'Credit Period (days)',
keyboardType: TextInputType.number,
),
),
const SizedBox(height: 12),
AppTextField(
controller: _remarksController,
label: 'Remarks',
maxLines: 3,
),
const SizedBox(height: 12),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Active'),
value: _isActive,
onChanged: (v) => setState(() => _isActive = v),
),
],
),
);
}
Widget _paymentTermDropdown(List<FilterOptionModel> terms) {
final termIds = terms.map((t) => int.tryParse(t.id)).whereType<int>().toList();
final value = _paymentTermId != null && termIds.contains(_paymentTermId)
? _paymentTermId
: null;
return AppSearchableDropdown<int>(
label: 'Payment Term',
value: value,
searchHint: 'Search payment term...',
options: terms
.map(
(t) => AppDropdownOption(
value: int.tryParse(t.id) ?? 0,
label: t.name,
),
)
.where((option) => option.value != 0)
.toList(),
onChanged: (v) => setState(() => _paymentTermId = v),
);
}
}
final vendorPaymentTermsProvider =
FutureProvider<List<FilterOptionModel>>((ref) async {
final dataSource = ref.watch(masterRemoteDataSourceProvider);
return dataSource.listPaymentTerms();
});

View File

@ -0,0 +1,538 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/utils/validators.dart';
import '../../../../shared/models/vendor_model.dart';
import '../../../../shared/widgets/app_button.dart';
import '../../../../shared/widgets/app_dropdown.dart';
import '../../../../shared/widgets/app_side_panel.dart';
import '../../../../shared/widgets/app_text_field.dart';
import '../providers/vendors_provider.dart';
Future<bool?> openVendorAddressPanel(
BuildContext context, {
required String vendorId,
VendorAddressModel? address,
}) {
return showSidePanel<bool>(
context,
VendorAddressPanel(vendorId: vendorId, address: address),
width: 520,
);
}
Future<bool?> openVendorContactPanel(
BuildContext context, {
required String vendorId,
VendorContactModel? contact,
}) {
return showSidePanel<bool>(
context,
VendorContactPanel(vendorId: vendorId, contact: contact),
width: 480,
);
}
Future<bool?> openVendorBankDetailPanel(
BuildContext context, {
required String vendorId,
VendorBankDetailModel? bankDetail,
}) {
return showSidePanel<bool>(
context,
VendorBankDetailPanel(vendorId: vendorId, bankDetail: bankDetail),
width: 520,
);
}
class VendorAddressPanel extends ConsumerStatefulWidget {
const VendorAddressPanel({super.key, required this.vendorId, this.address});
final String vendorId;
final VendorAddressModel? address;
bool get isEditing => address != null;
@override
ConsumerState<VendorAddressPanel> createState() => _VendorAddressPanelState();
}
class _VendorAddressPanelState extends ConsumerState<VendorAddressPanel> {
final _formKey = GlobalKey<FormState>();
final _line1Controller = TextEditingController();
final _line2Controller = TextEditingController();
final _cityController = TextEditingController();
final _stateController = TextEditingController();
final _pincodeController = TextEditingController();
final _countryController = TextEditingController(text: 'India');
final _gstinController = TextEditingController();
String? _addressType;
bool _isActive = true;
bool _isSubmitting = false;
@override
void initState() {
super.initState();
final address = widget.address;
if (address != null) {
_addressType = address.addressType;
_line1Controller.text = address.addressLine1 ?? '';
_line2Controller.text = address.addressLine2 ?? '';
_cityController.text = address.city ?? '';
_stateController.text = address.state ?? '';
_pincodeController.text = address.pincode ?? '';
_countryController.text = address.country ?? 'India';
_gstinController.text = address.gstin ?? '';
_isActive = address.isActive;
}
}
@override
void dispose() {
_line1Controller.dispose();
_line2Controller.dispose();
_cityController.dispose();
_stateController.dispose();
_pincodeController.dispose();
_countryController.dispose();
_gstinController.dispose();
super.dispose();
}
Future<void> _save() async {
if (!_formKey.currentState!.validate()) return;
if (_addressType == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please select address type')),
);
return;
}
final payload = {
'address_type': _addressType,
if (_line1Controller.text.trim().isNotEmpty)
'address_line1': _line1Controller.text.trim(),
if (_line2Controller.text.trim().isNotEmpty)
'address_line2': _line2Controller.text.trim(),
if (_cityController.text.trim().isNotEmpty) 'city': _cityController.text.trim(),
if (_stateController.text.trim().isNotEmpty)
'state': _stateController.text.trim(),
if (_pincodeController.text.trim().isNotEmpty)
'pincode': _pincodeController.text.trim(),
if (_countryController.text.trim().isNotEmpty)
'country': _countryController.text.trim(),
if (_gstinController.text.trim().isNotEmpty)
'gstin': _gstinController.text.trim(),
'is_active': _isActive,
};
setState(() => _isSubmitting = true);
try {
final notifier = ref.read(vendorDetailProvider(widget.vendorId).notifier);
if (widget.isEditing) {
await notifier.updateAddress(widget.address!.id, payload);
} else {
await notifier.createAddress(payload);
}
if (mounted) Navigator.of(context, rootNavigator: true).pop(true);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString())));
}
} finally {
if (mounted) setState(() => _isSubmitting = false);
}
}
@override
Widget build(BuildContext context) {
return SidePanelScaffold(
title: widget.isEditing ? 'Edit address' : 'Add address',
footer: _panelFooter(
context,
isSubmitting: _isSubmitting,
saveLabel: widget.isEditing ? 'Update address' : 'Save address',
onSave: _save,
),
child: Form(
key: _formKey,
child: Column(
children: [
AppDropdown<String>(
label: 'Address Type *',
value: _addressType,
options: addressTypeOptions
.map((e) => AppDropdownOption(value: e.$1, label: e.$2))
.toList(),
onChanged: (v) => setState(() => _addressType = v),
validator: (v) => v == null ? 'Address type is required' : null,
),
const SizedBox(height: 12),
AppTextField(controller: _line1Controller, label: 'Address Line 1'),
const SizedBox(height: 12),
AppTextField(controller: _line2Controller, label: 'Address Line 2'),
const SizedBox(height: 12),
SidePanelFormRow(
left: AppTextField(controller: _cityController, label: 'City'),
right: AppTextField(controller: _stateController, label: 'State'),
),
const SizedBox(height: 12),
SidePanelFormRow(
left: AppTextField(
controller: _pincodeController,
label: 'Pincode',
keyboardType: TextInputType.number,
validator: Validators.optionalPincode,
inputFormatters: Validators.pincodeInput,
),
right: AppTextField(controller: _countryController, label: 'Country'),
),
const SizedBox(height: 12),
AppTextField(
controller: _gstinController,
label: 'GSTIN',
validator: Validators.optionalGstin,
inputFormatters: Validators.gstinInput,
),
const SizedBox(height: 12),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Active'),
value: _isActive,
onChanged: (v) => setState(() => _isActive = v),
),
],
),
),
);
}
}
class VendorContactPanel extends ConsumerStatefulWidget {
const VendorContactPanel({super.key, required this.vendorId, this.contact});
final String vendorId;
final VendorContactModel? contact;
bool get isEditing => contact != null;
@override
ConsumerState<VendorContactPanel> createState() => _VendorContactPanelState();
}
class _VendorContactPanelState extends ConsumerState<VendorContactPanel> {
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
final _designationController = TextEditingController();
final _phoneController = TextEditingController();
final _emailController = TextEditingController();
bool _isPrimary = false;
bool _isActive = true;
bool _isSubmitting = false;
@override
void initState() {
super.initState();
final contact = widget.contact;
if (contact != null) {
_nameController.text = contact.contactName;
_designationController.text = contact.designation ?? '';
_phoneController.text = contact.phone ?? '';
_emailController.text = contact.email ?? '';
_isPrimary = contact.isPrimary;
_isActive = contact.isActive;
}
}
@override
void dispose() {
_nameController.dispose();
_designationController.dispose();
_phoneController.dispose();
_emailController.dispose();
super.dispose();
}
Future<void> _save() async {
if (!_formKey.currentState!.validate()) return;
final payload = {
'contact_name': _nameController.text.trim(),
if (_designationController.text.trim().isNotEmpty)
'designation': _designationController.text.trim(),
if (_phoneController.text.trim().isNotEmpty)
'phone': _phoneController.text.trim(),
if (_emailController.text.trim().isNotEmpty)
'email': _emailController.text.trim(),
'is_primary': _isPrimary,
'is_active': _isActive,
};
setState(() => _isSubmitting = true);
try {
final notifier = ref.read(vendorDetailProvider(widget.vendorId).notifier);
if (widget.isEditing) {
await notifier.updateContact(widget.contact!.id, payload);
} else {
await notifier.createContact(payload);
}
if (mounted) Navigator.of(context, rootNavigator: true).pop(true);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString())));
}
} finally {
if (mounted) setState(() => _isSubmitting = false);
}
}
@override
Widget build(BuildContext context) {
return SidePanelScaffold(
title: widget.isEditing ? 'Edit contact' : 'Add contact',
footer: _panelFooter(
context,
isSubmitting: _isSubmitting,
saveLabel: widget.isEditing ? 'Update contact' : 'Save contact',
onSave: _save,
),
child: Form(
key: _formKey,
child: Column(
children: [
AppTextField(
controller: _nameController,
label: 'Contact Name *',
validator: (v) => Validators.required(v, fieldName: 'Contact name'),
),
const SizedBox(height: 12),
AppTextField(controller: _designationController, label: 'Designation'),
const SizedBox(height: 12),
SidePanelFormRow(
left: AppTextField(
controller: _phoneController,
label: 'Phone',
keyboardType: TextInputType.phone,
validator: Validators.optionalMobile,
inputFormatters: Validators.mobileInput,
),
right: AppTextField(
controller: _emailController,
label: 'Email',
keyboardType: TextInputType.emailAddress,
validator: Validators.optionalEmail,
),
),
const SizedBox(height: 12),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Primary contact'),
value: _isPrimary,
onChanged: (v) => setState(() => _isPrimary = v),
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Active'),
value: _isActive,
onChanged: (v) => setState(() => _isActive = v),
),
],
),
),
);
}
}
class VendorBankDetailPanel extends ConsumerStatefulWidget {
const VendorBankDetailPanel({
super.key,
required this.vendorId,
this.bankDetail,
});
final String vendorId;
final VendorBankDetailModel? bankDetail;
bool get isEditing => bankDetail != null;
@override
ConsumerState<VendorBankDetailPanel> createState() =>
_VendorBankDetailPanelState();
}
class _VendorBankDetailPanelState extends ConsumerState<VendorBankDetailPanel> {
final _formKey = GlobalKey<FormState>();
final _bankNameController = TextEditingController();
final _branchController = TextEditingController();
final _accountNumberController = TextEditingController();
final _ifscController = TextEditingController();
final _holderNameController = TextEditingController();
String? _accountType;
bool _isPrimary = false;
bool _isActive = true;
bool _isSubmitting = false;
@override
void initState() {
super.initState();
final detail = widget.bankDetail;
if (detail != null) {
_bankNameController.text = detail.bankName ?? '';
_branchController.text = detail.branch ?? '';
_accountNumberController.text = detail.accountNumber ?? '';
_ifscController.text = detail.ifsc ?? '';
_holderNameController.text = detail.accountHolderName ?? '';
_accountType = detail.accountType;
_isPrimary = detail.isPrimary;
_isActive = detail.isActive;
} else {
_accountType = 'CURRENT';
}
}
@override
void dispose() {
_bankNameController.dispose();
_branchController.dispose();
_accountNumberController.dispose();
_ifscController.dispose();
_holderNameController.dispose();
super.dispose();
}
Future<void> _save() async {
if (!_formKey.currentState!.validate()) return;
final payload = {
'bank_name': _bankNameController.text.trim(),
if (_branchController.text.trim().isNotEmpty)
'branch': _branchController.text.trim(),
if (!widget.isEditing || _accountNumberController.text.trim().isNotEmpty)
'account_number': _accountNumberController.text.trim(),
'ifsc': _ifscController.text.trim(),
'account_holder_name': _holderNameController.text.trim(),
if (_accountType != null) 'account_type': _accountType,
'is_primary': _isPrimary,
'is_active': _isActive,
};
setState(() => _isSubmitting = true);
try {
final notifier = ref.read(vendorDetailProvider(widget.vendorId).notifier);
if (widget.isEditing) {
await notifier.updateBankDetail(widget.bankDetail!.id, payload);
} else {
await notifier.createBankDetail(payload);
}
if (mounted) Navigator.of(context, rootNavigator: true).pop(true);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString())));
}
} finally {
if (mounted) setState(() => _isSubmitting = false);
}
}
@override
Widget build(BuildContext context) {
return SidePanelScaffold(
title: widget.isEditing ? 'Edit bank detail' : 'Add bank detail',
footer: _panelFooter(
context,
isSubmitting: _isSubmitting,
saveLabel: widget.isEditing ? 'Update bank detail' : 'Save bank detail',
onSave: _save,
),
child: Form(
key: _formKey,
child: Column(
children: [
AppTextField(
controller: _bankNameController,
label: 'Bank Name *',
validator: (v) => Validators.required(v, fieldName: 'Bank name'),
),
const SizedBox(height: 12),
AppTextField(controller: _branchController, label: 'Branch'),
const SizedBox(height: 12),
AppTextField(
controller: _accountNumberController,
label: widget.isEditing ? 'Account Number' : 'Account Number *',
obscureText: true,
keyboardType: TextInputType.number,
inputFormatters: Validators.accountNumberInput,
validator: widget.isEditing
? Validators.optionalAccountNumber
: Validators.accountNumber,
),
const SizedBox(height: 12),
SidePanelFormRow(
left: AppTextField(
controller: _ifscController,
label: 'IFSC *',
validator: Validators.ifsc,
inputFormatters: Validators.ifscInput,
),
right: AppTextField(
controller: _holderNameController,
label: 'Account Holder *',
validator: (v) =>
Validators.required(v, fieldName: 'Account holder name'),
),
),
const SizedBox(height: 12),
AppDropdown<String>(
label: 'Account Type',
value: _accountType,
options: accountTypeOptions
.map((e) => AppDropdownOption(value: e.$1, label: e.$2))
.toList(),
onChanged: (v) => setState(() => _accountType = v),
),
const SizedBox(height: 12),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Primary account'),
value: _isPrimary,
onChanged: (v) => setState(() => _isPrimary = v),
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Active'),
value: _isActive,
onChanged: (v) => setState(() => _isActive = v),
),
],
),
),
);
}
}
Widget _panelFooter(
BuildContext context, {
required bool isSubmitting,
required String saveLabel,
required VoidCallback onSave,
}) {
return Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
OutlinedButton(
onPressed: isSubmitting
? null
: () => Navigator.of(context, rootNavigator: true).pop(),
child: const Text('Cancel'),
),
const SizedBox(width: 12),
AppButton(
label: saveLabel,
expand: false,
icon: Icons.check,
isLoading: isSubmitting,
onPressed: isSubmitting ? null : onSave,
),
],
);
}

View File

@ -0,0 +1,190 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'grn_model.freezed.dart';
part 'grn_model.g.dart';
String _idFromJson(Object? value) => value?.toString() ?? '';
int? _intFromJsonNullable(Object? value) {
if (value == null) return null;
if (value is int) return value;
if (value is num) return value.toInt();
return int.tryParse(value.toString());
}
double? _doubleFromJsonNullable(Object? value) {
if (value == null) return null;
if (value is double) return value;
if (value is num) return value.toDouble();
return double.tryParse(value.toString());
}
DateTime? _dateFromJsonNullable(Object? value) {
if (value == null) return null;
if (value is DateTime) return value;
return DateTime.tryParse(value.toString());
}
Object? _readNestedName(Map<dynamic, dynamic> json, String flatKey, String nestedKey) {
final flat = json[flatKey];
if (flat is String && flat.isNotEmpty) return flat;
final nested = json[nestedKey];
if (nested is Map) {
return nested['name'] ?? nested['vendor_name'];
}
return null;
}
Object? _readVendorName(Map<dynamic, dynamic> json, String key) =>
_readNestedName(json, 'vendor_name', 'vendor');
Object? _readWarehouseName(Map<dynamic, dynamic> json, String key) =>
_readNestedName(json, 'warehouse_name', 'warehouse');
Object? _readPoNumber(Map<dynamic, dynamic> json, String key) {
final grnNumber = json['grn_number'];
if (grnNumber != null && grnNumber.toString().trim().isNotEmpty) {
return grnNumber.toString();
}
final grnNo = json['grn_no'];
if (grnNo != null && grnNo.toString().trim().isNotEmpty) {
return grnNo.toString();
}
return null;
}
Object? _readPoRefNumber(Map<dynamic, dynamic> json, String key) {
final flat = json['po_number'];
if (flat is String && flat.isNotEmpty) return flat;
final nested = json['purchase_order'] ?? json['po'];
if (nested is Map) {
return nested['po_number'] ?? nested['po_no'];
}
return null;
}
Object? _readItemName(Map<dynamic, dynamic> json, String key) {
final flat = json['item_name'];
if (flat is String && flat.isNotEmpty) return flat;
final nested = json['item'];
if (nested is Map) return nested['name'] ?? nested['item_name'];
return null;
}
Object? _readItemCode(Map<dynamic, dynamic> json, String key) {
final flat = json['item_code'];
if (flat is String && flat.isNotEmpty) return flat;
final nested = json['item'];
if (nested is Map) return nested['code'] ?? nested['item_code'];
return null;
}
Object? _readUomName(Map<dynamic, dynamic> json, String key) {
final flat = json['uom_name'];
if (flat is String && flat.isNotEmpty) return flat;
final nested = json['uom'];
if (nested is Map) return nested['name'];
return null;
}
@freezed
class GrnModel with _$GrnModel {
const GrnModel._();
const factory GrnModel({
@JsonKey(fromJson: _idFromJson) required String id,
@JsonKey(name: 'grn_number', readValue: _readPoNumber) String? grnNumber,
@JsonKey(name: 'grn_date', fromJson: _dateFromJsonNullable) DateTime? grnDate,
@Default('POSTED') String status,
@JsonKey(name: 'po_id', fromJson: _intFromJsonNullable) int? poId,
@JsonKey(name: 'po_number', readValue: _readPoRefNumber) String? poNumber,
@JsonKey(name: 'vendor_id', fromJson: _intFromJsonNullable) int? vendorId,
@JsonKey(name: 'vendor_name', readValue: _readVendorName) String? vendorName,
@JsonKey(name: 'warehouse_id', fromJson: _intFromJsonNullable) int? warehouseId,
@JsonKey(name: 'warehouse_name', readValue: _readWarehouseName) String? warehouseName,
@JsonKey(name: 'vendor_invoice_no') String? vendorInvoiceNo,
@JsonKey(name: 'vendor_invoice_date', fromJson: _dateFromJsonNullable)
DateTime? vendorInvoiceDate,
@JsonKey(name: 'vendor_invoice_amount', fromJson: _doubleFromJsonNullable)
double? vendorInvoiceAmount,
@JsonKey(name: 'vehicle_no') String? vehicleNo,
@JsonKey(name: 'lr_no') String? lrNo,
@JsonKey(name: 'lr_date', fromJson: _dateFromJsonNullable) DateTime? lrDate,
@JsonKey(name: 'received_by', fromJson: _intFromJsonNullable) int? receivedBy,
@JsonKey(name: 'quality_checked_by', fromJson: _intFromJsonNullable)
int? qualityCheckedBy,
String? remarks,
@JsonKey(name: 'cancellation_reason') String? cancellationReason,
@JsonKey(name: 'created_at', fromJson: _dateFromJsonNullable) DateTime? createdAt,
@JsonKey(name: 'updated_at', fromJson: _dateFromJsonNullable) DateTime? updatedAt,
@Default([]) List<GrnItemModel> items,
}) = _GrnModel;
factory GrnModel.fromJson(Map<String, dynamic> json) => _$GrnModelFromJson(json);
bool get canEdit => status.toUpperCase() == 'POSTED';
bool get canCancel => status.toUpperCase() == 'POSTED';
}
@freezed
class GrnItemModel with _$GrnItemModel {
const factory GrnItemModel({
@JsonKey(fromJson: _idFromJson) required String id,
@JsonKey(name: 'grn_id', fromJson: _idFromJson) String? grnId,
@JsonKey(name: 'po_item_id', fromJson: _intFromJsonNullable) int? poItemId,
@JsonKey(name: 'item_id', fromJson: _intFromJsonNullable) int? itemId,
@JsonKey(name: 'item_code', readValue: _readItemCode) String? itemCode,
@JsonKey(name: 'item_name', readValue: _readItemName) String? itemName,
@JsonKey(name: 'line_no', fromJson: _intFromJsonNullable) int? lineNo,
@JsonKey(name: 'current_qty', fromJson: _doubleFromJsonNullable) double? currentQty,
@JsonKey(name: 'accepted_qty', fromJson: _doubleFromJsonNullable) double? acceptedQty,
@JsonKey(name: 'damaged_qty', fromJson: _doubleFromJsonNullable) double? damagedQty,
@JsonKey(name: 'short_qty', fromJson: _doubleFromJsonNullable) double? shortQty,
@JsonKey(name: 'excess_qty', fromJson: _doubleFromJsonNullable) double? excessQty,
@JsonKey(name: 'rejected_qty', fromJson: _doubleFromJsonNullable) double? rejectedQty,
@JsonKey(name: 'rejection_reason') String? rejectionReason,
@JsonKey(fromJson: _doubleFromJsonNullable) double? rate,
@JsonKey(name: 'batch_no') String? batchNo,
@JsonKey(name: 'mfg_date', fromJson: _dateFromJsonNullable) DateTime? mfgDate,
@JsonKey(name: 'expiry_date', fromJson: _dateFromJsonNullable) DateTime? expiryDate,
@JsonKey(name: 'storage_location') String? storageLocation,
@JsonKey(name: 'asset_category_id', fromJson: _intFromJsonNullable)
int? assetCategoryId,
@JsonKey(name: 'uom_id', fromJson: _intFromJsonNullable) int? uomId,
@JsonKey(name: 'uom_name', readValue: _readUomName) String? uomName,
String? remarks,
}) = _GrnItemModel;
factory GrnItemModel.fromJson(Map<String, dynamic> json) =>
_$GrnItemModelFromJson(json);
}
@freezed
class GrnListQuery with _$GrnListQuery {
const factory GrnListQuery({
@Default(1) int page,
@Default(20) int limit,
String? search,
String? status,
int? poId,
int? vendorId,
int? warehouseId,
String? dateFrom,
String? dateTo,
}) = _GrnListQuery;
}
const grnStatusOptions = [
('POSTED', 'Posted'),
('CANCELLED', 'Cancelled'),
];
String grnStatusLabel(String? value) {
if (value == null) return '';
return grnStatusOptions
.where((e) => e.$1 == value.toUpperCase())
.map((e) => e.$2)
.firstOrNull ??
value.replaceAll('_', ' ');
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,121 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'grn_model.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$GrnModelImpl _$$GrnModelImplFromJson(Map<String, dynamic> json) =>
_$GrnModelImpl(
id: _idFromJson(json['id']),
grnNumber: _readPoNumber(json, 'grn_number') as String?,
grnDate: _dateFromJsonNullable(json['grn_date']),
status: json['status'] as String? ?? 'POSTED',
poId: _intFromJsonNullable(json['po_id']),
poNumber: _readPoRefNumber(json, 'po_number') as String?,
vendorId: _intFromJsonNullable(json['vendor_id']),
vendorName: _readVendorName(json, 'vendor_name') as String?,
warehouseId: _intFromJsonNullable(json['warehouse_id']),
warehouseName: _readWarehouseName(json, 'warehouse_name') as String?,
vendorInvoiceNo: json['vendor_invoice_no'] as String?,
vendorInvoiceDate: _dateFromJsonNullable(json['vendor_invoice_date']),
vendorInvoiceAmount: _doubleFromJsonNullable(
json['vendor_invoice_amount'],
),
vehicleNo: json['vehicle_no'] as String?,
lrNo: json['lr_no'] as String?,
lrDate: _dateFromJsonNullable(json['lr_date']),
receivedBy: _intFromJsonNullable(json['received_by']),
qualityCheckedBy: _intFromJsonNullable(json['quality_checked_by']),
remarks: json['remarks'] as String?,
cancellationReason: json['cancellation_reason'] as String?,
createdAt: _dateFromJsonNullable(json['created_at']),
updatedAt: _dateFromJsonNullable(json['updated_at']),
items:
(json['items'] as List<dynamic>?)
?.map((e) => GrnItemModel.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
);
Map<String, dynamic> _$$GrnModelImplToJson(_$GrnModelImpl instance) =>
<String, dynamic>{
'id': instance.id,
'grn_number': instance.grnNumber,
'grn_date': instance.grnDate?.toIso8601String(),
'status': instance.status,
'po_id': instance.poId,
'po_number': instance.poNumber,
'vendor_id': instance.vendorId,
'vendor_name': instance.vendorName,
'warehouse_id': instance.warehouseId,
'warehouse_name': instance.warehouseName,
'vendor_invoice_no': instance.vendorInvoiceNo,
'vendor_invoice_date': instance.vendorInvoiceDate?.toIso8601String(),
'vendor_invoice_amount': instance.vendorInvoiceAmount,
'vehicle_no': instance.vehicleNo,
'lr_no': instance.lrNo,
'lr_date': instance.lrDate?.toIso8601String(),
'received_by': instance.receivedBy,
'quality_checked_by': instance.qualityCheckedBy,
'remarks': instance.remarks,
'cancellation_reason': instance.cancellationReason,
'created_at': instance.createdAt?.toIso8601String(),
'updated_at': instance.updatedAt?.toIso8601String(),
'items': instance.items,
};
_$GrnItemModelImpl _$$GrnItemModelImplFromJson(Map<String, dynamic> json) =>
_$GrnItemModelImpl(
id: _idFromJson(json['id']),
grnId: _idFromJson(json['grn_id']),
poItemId: _intFromJsonNullable(json['po_item_id']),
itemId: _intFromJsonNullable(json['item_id']),
itemCode: _readItemCode(json, 'item_code') as String?,
itemName: _readItemName(json, 'item_name') as String?,
lineNo: _intFromJsonNullable(json['line_no']),
currentQty: _doubleFromJsonNullable(json['current_qty']),
acceptedQty: _doubleFromJsonNullable(json['accepted_qty']),
damagedQty: _doubleFromJsonNullable(json['damaged_qty']),
shortQty: _doubleFromJsonNullable(json['short_qty']),
excessQty: _doubleFromJsonNullable(json['excess_qty']),
rejectedQty: _doubleFromJsonNullable(json['rejected_qty']),
rejectionReason: json['rejection_reason'] as String?,
rate: _doubleFromJsonNullable(json['rate']),
batchNo: json['batch_no'] as String?,
mfgDate: _dateFromJsonNullable(json['mfg_date']),
expiryDate: _dateFromJsonNullable(json['expiry_date']),
storageLocation: json['storage_location'] as String?,
assetCategoryId: _intFromJsonNullable(json['asset_category_id']),
uomId: _intFromJsonNullable(json['uom_id']),
uomName: _readUomName(json, 'uom_name') as String?,
remarks: json['remarks'] as String?,
);
Map<String, dynamic> _$$GrnItemModelImplToJson(_$GrnItemModelImpl instance) =>
<String, dynamic>{
'id': instance.id,
'grn_id': instance.grnId,
'po_item_id': instance.poItemId,
'item_id': instance.itemId,
'item_code': instance.itemCode,
'item_name': instance.itemName,
'line_no': instance.lineNo,
'current_qty': instance.currentQty,
'accepted_qty': instance.acceptedQty,
'damaged_qty': instance.damagedQty,
'short_qty': instance.shortQty,
'excess_qty': instance.excessQty,
'rejected_qty': instance.rejectedQty,
'rejection_reason': instance.rejectionReason,
'rate': instance.rate,
'batch_no': instance.batchNo,
'mfg_date': instance.mfgDate?.toIso8601String(),
'expiry_date': instance.expiryDate?.toIso8601String(),
'storage_location': instance.storageLocation,
'asset_category_id': instance.assetCategoryId,
'uom_id': instance.uomId,
'uom_name': instance.uomName,
'remarks': instance.remarks,
};

View File

@ -0,0 +1,239 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'purchase_order_model.freezed.dart';
part 'purchase_order_model.g.dart';
String _idFromJson(Object? value) => value?.toString() ?? '';
int? _intFromJsonNullable(Object? value) {
if (value == null) return null;
if (value is int) return value;
if (value is num) return value.toInt();
return int.tryParse(value.toString());
}
double? _doubleFromJsonNullable(Object? value) {
if (value == null) return null;
if (value is double) return value;
if (value is num) return value.toDouble();
return double.tryParse(value.toString());
}
DateTime? _dateFromJsonNullable(Object? value) {
if (value == null) return null;
if (value is DateTime) return value;
return DateTime.tryParse(value.toString());
}
Object? _readNestedName(Map<dynamic, dynamic> json, String flatKey, String nestedKey) {
final flat = json[flatKey];
if (flat is String && flat.isNotEmpty) return flat;
final nested = json[nestedKey];
if (nested is Map) {
return nested['name'] ?? nested['vendor_name'];
}
return null;
}
Object? _readVendorName(Map<dynamic, dynamic> json, String key) =>
_readNestedName(json, 'vendor_name', 'vendor');
Object? _readPlantName(Map<dynamic, dynamic> json, String key) =>
_readNestedName(json, 'plant_name', 'plant');
Object? _readWarehouseName(Map<dynamic, dynamic> json, String key) =>
_readNestedName(json, 'warehouse_name', 'warehouse');
Object? _readItemName(Map<dynamic, dynamic> json, String key) {
final flat = json['item_name'];
if (flat is String && flat.isNotEmpty) return flat;
final nested = json['item'];
if (nested is Map) return nested['name'];
return null;
}
Object? _readItemCode(Map<dynamic, dynamic> json, String key) {
final flat = json['item_code'];
if (flat is String && flat.isNotEmpty) return flat;
final nested = json['item'];
if (nested is Map) return nested['code'];
return null;
}
Object? _readUomName(Map<dynamic, dynamic> json, String key) {
final flat = json['uom_name'];
if (flat is String && flat.isNotEmpty) return flat;
final nested = json['uom'];
if (nested is Map) return nested['name'];
return null;
}
Object? _readPoNumber(Map<dynamic, dynamic> json, String key) {
final poNumber = json['po_number'];
if (poNumber != null && poNumber.toString().trim().isNotEmpty) {
return poNumber.toString();
}
final poNo = json['po_no'];
if (poNo != null && poNo.toString().trim().isNotEmpty) {
return poNo.toString();
}
return null;
}
Object? _readGrandTotal(Map<dynamic, dynamic> json, String key) =>
_doubleFromJsonNullable(json['grand_total'] ?? json['total_amount']);
Object? _readTaxTotal(Map<dynamic, dynamic> json, String key) =>
_doubleFromJsonNullable(json['tax_total'] ?? json['tax_amount']);
Object? _readSubTotal(Map<dynamic, dynamic> json, String key) =>
_doubleFromJsonNullable(json['sub_total'] ?? json['taxable_amount']);
@freezed
class PurchaseOrderModel with _$PurchaseOrderModel {
const PurchaseOrderModel._();
const factory PurchaseOrderModel({
@JsonKey(fromJson: _idFromJson) required String id,
@JsonKey(name: 'po_number', readValue: _readPoNumber) String? poNo,
@JsonKey(name: 'po_date', fromJson: _dateFromJsonNullable) DateTime? poDate,
@JsonKey(name: 'po_type') String? poType,
@Default('DRAFT') String status,
@JsonKey(name: 'vendor_id', fromJson: _intFromJsonNullable) int? vendorId,
@JsonKey(name: 'vendor_name', readValue: _readVendorName) String? vendorName,
@JsonKey(name: 'plant_id', fromJson: _intFromJsonNullable) int? plantId,
@JsonKey(name: 'plant_name', readValue: _readPlantName) String? plantName,
@JsonKey(name: 'warehouse_id', fromJson: _intFromJsonNullable) int? warehouseId,
@JsonKey(name: 'warehouse_name', readValue: _readWarehouseName) String? warehouseName,
@JsonKey(name: 'brand_id', fromJson: _intFromJsonNullable) int? brandId,
@JsonKey(name: 'payment_term_id', fromJson: _intFromJsonNullable) int? paymentTermId,
@JsonKey(name: 'delivery_term_id', fromJson: _intFromJsonNullable) int? deliveryTermId,
@JsonKey(name: 'expected_delivery_date', fromJson: _dateFromJsonNullable)
DateTime? expectedDeliveryDate,
@JsonKey(name: 'discount_amount', fromJson: _doubleFromJsonNullable)
double? discountAmount,
@JsonKey(name: 'freight_charges', fromJson: _doubleFromJsonNullable)
double? freightCharges,
@JsonKey(name: 'other_charges', fromJson: _doubleFromJsonNullable)
double? otherCharges,
@JsonKey(name: 'sub_total', readValue: _readSubTotal)
double? taxableAmount,
@JsonKey(name: 'tax_total', readValue: _readTaxTotal)
double? taxAmount,
@JsonKey(name: 'grand_total', readValue: _readGrandTotal)
double? totalAmount,
@JsonKey(name: 'terms_and_conditions') String? termsAndConditions,
String? remarks,
@JsonKey(name: 'revision_no', fromJson: _intFromJsonNullable) int? revisionNo,
@JsonKey(name: 'created_at', fromJson: _dateFromJsonNullable) DateTime? createdAt,
@JsonKey(name: 'updated_at', fromJson: _dateFromJsonNullable) DateTime? updatedAt,
@Default([]) List<PurchaseOrderItemModel> items,
}) = _PurchaseOrderModel;
factory PurchaseOrderModel.fromJson(Map<String, dynamic> json) =>
_$PurchaseOrderModelFromJson(json);
bool get canEdit =>
status.toUpperCase() == 'DRAFT' || status.toUpperCase() == 'REJECTED';
bool get canDelete => canEdit;
bool get canSubmit => status.toUpperCase() == 'DRAFT';
bool get canApprove {
final s = status.toUpperCase();
return s == 'SUBMITTED' || s == 'PENDING_APPROVAL' || s == 'PENDING';
}
bool get canReject => canApprove;
bool get canAmend => status.toUpperCase() == 'APPROVED';
bool get canCancel {
final s = status.toUpperCase();
return s != 'CANCELLED' && s != 'DRAFT';
}
}
@freezed
class PurchaseOrderItemModel with _$PurchaseOrderItemModel {
const factory PurchaseOrderItemModel({
@JsonKey(fromJson: _idFromJson) required String id,
@JsonKey(name: 'po_id', fromJson: _idFromJson) String? poId,
@JsonKey(name: 'item_id', fromJson: _intFromJsonNullable) int? itemId,
@JsonKey(name: 'item_code', readValue: _readItemCode) String? itemCode,
@JsonKey(name: 'item_name', readValue: _readItemName) String? itemName,
@JsonKey(name: 'line_no', fromJson: _intFromJsonNullable) int? lineNo,
@JsonKey(name: 'ordered_qty', fromJson: _doubleFromJsonNullable)
double? orderedQty,
@JsonKey(name: 'received_qty', fromJson: _doubleFromJsonNullable)
double? receivedQty,
@JsonKey(name: 'uom_id', fromJson: _intFromJsonNullable) int? uomId,
@JsonKey(name: 'uom_name', readValue: _readUomName) String? uomName,
@JsonKey(fromJson: _doubleFromJsonNullable) double? rate,
@JsonKey(name: 'discount_pct', fromJson: _doubleFromJsonNullable)
double? discountPct,
@JsonKey(name: 'discount_amount', fromJson: _doubleFromJsonNullable)
double? discountAmount,
@JsonKey(name: 'gst_rate_id', fromJson: _intFromJsonNullable) int? gstRateId,
@JsonKey(name: 'hsn_code_id', fromJson: _intFromJsonNullable) int? hsnCodeId,
@JsonKey(name: 'line_amount', fromJson: _doubleFromJsonNullable)
double? lineAmount,
String? remarks,
}) = _PurchaseOrderItemModel;
factory PurchaseOrderItemModel.fromJson(Map<String, dynamic> json) =>
_$PurchaseOrderItemModelFromJson(json);
}
@freezed
class PurchaseOrderListQuery with _$PurchaseOrderListQuery {
const factory PurchaseOrderListQuery({
@Default(1) int page,
@Default(20) int limit,
String? search,
String? status,
String? poType,
int? vendorId,
int? plantId,
String? dateFrom,
String? dateTo,
}) = _PurchaseOrderListQuery;
}
const poTypeOptions = [
('RAW_MATERIAL', 'Raw Material'),
('PACKING_MATERIAL', 'Packing Material'),
('ASSET_CAPITAL', 'Asset / Capital'),
('SERVICE', 'Service'),
('GENERAL', 'General'),
];
const poStatusOptions = [
('DRAFT', 'Draft'),
('SUBMITTED', 'Submitted'),
('PENDING_APPROVAL', 'Pending Approval'),
('APPROVED', 'Approved'),
('REJECTED', 'Rejected'),
('CANCELLED', 'Cancelled'),
('PARTIALLY_RECEIVED', 'Partially Received'),
('FULLY_RECEIVED', 'Fully Received'),
];
String poTypeLabel(String? value) {
if (value == null) return '';
return poTypeOptions
.where((e) => e.$1 == value)
.map((e) => e.$2)
.firstOrNull ??
value;
}
String poStatusLabel(String? value) {
if (value == null) return '';
return poStatusOptions
.where((e) => e.$1 == value)
.map((e) => e.$2)
.firstOrNull ??
value.replaceAll('_', ' ');
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,121 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'purchase_order_model.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$PurchaseOrderModelImpl _$$PurchaseOrderModelImplFromJson(
Map<String, dynamic> json,
) => _$PurchaseOrderModelImpl(
id: _idFromJson(json['id']),
poNo: _readPoNumber(json, 'po_number') as String?,
poDate: _dateFromJsonNullable(json['po_date']),
poType: json['po_type'] as String?,
status: json['status'] as String? ?? 'DRAFT',
vendorId: _intFromJsonNullable(json['vendor_id']),
vendorName: _readVendorName(json, 'vendor_name') as String?,
plantId: _intFromJsonNullable(json['plant_id']),
plantName: _readPlantName(json, 'plant_name') as String?,
warehouseId: _intFromJsonNullable(json['warehouse_id']),
warehouseName: _readWarehouseName(json, 'warehouse_name') as String?,
brandId: _intFromJsonNullable(json['brand_id']),
paymentTermId: _intFromJsonNullable(json['payment_term_id']),
deliveryTermId: _intFromJsonNullable(json['delivery_term_id']),
expectedDeliveryDate: _dateFromJsonNullable(json['expected_delivery_date']),
discountAmount: _doubleFromJsonNullable(json['discount_amount']),
freightCharges: _doubleFromJsonNullable(json['freight_charges']),
otherCharges: _doubleFromJsonNullable(json['other_charges']),
taxableAmount: (_readSubTotal(json, 'sub_total') as num?)?.toDouble(),
taxAmount: (_readTaxTotal(json, 'tax_total') as num?)?.toDouble(),
totalAmount: (_readGrandTotal(json, 'grand_total') as num?)?.toDouble(),
termsAndConditions: json['terms_and_conditions'] as String?,
remarks: json['remarks'] as String?,
revisionNo: _intFromJsonNullable(json['revision_no']),
createdAt: _dateFromJsonNullable(json['created_at']),
updatedAt: _dateFromJsonNullable(json['updated_at']),
items:
(json['items'] as List<dynamic>?)
?.map(
(e) => PurchaseOrderItemModel.fromJson(e as Map<String, dynamic>),
)
.toList() ??
const [],
);
Map<String, dynamic> _$$PurchaseOrderModelImplToJson(
_$PurchaseOrderModelImpl instance,
) => <String, dynamic>{
'id': instance.id,
'po_number': instance.poNo,
'po_date': instance.poDate?.toIso8601String(),
'po_type': instance.poType,
'status': instance.status,
'vendor_id': instance.vendorId,
'vendor_name': instance.vendorName,
'plant_id': instance.plantId,
'plant_name': instance.plantName,
'warehouse_id': instance.warehouseId,
'warehouse_name': instance.warehouseName,
'brand_id': instance.brandId,
'payment_term_id': instance.paymentTermId,
'delivery_term_id': instance.deliveryTermId,
'expected_delivery_date': instance.expectedDeliveryDate?.toIso8601String(),
'discount_amount': instance.discountAmount,
'freight_charges': instance.freightCharges,
'other_charges': instance.otherCharges,
'sub_total': instance.taxableAmount,
'tax_total': instance.taxAmount,
'grand_total': instance.totalAmount,
'terms_and_conditions': instance.termsAndConditions,
'remarks': instance.remarks,
'revision_no': instance.revisionNo,
'created_at': instance.createdAt?.toIso8601String(),
'updated_at': instance.updatedAt?.toIso8601String(),
'items': instance.items,
};
_$PurchaseOrderItemModelImpl _$$PurchaseOrderItemModelImplFromJson(
Map<String, dynamic> json,
) => _$PurchaseOrderItemModelImpl(
id: _idFromJson(json['id']),
poId: _idFromJson(json['po_id']),
itemId: _intFromJsonNullable(json['item_id']),
itemCode: _readItemCode(json, 'item_code') as String?,
itemName: _readItemName(json, 'item_name') as String?,
lineNo: _intFromJsonNullable(json['line_no']),
orderedQty: _doubleFromJsonNullable(json['ordered_qty']),
receivedQty: _doubleFromJsonNullable(json['received_qty']),
uomId: _intFromJsonNullable(json['uom_id']),
uomName: _readUomName(json, 'uom_name') as String?,
rate: _doubleFromJsonNullable(json['rate']),
discountPct: _doubleFromJsonNullable(json['discount_pct']),
discountAmount: _doubleFromJsonNullable(json['discount_amount']),
gstRateId: _intFromJsonNullable(json['gst_rate_id']),
hsnCodeId: _intFromJsonNullable(json['hsn_code_id']),
lineAmount: _doubleFromJsonNullable(json['line_amount']),
remarks: json['remarks'] as String?,
);
Map<String, dynamic> _$$PurchaseOrderItemModelImplToJson(
_$PurchaseOrderItemModelImpl instance,
) => <String, dynamic>{
'id': instance.id,
'po_id': instance.poId,
'item_id': instance.itemId,
'item_code': instance.itemCode,
'item_name': instance.itemName,
'line_no': instance.lineNo,
'ordered_qty': instance.orderedQty,
'received_qty': instance.receivedQty,
'uom_id': instance.uomId,
'uom_name': instance.uomName,
'rate': instance.rate,
'discount_pct': instance.discountPct,
'discount_amount': instance.discountAmount,
'gst_rate_id': instance.gstRateId,
'hsn_code_id': instance.hsnCodeId,
'line_amount': instance.lineAmount,
'remarks': instance.remarks,
};

View File

@ -0,0 +1,178 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'vendor_model.freezed.dart';
part 'vendor_model.g.dart';
String _idFromJson(Object? value) => value?.toString() ?? '';
int? _intFromJsonNullable(Object? value) {
if (value == null) return null;
if (value is int) return value;
if (value is num) return value.toInt();
return int.tryParse(value.toString());
}
DateTime? _dateFromJsonNullable(Object? value) {
if (value == null) return null;
if (value is DateTime) return value;
return DateTime.tryParse(value.toString());
}
Object? _readPaymentTermName(Map<dynamic, dynamic> json, String key) {
final flat = json['payment_term_name'];
if (flat is String && flat.isNotEmpty) return flat;
final nested = json['payment_term'];
if (nested is Map) return nested['name'];
return null;
}
Object? _readPaymentTermId(Map<dynamic, dynamic> json, String key) {
final flat = json['payment_term_id'];
if (flat != null) return flat;
final nested = json['payment_term'];
if (nested is Map) return nested['id'];
return null;
}
@freezed
class VendorModel with _$VendorModel {
const factory VendorModel({
@JsonKey(fromJson: _idFromJson) required String id,
@JsonKey(name: 'vendor_code') String? vendorCode,
@JsonKey(name: 'vendor_name') required String vendorName,
@JsonKey(name: 'vendor_type') String? vendorType,
String? gstin,
String? pan,
@JsonKey(name: 'payment_term_id', fromJson: _intFromJsonNullable)
int? paymentTermId,
@JsonKey(name: 'payment_term_name', readValue: _readPaymentTermName)
String? paymentTermName,
@JsonKey(name: 'credit_period_days', fromJson: _intFromJsonNullable)
int? creditPeriodDays,
String? remarks,
String? status,
@JsonKey(name: 'is_active') @Default(true) bool isActive,
@JsonKey(name: 'created_at', fromJson: _dateFromJsonNullable)
DateTime? createdAt,
@JsonKey(name: 'updated_at', fromJson: _dateFromJsonNullable)
DateTime? updatedAt,
@Default([]) List<VendorAddressModel> addresses,
@Default([]) List<VendorContactModel> contacts,
@JsonKey(name: 'bank_details') @Default([]) List<VendorBankDetailModel> bankDetails,
}) = _VendorModel;
factory VendorModel.fromJson(Map<String, dynamic> json) =>
_$VendorModelFromJson(json);
}
@freezed
class VendorAddressModel with _$VendorAddressModel {
const factory VendorAddressModel({
@JsonKey(fromJson: _idFromJson) required String id,
@JsonKey(name: 'vendor_id', fromJson: _idFromJson) String? vendorId,
@JsonKey(name: 'address_type') String? addressType,
@JsonKey(name: 'address_line1') String? addressLine1,
@JsonKey(name: 'address_line2') String? addressLine2,
String? city,
String? state,
String? pincode,
String? country,
String? gstin,
@JsonKey(name: 'is_active') @Default(true) bool isActive,
}) = _VendorAddressModel;
factory VendorAddressModel.fromJson(Map<String, dynamic> json) =>
_$VendorAddressModelFromJson(json);
}
@freezed
class VendorContactModel with _$VendorContactModel {
const factory VendorContactModel({
@JsonKey(fromJson: _idFromJson) required String id,
@JsonKey(name: 'vendor_id', fromJson: _idFromJson) String? vendorId,
@JsonKey(name: 'contact_name') required String contactName,
String? designation,
String? phone,
String? email,
@JsonKey(name: 'is_primary') @Default(false) bool isPrimary,
@JsonKey(name: 'is_active') @Default(true) bool isActive,
}) = _VendorContactModel;
factory VendorContactModel.fromJson(Map<String, dynamic> json) =>
_$VendorContactModelFromJson(json);
}
@freezed
class VendorBankDetailModel with _$VendorBankDetailModel {
const factory VendorBankDetailModel({
@JsonKey(fromJson: _idFromJson) required String id,
@JsonKey(name: 'vendor_id', fromJson: _idFromJson) String? vendorId,
@JsonKey(name: 'bank_name') String? bankName,
String? branch,
@JsonKey(name: 'account_number') String? accountNumber,
String? ifsc,
@JsonKey(name: 'account_holder_name') String? accountHolderName,
@JsonKey(name: 'account_type') String? accountType,
@JsonKey(name: 'is_primary') @Default(false) bool isPrimary,
@JsonKey(name: 'is_active') @Default(true) bool isActive,
}) = _VendorBankDetailModel;
factory VendorBankDetailModel.fromJson(Map<String, dynamic> json) =>
_$VendorBankDetailModelFromJson(json);
}
@freezed
class VendorListQuery with _$VendorListQuery {
const factory VendorListQuery({
@Default(1) int page,
@Default(20) int limit,
String? search,
String? status,
String? vendorType,
bool? isActive,
}) = _VendorListQuery;
}
const vendorTypeOptions = [
('RAW_MATERIAL', 'Raw Material'),
('PACKING_MATERIAL', 'Packing Material'),
('ASSET_CAPITAL', 'Asset / Capital'),
('SERVICE', 'Service'),
('GENERAL', 'General'),
];
const vendorStatusOptions = [
('active', 'Active'),
('inactive', 'Inactive'),
('blacklisted', 'Blacklisted'),
];
const addressTypeOptions = [
('REGISTERED', 'Registered'),
('BILLING', 'Billing'),
('DISPATCH', 'Dispatch'),
];
const accountTypeOptions = [
('CURRENT', 'Current'),
('SAVINGS', 'Savings'),
('OVERDRAFT', 'Overdraft'),
];
String vendorTypeLabel(String? value) {
if (value == null) return '';
return vendorTypeOptions
.where((e) => e.$1 == value)
.map((e) => e.$2)
.firstOrNull ??
value;
}
String vendorStatusLabel(String? value) {
if (value == null) return '';
return vendorStatusOptions
.where((e) => e.$1 == value)
.map((e) => e.$2)
.firstOrNull ??
value;
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,152 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'vendor_model.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$VendorModelImpl _$$VendorModelImplFromJson(
Map<String, dynamic> json,
) => _$VendorModelImpl(
id: _idFromJson(json['id']),
vendorCode: json['vendor_code'] as String?,
vendorName: json['vendor_name'] as String,
vendorType: json['vendor_type'] as String?,
gstin: json['gstin'] as String?,
pan: json['pan'] as String?,
paymentTermId: _intFromJsonNullable(json['payment_term_id']),
paymentTermName: _readPaymentTermName(json, 'payment_term_name') as String?,
creditPeriodDays: _intFromJsonNullable(json['credit_period_days']),
remarks: json['remarks'] as String?,
status: json['status'] as String?,
isActive: json['is_active'] as bool? ?? true,
createdAt: _dateFromJsonNullable(json['created_at']),
updatedAt: _dateFromJsonNullable(json['updated_at']),
addresses:
(json['addresses'] as List<dynamic>?)
?.map((e) => VendorAddressModel.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
contacts:
(json['contacts'] as List<dynamic>?)
?.map((e) => VendorContactModel.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
bankDetails:
(json['bank_details'] as List<dynamic>?)
?.map(
(e) => VendorBankDetailModel.fromJson(e as Map<String, dynamic>),
)
.toList() ??
const [],
);
Map<String, dynamic> _$$VendorModelImplToJson(_$VendorModelImpl instance) =>
<String, dynamic>{
'id': instance.id,
'vendor_code': instance.vendorCode,
'vendor_name': instance.vendorName,
'vendor_type': instance.vendorType,
'gstin': instance.gstin,
'pan': instance.pan,
'payment_term_id': instance.paymentTermId,
'payment_term_name': instance.paymentTermName,
'credit_period_days': instance.creditPeriodDays,
'remarks': instance.remarks,
'status': instance.status,
'is_active': instance.isActive,
'created_at': instance.createdAt?.toIso8601String(),
'updated_at': instance.updatedAt?.toIso8601String(),
'addresses': instance.addresses,
'contacts': instance.contacts,
'bank_details': instance.bankDetails,
};
_$VendorAddressModelImpl _$$VendorAddressModelImplFromJson(
Map<String, dynamic> json,
) => _$VendorAddressModelImpl(
id: _idFromJson(json['id']),
vendorId: _idFromJson(json['vendor_id']),
addressType: json['address_type'] as String?,
addressLine1: json['address_line1'] as String?,
addressLine2: json['address_line2'] as String?,
city: json['city'] as String?,
state: json['state'] as String?,
pincode: json['pincode'] as String?,
country: json['country'] as String?,
gstin: json['gstin'] as String?,
isActive: json['is_active'] as bool? ?? true,
);
Map<String, dynamic> _$$VendorAddressModelImplToJson(
_$VendorAddressModelImpl instance,
) => <String, dynamic>{
'id': instance.id,
'vendor_id': instance.vendorId,
'address_type': instance.addressType,
'address_line1': instance.addressLine1,
'address_line2': instance.addressLine2,
'city': instance.city,
'state': instance.state,
'pincode': instance.pincode,
'country': instance.country,
'gstin': instance.gstin,
'is_active': instance.isActive,
};
_$VendorContactModelImpl _$$VendorContactModelImplFromJson(
Map<String, dynamic> json,
) => _$VendorContactModelImpl(
id: _idFromJson(json['id']),
vendorId: _idFromJson(json['vendor_id']),
contactName: json['contact_name'] as String,
designation: json['designation'] as String?,
phone: json['phone'] as String?,
email: json['email'] as String?,
isPrimary: json['is_primary'] as bool? ?? false,
isActive: json['is_active'] as bool? ?? true,
);
Map<String, dynamic> _$$VendorContactModelImplToJson(
_$VendorContactModelImpl instance,
) => <String, dynamic>{
'id': instance.id,
'vendor_id': instance.vendorId,
'contact_name': instance.contactName,
'designation': instance.designation,
'phone': instance.phone,
'email': instance.email,
'is_primary': instance.isPrimary,
'is_active': instance.isActive,
};
_$VendorBankDetailModelImpl _$$VendorBankDetailModelImplFromJson(
Map<String, dynamic> json,
) => _$VendorBankDetailModelImpl(
id: _idFromJson(json['id']),
vendorId: _idFromJson(json['vendor_id']),
bankName: json['bank_name'] as String?,
branch: json['branch'] as String?,
accountNumber: json['account_number'] as String?,
ifsc: json['ifsc'] as String?,
accountHolderName: json['account_holder_name'] as String?,
accountType: json['account_type'] as String?,
isPrimary: json['is_primary'] as bool? ?? false,
isActive: json['is_active'] as bool? ?? true,
);
Map<String, dynamic> _$$VendorBankDetailModelImplToJson(
_$VendorBankDetailModelImpl instance,
) => <String, dynamic>{
'id': instance.id,
'vendor_id': instance.vendorId,
'bank_name': instance.bankName,
'branch': instance.branch,
'account_number': instance.accountNumber,
'ifsc': instance.ifsc,
'account_holder_name': instance.accountHolderName,
'account_type': instance.accountType,
'is_primary': instance.isPrimary,
'is_active': instance.isActive,
};

View File

@ -29,6 +29,14 @@ import '../../modules/rbac/presentation/screens/users_role_management_screen.dar
import '../../modules/users/presentation/screens/user_detail_screen.dart';
import '../../modules/users/presentation/screens/user_form_screen.dart';
import '../../modules/users/presentation/screens/user_profile_screen.dart';
import '../../modules/grn/presentation/screens/grn_detail_screen.dart';
import '../../modules/grn/presentation/screens/grn_form_screen.dart';
import '../../modules/grn/presentation/screens/grn_list_screen.dart';
import '../../modules/purchase_orders/presentation/screens/purchase_order_detail_screen.dart';
import '../../modules/purchase_orders/presentation/screens/purchase_order_form_screen.dart';
import '../../modules/purchase_orders/presentation/screens/purchase_order_list_screen.dart';
import '../../modules/vendors/presentation/screens/vendor_detail_screen.dart';
import '../../modules/vendors/presentation/screens/vendor_list_screen.dart';
import '../../modules/roles/presentation/screens/permission_matrix_screen.dart';
import '../../modules/settings/presentation/screens/appearance_settings_screen.dart';
import '../../modules/settings/presentation/screens/asset_settings_screen.dart';
@ -64,12 +72,12 @@ final routerProvider = Provider<GoRouter>((ref) {
return null;
}
// Screen gallery and ?preview=true routes work without login API.
// Screen gallery and ?preview=true routes work without login API (dev only).
if (DevConfig.screenPreviewEnabled &&
location == RouteConstants.screenGallery) {
return null;
}
if (isPreview) return null;
if (isPreview && DevConfig.screenPreviewEnabled) return null;
if (!isAuthenticated && !isAuthRoute) {
return RouteConstants.login;
@ -200,6 +208,65 @@ final routerProvider = Provider<GoRouter>((ref) {
pageBuilder: (context, state) =>
shellPage(state, const UserProfileScreen()),
),
GoRoute(
path: RouteConstants.purchaseOrders,
pageBuilder: (context, state) =>
shellPage(state, const PurchaseOrderListScreen()),
routes: [
GoRoute(
path: 'add',
builder: (context, state) => const PurchaseOrderFormScreen(),
),
GoRoute(
path: ':id/edit',
builder: (context, state) => PurchaseOrderFormScreen(
purchaseOrderId: state.pathParameters['id']!,
),
),
GoRoute(
path: ':id',
builder: (context, state) => PurchaseOrderDetailScreen(
purchaseOrderId: state.pathParameters['id']!,
),
),
],
),
GoRoute(
path: RouteConstants.grn,
pageBuilder: (context, state) =>
shellPage(state, const GrnListScreen()),
routes: [
GoRoute(
path: 'add',
builder: (context, state) => const GrnFormScreen(),
),
GoRoute(
path: ':id/edit',
builder: (context, state) => GrnFormScreen(
grnId: state.pathParameters['id']!,
),
),
GoRoute(
path: ':id',
builder: (context, state) => GrnDetailScreen(
grnId: state.pathParameters['id']!,
),
),
],
),
GoRoute(
path: RouteConstants.vendors,
pageBuilder: (context, state) =>
shellPage(state, const VendorListScreen()),
routes: [
GoRoute(
path: ':id',
builder: (context, state) => VendorDetailScreen(
vendorId: state.pathParameters['id']!,
),
),
],
),
GoRoute(
path: RouteConstants.assets,
pageBuilder: (context, state) =>

View File

@ -74,6 +74,24 @@ const List<MenuItem> appMenuItems = [
),
],
),
MenuItem(
label: 'Vendors',
icon: Icons.store_outlined,
route: RouteConstants.vendors,
module: 'vendors',
),
MenuItem(
label: 'Purchase Orders',
icon: Icons.receipt_long_outlined,
route: RouteConstants.purchaseOrders,
module: 'purchase_orders',
),
MenuItem(
label: 'GRN',
icon: Icons.inventory_2_outlined,
route: RouteConstants.grn,
module: 'grn',
),
MenuItem(
label: 'Master Data',
icon: Icons.dataset_outlined,

View File

@ -27,6 +27,8 @@ class AppDataTable<T> extends StatelessWidget {
this.sortAscending = true,
this.onSort,
this.emptyMessage = 'No records found',
this.wrapInCard = true,
this.shrinkWrap = false,
});
final List<AppDataColumn<T>> columns;
@ -35,11 +37,14 @@ class AppDataTable<T> extends StatelessWidget {
final bool sortAscending;
final void Function(String column, bool ascending)? onSort;
final String emptyMessage;
final bool wrapInCard;
/// Set true when the table is placed inside another scrollable.
final bool shrinkWrap;
@override
Widget build(BuildContext context) {
if (rows.isEmpty) {
return Center(
final empty = Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Text(
@ -50,40 +55,151 @@ class AppDataTable<T> extends StatelessWidget {
),
),
);
if (!wrapInCard) return empty;
return AppCard(clipBehavior: Clip.antiAlias, child: empty);
}
final table = LayoutBuilder(
builder: (context, constraints) {
return ListView(
padding: EdgeInsets.zero,
shrinkWrap: shrinkWrap,
physics: shrinkWrap
? const NeverScrollableScrollPhysics()
: null,
children: [
_TableHeaderRow<T>(
columns: columns,
sortColumn: sortColumn,
sortAscending: sortAscending,
onSort: onSort,
),
...rows.map(
(row) => _TableDataRow<T>(
columns: columns,
row: row,
),
),
],
);
},
);
if (!wrapInCard) return table;
return AppCard(
clipBehavior: Clip.antiAlias,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: ConstrainedBox(
constraints: const BoxConstraints(minWidth: 720),
child: DataTable(
sortColumnIndex: sortColumn == null
? null
: columns.indexWhere((c) => c.sortKey == sortColumn),
sortAscending: sortAscending,
columns: columns
.map(
(col) => DataColumn(
label: Text(col.label),
onSort: col.sortKey == null
? null
: (_, ascending) => onSort?.call(col.sortKey!, ascending),
),
)
.toList(),
rows: rows
.map(
(row) => DataRow(
cells: columns
.map((col) => DataCell(col.cellBuilder(context, row)))
.toList(),
),
)
.toList(),
),
),
child: table,
);
}
}
class _TableHeaderRow<T> extends StatelessWidget {
const _TableHeaderRow({
required this.columns,
required this.sortColumn,
required this.sortAscending,
required this.onSort,
});
final List<AppDataColumn<T>> columns;
final String? sortColumn;
final bool sortAscending;
final void Function(String column, bool ascending)? onSort;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
width: double.infinity,
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.4),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
child: Row(
children: columns.map((col) {
final isSorted = col.sortKey != null && col.sortKey == sortColumn;
final label = Text(
col.label.toUpperCase(),
style: theme.textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.w700,
letterSpacing: 0.6,
color: theme.colorScheme.onSurfaceVariant,
),
);
Widget header = label;
if (col.sortKey != null && onSort != null) {
header = InkWell(
onTap: () => onSort!(
col.sortKey!,
isSorted ? !sortAscending : true,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
label,
if (isSorted) ...[
const SizedBox(width: 4),
Icon(
sortAscending
? Icons.arrow_upward
: Icons.arrow_downward,
size: 14,
color: theme.colorScheme.onSurfaceVariant,
),
],
],
),
);
}
return Expanded(
flex: col.flex,
child: Align(
alignment: col.alignment,
child: header,
),
);
}).toList(),
),
);
}
}
class _TableDataRow<T> extends StatelessWidget {
const _TableDataRow({
required this.columns,
required this.row,
});
final List<AppDataColumn<T>> columns;
final T row;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
width: double.infinity,
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: theme.colorScheme.outline.withValues(alpha: 0.08),
),
),
),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: columns.map((col) {
return Expanded(
flex: col.flex,
child: Align(
alignment: col.alignment,
child: col.cellBuilder(context, row),
),
);
}).toList(),
),
);
}

View File

@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import 'app_searchable_dropdown.dart';
class AppDropdownOption<T> {
const AppDropdownOption({required this.value, required this.label});
@ -7,6 +9,7 @@ class AppDropdownOption<T> {
final String label;
}
/// Searchable dropdown all app dropdowns use [AppSearchableDropdown] under the hood.
class AppDropdown<T> extends StatelessWidget {
const AppDropdown({
super.key,
@ -16,7 +19,9 @@ class AppDropdown<T> extends StatelessWidget {
required this.onChanged,
this.validator,
this.hint,
this.searchHint,
this.enabled = true,
this.isDense = false,
});
final String label;
@ -25,23 +30,33 @@ class AppDropdown<T> extends StatelessWidget {
final ValueChanged<T?> onChanged;
final String? Function(T?)? validator;
final String? hint;
final String? searchHint;
final bool enabled;
final bool isDense;
@override
Widget build(BuildContext context) {
return DropdownButtonFormField<T>(
return AppSearchableDropdown<T>(
label: label,
value: value,
decoration: InputDecoration(labelText: label, hintText: hint),
items: options
.map(
(option) => DropdownMenuItem<T>(
value: option.value,
child: Text(option.label),
),
)
.toList(),
onChanged: enabled ? onChanged : null,
options: options,
onChanged: onChanged,
validator: validator,
hint: hint,
searchHint: searchHint ?? 'Search ${label.toLowerCase()}...',
enabled: enabled,
isDense: isDense,
);
}
}
List<AppDropdownOption<String>> stringDropdownOptions(List<String> values) {
return values
.map(
(value) => AppDropdownOption(
value: value,
label: value.replaceAll('_', ' '),
),
)
.toList();
}

View File

@ -55,7 +55,11 @@ class _AppSearchableDropdownState<T> extends State<AppSearchableDropdown<T>> {
if (_overlayEntry == null) return;
_overlayEntry!.remove();
_overlayEntry = null;
if (mounted) setState(() {});
if (mounted) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() {});
});
}
}
void _openPicker(FormFieldState<T> field) {

View File

@ -192,3 +192,52 @@ class SidePanelFormRow extends StatelessWidget {
);
}
}
/// Responsive row with up to three equal-width form fields.
class FormRowThree extends StatelessWidget {
const FormRowThree({
super.key,
required this.children,
this.spacing = 12,
this.stackBelowWidth = 768,
});
final List<Widget> children;
final double spacing;
final double stackBelowWidth;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth < stackBelowWidth) {
return Padding(
padding: EdgeInsets.only(bottom: spacing),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (var i = 0; i < children.length; i++) ...[
if (i > 0) SizedBox(height: spacing),
children[i],
],
],
),
);
}
return Padding(
padding: EdgeInsets.only(bottom: spacing),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (var i = 0; i < children.length; i++) ...[
if (i > 0) SizedBox(width: spacing),
Expanded(child: children[i]),
],
],
),
);
},
);
}
}

View File

@ -0,0 +1,50 @@
import 'package:flutter/material.dart';
/// Compact outline icon for data-table action columns (matches Users & Roles table).
class AppTableActionIcon extends StatelessWidget {
const AppTableActionIcon({
super.key,
required this.tooltip,
required this.icon,
required this.onPressed,
this.color,
});
final String tooltip;
final IconData icon;
final VoidCallback onPressed;
final Color? color;
@override
Widget build(BuildContext context) {
final iconColor = color ?? Theme.of(context).colorScheme.onSurfaceVariant;
return Tooltip(
message: tooltip,
child: InkWell(
onTap: onPressed,
borderRadius: BorderRadius.circular(6),
child: Padding(
padding: const EdgeInsets.all(6),
child: Icon(icon, size: 18, color: iconColor),
),
),
);
}
}
/// Right-aligned row of [AppTableActionIcon] widgets for table cells.
class AppTableActions extends StatelessWidget {
const AppTableActions({super.key, required this.children});
final List<Widget> children;
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.end,
children: children,
);
}
}

View File

@ -0,0 +1,52 @@
import 'package:flutter/material.dart';
import 'app_card.dart';
/// Card shell for list pages: toolbar, divider, full-width table body, footer.
class AppTableShell extends StatelessWidget {
const AppTableShell({
super.key,
required this.toolbar,
required this.child,
this.footer,
});
final Widget toolbar;
final Widget child;
final Widget? footer;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AppCard(
enableHover: false,
clipBehavior: Clip.none,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(
color: theme.colorScheme.outline.withValues(alpha: 0.12),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: toolbar,
),
const Divider(height: 1),
Expanded(child: child),
if (footer != null) ...[
const Divider(height: 1),
Padding(
padding: const EdgeInsets.all(16),
child: footer!,
),
],
],
),
);
}
}

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class AppTextField extends StatelessWidget {
const AppTextField({
@ -13,6 +14,8 @@ class AppTextField extends StatelessWidget {
this.validator,
this.onChanged,
this.maxLines = 1,
this.maxLength,
this.inputFormatters,
this.enabled = true,
this.autofillHints,
});
@ -27,6 +30,8 @@ class AppTextField extends StatelessWidget {
final String? Function(String?)? validator;
final void Function(String)? onChanged;
final int maxLines;
final int? maxLength;
final List<TextInputFormatter>? inputFormatters;
final bool enabled;
final Iterable<String>? autofillHints;
@ -39,6 +44,8 @@ class AppTextField extends StatelessWidget {
validator: validator,
onChanged: onChanged,
maxLines: maxLines,
maxLength: maxLength,
inputFormatters: inputFormatters,
enabled: enabled,
autofillHints: autofillHints,
decoration: InputDecoration(

View File

@ -404,7 +404,7 @@ packages:
source: sdk
version: "0.0.0"
flutter_web_plugins:
dependency: transitive
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"

View File

@ -9,6 +9,8 @@ environment:
dependencies:
flutter:
sdk: flutter
flutter_web_plugins:
sdk: flutter
cupertino_icons: ^1.0.8

View File

@ -0,0 +1,80 @@
import 'package:bharat_erp/core/utils/validators.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('email', () {
test('accepts valid addresses', () {
expect(Validators.email('user@example.com'), isNull);
expect(Validators.email('user.name+tag@company.co.in'), isNull);
});
test('rejects invalid addresses', () {
expect(Validators.email('not-an-email'), isNotNull);
expect(Validators.email('user@'), isNotNull);
});
test('optional allows empty', () {
expect(Validators.optionalEmail(''), isNull);
expect(Validators.optionalEmail('bad'), isNotNull);
});
});
group('accountNumber', () {
test('accepts 918 digits', () {
expect(Validators.accountNumber('123456789'), isNull);
expect(Validators.accountNumber('123456789012345678'), isNull);
});
test('rejects invalid lengths and non-digits', () {
expect(Validators.accountNumber('12345'), isNotNull);
expect(Validators.accountNumber('1234567890123456789'), isNotNull);
expect(Validators.accountNumber('12345ABC'), isNotNull);
});
});
group('ifsc', () {
test('accepts valid IFSC', () {
expect(Validators.ifsc('SBIN0001234'), isNull);
expect(Validators.ifsc('sbin0001234'), isNull);
});
test('rejects invalid IFSC', () {
expect(Validators.ifsc('SBIN001234'), isNotNull);
expect(Validators.ifsc('SBIN00012345'), isNotNull);
expect(Validators.ifsc('SB1N0001234'), isNotNull);
});
});
group('pincode', () {
test('accepts valid 6-digit pincode', () {
expect(Validators.pincode('560001'), isNull);
});
test('rejects invalid pincode', () {
expect(Validators.pincode('056001'), isNotNull);
expect(Validators.pincode('56001'), isNotNull);
expect(Validators.pincode('5600011'), isNotNull);
});
test('optional allows empty', () {
expect(Validators.optionalPincode(''), isNull);
});
});
group('forFieldKey', () {
test('routes email and pincode keys', () {
expect(
Validators.forFieldKey('email', 'a@b.com', required: true),
isNull,
);
expect(
Validators.forFieldKey('pincode', '560001', required: false),
isNull,
);
expect(
Validators.forFieldKey('ifsc', 'HDFC0001234', required: true),
isNull,
);
});
});
}

View File

@ -0,0 +1,118 @@
import 'package:bharat_erp/core/constants/enums.dart';
import 'package:bharat_erp/core/utils/permission_utils.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('Permission security', () {
test('wildcard grants all module actions', () {
expect(
hasPermission(
userPermissions: const ['*'],
module: 'assets',
action: PermissionAction.delete,
),
isTrue,
);
});
test('denies action when permission missing', () {
expect(
hasPermission(
userPermissions: const ['ASSET:read'],
module: 'assets',
action: PermissionAction.delete,
),
isFalse,
);
});
test('grants read via view alias', () {
expect(
hasPermission(
userPermissions: const ['asset:view'],
module: 'assets',
action: PermissionAction.read,
),
isTrue,
);
});
test('grants update via edit alias', () {
expect(
hasPermission(
userPermissions: const ['ASSET:edit'],
module: 'assets',
action: PermissionAction.update,
),
isTrue,
);
});
test('module aliases map master_data to MASTERS', () {
expect(normalizePermissionModule('master_data'), 'MASTERS');
expect(normalizePermissionModule('assets'), 'ASSET');
});
test('super admin can see all menu modules', () {
expect(
canSeeMenuModule(
permissions: const [],
module: 'assets',
role: UserRole.superAdmin,
),
isTrue,
);
});
test('employee without permissions cannot see assets menu', () {
expect(
canSeeMenuModule(
permissions: const [],
module: 'assets',
role: UserRole.employee,
),
isFalse,
);
});
test('users menu visible with roles read permission', () {
expect(
canSeeMenuModule(
permissions: const ['ROLES:read'],
module: 'users',
role: UserRole.employee,
),
isTrue,
);
});
});
group('Route preview guard logic', () {
bool shouldAllowUnauthenticatedPreview({
required bool isPreview,
required bool screenPreviewEnabled,
}) {
return isPreview && screenPreviewEnabled;
}
test('preview query blocked when screen preview disabled (production)', () {
expect(
shouldAllowUnauthenticatedPreview(
isPreview: true,
screenPreviewEnabled: false,
),
isFalse,
);
});
test('preview query allowed only in dev preview mode', () {
expect(
shouldAllowUnauthenticatedPreview(
isPreview: true,
screenPreviewEnabled: true,
),
isTrue,
);
});
});
}

9
web/.htaccess Normal file
View File

@ -0,0 +1,9 @@
# SPA fallback for Flutter web path URL strategy (dev: /erp/).
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /erp/
RewriteRule ^index\.html$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /erp/index.html [L]
</IfModule>

View File

@ -16,6 +16,23 @@
-->
<base href="$FLUTTER_BASE_HREF">
<script>
// Migrate legacy hash URLs (…/index.html#/login) to path URLs (…/erp/login).
(function () {
var hash = window.location.hash;
if (!hash || hash.charAt(1) !== '/') return;
var route = hash.slice(1);
var basePath = window.location.pathname
.replace(/index\.html$/, '')
.replace(/\/$/, '');
window.history.replaceState(
null,
'',
(basePath || '') + route + window.location.search,
);
})();
</script>
<meta charset="UTF-8">
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
<meta name="description" content="A new Flutter project.">