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_BASE_URL=https://demo.venbait.in/api/v1
|
||||||
API_TIMEOUT_SECONDS=30
|
API_TIMEOUT_SECONDS=30
|
||||||
DEV_BYPASS_AUTH=false
|
DEV_BYPASS_AUTH=false
|
||||||
|
|||||||
61
README.md
61
README.md
@ -39,24 +39,59 @@ flutter pub get
|
|||||||
|
|
||||||
# Generate Freezed/JSON models
|
# Generate Freezed/JSON models
|
||||||
dart run build_runner build --delete-conflicting-outputs
|
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
|
```bash
|
||||||
# Release builds
|
# Dev
|
||||||
flutter build apk --release --dart-define=ENV=production
|
flutter build web -t lib/config/main_dev.dart --release --base-href /erp/
|
||||||
flutter build ios --release --dart-define=ENV=production
|
|
||||||
flutter build web --release --dart-define=ENV=production
|
# 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
|
## Project Structure
|
||||||
|
|
||||||
See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for full architecture documentation.
|
See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for full architecture documentation.
|
||||||
|
|||||||
@ -10,6 +10,26 @@ android {
|
|||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
ndkVersion = flutter.ndkVersion
|
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 {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_11
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
targetCompatibility = JavaVersion.VERSION_11
|
targetCompatibility = JavaVersion.VERSION_11
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<application
|
<application
|
||||||
android:label="bharat_erp"
|
android:label="@string/app_name"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/ic_launcher">
|
||||||
<activity
|
<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 {
|
class AppEnv {
|
||||||
AppEnv._();
|
AppEnv._();
|
||||||
|
|
||||||
static const String development = 'development';
|
static String get development => Environment.name;
|
||||||
static const String uat = 'uat';
|
static String get uat => 'uat';
|
||||||
static const String production = 'production';
|
static String get production => 'production';
|
||||||
|
|
||||||
static String get current {
|
static String get current => Environment.name;
|
||||||
const env = String.fromEnvironment('APP_ENV', defaultValue: development);
|
|
||||||
return env;
|
|
||||||
}
|
|
||||||
|
|
||||||
static String get envFileName {
|
static String get apiBaseUrl => Environment.apiBaseUrl;
|
||||||
switch (current) {
|
|
||||||
case uat:
|
static String get envFileName => Environment.envFileName;
|
||||||
return '.env.uat';
|
|
||||||
case production:
|
|
||||||
return '.env.production';
|
|
||||||
case development:
|
|
||||||
default:
|
|
||||||
return kReleaseMode ? '.env.production' : '.env.development';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,14 +5,18 @@ class DevConfig {
|
|||||||
DevConfig._();
|
DevConfig._();
|
||||||
|
|
||||||
/// True when dev bypass is enabled via .env or running in debug mode.
|
/// True when dev bypass is enabled via .env or running in debug mode.
|
||||||
|
/// Never enabled in release builds.
|
||||||
static bool get bypassAuth {
|
static bool get bypassAuth {
|
||||||
|
if (kReleaseMode) return false;
|
||||||
final env = dotenv.env['DEV_BYPASS_AUTH'];
|
final env = dotenv.env['DEV_BYPASS_AUTH'];
|
||||||
if (env != null) return env.toLowerCase() == 'true';
|
if (env != null) return env.toLowerCase() == 'true';
|
||||||
return kDebugMode;
|
return kDebugMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// UI preview without a working login API (screen gallery, explore mode).
|
/// UI preview without a working login API (screen gallery, explore mode).
|
||||||
|
/// Never enabled in release builds.
|
||||||
static bool get screenPreviewEnabled {
|
static bool get screenPreviewEnabled {
|
||||||
|
if (kReleaseMode) return false;
|
||||||
if (bypassAuth) return true;
|
if (bypassAuth) return true;
|
||||||
final env = dotenv.env['DEV_SCREEN_PREVIEW'];
|
final env = dotenv.env['DEV_SCREEN_PREVIEW'];
|
||||||
if (env != null) return env.toLowerCase() == 'true';
|
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 const String warehouses = '/masters/warehouses';
|
||||||
static String warehouseById(String id) => '/masters/warehouses/$id';
|
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
|
// Assets
|
||||||
static const String assets = '/assets';
|
static const String assets = '/assets';
|
||||||
static String assetById(String id) => '/assets/$id';
|
static String assetById(String id) => '/assets/$id';
|
||||||
|
|||||||
@ -39,6 +39,24 @@ class RouteConstants {
|
|||||||
// Profile
|
// Profile
|
||||||
static const String profile = '/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
|
// Assets
|
||||||
static const String assets = '/assets';
|
static const String assets = '/assets';
|
||||||
static const String assetAdd = '/assets/add';
|
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_dotenv/flutter_dotenv.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../config/environment.dart';
|
||||||
import 'auth_interceptor.dart';
|
import 'auth_interceptor.dart';
|
||||||
import 'error_interceptor.dart';
|
import 'error_interceptor.dart';
|
||||||
|
|
||||||
final dioProvider = Provider<Dio>((ref) {
|
final dioProvider = Provider<Dio>((ref) {
|
||||||
final dio = Dio(
|
final dio = Dio(
|
||||||
BaseOptions(
|
BaseOptions(
|
||||||
baseUrl: dotenv.env['API_BASE_URL'] ?? 'https://api.bharaterp.example.com/v1',
|
baseUrl: Environment.apiBaseUrl,
|
||||||
connectTimeout: Duration(
|
connectTimeout: Duration(
|
||||||
seconds: int.tryParse(dotenv.env['API_TIMEOUT_SECONDS'] ?? '30') ?? 30,
|
seconds: int.tryParse(dotenv.env['API_TIMEOUT_SECONDS'] ?? '30') ?? 30,
|
||||||
),
|
),
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../config/environment.dart';
|
||||||
import '../constants/api_endpoints.dart';
|
import '../constants/api_endpoints.dart';
|
||||||
import '../errors/exceptions.dart';
|
import '../errors/exceptions.dart';
|
||||||
import 'api_envelope.dart';
|
import 'api_envelope.dart';
|
||||||
@ -33,8 +33,7 @@ class TokenRefreshService {
|
|||||||
|
|
||||||
final dio = Dio(
|
final dio = Dio(
|
||||||
BaseOptions(
|
BaseOptions(
|
||||||
baseUrl: dotenv.env['API_BASE_URL'] ??
|
baseUrl: Environment.apiBaseUrl,
|
||||||
'https://demo.venbait.in/api/v1',
|
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
|
|||||||
@ -27,6 +27,21 @@ class DateFormatter {
|
|||||||
if (value == null || value.isEmpty) return null;
|
if (value == null || value.isEmpty) return null;
|
||||||
return DateTime.tryParse(value);
|
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 {
|
class CurrencyFormatter {
|
||||||
|
|||||||
@ -15,6 +15,8 @@ const Map<String, String> permissionModuleAliases = {
|
|||||||
'asset_maintenance': 'ASSET',
|
'asset_maintenance': 'ASSET',
|
||||||
'asset_disposal': 'ASSET',
|
'asset_disposal': 'ASSET',
|
||||||
'vendor': 'VENDOR',
|
'vendor': 'VENDOR',
|
||||||
|
'vendors': 'VENDOR',
|
||||||
|
'purchase_orders': 'PURCHASE_ORDER',
|
||||||
'purchase_order': 'PURCHASE_ORDER',
|
'purchase_order': 'PURCHASE_ORDER',
|
||||||
'grn': 'GRN',
|
'grn': 'GRN',
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,6 +1,17 @@
|
|||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
class Validators {
|
class Validators {
|
||||||
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'}) {
|
static String? required(String? value, {String fieldName = 'This field'}) {
|
||||||
if (value == null || value.trim().isEmpty) {
|
if (value == null || value.trim().isEmpty) {
|
||||||
return '$fieldName is required';
|
return '$fieldName is required';
|
||||||
@ -10,15 +21,119 @@ class Validators {
|
|||||||
|
|
||||||
static String? email(String? value) {
|
static String? email(String? value) {
|
||||||
if (value == null || value.trim().isEmpty) return 'Email is required';
|
if (value == null || value.trim().isEmpty) return 'Email is required';
|
||||||
final regex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');
|
return _validateEmail(value.trim());
|
||||||
if (!regex.hasMatch(value.trim())) return 'Enter a valid email address';
|
}
|
||||||
|
|
||||||
|
/// 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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
static String? phone(String? value) {
|
/// Required 9–18 digit bank account number.
|
||||||
if (value == null || value.trim().isEmpty) return 'Phone is required';
|
static String? accountNumber(String? value) {
|
||||||
final regex = RegExp(r'^[6-9]\d{9}$');
|
if (value == null || value.trim().isEmpty) {
|
||||||
if (!regex.hasMatch(value.trim())) return 'Enter a valid 10-digit mobile number';
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -31,10 +146,56 @@ class Validators {
|
|||||||
return null;
|
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;
|
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}$');
|
return _validateGstin(value.trim());
|
||||||
if (!regex.hasMatch(value.trim().toUpperCase())) return 'Enter a valid GST number';
|
}
|
||||||
|
|
||||||
|
/// 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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -44,4 +205,114 @@ class Validators {
|
|||||||
}
|
}
|
||||||
return null;
|
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 'core/config/app_bootstrap.dart';
|
||||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
import 'core/config/environment.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';
|
|
||||||
|
|
||||||
|
/// Default entry point — development flavor.
|
||||||
|
/// For UAT/Prod use `lib/config/main_uat.dart` or `lib/config/main_prod.dart`.
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
Environment.flavor = Flavor.dev;
|
||||||
await dotenv.load(fileName: AppEnv.envFileName);
|
await startApp();
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
|
|
||||||
runApp(
|
|
||||||
ProviderScope(
|
|
||||||
overrides: [
|
|
||||||
sharedPreferencesProvider.overrideWithValue(prefs),
|
|
||||||
],
|
|
||||||
child: const BharatErpApp(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,14 +11,16 @@ import '../../../../shared/models/asset_model.dart';
|
|||||||
import '../../../../shared/providers/permissions_provider.dart';
|
import '../../../../shared/providers/permissions_provider.dart';
|
||||||
import '../../../../shared/widgets/app_card.dart';
|
import '../../../../shared/widgets/app_card.dart';
|
||||||
import '../../../../shared/widgets/app_confirmation_dialog.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_dropdown.dart';
|
||||||
import '../../../../shared/widgets/app_empty_state.dart';
|
import '../../../../shared/widgets/app_empty_state.dart';
|
||||||
import '../../../../shared/widgets/app_loading_view.dart';
|
import '../../../../shared/widgets/app_loading_view.dart';
|
||||||
import '../../../../shared/widgets/app_searchable_dropdown.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/can_permission.dart';
|
||||||
import '../../../../shared/widgets/error_view.dart';
|
import '../../../../shared/widgets/error_view.dart';
|
||||||
import '../../../../shared/widgets/page_header.dart';
|
import '../../../../shared/widgets/page_header.dart';
|
||||||
import '../../../rbac/presentation/widgets/rbac_widgets.dart';
|
|
||||||
import '../providers/asset_categories_provider.dart';
|
import '../providers/asset_categories_provider.dart';
|
||||||
import '../providers/assets_provider.dart';
|
import '../providers/assets_provider.dart';
|
||||||
import '../widgets/asset_form_panel.dart';
|
import '../widgets/asset_form_panel.dart';
|
||||||
@ -31,8 +33,6 @@ class AssetListScreen extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _AssetListScreenState extends ConsumerState<AssetListScreen> {
|
class _AssetListScreenState extends ConsumerState<AssetListScreen> {
|
||||||
static const _tableMinWidth = 1040.0;
|
|
||||||
|
|
||||||
String? _selectedCategory;
|
String? _selectedCategory;
|
||||||
String? _selectedPlant;
|
String? _selectedPlant;
|
||||||
String? _selectedStatus;
|
String? _selectedStatus;
|
||||||
@ -182,119 +182,13 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
|
|||||||
)
|
)
|
||||||
else
|
else
|
||||||
Expanded(
|
Expanded(
|
||||||
child: LayoutBuilder(
|
child: _AssetDataTable(
|
||||||
builder: (context, constraints) {
|
assets: filteredAssets,
|
||||||
final tableWidth =
|
canEdit: canEdit,
|
||||||
constraints.maxWidth < _tableMinWidth
|
canDelete: canDelete,
|
||||||
? _tableMinWidth
|
onView: _viewAsset,
|
||||||
: constraints.maxWidth;
|
onEdit: _editAsset,
|
||||||
|
onDelete: _deleteAsset,
|
||||||
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(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Divider(height: 1),
|
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) {
|
({String label, Color color}) assetStatusDisplay(String? status) {
|
||||||
return switch (status?.toUpperCase().replaceAll(' ', '_')) {
|
return switch (status?.toUpperCase().replaceAll(' ', '_')) {
|
||||||
'IN_USE' => (label: 'In Use', color: const Color(0xFF16A34A)),
|
'IN_USE' => (label: 'In Use', color: const Color(0xFF16A34A)),
|
||||||
@ -599,119 +478,90 @@ class _AssetFilterDropdown extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AssetTableActions extends StatelessWidget {
|
class _AssetDataTable extends StatelessWidget {
|
||||||
const _AssetTableActions({
|
const _AssetDataTable({
|
||||||
|
required this.assets,
|
||||||
|
required this.canEdit,
|
||||||
|
required this.canDelete,
|
||||||
required this.onView,
|
required this.onView,
|
||||||
required this.onEdit,
|
required this.onEdit,
|
||||||
required this.onDelete,
|
required this.onDelete,
|
||||||
this.canView = true,
|
|
||||||
this.canEdit = true,
|
|
||||||
this.canDelete = true,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
static const columnWidth = 108.0;
|
final List<AssetModel> assets;
|
||||||
|
|
||||||
final VoidCallback onView;
|
|
||||||
final VoidCallback onEdit;
|
|
||||||
final VoidCallback onDelete;
|
|
||||||
final bool canView;
|
|
||||||
final bool canEdit;
|
final bool canEdit;
|
||||||
final bool canDelete;
|
final bool canDelete;
|
||||||
|
final void Function(AssetModel asset) onView;
|
||||||
|
final void Function(AssetModel asset) onEdit;
|
||||||
|
final Future<void> Function(AssetModel asset) onDelete;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final muted = Theme.of(context).colorScheme.onSurfaceVariant;
|
return AppDataTable<AssetModel>(
|
||||||
|
wrapInCard: false,
|
||||||
return Row(
|
columns: [
|
||||||
mainAxisSize: MainAxisSize.min,
|
AppDataColumn(
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
label: 'Asset Code',
|
||||||
children: [
|
flex: 1,
|
||||||
if (canView)
|
cellBuilder: (_, asset) => Text(asset.assetCode ?? '—'),
|
||||||
_AssetActionIcon(
|
),
|
||||||
tooltip: 'View asset',
|
AppDataColumn(
|
||||||
icon: Icons.visibility_outlined,
|
label: 'Asset Name',
|
||||||
color: muted,
|
flex: 2,
|
||||||
onPressed: onView,
|
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(
|
AppDataColumn(
|
||||||
tooltip: 'Edit asset',
|
label: 'Status',
|
||||||
icon: Icons.edit_outlined,
|
flex: 1,
|
||||||
color: muted,
|
cellBuilder: (_, asset) => AppStatusChip(
|
||||||
onPressed: onEdit,
|
status: asset.status?.toLowerCase().replaceAll(' ', '_') ?? 'active',
|
||||||
),
|
),
|
||||||
if (canDelete)
|
),
|
||||||
_AssetActionIcon(
|
AppDataColumn(
|
||||||
tooltip: 'Delete asset',
|
label: 'Actions',
|
||||||
icon: Icons.delete_outline,
|
flex: 1,
|
||||||
color: muted,
|
alignment: Alignment.centerRight,
|
||||||
onPressed: onDelete,
|
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),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
rows: assets,
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -748,50 +598,54 @@ class _AssetMobileList extends StatelessWidget {
|
|||||||
separatorBuilder: (_, __) => const SizedBox(height: 8),
|
separatorBuilder: (_, __) => const SizedBox(height: 8),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final asset = assets[index];
|
final asset = assets[index];
|
||||||
final status = assetStatusDisplay(asset.status);
|
|
||||||
|
|
||||||
return AppCard(
|
return AppCard(
|
||||||
child: ListTile(
|
child: Padding(
|
||||||
onTap: () => onView(asset),
|
padding: const EdgeInsets.all(16),
|
||||||
leading: UserAvatarChip(
|
child: Column(
|
||||||
name: asset.assetName,
|
|
||||||
initials: assetInitials(asset),
|
|
||||||
),
|
|
||||||
title: Text(
|
|
||||||
asset.assetName,
|
|
||||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
|
||||||
),
|
|
||||||
subtitle: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(asset.assetCode ?? '—'),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Flexible(
|
Expanded(
|
||||||
child: RoleBadge(
|
child: Text(
|
||||||
label: asset.assetCategoryName ?? '—',
|
asset.assetName,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
AppStatusChip(
|
||||||
StatusBadge(label: status.label, color: status.color),
|
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/asset_model.dart';
|
||||||
import '../../../../shared/models/user_management_models.dart' show FilterOptionModel;
|
import '../../../../shared/models/user_management_models.dart' show FilterOptionModel;
|
||||||
import '../../../../shared/widgets/app_button.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_loading_view.dart';
|
||||||
import '../../../../shared/widgets/app_side_panel.dart';
|
import '../../../../shared/widgets/app_side_panel.dart';
|
||||||
import '../../../../shared/widgets/app_text_field.dart';
|
import '../../../../shared/widgets/app_text_field.dart';
|
||||||
@ -250,18 +252,18 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
|
|||||||
.map((c) => int.tryParse(c.id))
|
.map((c) => int.tryParse(c.id))
|
||||||
.whereType<int>()
|
.whereType<int>()
|
||||||
.toList();
|
.toList();
|
||||||
return DropdownButtonFormField<int>(
|
return AppSearchableDropdown<int>(
|
||||||
|
label: 'Category *',
|
||||||
value: _dropdownValue(_categoryId, categoryIds),
|
value: _dropdownValue(_categoryId, categoryIds),
|
||||||
isExpanded: true,
|
searchHint: 'Search category...',
|
||||||
decoration: const InputDecoration(labelText: 'Category *'),
|
options: categories
|
||||||
items: categories
|
|
||||||
.map(
|
.map(
|
||||||
(c) => DropdownMenuItem(
|
(c) => AppDropdownOption(
|
||||||
value: int.tryParse(c.id),
|
value: int.tryParse(c.id) ?? 0,
|
||||||
child: Text('${c.code} — ${c.name}'),
|
label: '${c.code} — ${c.name}',
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.where((item) => item.value != null)
|
.where((option) => option.value != 0)
|
||||||
.toList(),
|
.toList(),
|
||||||
onChanged: (v) => setState(() => _categoryId = v),
|
onChanged: (v) => setState(() => _categoryId = v),
|
||||||
validator: (v) => v == null ? 'Category is required' : null,
|
validator: (v) => v == null ? 'Category is required' : null,
|
||||||
@ -273,18 +275,18 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
|
|||||||
.map((p) => int.tryParse(p.id))
|
.map((p) => int.tryParse(p.id))
|
||||||
.whereType<int>()
|
.whereType<int>()
|
||||||
.toList();
|
.toList();
|
||||||
return DropdownButtonFormField<int>(
|
return AppSearchableDropdown<int>(
|
||||||
|
label: 'Plant *',
|
||||||
value: _dropdownValue(_plantId, plantIds),
|
value: _dropdownValue(_plantId, plantIds),
|
||||||
isExpanded: true,
|
searchHint: 'Search plant...',
|
||||||
decoration: const InputDecoration(labelText: 'Plant *'),
|
options: plants
|
||||||
items: plants
|
|
||||||
.map(
|
.map(
|
||||||
(p) => DropdownMenuItem(
|
(p) => AppDropdownOption(
|
||||||
value: int.tryParse(p.id),
|
value: int.tryParse(p.id) ?? 0,
|
||||||
child: Text(p.name),
|
label: p.name,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.where((item) => item.value != null)
|
.where((option) => option.value != 0)
|
||||||
.toList(),
|
.toList(),
|
||||||
onChanged: (v) => setState(() => _plantId = v),
|
onChanged: (v) => setState(() => _plantId = v),
|
||||||
validator: (v) => v == null ? 'Plant is required' : null,
|
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/formatters.dart';
|
||||||
import '../../../../core/utils/validators.dart';
|
import '../../../../core/utils/validators.dart';
|
||||||
import '../../../../shared/widgets/app_button.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_side_panel.dart';
|
||||||
import '../../../../shared/widgets/app_text_field.dart';
|
import '../../../../shared/widgets/app_text_field.dart';
|
||||||
import '../providers/assets_provider.dart';
|
import '../providers/assets_provider.dart';
|
||||||
@ -119,18 +121,16 @@ class _AddAmcPanelState extends ConsumerState<AddAmcPanel> {
|
|||||||
label: 'Contract No',
|
label: 'Contract No',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
DropdownButtonFormField<String>(
|
AppSearchableDropdown<String>(
|
||||||
|
label: 'Contract Type',
|
||||||
value: _contractType,
|
value: _contractType,
|
||||||
isExpanded: true,
|
searchHint: 'Search contract type...',
|
||||||
decoration: const InputDecoration(labelText: 'Contract Type'),
|
options: stringDropdownOptions(const [
|
||||||
items: const [
|
|
||||||
'COMPREHENSIVE',
|
'COMPREHENSIVE',
|
||||||
'LABOUR_ONLY',
|
'LABOUR_ONLY',
|
||||||
'PARTS_ONLY',
|
'PARTS_ONLY',
|
||||||
'PREVENTIVE_ONLY',
|
'PREVENTIVE_ONLY',
|
||||||
]
|
]),
|
||||||
.map((t) => DropdownMenuItem(value: t, child: Text(t)))
|
|
||||||
.toList(),
|
|
||||||
onChanged: (v) {
|
onChanged: (v) {
|
||||||
if (v != null) setState(() => _contractType = v);
|
if (v != null) setState(() => _contractType = v);
|
||||||
},
|
},
|
||||||
@ -238,37 +238,33 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
SidePanelFormRow(
|
SidePanelFormRow(
|
||||||
left: DropdownButtonFormField<String>(
|
left: AppSearchableDropdown<String>(
|
||||||
|
label: 'Visit Type *',
|
||||||
value: _visitType,
|
value: _visitType,
|
||||||
isExpanded: true,
|
searchHint: 'Search visit type...',
|
||||||
decoration: const InputDecoration(labelText: 'Visit Type *'),
|
options: stringDropdownOptions(const [
|
||||||
items: const [
|
|
||||||
'PREVENTIVE',
|
'PREVENTIVE',
|
||||||
'BREAKDOWN',
|
'BREAKDOWN',
|
||||||
'INSPECTION',
|
'INSPECTION',
|
||||||
'INSTALLATION',
|
'INSTALLATION',
|
||||||
'CALIBRATION',
|
'CALIBRATION',
|
||||||
'OTHER',
|
'OTHER',
|
||||||
]
|
]),
|
||||||
.map((t) => DropdownMenuItem(value: t, child: Text(t)))
|
|
||||||
.toList(),
|
|
||||||
onChanged: (v) {
|
onChanged: (v) {
|
||||||
if (v != null) setState(() => _visitType = v);
|
if (v != null) setState(() => _visitType = v);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
right: DropdownButtonFormField<String>(
|
right: AppSearchableDropdown<String>(
|
||||||
|
label: 'Status',
|
||||||
value: _status,
|
value: _status,
|
||||||
isExpanded: true,
|
searchHint: 'Search status...',
|
||||||
decoration: const InputDecoration(labelText: 'Status'),
|
options: stringDropdownOptions(const [
|
||||||
items: const [
|
|
||||||
'SCHEDULED',
|
'SCHEDULED',
|
||||||
'IN_PROGRESS',
|
'IN_PROGRESS',
|
||||||
'COMPLETED',
|
'COMPLETED',
|
||||||
'CANCELLED',
|
'CANCELLED',
|
||||||
'PENDING_PARTS',
|
'PENDING_PARTS',
|
||||||
]
|
]),
|
||||||
.map((t) => DropdownMenuItem(value: t, child: Text(t)))
|
|
||||||
.toList(),
|
|
||||||
onChanged: (v) {
|
onChanged: (v) {
|
||||||
if (v != null) setState(() => _status = v);
|
if (v != null) setState(() => _status = v);
|
||||||
},
|
},
|
||||||
@ -396,20 +392,18 @@ class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
SidePanelFormRow(
|
SidePanelFormRow(
|
||||||
left: DropdownButtonFormField<String>(
|
left: AppSearchableDropdown<String>(
|
||||||
|
label: 'Policy Type',
|
||||||
value: _policyType,
|
value: _policyType,
|
||||||
isExpanded: true,
|
searchHint: 'Search policy type...',
|
||||||
decoration: const InputDecoration(labelText: 'Policy Type'),
|
options: stringDropdownOptions(const [
|
||||||
items: const [
|
|
||||||
'FIRE_AND_ALLIED',
|
'FIRE_AND_ALLIED',
|
||||||
'MACHINERY_BREAKDOWN',
|
'MACHINERY_BREAKDOWN',
|
||||||
'COMPREHENSIVE',
|
'COMPREHENSIVE',
|
||||||
'THIRD_PARTY',
|
'THIRD_PARTY',
|
||||||
'VEHICLE',
|
'VEHICLE',
|
||||||
'OTHER',
|
'OTHER',
|
||||||
]
|
]),
|
||||||
.map((t) => DropdownMenuItem(value: t, child: Text(t)))
|
|
||||||
.toList(),
|
|
||||||
onChanged: (v) {
|
onChanged: (v) {
|
||||||
if (v != null) setState(() => _policyType = v);
|
if (v != null) setState(() => _policyType = v);
|
||||||
},
|
},
|
||||||
|
|||||||
@ -63,7 +63,8 @@ class _CompanyFormScreenState extends State<CompanyFormScreen> {
|
|||||||
AppTextField(
|
AppTextField(
|
||||||
controller: _gstController,
|
controller: _gstController,
|
||||||
label: 'GST Number',
|
label: 'GST Number',
|
||||||
validator: Validators.gstNumber,
|
validator: Validators.optionalGstin,
|
||||||
|
inputFormatters: Validators.gstinInput,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
AppTextField(
|
AppTextField(
|
||||||
@ -83,7 +84,8 @@ class _CompanyFormScreenState extends State<CompanyFormScreen> {
|
|||||||
controller: _phoneController,
|
controller: _phoneController,
|
||||||
label: 'Phone',
|
label: 'Phone',
|
||||||
keyboardType: TextInputType.phone,
|
keyboardType: TextInputType.phone,
|
||||||
validator: Validators.phone,
|
validator: Validators.mobile,
|
||||||
|
inputFormatters: Validators.mobileInput,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
AppButton(
|
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 }
|
enum MasterFieldType { text, number, boolean, dropdown }
|
||||||
|
|
||||||
|
const brandTypes = ['OWN', 'OEM', 'THIRD_PARTY'];
|
||||||
|
|
||||||
class MasterFieldDef {
|
class MasterFieldDef {
|
||||||
const MasterFieldDef({
|
const MasterFieldDef({
|
||||||
required this.key,
|
required this.key,
|
||||||
@ -10,6 +12,7 @@ class MasterFieldDef {
|
|||||||
this.required = false,
|
this.required = false,
|
||||||
this.showInList = false,
|
this.showInList = false,
|
||||||
this.optionsMasterKey,
|
this.optionsMasterKey,
|
||||||
|
this.staticOptions,
|
||||||
this.multiline = false,
|
this.multiline = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -20,6 +23,8 @@ class MasterFieldDef {
|
|||||||
final bool showInList;
|
final bool showInList;
|
||||||
/// Master key used to populate dropdown options (e.g. `plants` for plant_id).
|
/// Master key used to populate dropdown options (e.g. `plants` for plant_id).
|
||||||
final String? optionsMasterKey;
|
final String? optionsMasterKey;
|
||||||
|
/// Fixed dropdown choices (e.g. brand type) — no API lookup.
|
||||||
|
final List<String>? staticOptions;
|
||||||
final bool multiline;
|
final bool multiline;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -209,7 +214,14 @@ const masterDefinitions = <MasterDefinition>[
|
|||||||
fields: [
|
fields: [
|
||||||
MasterFieldDef(key: 'code', label: 'Code', required: true, showInList: true),
|
MasterFieldDef(key: 'code', label: 'Code', required: true, showInList: true),
|
||||||
MasterFieldDef(key: 'name', label: 'Name', 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: 'contact_person', label: 'Contact Person'),
|
||||||
MasterFieldDef(key: 'phone', label: 'Phone'),
|
MasterFieldDef(key: 'phone', label: 'Phone'),
|
||||||
MasterFieldDef(key: 'email', label: 'Email'),
|
MasterFieldDef(key: 'email', label: 'Email'),
|
||||||
@ -412,6 +424,16 @@ List<String> get masterCategories =>
|
|||||||
masterDefinitions.map((def) => def.category).toSet().toList();
|
masterDefinitions.map((def) => def.category).toSet().toList();
|
||||||
|
|
||||||
String masterRecordLabel(Map<String, dynamic> row) {
|
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']) {
|
for (final key in ['name', 'item_name', 'code', 'item_code', 'description']) {
|
||||||
final value = row[key];
|
final value = row[key];
|
||||||
if (value != null && value.toString().trim().isNotEmpty) {
|
if (value != null && value.toString().trim().isNotEmpty) {
|
||||||
@ -432,6 +454,9 @@ String masterCellValue(Map<String, dynamic> row, MasterFieldDef field) {
|
|||||||
if (field.type == MasterFieldType.dropdown) {
|
if (field.type == MasterFieldType.dropdown) {
|
||||||
final label = row['${field.key}_label'];
|
final label = row['${field.key}_label'];
|
||||||
if (label != null && label.toString().isNotEmpty) return label.toString();
|
if (label != null && label.toString().isNotEmpty) return label.toString();
|
||||||
|
if (field.staticOptions != null) {
|
||||||
|
return value.toString().replaceAll('_', ' ');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return value.toString();
|
return value.toString();
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import '../../../../shared/utils/file_download_helper.dart';
|
|||||||
import '../../../../shared/providers/permissions_provider.dart';
|
import '../../../../shared/providers/permissions_provider.dart';
|
||||||
import '../../../../shared/widgets/app_card.dart';
|
import '../../../../shared/widgets/app_card.dart';
|
||||||
import '../../../../shared/widgets/app_confirmation_dialog.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_empty_state.dart';
|
||||||
import '../../../../shared/widgets/app_loading_view.dart';
|
import '../../../../shared/widgets/app_loading_view.dart';
|
||||||
import '../../../../shared/widgets/app_search_export_bar.dart';
|
import '../../../../shared/widgets/app_search_export_bar.dart';
|
||||||
@ -327,181 +328,67 @@ class _MasterListTable extends StatelessWidget {
|
|||||||
final ValueChanged<String> onEdit;
|
final ValueChanged<String> onEdit;
|
||||||
final ValueChanged<Map<String, dynamic>> onDelete;
|
final ValueChanged<Map<String, dynamic>> onDelete;
|
||||||
|
|
||||||
static const double _horizontalPadding = 16;
|
@override
|
||||||
static const double _columnSpacing = 16;
|
Widget build(BuildContext context) {
|
||||||
static const double _statusWidth = 110;
|
final theme = Theme.of(context);
|
||||||
static const double _actionsWidth = 96;
|
|
||||||
|
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) {
|
int _columnFlex(MasterFieldDef field) {
|
||||||
return switch (field.key) {
|
return switch (field.key) {
|
||||||
'code' || 'item_code' || 'series_code' => 1,
|
'code' || 'item_code' || 'series_code' => 1,
|
||||||
'name' ||
|
'name' || 'item_name' || 'description' || 'term_name' => 3,
|
||||||
'item_name' ||
|
|
||||||
'description' ||
|
|
||||||
'term_name' =>
|
|
||||||
3,
|
|
||||||
_ => 2,
|
_ => 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:
|
case MasterFieldType.dropdown:
|
||||||
final options = formState.dropdownOptions[field.optionsMasterKey] ??
|
final List<AppDropdownOption<String>> dropdownOptions;
|
||||||
const <Map<String, dynamic>>[];
|
if (field.staticOptions != null) {
|
||||||
final dropdownOptions = <AppDropdownOption<String>>[];
|
dropdownOptions = stringDropdownOptions(field.staticOptions!);
|
||||||
for (final item in options) {
|
} else {
|
||||||
final id = item['id']?.toString();
|
final options = formState.dropdownOptions[field.optionsMasterKey] ??
|
||||||
if (id == null || id.isEmpty) continue;
|
const <Map<String, dynamic>>[];
|
||||||
dropdownOptions.add(
|
dropdownOptions = <AppDropdownOption<String>>[];
|
||||||
AppDropdownOption(value: id, label: masterRecordLabel(item)),
|
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>(
|
return AppSearchableDropdown<String>(
|
||||||
@ -123,7 +128,7 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
|
|||||||
|
|
||||||
case MasterFieldType.number:
|
case MasterFieldType.number:
|
||||||
return TextFormField(
|
return TextFormField(
|
||||||
key: ValueKey('${field.key}-${value ?? ''}'),
|
key: ValueKey(field.key),
|
||||||
initialValue: value?.toString(),
|
initialValue: value?.toString(),
|
||||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
decoration: InputDecoration(labelText: _fieldLabel(field)),
|
decoration: InputDecoration(labelText: _fieldLabel(field)),
|
||||||
@ -134,19 +139,44 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
case MasterFieldType.text:
|
case MasterFieldType.text:
|
||||||
|
final formatters = Validators.inputFormattersForFieldKey(field.key);
|
||||||
return TextFormField(
|
return TextFormField(
|
||||||
key: ValueKey('${field.key}-${value ?? ''}'),
|
key: ValueKey(field.key),
|
||||||
initialValue: value?.toString(),
|
initialValue: value?.toString(),
|
||||||
maxLines: field.multiline ? 3 : 1,
|
maxLines: field.multiline ? 3 : 1,
|
||||||
|
keyboardType: _keyboardTypeForFieldKey(field.key),
|
||||||
|
inputFormatters: formatters.isEmpty ? null : formatters,
|
||||||
decoration: InputDecoration(labelText: _fieldLabel(field)),
|
decoration: InputDecoration(labelText: _fieldLabel(field)),
|
||||||
validator: field.required
|
validator: (v) => Validators.forFieldKey(
|
||||||
? (v) => Validators.required(v, fieldName: field.label)
|
field.key,
|
||||||
: null,
|
v,
|
||||||
|
required: field.required,
|
||||||
|
fieldName: field.label,
|
||||||
|
),
|
||||||
onChanged: (text) => notifier.updateValue(field.key, text),
|
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(
|
List<Widget> _buildFieldLayout(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
MasterFormState formState,
|
MasterFormState formState,
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import 'package:dio/dio.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../../../../core/constants/api_endpoints.dart';
|
import '../../../../core/constants/api_endpoints.dart';
|
||||||
|
import '../../../../core/constants/app_constants.dart';
|
||||||
import '../../../../core/network/dio_client.dart';
|
import '../../../../core/network/dio_client.dart';
|
||||||
import '../../../../shared/models/user_management_models.dart';
|
import '../../../../shared/models/user_management_models.dart';
|
||||||
|
|
||||||
@ -24,10 +25,35 @@ class MasterRemoteDataSource {
|
|||||||
Future<List<FilterOptionModel>> listDesignations() =>
|
Future<List<FilterOptionModel>> listDesignations() =>
|
||||||
_listOptions(ApiEndpoints.designations);
|
_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 {
|
Future<List<FilterOptionModel>> _listOptions(String endpoint) async {
|
||||||
final response = await dio.get(
|
final response = await dio.get(
|
||||||
endpoint,
|
endpoint,
|
||||||
queryParameters: const {'limit': 100, 'is_active': true},
|
queryParameters: {
|
||||||
|
'limit': AppConstants.maxPageSize,
|
||||||
|
'is_active': true,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
final body = response.data as Map<String, dynamic>;
|
final body = response.data as Map<String, dynamic>;
|
||||||
final raw = body['data'];
|
final raw = body['data'];
|
||||||
@ -44,12 +70,45 @@ class MasterRemoteDataSource {
|
|||||||
.map(
|
.map(
|
||||||
(item) => FilterOptionModel(
|
(item) => FilterOptionModel(
|
||||||
id: item['id']?.toString() ?? '',
|
id: item['id']?.toString() ?? '',
|
||||||
name: item['name'] as String? ?? '',
|
name: _optionLabel(item),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.where((item) => item.id.isNotEmpty && item.name.isNotEmpty)
|
.where((item) => item.id.isNotEmpty && item.name.isNotEmpty)
|
||||||
.toList();
|
.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).
|
/// 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/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../../../core/utils/formatters.dart';
|
|
||||||
|
|
||||||
import '../../../../core/constants/enums.dart';
|
import '../../../../core/constants/enums.dart';
|
||||||
import '../../../../core/constants/route_constants.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/utils/file_download_helper.dart';
|
||||||
import '../../../../shared/providers/permissions_provider.dart';
|
import '../../../../shared/providers/permissions_provider.dart';
|
||||||
import '../../../../shared/widgets/app_confirmation_dialog.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_dropdown.dart';
|
||||||
import '../../../../shared/widgets/app_card.dart';
|
import '../../../../shared/widgets/app_card.dart';
|
||||||
import '../../../../shared/widgets/app_loading_view.dart';
|
import '../../../../shared/widgets/app_loading_view.dart';
|
||||||
@ -466,17 +466,10 @@ class _UsersTab extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _UsersTabState extends ConsumerState<_UsersTab> {
|
class _UsersTabState extends ConsumerState<_UsersTab> {
|
||||||
static const _tableMinWidth = 1120.0;
|
|
||||||
|
|
||||||
String? _selectedRoleName;
|
String? _selectedRoleName;
|
||||||
String? _selectedDepartmentName;
|
String? _selectedDepartmentName;
|
||||||
String? _selectedStatusLabel;
|
String? _selectedStatusLabel;
|
||||||
|
|
||||||
String _formatLastLogin(DateTime? value) {
|
|
||||||
if (value == null) return '—';
|
|
||||||
return DateFormatter.displayDateTime(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
int? _roleIdForFilter(UserFiltersModel? filters) {
|
int? _roleIdForFilter(UserFiltersModel? filters) {
|
||||||
if (_selectedRoleName == null || filters == null) return null;
|
if (_selectedRoleName == null || filters == null) return null;
|
||||||
for (final role in filters.roles) {
|
for (final role in filters.roles) {
|
||||||
@ -729,102 +722,18 @@ class _UsersTabState extends ConsumerState<_UsersTab> {
|
|||||||
)
|
)
|
||||||
else
|
else
|
||||||
Expanded(
|
Expanded(
|
||||||
child: LayoutBuilder(
|
child: UserRichDataTable(
|
||||||
builder: (context, constraints) {
|
wrapInCard: false,
|
||||||
final tableWidth = constraints.maxWidth < _tableMinWidth
|
users: usersState.users,
|
||||||
? _tableMinWidth
|
actionsBuilder: (_, user) => UserTableActions(
|
||||||
: constraints.maxWidth;
|
user: user,
|
||||||
|
canEdit: canEditUser,
|
||||||
return SingleChildScrollView(
|
canResetPassword: canEditUser,
|
||||||
scrollDirection: Axis.horizontal,
|
canDeactivate: canDeleteUser,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
onEdit: () => _editUser(user),
|
||||||
child: ConstrainedBox(
|
onResetPassword: () => _resetPassword(user),
|
||||||
constraints: BoxConstraints(minWidth: tableWidth),
|
onDeactivate: () => _deactivateUser(user),
|
||||||
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(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
|
|||||||
@ -265,6 +265,8 @@ class _AddUserPanelState extends ConsumerState<AddUserPanel> {
|
|||||||
label: 'Mobile',
|
label: 'Mobile',
|
||||||
hint: '9XXXXXXXXX',
|
hint: '9XXXXXXXXX',
|
||||||
keyboardType: TextInputType.phone,
|
keyboardType: TextInputType.phone,
|
||||||
|
validator: Validators.optionalMobile,
|
||||||
|
inputFormatters: Validators.mobileInput,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
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 {
|
class UserAvatarChip extends StatelessWidget {
|
||||||
const UserAvatarChip({super.key, required this.name, this.initials});
|
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_empty_state.dart';
|
||||||
import '../../../../shared/widgets/app_loading_view.dart';
|
import '../../../../shared/widgets/app_loading_view.dart';
|
||||||
import '../../../../shared/widgets/app_search_field.dart';
|
import '../../../../shared/widgets/app_search_field.dart';
|
||||||
|
import '../../../../shared/widgets/app_table_shell.dart';
|
||||||
import '../../../../shared/widgets/error_view.dart';
|
import '../../../../shared/widgets/error_view.dart';
|
||||||
import '../../../../shared/widgets/page_header.dart';
|
import '../../../../shared/widgets/page_header.dart';
|
||||||
import '../providers/roles_provider.dart';
|
import '../providers/roles_provider.dart';
|
||||||
@ -52,31 +53,37 @@ class _RoleListScreenState extends ConsumerState<RoleListScreen> {
|
|||||||
subtitle: 'Manage roles and permission assignments',
|
subtitle: 'Manage roles and permission assignments',
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
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(
|
Expanded(
|
||||||
child: RefreshIndicator(
|
child: AppTableShell(
|
||||||
onRefresh: () => ref.read(rolesListProvider.notifier).refresh(),
|
toolbar: SizedBox(
|
||||||
child: roles.isEmpty
|
width: context.isMobile ? double.infinity : 320,
|
||||||
? ListView(
|
child: AppSearchField(
|
||||||
children: const [
|
controller: _searchController,
|
||||||
AppEmptyState(
|
hint: 'Search roles...',
|
||||||
title: 'No roles found',
|
onChanged: ref.read(rolesListProvider.notifier).setSearch,
|
||||||
description: 'Roles from the API will appear here.',
|
),
|
||||||
icon: Icons.security_outlined,
|
),
|
||||||
),
|
child: RefreshIndicator(
|
||||||
],
|
onRefresh: () => ref.read(rolesListProvider.notifier).refresh(),
|
||||||
)
|
child: roles.isEmpty
|
||||||
: context.isMobile
|
? ListView(
|
||||||
? _RoleCardList(roles: roles, onOpen: _openRole)
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
: _RoleDataTable(roles: roles, onOpen: _openRole),
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return AppDataTable<RoleCardModel>(
|
return AppDataTable<RoleCardModel>(
|
||||||
|
wrapInCard: false,
|
||||||
columns: [
|
columns: [
|
||||||
AppDataColumn(label: 'Role Name', cellBuilder: (_, r) => Text(r.name)),
|
AppDataColumn(label: 'Role Name', flex: 2, cellBuilder: (_, r) => Text(r.name)),
|
||||||
AppDataColumn(
|
AppDataColumn(
|
||||||
label: 'Description',
|
label: 'Description',
|
||||||
|
flex: 3,
|
||||||
cellBuilder: (_, r) => Text(r.description ?? '—'),
|
cellBuilder: (_, r) => Text(r.description ?? '—'),
|
||||||
),
|
),
|
||||||
AppDataColumn(
|
AppDataColumn(
|
||||||
label: 'Users Count',
|
label: 'Users Count',
|
||||||
|
flex: 1,
|
||||||
cellBuilder: (_, r) => Text('${r.userCount}'),
|
cellBuilder: (_, r) => Text('${r.userCount}'),
|
||||||
),
|
),
|
||||||
AppDataColumn(
|
AppDataColumn(
|
||||||
label: 'Actions',
|
label: 'Actions',
|
||||||
cellBuilder: (_, r) => TextButton(
|
flex: 1,
|
||||||
onPressed: () => onOpen(r),
|
alignment: Alignment.centerRight,
|
||||||
child: const Text('View Matrix'),
|
cellBuilder: (_, r) => Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: () => onOpen(r),
|
||||||
|
child: const Text('View Matrix'),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@ -151,7 +151,8 @@ class _CompanyProfileSettingsScreenState
|
|||||||
AppTextField(
|
AppTextField(
|
||||||
controller: _gstController,
|
controller: _gstController,
|
||||||
label: 'GST/VAT Number',
|
label: 'GST/VAT Number',
|
||||||
validator: Validators.gstNumber,
|
validator: Validators.optionalGstin,
|
||||||
|
inputFormatters: Validators.gstinInput,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
AppTextField(
|
AppTextField(
|
||||||
@ -171,6 +172,8 @@ class _CompanyProfileSettingsScreenState
|
|||||||
controller: _phoneController,
|
controller: _phoneController,
|
||||||
label: 'Phone',
|
label: 'Phone',
|
||||||
keyboardType: TextInputType.phone,
|
keyboardType: TextInputType.phone,
|
||||||
|
validator: Validators.optionalMobile,
|
||||||
|
inputFormatters: Validators.mobileInput,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
AppTextField(
|
AppTextField(
|
||||||
|
|||||||
@ -3,6 +3,8 @@ import 'package:go_router/go_router.dart';
|
|||||||
|
|
||||||
import '../../../../core/utils/responsive_utils.dart';
|
import '../../../../core/utils/responsive_utils.dart';
|
||||||
import '../../../../shared/widgets/app_card.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 '../../../../shared/widgets/page_header.dart';
|
||||||
import '../../domain/entities/app_settings.dart';
|
import '../../domain/entities/app_settings.dart';
|
||||||
|
|
||||||
@ -138,14 +140,15 @@ class SettingsDropdownField<T> extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return DropdownButtonFormField<T>(
|
return AppSearchableDropdown<T>(
|
||||||
|
label: label,
|
||||||
value: value,
|
value: value,
|
||||||
decoration: InputDecoration(labelText: label),
|
searchHint: 'Search ${label.toLowerCase()}...',
|
||||||
items: items
|
options: items
|
||||||
.map(
|
.map(
|
||||||
(item) => DropdownMenuItem(
|
(item) => AppDropdownOption(
|
||||||
value: item,
|
value: item,
|
||||||
child: Text(itemLabel(item)),
|
label: itemLabel(item),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.toList(),
|
.toList(),
|
||||||
|
|||||||
@ -194,7 +194,8 @@ class _UserFormScreenState extends ConsumerState<UserFormScreen> {
|
|||||||
controller: _mobileController,
|
controller: _mobileController,
|
||||||
label: 'Mobile',
|
label: 'Mobile',
|
||||||
keyboardType: TextInputType.phone,
|
keyboardType: TextInputType.phone,
|
||||||
validator: Validators.phone,
|
validator: Validators.mobile,
|
||||||
|
inputFormatters: Validators.mobileInput,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
if (!isEditing) ...[
|
if (!isEditing) ...[
|
||||||
|
|||||||
@ -8,7 +8,6 @@ import '../../../../core/errors/failure.dart';
|
|||||||
import '../../../../core/utils/responsive_utils.dart';
|
import '../../../../core/utils/responsive_utils.dart';
|
||||||
import '../../../../shared/models/user_management_models.dart';
|
import '../../../../shared/models/user_management_models.dart';
|
||||||
import '../../../../shared/widgets/app_confirmation_dialog.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_dropdown.dart';
|
||||||
import '../../../../shared/widgets/app_empty_state.dart';
|
import '../../../../shared/widgets/app_empty_state.dart';
|
||||||
import '../../../../shared/widgets/app_loading_view.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/app_status_chip.dart';
|
||||||
import '../../../../shared/widgets/error_view.dart';
|
import '../../../../shared/widgets/error_view.dart';
|
||||||
import '../../../../shared/widgets/kpi_card.dart';
|
import '../../../../shared/widgets/kpi_card.dart';
|
||||||
|
import '../../../../shared/widgets/app_table_shell.dart';
|
||||||
import '../../../../shared/widgets/page_header.dart';
|
import '../../../../shared/widgets/page_header.dart';
|
||||||
|
import '../widgets/user_rich_data_table.dart';
|
||||||
import '../providers/users_provider.dart';
|
import '../providers/users_provider.dart';
|
||||||
|
|
||||||
class UserListScreen extends ConsumerStatefulWidget {
|
class UserListScreen extends ConsumerStatefulWidget {
|
||||||
@ -68,63 +69,70 @@ class _UserListScreenState extends ConsumerState<UserListScreen> {
|
|||||||
_SummaryStrip(summary: state.summary!),
|
_SummaryStrip(summary: state.summary!),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 16),
|
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(
|
Expanded(
|
||||||
child: RefreshIndicator(
|
child: AppTableShell(
|
||||||
onRefresh: () => ref.read(usersListProvider.notifier).refresh(),
|
toolbar: _FiltersBar(
|
||||||
child: state.users.isEmpty
|
searchController: _searchController,
|
||||||
? ListView(
|
filters: state.filters,
|
||||||
children: const [
|
query: state.query,
|
||||||
AppEmptyState(
|
onSearch: ref.read(usersListProvider.notifier).setSearch,
|
||||||
title: 'No users found',
|
onStatusChanged: (status) =>
|
||||||
description: 'Try adjusting filters or add a new user.',
|
ref.read(usersListProvider.notifier).setStatusFilter(status),
|
||||||
icon: Icons.people_outline,
|
onRoleChanged: (roleId) =>
|
||||||
),
|
ref.read(usersListProvider.notifier).setRoleFilter(roleId),
|
||||||
],
|
onDepartmentChanged: (deptId) => ref
|
||||||
)
|
.read(usersListProvider.notifier)
|
||||||
: context.isMobile
|
.setDepartmentFilter(deptId),
|
||||||
? _UserCardList(
|
),
|
||||||
users: state.users,
|
footer: AppPagination(
|
||||||
onView: _viewUser,
|
currentPage: state.query.page,
|
||||||
onEdit: _editUser,
|
totalPages: state.totalPages,
|
||||||
onToggleStatus: _toggleStatus,
|
totalItems: state.total,
|
||||||
onDeactivate: _deactivateUser,
|
pageSize: state.query.limit,
|
||||||
)
|
onPageChanged: ref.read(usersListProvider.notifier).setPage,
|
||||||
: _UserDataTable(
|
onPageSizeChanged:
|
||||||
users: state.users,
|
ref.read(usersListProvider.notifier).setPageSize,
|
||||||
sortBy: state.query.sortBy,
|
),
|
||||||
sortOrder: state.query.sortOrder,
|
child: RefreshIndicator(
|
||||||
onSort: (column, ascending) => ref
|
onRefresh: () => ref.read(usersListProvider.notifier).refresh(),
|
||||||
.read(usersListProvider.notifier)
|
child: state.users.isEmpty
|
||||||
.setSort(column, ascending ? 'asc' : 'desc'),
|
? ListView(
|
||||||
onView: _viewUser,
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
onEdit: _editUser,
|
children: const [
|
||||||
onToggleStatus: _toggleStatus,
|
SizedBox(
|
||||||
onDeactivate: _deactivateUser,
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Wrap(
|
final searchField = AppSearchField(
|
||||||
spacing: 12,
|
controller: searchController,
|
||||||
runSpacing: 12,
|
hint: 'Search users...',
|
||||||
crossAxisAlignment: WrapCrossAlignment.center,
|
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: [
|
children: [
|
||||||
SizedBox(
|
Expanded(flex: 3, child: searchField),
|
||||||
width: context.isMobile ? double.infinity : 280,
|
const SizedBox(width: 12),
|
||||||
child: AppSearchField(
|
Expanded(flex: 2, child: dropdowns[0]),
|
||||||
controller: searchController,
|
const SizedBox(width: 12),
|
||||||
hint: 'Search users...',
|
Expanded(flex: 2, child: dropdowns[1]),
|
||||||
onChanged: onSearch,
|
const SizedBox(width: 12),
|
||||||
),
|
Expanded(flex: 2, child: dropdowns[2]),
|
||||||
),
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -303,40 +319,19 @@ class _UserDataTable extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return AppDataTable<ManagedUserModel>(
|
return UserRichDataTable(
|
||||||
|
users: users,
|
||||||
sortColumn: sortBy,
|
sortColumn: sortBy,
|
||||||
sortAscending: sortOrder == 'asc',
|
sortAscending: sortOrder == 'asc',
|
||||||
onSort: onSort,
|
onSort: onSort,
|
||||||
columns: [
|
wrapInCard: false,
|
||||||
AppDataColumn(
|
actionsBuilder: (_, user) => _UserActions(
|
||||||
label: 'Employee ID',
|
user: user,
|
||||||
sortKey: 'employee_code',
|
onView: onView,
|
||||||
cellBuilder: (_, user) => Text(user.employeeCode),
|
onEdit: onEdit,
|
||||||
),
|
onToggleStatus: onToggleStatus,
|
||||||
AppDataColumn(
|
onDeactivate: onDeactivate,
|
||||||
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,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -201,7 +201,8 @@ class _UserProfileScreenState extends ConsumerState<UserProfileScreen> {
|
|||||||
controller: _mobileController,
|
controller: _mobileController,
|
||||||
label: 'Mobile',
|
label: 'Mobile',
|
||||||
keyboardType: TextInputType.phone,
|
keyboardType: TextInputType.phone,
|
||||||
validator: Validators.phone,
|
validator: Validators.mobile,
|
||||||
|
inputFormatters: Validators.mobileInput,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Align(
|
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_detail_screen.dart';
|
||||||
import '../../modules/users/presentation/screens/user_form_screen.dart';
|
import '../../modules/users/presentation/screens/user_form_screen.dart';
|
||||||
import '../../modules/users/presentation/screens/user_profile_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/roles/presentation/screens/permission_matrix_screen.dart';
|
||||||
import '../../modules/settings/presentation/screens/appearance_settings_screen.dart';
|
import '../../modules/settings/presentation/screens/appearance_settings_screen.dart';
|
||||||
import '../../modules/settings/presentation/screens/asset_settings_screen.dart';
|
import '../../modules/settings/presentation/screens/asset_settings_screen.dart';
|
||||||
@ -64,12 +72,12 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
return null;
|
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 &&
|
if (DevConfig.screenPreviewEnabled &&
|
||||||
location == RouteConstants.screenGallery) {
|
location == RouteConstants.screenGallery) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (isPreview) return null;
|
if (isPreview && DevConfig.screenPreviewEnabled) return null;
|
||||||
|
|
||||||
if (!isAuthenticated && !isAuthRoute) {
|
if (!isAuthenticated && !isAuthRoute) {
|
||||||
return RouteConstants.login;
|
return RouteConstants.login;
|
||||||
@ -200,6 +208,65 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
pageBuilder: (context, state) =>
|
pageBuilder: (context, state) =>
|
||||||
shellPage(state, const UserProfileScreen()),
|
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(
|
GoRoute(
|
||||||
path: RouteConstants.assets,
|
path: RouteConstants.assets,
|
||||||
pageBuilder: (context, state) =>
|
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(
|
MenuItem(
|
||||||
label: 'Master Data',
|
label: 'Master Data',
|
||||||
icon: Icons.dataset_outlined,
|
icon: Icons.dataset_outlined,
|
||||||
|
|||||||
@ -27,6 +27,8 @@ class AppDataTable<T> extends StatelessWidget {
|
|||||||
this.sortAscending = true,
|
this.sortAscending = true,
|
||||||
this.onSort,
|
this.onSort,
|
||||||
this.emptyMessage = 'No records found',
|
this.emptyMessage = 'No records found',
|
||||||
|
this.wrapInCard = true,
|
||||||
|
this.shrinkWrap = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
final List<AppDataColumn<T>> columns;
|
final List<AppDataColumn<T>> columns;
|
||||||
@ -35,11 +37,14 @@ class AppDataTable<T> extends StatelessWidget {
|
|||||||
final bool sortAscending;
|
final bool sortAscending;
|
||||||
final void Function(String column, bool ascending)? onSort;
|
final void Function(String column, bool ascending)? onSort;
|
||||||
final String emptyMessage;
|
final String emptyMessage;
|
||||||
|
final bool wrapInCard;
|
||||||
|
/// Set true when the table is placed inside another scrollable.
|
||||||
|
final bool shrinkWrap;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (rows.isEmpty) {
|
if (rows.isEmpty) {
|
||||||
return Center(
|
final empty = Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(32),
|
padding: const EdgeInsets.all(32),
|
||||||
child: Text(
|
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(
|
return AppCard(
|
||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
child: SingleChildScrollView(
|
child: table,
|
||||||
scrollDirection: Axis.horizontal,
|
);
|
||||||
child: ConstrainedBox(
|
}
|
||||||
constraints: const BoxConstraints(minWidth: 720),
|
}
|
||||||
child: DataTable(
|
|
||||||
sortColumnIndex: sortColumn == null
|
class _TableHeaderRow<T> extends StatelessWidget {
|
||||||
? null
|
const _TableHeaderRow({
|
||||||
: columns.indexWhere((c) => c.sortKey == sortColumn),
|
required this.columns,
|
||||||
sortAscending: sortAscending,
|
required this.sortColumn,
|
||||||
columns: columns
|
required this.sortAscending,
|
||||||
.map(
|
required this.onSort,
|
||||||
(col) => DataColumn(
|
});
|
||||||
label: Text(col.label),
|
|
||||||
onSort: col.sortKey == null
|
final List<AppDataColumn<T>> columns;
|
||||||
? null
|
final String? sortColumn;
|
||||||
: (_, ascending) => onSort?.call(col.sortKey!, ascending),
|
final bool sortAscending;
|
||||||
),
|
final void Function(String column, bool ascending)? onSort;
|
||||||
)
|
|
||||||
.toList(),
|
@override
|
||||||
rows: rows
|
Widget build(BuildContext context) {
|
||||||
.map(
|
final theme = Theme.of(context);
|
||||||
(row) => DataRow(
|
|
||||||
cells: columns
|
return Container(
|
||||||
.map((col) => DataCell(col.cellBuilder(context, row)))
|
width: double.infinity,
|
||||||
.toList(),
|
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.4),
|
||||||
),
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
|
||||||
)
|
child: Row(
|
||||||
.toList(),
|
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 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'app_searchable_dropdown.dart';
|
||||||
|
|
||||||
class AppDropdownOption<T> {
|
class AppDropdownOption<T> {
|
||||||
const AppDropdownOption({required this.value, required this.label});
|
const AppDropdownOption({required this.value, required this.label});
|
||||||
|
|
||||||
@ -7,6 +9,7 @@ class AppDropdownOption<T> {
|
|||||||
final String label;
|
final String label;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Searchable dropdown — all app dropdowns use [AppSearchableDropdown] under the hood.
|
||||||
class AppDropdown<T> extends StatelessWidget {
|
class AppDropdown<T> extends StatelessWidget {
|
||||||
const AppDropdown({
|
const AppDropdown({
|
||||||
super.key,
|
super.key,
|
||||||
@ -16,7 +19,9 @@ class AppDropdown<T> extends StatelessWidget {
|
|||||||
required this.onChanged,
|
required this.onChanged,
|
||||||
this.validator,
|
this.validator,
|
||||||
this.hint,
|
this.hint,
|
||||||
|
this.searchHint,
|
||||||
this.enabled = true,
|
this.enabled = true,
|
||||||
|
this.isDense = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String label;
|
final String label;
|
||||||
@ -25,23 +30,33 @@ class AppDropdown<T> extends StatelessWidget {
|
|||||||
final ValueChanged<T?> onChanged;
|
final ValueChanged<T?> onChanged;
|
||||||
final String? Function(T?)? validator;
|
final String? Function(T?)? validator;
|
||||||
final String? hint;
|
final String? hint;
|
||||||
|
final String? searchHint;
|
||||||
final bool enabled;
|
final bool enabled;
|
||||||
|
final bool isDense;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return DropdownButtonFormField<T>(
|
return AppSearchableDropdown<T>(
|
||||||
|
label: label,
|
||||||
value: value,
|
value: value,
|
||||||
decoration: InputDecoration(labelText: label, hintText: hint),
|
options: options,
|
||||||
items: options
|
onChanged: onChanged,
|
||||||
.map(
|
|
||||||
(option) => DropdownMenuItem<T>(
|
|
||||||
value: option.value,
|
|
||||||
child: Text(option.label),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.toList(),
|
|
||||||
onChanged: enabled ? onChanged : null,
|
|
||||||
validator: validator,
|
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;
|
if (_overlayEntry == null) return;
|
||||||
_overlayEntry!.remove();
|
_overlayEntry!.remove();
|
||||||
_overlayEntry = null;
|
_overlayEntry = null;
|
||||||
if (mounted) setState(() {});
|
if (mounted) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _openPicker(FormFieldState<T> field) {
|
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/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
class AppTextField extends StatelessWidget {
|
class AppTextField extends StatelessWidget {
|
||||||
const AppTextField({
|
const AppTextField({
|
||||||
@ -13,6 +14,8 @@ class AppTextField extends StatelessWidget {
|
|||||||
this.validator,
|
this.validator,
|
||||||
this.onChanged,
|
this.onChanged,
|
||||||
this.maxLines = 1,
|
this.maxLines = 1,
|
||||||
|
this.maxLength,
|
||||||
|
this.inputFormatters,
|
||||||
this.enabled = true,
|
this.enabled = true,
|
||||||
this.autofillHints,
|
this.autofillHints,
|
||||||
});
|
});
|
||||||
@ -27,6 +30,8 @@ class AppTextField extends StatelessWidget {
|
|||||||
final String? Function(String?)? validator;
|
final String? Function(String?)? validator;
|
||||||
final void Function(String)? onChanged;
|
final void Function(String)? onChanged;
|
||||||
final int maxLines;
|
final int maxLines;
|
||||||
|
final int? maxLength;
|
||||||
|
final List<TextInputFormatter>? inputFormatters;
|
||||||
final bool enabled;
|
final bool enabled;
|
||||||
final Iterable<String>? autofillHints;
|
final Iterable<String>? autofillHints;
|
||||||
|
|
||||||
@ -39,6 +44,8 @@ class AppTextField extends StatelessWidget {
|
|||||||
validator: validator,
|
validator: validator,
|
||||||
onChanged: onChanged,
|
onChanged: onChanged,
|
||||||
maxLines: maxLines,
|
maxLines: maxLines,
|
||||||
|
maxLength: maxLength,
|
||||||
|
inputFormatters: inputFormatters,
|
||||||
enabled: enabled,
|
enabled: enabled,
|
||||||
autofillHints: autofillHints,
|
autofillHints: autofillHints,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
|
|||||||
@ -404,7 +404,7 @@ packages:
|
|||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
flutter_web_plugins:
|
flutter_web_plugins:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
|||||||
@ -9,6 +9,8 @@ environment:
|
|||||||
dependencies:
|
dependencies:
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
flutter_web_plugins:
|
||||||
|
sdk: flutter
|
||||||
|
|
||||||
cupertino_icons: ^1.0.8
|
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">
|
<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 charset="UTF-8">
|
||||||
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
|
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
|
||||||
<meta name="description" content="A new Flutter project.">
|
<meta name="description" content="A new Flutter project.">
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user