purchase and vendor and GRN
This commit is contained in:
parent
f496be0c7f
commit
1fc8abaee7
@ -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
|
||||
|
||||
61
README.md
61
README.md
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
4
android/app/src/main/res/values/strings.xml
Normal file
4
android/app/src/main/res/values/strings.xml
Normal 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
7
lib/config/main_dev.dart
Normal 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();
|
||||
}
|
||||
7
lib/config/main_prod.dart
Normal file
7
lib/config/main_prod.dart
Normal 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
7
lib/config/main_uat.dart
Normal 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();
|
||||
}
|
||||
28
lib/core/config/app_bootstrap.dart
Normal file
28
lib/core/config/app_bootstrap.dart
Normal 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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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';
|
||||
|
||||
54
lib/core/config/environment.dart
Normal file
54
lib/core/config/environment.dart
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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';
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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,
|
||||
),
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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',
|
||||
};
|
||||
|
||||
@ -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 9–18 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
},
|
||||
|
||||
@ -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(
|
||||
|
||||
121
lib/modules/grn/data/datasources/grn_remote_data_source.dart
Normal file
121
lib/modules/grn/data/datasources/grn_remote_data_source.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
57
lib/modules/grn/data/repositories/grn_repository_impl.dart
Normal file
57
lib/modules/grn/data/repositories/grn_repository_impl.dart
Normal 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));
|
||||
}
|
||||
}
|
||||
12
lib/modules/grn/domain/repositories/grn_repository.dart
Normal file
12
lib/modules/grn/domain/repositories/grn_repository.dart
Normal 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);
|
||||
}
|
||||
@ -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;
|
||||
});
|
||||
188
lib/modules/grn/presentation/providers/grn_provider.dart
Normal file
188
lib/modules/grn/presentation/providers/grn_provider.dart
Normal 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!;
|
||||
}
|
||||
}
|
||||
276
lib/modules/grn/presentation/screens/grn_detail_screen.dart
Normal file
276
lib/modules/grn/presentation/screens/grn_detail_screen.dart
Normal 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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
562
lib/modules/grn/presentation/screens/grn_form_screen.dart
Normal file
562
lib/modules/grn/presentation/screens/grn_form_screen.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
349
lib/modules/grn/presentation/screens/grn_list_screen.dart
Normal file
349
lib/modules/grn/presentation/screens/grn_list_screen.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
358
lib/modules/grn/presentation/widgets/grn_line_items_editor.dart
Normal file
358
lib/modules/grn/presentation/widgets/grn_line_items_editor.dart
Normal 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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
44
lib/modules/grn/presentation/widgets/grn_status_chip.dart
Normal file
44
lib/modules/grn/presentation/widgets/grn_status_chip.dart
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
|
||||
@ -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(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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!;
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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});
|
||||
|
||||
|
||||
@ -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'),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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) ...[
|
||||
|
||||
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
229
lib/modules/vendors/data/datasources/vendor_remote_data_source.dart
vendored
Normal file
229
lib/modules/vendors/data/datasources/vendor_remote_data_source.dart
vendored
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
133
lib/modules/vendors/data/repositories/vendor_repository_impl.dart
vendored
Normal file
133
lib/modules/vendors/data/repositories/vendor_repository_impl.dart
vendored
Normal 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));
|
||||
}
|
||||
}
|
||||
45
lib/modules/vendors/domain/repositories/vendor_repository.dart
vendored
Normal file
45
lib/modules/vendors/domain/repositories/vendor_repository.dart
vendored
Normal 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);
|
||||
}
|
||||
329
lib/modules/vendors/presentation/providers/vendors_provider.dart
vendored
Normal file
329
lib/modules/vendors/presentation/providers/vendors_provider.dart
vendored
Normal 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!;
|
||||
}
|
||||
}
|
||||
660
lib/modules/vendors/presentation/screens/vendor_detail_screen.dart
vendored
Normal file
660
lib/modules/vendors/presentation/screens/vendor_detail_screen.dart
vendored
Normal 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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
399
lib/modules/vendors/presentation/screens/vendor_list_screen.dart
vendored
Normal file
399
lib/modules/vendors/presentation/screens/vendor_list_screen.dart
vendored
Normal 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),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
288
lib/modules/vendors/presentation/widgets/vendor_form_panel.dart
vendored
Normal file
288
lib/modules/vendors/presentation/widgets/vendor_form_panel.dart
vendored
Normal 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();
|
||||
});
|
||||
538
lib/modules/vendors/presentation/widgets/vendor_sub_resource_panels.dart
vendored
Normal file
538
lib/modules/vendors/presentation/widgets/vendor_sub_resource_panels.dart
vendored
Normal 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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
190
lib/shared/models/grn_model.dart
Normal file
190
lib/shared/models/grn_model.dart
Normal 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('_', ' ');
|
||||
}
|
||||
1833
lib/shared/models/grn_model.freezed.dart
Normal file
1833
lib/shared/models/grn_model.freezed.dart
Normal file
File diff suppressed because it is too large
Load Diff
121
lib/shared/models/grn_model.g.dart
Normal file
121
lib/shared/models/grn_model.g.dart
Normal 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,
|
||||
};
|
||||
239
lib/shared/models/purchase_order_model.dart
Normal file
239
lib/shared/models/purchase_order_model.dart
Normal 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('_', ' ');
|
||||
}
|
||||
1813
lib/shared/models/purchase_order_model.freezed.dart
Normal file
1813
lib/shared/models/purchase_order_model.freezed.dart
Normal file
File diff suppressed because it is too large
Load Diff
121
lib/shared/models/purchase_order_model.g.dart
Normal file
121
lib/shared/models/purchase_order_model.g.dart
Normal 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,
|
||||
};
|
||||
178
lib/shared/models/vendor_model.dart
Normal file
178
lib/shared/models/vendor_model.dart
Normal 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;
|
||||
}
|
||||
2003
lib/shared/models/vendor_model.freezed.dart
Normal file
2003
lib/shared/models/vendor_model.freezed.dart
Normal file
File diff suppressed because it is too large
Load Diff
152
lib/shared/models/vendor_model.g.dart
Normal file
152
lib/shared/models/vendor_model.g.dart
Normal 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,
|
||||
};
|
||||
@ -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) =>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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]),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
50
lib/shared/widgets/app_table_action_icon.dart
Normal file
50
lib/shared/widgets/app_table_action_icon.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
52
lib/shared/widgets/app_table_shell.dart
Normal file
52
lib/shared/widgets/app_table_shell.dart
Normal 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!,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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(
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -9,6 +9,8 @@ environment:
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
flutter_web_plugins:
|
||||
sdk: flutter
|
||||
|
||||
cupertino_icons: ^1.0.8
|
||||
|
||||
|
||||
80
test/core/validators_test.dart
Normal file
80
test/core/validators_test.dart
Normal 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 9–18 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
118
test/security/security_test.dart
Normal file
118
test/security/security_test.dart
Normal 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
9
web/.htaccess
Normal 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>
|
||||
@ -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.">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user