Compare commits
10 Commits
4360931be4
...
f2d5b49dac
| Author | SHA1 | Date | |
|---|---|---|---|
| f2d5b49dac | |||
| ef0f33ecbb | |||
| 048ecab961 | |||
| 167bd99e0a | |||
| b3a0366848 | |||
| 0978f9bfac | |||
| 1d44187942 | |||
| 4046633340 | |||
| 09ffc5fb6a | |||
| 03486ea3f4 |
16
README.md
16
README.md
@ -46,25 +46,25 @@ dart run build_runner build --delete-conflicting-outputs
|
||||
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/` |
|
||||
| Flavor | Entry point | API URL (auto) | Web base-href |
|
||||
|--------|-------------|----------------|---------------|
|
||||
| **dev** | `lib/config/main_dev.dart` | `https://demo.venbait.in/api/v1` | `/` |
|
||||
| **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` | `/` |
|
||||
|
||||
Dev web uses **path URLs** (no `index.html#`). Build with `--base-href /erp/` and deploy `web/.htaccess` for Apache SPA fallback.
|
||||
Dev web uses **path URLs** (no `index.html#`). Build with `--base-href /` and deploy `web/.htaccess` for Apache/LiteSpeed SPA fallback.
|
||||
|
||||
### Web builds
|
||||
|
||||
```bash
|
||||
# Dev
|
||||
flutter build web -t lib/config/main_dev.dart --release --base-href /erp/
|
||||
flutter build web -t lib/config/main_dev.dart --release --base-href /
|
||||
|
||||
# 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/
|
||||
flutter build web -t lib/config/main_prod.dart --release --base-href /
|
||||
```
|
||||
|
||||
### Android APK / AAB
|
||||
|
||||
11
lib/app.dart
11
lib/app.dart
@ -4,8 +4,10 @@ import 'package:responsive_framework/responsive_framework.dart';
|
||||
|
||||
import 'core/constants/app_constants.dart';
|
||||
import 'core/theme/theme_provider.dart';
|
||||
import 'modules/settings/presentation/providers/settings_provider.dart';
|
||||
import 'shared/routes/app_router.dart';
|
||||
import 'shared/widgets/app_toast.dart';
|
||||
import 'shared/widgets/sidebar_logo.dart';
|
||||
|
||||
class BharatErpApp extends ConsumerWidget {
|
||||
const BharatErpApp({super.key});
|
||||
@ -15,9 +17,16 @@ class BharatErpApp extends ConsumerWidget {
|
||||
final router = ref.watch(routerProvider);
|
||||
final themeMode = ref.watch(themeModeProvider);
|
||||
final branding = ref.watch(brandingProvider);
|
||||
final companyProfile = ref.watch(appSettingsProvider).companyProfile;
|
||||
final appTitle = resolveSidebarTitle(
|
||||
companyName: companyProfile.companyName.isNotEmpty
|
||||
? companyProfile.companyName
|
||||
: (branding.companyName ?? ''),
|
||||
fallback: AppConstants.appName,
|
||||
);
|
||||
|
||||
return MaterialApp.router(
|
||||
title: AppConstants.appName,
|
||||
title: appTitle,
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: buildLightTheme(branding),
|
||||
darkTheme: buildDarkTheme(branding),
|
||||
|
||||
@ -17,20 +17,6 @@ class Environment {
|
||||
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',
|
||||
|
||||
@ -213,4 +213,5 @@ class ApiEndpoints {
|
||||
|
||||
// Notifications
|
||||
static const String notifications = '/notifications';
|
||||
static const String notificationsTrigger = '/notifications/trigger';
|
||||
}
|
||||
|
||||
@ -59,13 +59,13 @@ class RouteConstants {
|
||||
static const String grnEdit = '/grn/:id/edit';
|
||||
static const String grnDetail = '/grn/:id';
|
||||
|
||||
// Assets
|
||||
static const String assets = '/assets';
|
||||
static const String assetAdd = '/assets/add';
|
||||
static const String assetEdit = '/assets/:id/edit';
|
||||
static const String assetDetail = '/assets/:id';
|
||||
static const String assetAlerts = '/assets/alerts';
|
||||
static const String assetMaintenance = '/assets/maintenance';
|
||||
// Assets (Asset Master)
|
||||
static const String assets = '/assetsmaster';
|
||||
static const String assetAdd = '/assetsmaster/add';
|
||||
static const String assetEdit = '/assetsmaster/:id/edit';
|
||||
static const String assetDetail = '/assetsmaster/:id';
|
||||
static const String assetAlerts = '/assetsmaster/alerts';
|
||||
static const String assetMaintenance = '/assetsmaster/maintenance';
|
||||
|
||||
// Master Data
|
||||
static const String masterData = '/master-data';
|
||||
|
||||
@ -13,4 +13,9 @@ class StorageKeys {
|
||||
static const String appSettings = 'app_settings';
|
||||
static const String rememberMe = 'remember_me';
|
||||
static const String rememberedEmail = 'remembered_email';
|
||||
|
||||
/// Prefix for per-table column visibility/order prefs (`table_columns_<tableId>`).
|
||||
static const String tableColumnsPrefix = 'table_columns_';
|
||||
|
||||
static String tableColumns(String tableId) => '$tableColumnsPrefix$tableId';
|
||||
}
|
||||
|
||||
@ -3,8 +3,8 @@ import 'package:flutter/material.dart';
|
||||
class AppColors {
|
||||
AppColors._();
|
||||
|
||||
static const Color primary = Color(0xFF1565C0);
|
||||
static const Color secondary = Color(0xFF00897B);
|
||||
static const Color primary = Color(0xFF2563EB);
|
||||
static const Color secondary = Color(0xFF0891B2);
|
||||
static const Color error = Color(0xFFD32F2F);
|
||||
static const Color warning = Color(0xFFF57C00);
|
||||
static const Color success = Color(0xFF388E3C);
|
||||
|
||||
@ -12,8 +12,9 @@ class AppTheme {
|
||||
final primary = branding?.primaryColor ?? AppColors.primary;
|
||||
final secondary = branding?.secondaryColor ?? AppColors.secondary;
|
||||
|
||||
final colorScheme = ColorScheme.fromSeed(
|
||||
seedColor: primary,
|
||||
final colorScheme = _brandedColorScheme(
|
||||
seed: primary,
|
||||
primary: primary,
|
||||
secondary: secondary,
|
||||
brightness: Brightness.light,
|
||||
surface: AppColors.lightSurface,
|
||||
@ -26,8 +27,9 @@ class AppTheme {
|
||||
final primary = branding?.primaryColor ?? AppColors.primary;
|
||||
final secondary = branding?.secondaryColor ?? AppColors.secondary;
|
||||
|
||||
final colorScheme = ColorScheme.fromSeed(
|
||||
seedColor: primary,
|
||||
final colorScheme = _brandedColorScheme(
|
||||
seed: primary,
|
||||
primary: primary,
|
||||
secondary: secondary,
|
||||
brightness: Brightness.dark,
|
||||
surface: AppColors.darkSurface,
|
||||
@ -36,12 +38,56 @@ class AppTheme {
|
||||
return _buildTheme(colorScheme, Brightness.dark);
|
||||
}
|
||||
|
||||
/// Keeps Material tonal containers from [seed], but locks brand [primary] /
|
||||
/// [secondary] to the exact chosen hex values (fromSeed remaps primary).
|
||||
static ColorScheme _brandedColorScheme({
|
||||
required Color seed,
|
||||
required Color primary,
|
||||
required Color secondary,
|
||||
required Brightness brightness,
|
||||
required Color surface,
|
||||
}) {
|
||||
final base = ColorScheme.fromSeed(
|
||||
seedColor: seed,
|
||||
secondary: secondary,
|
||||
brightness: brightness,
|
||||
surface: surface,
|
||||
);
|
||||
|
||||
final onPrimary = _onColor(primary);
|
||||
final onSecondary = _onColor(secondary);
|
||||
|
||||
return base.copyWith(
|
||||
primary: primary,
|
||||
onPrimary: onPrimary,
|
||||
primaryContainer: Color.alphaBlend(
|
||||
primary.withValues(alpha: 0.18),
|
||||
surface,
|
||||
),
|
||||
onPrimaryContainer: primary,
|
||||
secondary: secondary,
|
||||
onSecondary: onSecondary,
|
||||
secondaryContainer: Color.alphaBlend(
|
||||
secondary.withValues(alpha: 0.18),
|
||||
surface,
|
||||
),
|
||||
onSecondaryContainer: secondary,
|
||||
);
|
||||
}
|
||||
|
||||
static Color _onColor(Color color) {
|
||||
return ThemeData.estimateBrightnessForColor(color) == Brightness.dark
|
||||
? Colors.white
|
||||
: const Color(0xFF212121);
|
||||
}
|
||||
|
||||
/// Theme for white cards, form fields, and picker sheets (unchanged in dark mode).
|
||||
static ThemeData cardContentTheme(ThemeData theme) {
|
||||
final scheme = theme.brightness == Brightness.light
|
||||
? theme.colorScheme
|
||||
: ColorScheme.fromSeed(
|
||||
seedColor: theme.colorScheme.primary,
|
||||
: _brandedColorScheme(
|
||||
seed: theme.colorScheme.primary,
|
||||
primary: theme.colorScheme.primary,
|
||||
secondary: theme.colorScheme.secondary,
|
||||
brightness: Brightness.light,
|
||||
surface: AppColors.card,
|
||||
@ -155,13 +201,27 @@ class AppTheme {
|
||||
borderSide: BorderSide(color: colorScheme.primary, width: 2),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
labelStyle: textTheme.bodyMedium,
|
||||
labelStyle: textTheme.labelLarge,
|
||||
hintStyle: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
helperStyle: textTheme.bodySmall,
|
||||
errorStyle: textTheme.bodySmall?.copyWith(color: colorScheme.error),
|
||||
errorStyle: textTheme.labelSmall?.copyWith(color: colorScheme.error),
|
||||
),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colorScheme.primary,
|
||||
foregroundColor: colorScheme.onPrimary,
|
||||
disabledBackgroundColor: colorScheme.onSurface.withValues(alpha: 0.12),
|
||||
disabledForegroundColor: colorScheme.onSurface.withValues(alpha: 0.38),
|
||||
minimumSize: const Size(0, 48),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
textStyle: textTheme.labelLarge,
|
||||
),
|
||||
),
|
||||
filledButtonTheme: FilledButtonThemeData(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: colorScheme.primary,
|
||||
foregroundColor: colorScheme.onPrimary,
|
||||
minimumSize: const Size(0, 48),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
@ -170,18 +230,30 @@ class AppTheme {
|
||||
),
|
||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: colorScheme.secondary,
|
||||
minimumSize: const Size(0, 48),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
side: BorderSide(color: colorScheme.secondary.withValues(alpha: 0.55)),
|
||||
textStyle: textTheme.labelLarge,
|
||||
),
|
||||
),
|
||||
textButtonTheme: TextButtonThemeData(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: colorScheme.secondary,
|
||||
minimumSize: const Size(0, 48),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
textStyle: textTheme.labelLarge,
|
||||
),
|
||||
),
|
||||
iconButtonTheme: IconButtonThemeData(
|
||||
style: IconButton.styleFrom(
|
||||
foregroundColor: colorScheme.secondary,
|
||||
),
|
||||
),
|
||||
floatingActionButtonTheme: FloatingActionButtonThemeData(
|
||||
backgroundColor: colorScheme.primary,
|
||||
foregroundColor: colorScheme.onPrimary,
|
||||
),
|
||||
navigationRailTheme: NavigationRailThemeData(
|
||||
backgroundColor: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
selectedIconTheme: IconThemeData(color: colorScheme.primary),
|
||||
@ -215,7 +287,17 @@ class AppTheme {
|
||||
dataTextStyle: textTheme.bodyMedium,
|
||||
),
|
||||
chipTheme: ChipThemeData(
|
||||
labelStyle: textTheme.labelSmall,
|
||||
backgroundColor: colorScheme.secondaryContainer.withValues(alpha: 0.55),
|
||||
selectedColor: colorScheme.secondary.withValues(alpha: 0.18),
|
||||
disabledColor: colorScheme.onSurface.withValues(alpha: 0.08),
|
||||
labelStyle: textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onSecondaryContainer,
|
||||
),
|
||||
secondaryLabelStyle: textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.secondary,
|
||||
),
|
||||
side: BorderSide(color: colorScheme.secondary.withValues(alpha: 0.28)),
|
||||
iconTheme: IconThemeData(color: colorScheme.secondary, size: 18),
|
||||
),
|
||||
dialogTheme: DialogThemeData(
|
||||
titleTextStyle: textTheme.titleLarge,
|
||||
|
||||
@ -1,57 +1,189 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
|
||||
/// ERP typography — Figtree (Google Font).
|
||||
/// Weights: Regular 400, Medium 500, SemiBold 600.
|
||||
/// ERP typography — Figtree design tokens.
|
||||
///
|
||||
/// | Token | Sizes | Weights |
|
||||
/// |--------------|-------|----------------------------------|
|
||||
/// | Heading 1–6 | 56→20 | Medium, SemiBold, Bold |
|
||||
/// | Body 1–4 | 18→12 | Regular, Medium |
|
||||
/// | Label 1–3 | 16→12 | Regular, Medium, SemiBold, Bold |
|
||||
/// | Caption 1–2 | 10→9 | Medium, SemiBold |
|
||||
class AppTypography {
|
||||
AppTypography._();
|
||||
|
||||
static const FontWeight regular = FontWeight.w400;
|
||||
static const FontWeight medium = FontWeight.w500;
|
||||
static const FontWeight semiBold = FontWeight.w600;
|
||||
static const FontWeight bold = FontWeight.w700;
|
||||
|
||||
static String get fontFamily => GoogleFonts.figtree().fontFamily ?? 'Figtree';
|
||||
|
||||
static TextTheme textTheme(ColorScheme colorScheme) {
|
||||
TextStyle token({
|
||||
required double size,
|
||||
FontWeight weight = regular,
|
||||
Color? color,
|
||||
double? height,
|
||||
}) {
|
||||
return GoogleFonts.figtree(
|
||||
fontSize: size,
|
||||
fontWeight: weight,
|
||||
letterSpacing: 0,
|
||||
height: height,
|
||||
color: color ?? colorScheme.onSurface,
|
||||
);
|
||||
}
|
||||
// ─── Design tokens ─────────────────────────────────────────────────────────
|
||||
|
||||
static TextStyle heading1({
|
||||
FontWeight weight = semiBold,
|
||||
Color? color,
|
||||
double? height,
|
||||
}) =>
|
||||
_token(56, weight, color, height);
|
||||
|
||||
static TextStyle heading2({
|
||||
FontWeight weight = semiBold,
|
||||
Color? color,
|
||||
double? height,
|
||||
}) =>
|
||||
_token(48, weight, color, height);
|
||||
|
||||
static TextStyle heading3({
|
||||
FontWeight weight = semiBold,
|
||||
Color? color,
|
||||
double? height,
|
||||
}) =>
|
||||
_token(40, weight, color, height);
|
||||
|
||||
static TextStyle heading4({
|
||||
FontWeight weight = semiBold,
|
||||
Color? color,
|
||||
double? height,
|
||||
}) =>
|
||||
_token(32, weight, color, height);
|
||||
|
||||
static TextStyle heading5({
|
||||
FontWeight weight = semiBold,
|
||||
Color? color,
|
||||
double? height,
|
||||
}) =>
|
||||
_token(24, weight, color, height);
|
||||
|
||||
static TextStyle heading6({
|
||||
FontWeight weight = semiBold,
|
||||
Color? color,
|
||||
double? height,
|
||||
}) =>
|
||||
_token(20, weight, color, height);
|
||||
|
||||
static TextStyle body1({
|
||||
FontWeight weight = regular,
|
||||
Color? color,
|
||||
double? height,
|
||||
}) =>
|
||||
_token(18, weight, color, height);
|
||||
|
||||
static TextStyle body2({
|
||||
FontWeight weight = regular,
|
||||
Color? color,
|
||||
double? height,
|
||||
}) =>
|
||||
_token(16, weight, color, height);
|
||||
|
||||
static TextStyle body3({
|
||||
FontWeight weight = regular,
|
||||
Color? color,
|
||||
double? height,
|
||||
}) =>
|
||||
_token(14, weight, color, height);
|
||||
|
||||
static TextStyle body4({
|
||||
FontWeight weight = regular,
|
||||
Color? color,
|
||||
double? height,
|
||||
}) =>
|
||||
_token(12, weight, color, height);
|
||||
|
||||
static TextStyle label1({
|
||||
FontWeight weight = medium,
|
||||
Color? color,
|
||||
double? height,
|
||||
}) =>
|
||||
_token(16, weight, color, height);
|
||||
|
||||
static TextStyle label2({
|
||||
FontWeight weight = medium,
|
||||
Color? color,
|
||||
double? height,
|
||||
}) =>
|
||||
_token(14, weight, color, height);
|
||||
|
||||
static TextStyle label3({
|
||||
FontWeight weight = medium,
|
||||
Color? color,
|
||||
double? height,
|
||||
}) =>
|
||||
_token(12, weight, color, height);
|
||||
|
||||
static TextStyle caption1({
|
||||
FontWeight weight = medium,
|
||||
Color? color,
|
||||
double? height,
|
||||
}) =>
|
||||
_token(10, weight, color, height);
|
||||
|
||||
static TextStyle caption2({
|
||||
FontWeight weight = medium,
|
||||
Color? color,
|
||||
double? height,
|
||||
}) =>
|
||||
_token(9, weight, color, height);
|
||||
|
||||
// ─── Material TextTheme mapping ────────────────────────────────────────────
|
||||
|
||||
/// Maps design tokens onto Material slots used across the app.
|
||||
///
|
||||
/// | Material slot | Token | Default weight |
|
||||
/// |-----------------|-----------|----------------|
|
||||
/// | displayLarge | Heading-1 | SemiBold |
|
||||
/// | displayMedium | Heading-2 | SemiBold |
|
||||
/// | displaySmall | Heading-3 | SemiBold |
|
||||
/// | headlineLarge | Heading-4 | SemiBold |
|
||||
/// | headlineMedium | Heading-5 | SemiBold |
|
||||
/// | headlineSmall | Heading-6 | SemiBold |
|
||||
/// | titleLarge | Heading-5 | SemiBold |
|
||||
/// | titleMedium | Label-1 | Medium |
|
||||
/// | titleSmall | Label-2 | Medium |
|
||||
/// | bodyLarge | Body-1 | Regular |
|
||||
/// | bodyMedium | Body-2 | Regular |
|
||||
/// | bodySmall | Body-3 | Regular |
|
||||
/// | labelLarge | Label-2 | Medium |
|
||||
/// | labelMedium | Label-3 | Medium |
|
||||
/// | labelSmall | Caption-1 | Medium |
|
||||
///
|
||||
/// Use [body4], [caption2], or weight variants via the named helpers above.
|
||||
static TextTheme textTheme(ColorScheme colorScheme) {
|
||||
final onSurface = colorScheme.onSurface;
|
||||
final onSurfaceVariant = colorScheme.onSurfaceVariant;
|
||||
|
||||
return TextTheme(
|
||||
// Display
|
||||
displayLarge: token(size: 57, weight: semiBold, color: onSurface),
|
||||
displayMedium: token(size: 45, weight: semiBold, color: onSurface),
|
||||
displaySmall: token(size: 36, weight: semiBold, color: onSurface),
|
||||
// Headline
|
||||
headlineLarge: token(size: 20, weight: semiBold, color: onSurface),
|
||||
headlineMedium: token(size: 16, weight: regular, color: onSurface),
|
||||
headlineSmall: token(size: 18, weight: semiBold, color: onSurface),
|
||||
// Title
|
||||
titleLarge: token(size: 22, weight: semiBold, color: onSurface),
|
||||
titleMedium: token(size: 16, weight: medium, color: onSurface),
|
||||
titleSmall: token(size: 14, weight: medium, color: onSurface),
|
||||
// Body
|
||||
bodyLarge: token(size: 16, weight: regular, color: onSurface),
|
||||
bodyMedium: token(size: 16, weight: regular, color: onSurface),
|
||||
bodySmall: token(size: 12, weight: regular, color: onSurfaceVariant),
|
||||
// Label
|
||||
labelLarge: token(size: 14, weight: medium, color: onSurface),
|
||||
labelMedium: token(size: 12, weight: medium, color: onSurfaceVariant),
|
||||
labelSmall: token(size: 10, weight: regular, color: onSurfaceVariant),
|
||||
displayLarge: heading1(color: onSurface),
|
||||
displayMedium: heading2(color: onSurface),
|
||||
displaySmall: heading3(color: onSurface),
|
||||
headlineLarge: heading4(color: onSurface),
|
||||
headlineMedium: heading5(color: onSurface),
|
||||
headlineSmall: heading6(color: onSurface),
|
||||
titleLarge: heading5(color: onSurface),
|
||||
titleMedium: label1(color: onSurface),
|
||||
titleSmall: label2(color: onSurface),
|
||||
bodyLarge: body1(color: onSurface),
|
||||
bodyMedium: body2(color: onSurface),
|
||||
bodySmall: body3(color: onSurfaceVariant),
|
||||
labelLarge: label2(color: onSurface),
|
||||
labelMedium: label3(color: onSurfaceVariant),
|
||||
labelSmall: caption1(color: onSurfaceVariant),
|
||||
);
|
||||
}
|
||||
|
||||
static TextStyle _token(
|
||||
double size,
|
||||
FontWeight weight,
|
||||
Color? color,
|
||||
double? height,
|
||||
) {
|
||||
return GoogleFonts.figtree(
|
||||
fontSize: size,
|
||||
fontWeight: weight,
|
||||
letterSpacing: 0,
|
||||
height: height,
|
||||
color: color,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,8 +8,8 @@ part 'branding_config.g.dart';
|
||||
class BrandingConfig with _$BrandingConfig {
|
||||
const factory BrandingConfig({
|
||||
String? logoUrl,
|
||||
@Default(0xFF1565C0) int primaryColorValue,
|
||||
@Default(0xFF00897B) int secondaryColorValue,
|
||||
@Default(0xFF2563EB) int primaryColorValue,
|
||||
@Default(0xFF0891B2) int secondaryColorValue,
|
||||
String? companyName,
|
||||
}) = _BrandingConfig;
|
||||
|
||||
|
||||
@ -159,8 +159,8 @@ class __$$BrandingConfigImplCopyWithImpl<$Res>
|
||||
class _$BrandingConfigImpl implements _BrandingConfig {
|
||||
const _$BrandingConfigImpl({
|
||||
this.logoUrl,
|
||||
this.primaryColorValue = 0xFF1565C0,
|
||||
this.secondaryColorValue = 0xFF00897B,
|
||||
this.primaryColorValue = 0xFF2563EB,
|
||||
this.secondaryColorValue = 0xFF0891B2,
|
||||
this.companyName,
|
||||
});
|
||||
|
||||
|
||||
@ -10,9 +10,9 @@ _$BrandingConfigImpl _$$BrandingConfigImplFromJson(Map<String, dynamic> json) =>
|
||||
_$BrandingConfigImpl(
|
||||
logoUrl: json['logoUrl'] as String?,
|
||||
primaryColorValue:
|
||||
(json['primaryColorValue'] as num?)?.toInt() ?? 0xFF1565C0,
|
||||
(json['primaryColorValue'] as num?)?.toInt() ?? 0xFF2563EB,
|
||||
secondaryColorValue:
|
||||
(json['secondaryColorValue'] as num?)?.toInt() ?? 0xFF00897B,
|
||||
(json['secondaryColorValue'] as num?)?.toInt() ?? 0xFF0891B2,
|
||||
companyName: json['companyName'] as String?,
|
||||
);
|
||||
|
||||
|
||||
54
lib/core/utils/column_search_paging.dart
Normal file
54
lib/core/utils/column_search_paging.dart
Normal file
@ -0,0 +1,54 @@
|
||||
import 'table_search.dart';
|
||||
|
||||
/// Tracks full-dataset mode for table column search.
|
||||
///
|
||||
/// First activation loads every row with `limit = total`. Further typing
|
||||
/// filters client-side. Clearing restores the previous page size.
|
||||
class ColumnSearchPaging {
|
||||
ColumnSearchPaging({this.defaultLimit = 20});
|
||||
|
||||
final int defaultLimit;
|
||||
int? _previousLimit;
|
||||
bool _active = false;
|
||||
|
||||
bool get isActive => _active;
|
||||
|
||||
/// Marks full-dataset mode and returns the limit to fetch, or `null` if
|
||||
/// already active.
|
||||
int? beginFullDataset({
|
||||
required int currentLimit,
|
||||
required int total,
|
||||
}) {
|
||||
if (_active) return null;
|
||||
_active = true;
|
||||
_previousLimit ??= currentLimit;
|
||||
return total > 0 ? total : currentLimit;
|
||||
}
|
||||
|
||||
/// Returns the page size to restore, or `null` if not in full-dataset mode.
|
||||
int? endFullDataset() {
|
||||
if (!_active) return null;
|
||||
_active = false;
|
||||
final restored = _previousLimit ?? defaultLimit;
|
||||
_previousLimit = null;
|
||||
return restored;
|
||||
}
|
||||
|
||||
/// Legacy helper used by older `setColumnSearch` call sites.
|
||||
({String? search, int limit}) apply({
|
||||
required String search,
|
||||
required int currentLimit,
|
||||
required int total,
|
||||
}) {
|
||||
final normalized = TableSearch.normalize(search);
|
||||
if (normalized.isEmpty) {
|
||||
final restored = endFullDataset() ?? defaultLimit;
|
||||
return (search: null, limit: restored);
|
||||
}
|
||||
final limit = beginFullDataset(currentLimit: currentLimit, total: total);
|
||||
return (
|
||||
search: normalized,
|
||||
limit: limit ?? (total > 0 ? total : currentLimit),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,3 +1,4 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
class DateFormatter {
|
||||
@ -42,13 +43,31 @@ class DateFormatter {
|
||||
if (dayDiff < 7) return '$dayDiff days ago';
|
||||
return displayDateTime(local);
|
||||
}
|
||||
|
||||
/// Multiple date formats for client-side column search.
|
||||
static String searchableDate(DateTime? date) {
|
||||
if (date == null) return '';
|
||||
final local = date.toLocal();
|
||||
return [
|
||||
displayDate(local),
|
||||
displayDateTime(local),
|
||||
formatUserLastLogin(local),
|
||||
DateFormat('yyyy-MM-dd').format(local),
|
||||
DateFormat('dd-MM-yyyy').format(local),
|
||||
DateFormat('dd/MM/yy').format(local),
|
||||
DateFormat('d/M/yyyy').format(local),
|
||||
DateFormat('d/MM/yyyy').format(local),
|
||||
].join(' ');
|
||||
}
|
||||
}
|
||||
|
||||
class CurrencyFormatter {
|
||||
CurrencyFormatter._();
|
||||
|
||||
static const locale = 'en_IN';
|
||||
|
||||
static final _formatter = NumberFormat.currency(
|
||||
locale: 'en_IN',
|
||||
locale: locale,
|
||||
symbol: '₹',
|
||||
decimalDigits: 2,
|
||||
);
|
||||
@ -57,4 +76,150 @@ class CurrencyFormatter {
|
||||
if (amount == null) return '-';
|
||||
return _formatter.format(amount);
|
||||
}
|
||||
|
||||
/// Grouped amount for text fields (no currency symbol), e.g. `1,23,456.5`.
|
||||
static String formatEditable(num? amount, {int maxDecimals = 2}) {
|
||||
if (amount == null) return '';
|
||||
final value = amount.toDouble();
|
||||
if (value % 1 == 0) {
|
||||
return NumberFormat('#,##,##0', locale).format(value);
|
||||
}
|
||||
return NumberFormat(
|
||||
'#,##,##0.${'#' * maxDecimals}',
|
||||
locale,
|
||||
).format(value);
|
||||
}
|
||||
|
||||
/// Removes grouping commas so values can be parsed / sent to the API.
|
||||
static String stripGrouping(String value) => value.replaceAll(',', '').trim();
|
||||
|
||||
static double? tryParse(String? value) {
|
||||
if (value == null) return null;
|
||||
final cleaned = stripGrouping(value);
|
||||
if (cleaned.isEmpty || cleaned == '.' || cleaned == '-') return null;
|
||||
return double.tryParse(cleaned);
|
||||
}
|
||||
|
||||
/// Formatted + raw numeric text for client-side column search.
|
||||
static String searchable(double? amount) {
|
||||
if (amount == null) return '';
|
||||
return '${format(amount)} $amount';
|
||||
}
|
||||
|
||||
/// Input formatters for cost / amount fields (Indian comma grouping).
|
||||
static List<TextInputFormatter> get amountInput => const [
|
||||
AmountThousandsSeparatorFormatter(),
|
||||
];
|
||||
}
|
||||
|
||||
/// Formats numeric input with Indian-style thousand separators while typing.
|
||||
///
|
||||
/// Example: `1234567.5` → `12,34,567.5`
|
||||
class AmountThousandsSeparatorFormatter extends TextInputFormatter {
|
||||
const AmountThousandsSeparatorFormatter({this.maxDecimals = 2});
|
||||
|
||||
final int maxDecimals;
|
||||
|
||||
@override
|
||||
TextEditingValue formatEditUpdate(
|
||||
TextEditingValue oldValue,
|
||||
TextEditingValue newValue,
|
||||
) {
|
||||
final raw = newValue.text;
|
||||
if (raw.isEmpty) {
|
||||
return newValue;
|
||||
}
|
||||
|
||||
// Keep only digits and a single decimal point.
|
||||
final buffer = StringBuffer();
|
||||
var seenDot = false;
|
||||
var decimals = 0;
|
||||
for (final rune in raw.runes) {
|
||||
final ch = String.fromCharCode(rune);
|
||||
if (ch == ',') continue;
|
||||
if (ch == '.') {
|
||||
if (seenDot) continue;
|
||||
seenDot = true;
|
||||
buffer.write(ch);
|
||||
continue;
|
||||
}
|
||||
if (ch.compareTo('0') >= 0 && ch.compareTo('9') <= 0) {
|
||||
if (seenDot) {
|
||||
if (decimals >= maxDecimals) continue;
|
||||
decimals++;
|
||||
}
|
||||
buffer.write(ch);
|
||||
}
|
||||
}
|
||||
|
||||
final cleaned = buffer.toString();
|
||||
if (cleaned.isEmpty) {
|
||||
return const TextEditingValue(
|
||||
text: '',
|
||||
selection: TextSelection.collapsed(offset: 0),
|
||||
);
|
||||
}
|
||||
|
||||
final parts = cleaned.split('.');
|
||||
final intPart = parts[0];
|
||||
final hasDot = cleaned.contains('.');
|
||||
final fracPart = parts.length > 1 ? parts[1] : '';
|
||||
|
||||
final formattedInt = intPart.isEmpty
|
||||
? ''
|
||||
: NumberFormat('#,##,##0', CurrencyFormatter.locale)
|
||||
.format(int.parse(intPart));
|
||||
|
||||
final formatted = hasDot ? '$formattedInt.$fracPart' : formattedInt;
|
||||
|
||||
// Place caret at end; stable enough for amount entry.
|
||||
return TextEditingValue(
|
||||
text: formatted,
|
||||
selection: TextSelection.collapsed(offset: formatted.length),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts `snake_case` / `kebab-case` keys into readable Title Case labels.
|
||||
String humanizeLabel(String key) {
|
||||
final cleaned = key.trim().replaceAll(RegExp(r'[_\-.]+'), ' ');
|
||||
if (cleaned.isEmpty) return key;
|
||||
|
||||
const acronyms = {
|
||||
'id': 'ID',
|
||||
'amc': 'AMC',
|
||||
'gst': 'GST',
|
||||
'hsn': 'HSN',
|
||||
'uom': 'UOM',
|
||||
'po': 'PO',
|
||||
'grn': 'Purchase Receipt',
|
||||
'url': 'URL',
|
||||
'api': 'API',
|
||||
};
|
||||
|
||||
return cleaned
|
||||
.split(RegExp(r'\s+'))
|
||||
.where((part) => part.isNotEmpty)
|
||||
.map((part) {
|
||||
final lower = part.toLowerCase();
|
||||
if (acronyms.containsKey(lower)) return acronyms[lower]!;
|
||||
return '${lower[0].toUpperCase()}${lower.substring(1)}';
|
||||
})
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
/// Subject used in dropdown hints (`Select …` / `Search …`).
|
||||
/// Strips required asterisks and title-cases each word.
|
||||
String dropdownHintLabel(String label) {
|
||||
final cleaned =
|
||||
label.replaceAll('*', '').trim().replaceAll(RegExp(r'\s+'), ' ');
|
||||
if (cleaned.isEmpty) return label.trim();
|
||||
|
||||
return cleaned.split(' ').map((word) {
|
||||
if (word.isEmpty) return word;
|
||||
// Keep short all-caps tokens (GST, UOM, PO…).
|
||||
if (word.length <= 4 && word == word.toUpperCase()) return word;
|
||||
final lower = word.toLowerCase();
|
||||
return '${lower[0].toUpperCase()}${lower.substring(1)}';
|
||||
}).join(' ');
|
||||
}
|
||||
|
||||
102
lib/core/utils/pagination_meta.dart
Normal file
102
lib/core/utils/pagination_meta.dart
Normal file
@ -0,0 +1,102 @@
|
||||
/// Shared helpers for list API pagination meta.
|
||||
library;
|
||||
|
||||
int? paginationInt(dynamic value) {
|
||||
if (value is num) return value.toInt();
|
||||
if (value is String) return int.tryParse(value.trim());
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Prefer nested `meta`, then fields on `data` when it is a map, then top-level.
|
||||
Map<String, dynamic> extractPaginationMeta(dynamic body) {
|
||||
if (body is! Map) return const {};
|
||||
|
||||
final root = Map<String, dynamic>.from(body);
|
||||
final meta = <String, dynamic>{};
|
||||
|
||||
final topMeta = root['meta'];
|
||||
if (topMeta is Map) {
|
||||
meta.addAll(Map<String, dynamic>.from(topMeta));
|
||||
}
|
||||
|
||||
final data = root['data'];
|
||||
if (data is Map) {
|
||||
final nested = Map<String, dynamic>.from(data);
|
||||
for (final key in const [
|
||||
'page',
|
||||
'limit',
|
||||
'per_page',
|
||||
'page_size',
|
||||
'total',
|
||||
'totalPages',
|
||||
'total_pages',
|
||||
]) {
|
||||
if (nested[key] != null) meta[key] = nested[key];
|
||||
}
|
||||
}
|
||||
|
||||
for (final key in const [
|
||||
'page',
|
||||
'limit',
|
||||
'per_page',
|
||||
'page_size',
|
||||
'total',
|
||||
'totalPages',
|
||||
'total_pages',
|
||||
]) {
|
||||
if (meta[key] == null && root[key] != null) {
|
||||
meta[key] = root[key];
|
||||
}
|
||||
}
|
||||
|
||||
return meta;
|
||||
}
|
||||
|
||||
/// Page count from total rows and page size. Never trust a bad API `total_pages`.
|
||||
int resolveTotalPages({
|
||||
required int total,
|
||||
required int limit,
|
||||
}) {
|
||||
if (total <= 0 || limit <= 0) return 1;
|
||||
final pages = (total + limit - 1) ~/ limit;
|
||||
return pages < 1 ? 1 : pages;
|
||||
}
|
||||
|
||||
class ParsedPagination {
|
||||
const ParsedPagination({
|
||||
required this.page,
|
||||
required this.limit,
|
||||
required this.total,
|
||||
required this.totalPages,
|
||||
});
|
||||
|
||||
final int page;
|
||||
final int limit;
|
||||
final int total;
|
||||
final int totalPages;
|
||||
}
|
||||
|
||||
/// Parse pagination using request fallbacks. [limit] never falls back to item count.
|
||||
ParsedPagination parsePagination({
|
||||
required dynamic body,
|
||||
required int fallbackPage,
|
||||
required int fallbackLimit,
|
||||
required int itemCount,
|
||||
}) {
|
||||
final meta = extractPaginationMeta(body);
|
||||
final page = paginationInt(meta['page']) ?? fallbackPage;
|
||||
final limit = paginationInt(meta['limit']) ??
|
||||
paginationInt(meta['per_page']) ??
|
||||
paginationInt(meta['page_size']) ??
|
||||
fallbackLimit;
|
||||
final total = paginationInt(meta['total']) ?? itemCount;
|
||||
final safeLimit = limit > 0 ? limit : fallbackLimit;
|
||||
final totalPages = resolveTotalPages(total: total, limit: safeLimit);
|
||||
|
||||
return ParsedPagination(
|
||||
page: page < 1 ? 1 : page,
|
||||
limit: safeLimit,
|
||||
total: total < 0 ? 0 : total,
|
||||
totalPages: totalPages,
|
||||
);
|
||||
}
|
||||
@ -19,6 +19,8 @@ const Map<String, String> permissionModuleAliases = {
|
||||
'purchase_orders': 'PURCHASE_ORDER',
|
||||
'purchase_order': 'PURCHASE_ORDER',
|
||||
'grn': 'GRN',
|
||||
'purchase_receipt': 'GRN',
|
||||
'purchase_receipts': 'GRN',
|
||||
'reports': 'REPORTS',
|
||||
'audit_logs': 'AUDIT_LOGS',
|
||||
'audit': 'AUDIT_LOGS',
|
||||
|
||||
@ -8,6 +8,15 @@ class AppBreakpoints {
|
||||
static const double tablet = 600;
|
||||
static const double desktop = 1024;
|
||||
static const double wide = 1440;
|
||||
|
||||
/// Form grid: 2 cols from this width up.
|
||||
static const double formSmall = 600;
|
||||
|
||||
/// Form grid: 3 cols from this width up.
|
||||
static const double formMedium = 900;
|
||||
|
||||
/// Form grid: 4 cols from this width up.
|
||||
static const double formLarge = 1200;
|
||||
}
|
||||
|
||||
extension ResponsiveContext on BuildContext {
|
||||
@ -22,23 +31,27 @@ extension ResponsiveContext on BuildContext {
|
||||
return double.infinity;
|
||||
}
|
||||
|
||||
/// Form grid columns: 4 on medium+, 2 on small screens.
|
||||
/// Form grid columns: 4 / 3 / 2 / 1 by viewport width.
|
||||
int get formGridColumns {
|
||||
final width = MediaQuery.sizeOf(this).width;
|
||||
return formGridColumnsForWidth(width);
|
||||
}
|
||||
}
|
||||
|
||||
/// Responsive form field columns based on viewport width.
|
||||
/// Responsive form field columns:
|
||||
/// large ≥1200 → 4, medium ≥900 → 3, small ≥600 → 2, else → 1.
|
||||
int formGridColumnsForWidth(
|
||||
double width, {
|
||||
int xsColumns = 1,
|
||||
int smallColumns = 2,
|
||||
int mediumColumns = 4,
|
||||
int mediumColumns = 3,
|
||||
int largeColumns = 4,
|
||||
double mediumBreakpoint = AppBreakpoints.tablet,
|
||||
double largeBreakpoint = AppBreakpoints.desktop,
|
||||
double smallBreakpoint = AppBreakpoints.formSmall,
|
||||
double mediumBreakpoint = AppBreakpoints.formMedium,
|
||||
double largeBreakpoint = AppBreakpoints.formLarge,
|
||||
}) {
|
||||
if (width >= largeBreakpoint) return largeColumns;
|
||||
if (width >= mediumBreakpoint) return mediumColumns;
|
||||
return smallColumns;
|
||||
if (width >= smallBreakpoint) return smallColumns;
|
||||
return xsColumns;
|
||||
}
|
||||
|
||||
@ -36,6 +36,26 @@ class TableSearch {
|
||||
}
|
||||
return items.where((item) => matches(q, valuesOf(item))).toList();
|
||||
}
|
||||
|
||||
/// Merges two lists by [idOf], preferring items from [primary] on conflict.
|
||||
static List<T> mergeById<T>(
|
||||
Iterable<T> primary,
|
||||
Iterable<T> secondary,
|
||||
String Function(T item) idOf,
|
||||
) {
|
||||
final map = <String, T>{};
|
||||
for (final item in secondary) {
|
||||
final id = idOf(item);
|
||||
if (id.isEmpty) continue;
|
||||
map[id] = item;
|
||||
}
|
||||
for (final item in primary) {
|
||||
final id = idOf(item);
|
||||
if (id.isEmpty) continue;
|
||||
map[id] = item;
|
||||
}
|
||||
return map.values.toList();
|
||||
}
|
||||
}
|
||||
|
||||
/// Debounces search input so API-backed lists are not hit on every keystroke.
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'formatters.dart';
|
||||
|
||||
class Validators {
|
||||
Validators._();
|
||||
|
||||
@ -137,15 +139,35 @@ class Validators {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Strong password policy used for create / change / reset flows.
|
||||
///
|
||||
/// Requires: 8+ chars, uppercase, lowercase, digit, and a special character.
|
||||
static String? password(String? value) {
|
||||
if (value == null || value.isEmpty) return 'Password is required';
|
||||
if (value.length < 8) return 'Password must be at least 8 characters';
|
||||
if (!RegExp(r'[A-Z]').hasMatch(value)) return 'Must contain an uppercase letter';
|
||||
if (!RegExp(r'[a-z]').hasMatch(value)) return 'Must contain a lowercase letter';
|
||||
if (!RegExp(r'[0-9]').hasMatch(value)) return 'Must contain a number';
|
||||
if (value.length < 8) {
|
||||
return 'Password must be at least 8 characters';
|
||||
}
|
||||
if (!RegExp(r'[A-Z]').hasMatch(value)) {
|
||||
return 'Password must contain at least one uppercase letter (A–Z)';
|
||||
}
|
||||
if (!RegExp(r'[a-z]').hasMatch(value)) {
|
||||
return 'Password must contain at least one lowercase letter (a–z)';
|
||||
}
|
||||
if (!RegExp(r'[0-9]').hasMatch(value)) {
|
||||
return 'Password must contain at least one numeric digit (0–9)';
|
||||
}
|
||||
if (!RegExp(r'''[!@#$%^&*(),.?":{}|<>_\-+=\[\]\\;/`'~]''').hasMatch(value)) {
|
||||
return 'Password must contain at least one special character (e.g. @, #, \$, %, &, !)';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Same as [password], but allows empty (e.g. edit user keep current password).
|
||||
static String? optionalPassword(String? value) {
|
||||
if (value == null || value.isEmpty) return null;
|
||||
return password(value);
|
||||
}
|
||||
|
||||
/// Required 15-character GSTIN pattern.
|
||||
static String? gstin(String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
@ -212,7 +234,7 @@ class Validators {
|
||||
String fieldName = 'Value',
|
||||
}) {
|
||||
if (value == null || value.trim().isEmpty) return null;
|
||||
final parsed = double.tryParse(value.trim());
|
||||
final parsed = CurrencyFormatter.tryParse(value);
|
||||
if (parsed == null) return 'Enter a valid number';
|
||||
if (parsed <= 0) return '$fieldName must be greater than 0';
|
||||
return null;
|
||||
@ -224,7 +246,7 @@ class Validators {
|
||||
String fieldName = 'Value',
|
||||
}) {
|
||||
if (value == null || value.trim().isEmpty) return null;
|
||||
final parsed = double.tryParse(value.trim());
|
||||
final parsed = CurrencyFormatter.tryParse(value);
|
||||
if (parsed == null) return 'Enter a valid number';
|
||||
if (parsed < 0) return '$fieldName cannot be negative';
|
||||
return null;
|
||||
@ -248,7 +270,7 @@ class Validators {
|
||||
String fieldName = 'Percentage',
|
||||
}) {
|
||||
if (value == null || value.trim().isEmpty) return null;
|
||||
final parsed = double.tryParse(value.trim());
|
||||
final parsed = CurrencyFormatter.tryParse(value);
|
||||
if (parsed == null) return 'Enter a valid percentage';
|
||||
if (parsed < 0 || parsed > 100) {
|
||||
return '$fieldName must be between 0 and 100';
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../../../core/constants/api_endpoints.dart';
|
||||
import '../../../../core/utils/pagination_meta.dart';
|
||||
import '../../../../core/utils/export_file_name.dart';
|
||||
import '../../../../shared/models/api_response.dart';
|
||||
import '../../../../shared/models/asset_model.dart';
|
||||
@ -17,7 +18,12 @@ class AssetRemoteDataSource {
|
||||
ApiEndpoints.assets,
|
||||
queryParameters: _queryToMap(query),
|
||||
);
|
||||
return _parsePaginated(response.data, AssetModel.fromJson);
|
||||
return _parsePaginated(
|
||||
response.data,
|
||||
AssetModel.fromJson,
|
||||
fallbackPage: query.page,
|
||||
fallbackLimit: query.limit,
|
||||
);
|
||||
}
|
||||
|
||||
Future<AssetModel> getAssetById(String id) async {
|
||||
@ -215,7 +221,12 @@ class AssetRemoteDataSource {
|
||||
if (type != null) 'type': type,
|
||||
},
|
||||
);
|
||||
return _parsePaginated(response.data, AssetAlertModel.fromJson);
|
||||
return _parsePaginated(
|
||||
response.data,
|
||||
AssetAlertModel.fromJson,
|
||||
fallbackPage: page,
|
||||
fallbackLimit: limit,
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<AssetAlertModel>> getServiceAlerts({String? status}) async {
|
||||
@ -370,7 +381,12 @@ class AssetRemoteDataSource {
|
||||
if (dueOnly) 'due_only': true,
|
||||
},
|
||||
);
|
||||
return _parsePaginated(response.data, AssetModel.fromJson);
|
||||
return _parsePaginated(
|
||||
response.data,
|
||||
AssetModel.fromJson,
|
||||
fallbackPage: page,
|
||||
fallbackLimit: limit,
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<AssetMaintenanceLogModel>> getMaintenanceLogs(String assetId) async {
|
||||
@ -464,86 +480,45 @@ class AssetRemoteDataSource {
|
||||
|
||||
PaginatedResponse<T> _parsePaginated<T>(
|
||||
dynamic body,
|
||||
T Function(Map<String, dynamic>) fromJson,
|
||||
) {
|
||||
if (body is! Map) {
|
||||
return const PaginatedResponse(
|
||||
items: [],
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total: 0,
|
||||
totalPages: 1,
|
||||
);
|
||||
}
|
||||
|
||||
final map = Map<String, dynamic>.from(body);
|
||||
final raw = map['data'];
|
||||
final meta = map['meta'] is Map
|
||||
? Map<String, dynamic>.from(map['meta'] as Map)
|
||||
: <String, dynamic>{};
|
||||
|
||||
if (raw is List) {
|
||||
final items = raw
|
||||
.whereType<Map>()
|
||||
.map((e) => fromJson(Map<String, dynamic>.from(e)))
|
||||
.toList();
|
||||
final limit = (meta['limit'] as num?)?.toInt() ?? items.length;
|
||||
final total = (meta['total'] as num?)?.toInt() ?? items.length;
|
||||
final explicitTotalPages =
|
||||
(meta['totalPages'] as num?)?.toInt() ??
|
||||
(meta['total_pages'] as num?)?.toInt();
|
||||
return PaginatedResponse(
|
||||
items: items,
|
||||
page: (meta['page'] as num?)?.toInt() ?? 1,
|
||||
limit: limit,
|
||||
total: total,
|
||||
totalPages: explicitTotalPages ??
|
||||
(limit > 0
|
||||
? ((total + limit - 1) ~/ limit).clamp(1, 999999)
|
||||
: 1),
|
||||
);
|
||||
}
|
||||
|
||||
if (raw is Map) {
|
||||
final nested = Map<String, dynamic>.from(raw);
|
||||
final list = nested['items'];
|
||||
if (list is List) {
|
||||
final items = list
|
||||
T Function(Map<String, dynamic>) fromJson, {
|
||||
int fallbackPage = 1,
|
||||
int fallbackLimit = 20,
|
||||
}) {
|
||||
var items = <T>[];
|
||||
if (body is Map) {
|
||||
final raw = body['data'];
|
||||
if (raw is List) {
|
||||
items = raw
|
||||
.whereType<Map>()
|
||||
.map((e) => fromJson(Map<String, dynamic>.from(e)))
|
||||
.toList();
|
||||
final limit = (meta['limit'] as num?)?.toInt() ??
|
||||
(nested['limit'] as num?)?.toInt() ??
|
||||
20;
|
||||
final total = (meta['total'] as num?)?.toInt() ??
|
||||
(nested['total'] as num?)?.toInt() ??
|
||||
items.length;
|
||||
final explicitTotalPages =
|
||||
(meta['totalPages'] as num?)?.toInt() ??
|
||||
(meta['total_pages'] as num?)?.toInt() ??
|
||||
(nested['totalPages'] as num?)?.toInt() ??
|
||||
(nested['total_pages'] as num?)?.toInt();
|
||||
return PaginatedResponse(
|
||||
items: items,
|
||||
page: (meta['page'] as num?)?.toInt() ??
|
||||
(nested['page'] as num?)?.toInt() ??
|
||||
1,
|
||||
limit: limit,
|
||||
total: total,
|
||||
totalPages: explicitTotalPages ??
|
||||
(limit > 0
|
||||
? ((total + limit - 1) ~/ limit).clamp(1, 999999)
|
||||
: 1),
|
||||
);
|
||||
} else if (raw is Map) {
|
||||
final list = raw['items'];
|
||||
if (list is List) {
|
||||
items = list
|
||||
.whereType<Map>()
|
||||
.map((e) => fromJson(Map<String, dynamic>.from(e)))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return const PaginatedResponse(
|
||||
items: [],
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total: 0,
|
||||
totalPages: 1,
|
||||
final pagination = parsePagination(
|
||||
body: body,
|
||||
fallbackPage: fallbackPage,
|
||||
fallbackLimit: fallbackLimit,
|
||||
itemCount: items.length,
|
||||
);
|
||||
|
||||
return PaginatedResponse(
|
||||
items: items,
|
||||
page: pagination.page,
|
||||
limit: fallbackLimit,
|
||||
total: pagination.total,
|
||||
totalPages: resolveTotalPages(
|
||||
total: pagination.total,
|
||||
limit: fallbackLimit,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -2,9 +2,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../data/repositories/asset_repository_impl.dart';
|
||||
import '../../../../shared/models/asset_model.dart';
|
||||
import '../../../../shared/models/user_management_models.dart';
|
||||
import '../../../masters/data/datasources/master_remote_data_source.dart';
|
||||
|
||||
/// Categories for Assets list filter (paginated list API — no dropdown_call).
|
||||
final itemCategoriesProvider = FutureProvider<List<AssetCategoryModel>>((ref) async {
|
||||
final itemCategoriesProvider =
|
||||
FutureProvider.autoDispose<List<AssetCategoryModel>>((ref) async {
|
||||
final repository = ref.watch(assetRepositoryProvider);
|
||||
final result = await repository.getCategories();
|
||||
if (result.failure != null) throw result.failure!;
|
||||
@ -13,12 +16,20 @@ final itemCategoriesProvider = FutureProvider<List<AssetCategoryModel>>((ref) as
|
||||
|
||||
/// Categories for Asset form dropdowns (`dropdown_call=true`).
|
||||
final itemCategoriesFormProvider =
|
||||
FutureProvider<List<AssetCategoryModel>>((ref) async {
|
||||
FutureProvider.autoDispose<List<AssetCategoryModel>>((ref) async {
|
||||
final repository = ref.watch(assetRepositoryProvider);
|
||||
final result = await repository.getCategories(dropdownCall: true);
|
||||
if (result.failure != null) throw result.failure!;
|
||||
return result.data ?? [];
|
||||
});
|
||||
|
||||
/// Subcategories for a selected item category (Asset form).
|
||||
final itemSubcategoriesProvider = FutureProvider.autoDispose
|
||||
.family<List<FilterOptionModel>, int?>((ref, categoryId) async {
|
||||
if (categoryId == null) return [];
|
||||
final dataSource = ref.watch(masterRemoteDataSourceProvider);
|
||||
return dataSource.listItemSubcategories(itemCategoryId: categoryId);
|
||||
});
|
||||
|
||||
@Deprecated('Use itemCategoriesProvider')
|
||||
final assetCategoriesProvider = itemCategoriesProvider;
|
||||
|
||||
@ -67,6 +67,27 @@ final assetFormLookupsProvider =
|
||||
);
|
||||
});
|
||||
|
||||
/// Lightweight lookups for Asset Master list filters only.
|
||||
/// Avoids [assetFormLookupsProvider] (PO/GRN/vendors/users/options) on the list.
|
||||
final assetListFilterLookupsProvider = FutureProvider.autoDispose<
|
||||
({
|
||||
List<FilterOptionModel> locations,
|
||||
List<AssetDropdownOption> statuses,
|
||||
})>((ref) async {
|
||||
final master = ref.watch(masterRemoteDataSourceProvider);
|
||||
final locations = await _safeOptions(master.listLocations);
|
||||
|
||||
List<AssetDropdownOption> statuses = const [];
|
||||
try {
|
||||
final result = await ref.read(assetRepositoryProvider).getStatuses();
|
||||
if (result.failure == null && result.data != null) {
|
||||
statuses = result.data!;
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
return (locations: locations, statuses: statuses);
|
||||
});
|
||||
|
||||
final assetDropdownOptionsProvider =
|
||||
FutureProvider.autoDispose<AssetDropdownOptionsModel>((ref) async {
|
||||
return _safeAssetOptions(ref);
|
||||
@ -182,7 +203,7 @@ Future<List<FilterOptionModel>> _safeGrnOptions(Ref ref) async {
|
||||
.map(
|
||||
(grn) => FilterOptionModel(
|
||||
id: grn.id,
|
||||
name: grn.grnNumber ?? 'GRN #${grn.id}',
|
||||
name: grn.grnNumber ?? 'Purchase Receipt #${grn.id}',
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/utils/column_search_paging.dart';
|
||||
import '../../../../core/utils/table_search.dart';
|
||||
|
||||
import '../../../../shared/models/asset_model.dart';
|
||||
import '../../../../shared/models/entity_attachment_model.dart';
|
||||
import '../../../../shared/models/export_file_result.dart';
|
||||
import '../../data/repositories/asset_repository_impl.dart';
|
||||
import 'asset_categories_provider.dart';
|
||||
|
||||
class AssetsListState {
|
||||
const AssetsListState({
|
||||
@ -58,6 +60,8 @@ final assetsListProvider =
|
||||
);
|
||||
|
||||
class AssetsListNotifier extends AutoDisposeAsyncNotifier<AssetsListState> {
|
||||
final _columnSearch = ColumnSearchPaging();
|
||||
|
||||
@override
|
||||
Future<AssetsListState> build() async {
|
||||
return _load(const AssetListQuery(limit: 20));
|
||||
@ -68,8 +72,35 @@ class AssetsListNotifier extends AutoDisposeAsyncNotifier<AssetsListState> {
|
||||
final result = await repository.getAssets(query);
|
||||
if (result.failure != null) throw result.failure!;
|
||||
final page = result.data!;
|
||||
var items = List<AssetModel>.from(page.items);
|
||||
final search = TableSearch.normalize(query.search);
|
||||
|
||||
// API search often ignores category name — pull matching categories too.
|
||||
if (search.isNotEmpty && query.itemCategoryId == null) {
|
||||
final categories = await ref.read(itemCategoriesProvider.future);
|
||||
var categoryMatches = 0;
|
||||
for (final category in categories) {
|
||||
if (!TableSearch.matches(search, [category.name])) continue;
|
||||
if (++categoryMatches > 5) break;
|
||||
final categoryId = int.tryParse(category.id);
|
||||
if (categoryId == null) continue;
|
||||
final byCategory = await repository.getAssets(
|
||||
query.copyWith(search: null, itemCategoryId: categoryId),
|
||||
);
|
||||
if (byCategory.failure == null && byCategory.data != null) {
|
||||
items = TableSearch.mergeById(
|
||||
items,
|
||||
byCategory.data!.items,
|
||||
(asset) => asset.id,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Keep API page results + enriched matches; pagination stays server-driven.
|
||||
}
|
||||
|
||||
return AssetsListState(
|
||||
assets: page.items,
|
||||
assets: items,
|
||||
query: query,
|
||||
total: page.total,
|
||||
totalPages: page.totalPages,
|
||||
@ -106,6 +137,27 @@ class AssetsListNotifier extends AutoDisposeAsyncNotifier<AssetsListState> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> ensureColumnSearchDataset() async {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
final limit = _columnSearch.beginFullDataset(
|
||||
currentLimit: current.query.limit,
|
||||
total: current.total,
|
||||
);
|
||||
if (limit == null) return;
|
||||
await applyQuery(
|
||||
current.query.copyWith(search: null, page: 1, limit: limit),
|
||||
);
|
||||
}
|
||||
|
||||
void clearColumnSearchDataset() {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
final limit = _columnSearch.endFullDataset();
|
||||
if (limit == null) return;
|
||||
applyQuery(current.query.copyWith(search: null, page: 1, limit: limit));
|
||||
}
|
||||
|
||||
void setStatusFilter(String? status) {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
@ -171,6 +223,7 @@ class AssetsListNotifier extends AutoDisposeAsyncNotifier<AssetsListState> {
|
||||
return false;
|
||||
}
|
||||
await refresh();
|
||||
ref.invalidate(myMaintenanceProvider);
|
||||
final current = state.valueOrNull;
|
||||
if (current != null) {
|
||||
state = AsyncData(current.copyWith(actionSuccess: 'Asset deleted'));
|
||||
@ -303,6 +356,7 @@ class AssetDetailNotifier extends FamilyAsyncNotifier<AssetDetailState, String>
|
||||
if (result.failure != null) throw result.failure!;
|
||||
await reload();
|
||||
ref.invalidate(assetsListProvider);
|
||||
ref.invalidate(myMaintenanceProvider);
|
||||
return result.data;
|
||||
}
|
||||
|
||||
@ -311,6 +365,7 @@ class AssetDetailNotifier extends FamilyAsyncNotifier<AssetDetailState, String>
|
||||
final result = await repository.deleteAsset(arg);
|
||||
if (result.failure != null) return false;
|
||||
ref.invalidate(assetsListProvider);
|
||||
ref.invalidate(myMaintenanceProvider);
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -448,6 +503,7 @@ class AssetFormNotifier extends AutoDisposeFamilyAsyncNotifier<AssetModel?, Stri
|
||||
final result = await repository.createAsset(data);
|
||||
if (result.failure != null) throw result.failure!;
|
||||
ref.invalidate(assetsListProvider);
|
||||
ref.invalidate(myMaintenanceProvider);
|
||||
return result.data;
|
||||
}
|
||||
|
||||
@ -457,6 +513,7 @@ class AssetFormNotifier extends AutoDisposeFamilyAsyncNotifier<AssetModel?, Stri
|
||||
if (result.failure != null) throw result.failure!;
|
||||
ref.invalidate(assetsListProvider);
|
||||
ref.invalidate(assetDetailProvider(id));
|
||||
ref.invalidate(myMaintenanceProvider);
|
||||
|
||||
final fresh = await repository.getAssetById(id);
|
||||
final asset = fresh.data ?? result.data;
|
||||
@ -636,11 +693,11 @@ class MyMaintenanceState {
|
||||
}
|
||||
|
||||
final myMaintenanceProvider =
|
||||
AsyncNotifierProvider<MyMaintenanceNotifier, MyMaintenanceState>(
|
||||
AsyncNotifierProvider.autoDispose<MyMaintenanceNotifier, MyMaintenanceState>(
|
||||
MyMaintenanceNotifier.new,
|
||||
);
|
||||
|
||||
class MyMaintenanceNotifier extends AsyncNotifier<MyMaintenanceState> {
|
||||
class MyMaintenanceNotifier extends AutoDisposeAsyncNotifier<MyMaintenanceState> {
|
||||
@override
|
||||
Future<MyMaintenanceState> build() async {
|
||||
return _load(const MyMaintenanceState());
|
||||
|
||||
@ -13,6 +13,7 @@ import '../../../../shared/widgets/app_loading_view.dart';
|
||||
import '../../../../shared/widgets/app_pagination.dart';
|
||||
import '../../../../shared/widgets/error_view.dart';
|
||||
import '../../../../shared/widgets/page_header.dart';
|
||||
import '../../../../shared/widgets/app_segmented_tab_bar.dart';
|
||||
import '../providers/assets_provider.dart';
|
||||
|
||||
class AssetAlertsScreen extends ConsumerStatefulWidget {
|
||||
@ -64,11 +65,17 @@ class _AssetAlertsScreenState extends ConsumerState<AssetAlertsScreen>
|
||||
),
|
||||
],
|
||||
),
|
||||
TabBar(
|
||||
AppSegmentedTabBar(
|
||||
controller: _tabController,
|
||||
tabs: const [
|
||||
Tab(text: 'Expiry Alerts'),
|
||||
Tab(text: 'Service Alerts'),
|
||||
AppSegmentedTab(
|
||||
label: 'Expiry Alerts',
|
||||
icon: Icons.event_busy_outlined,
|
||||
),
|
||||
AppSegmentedTab(
|
||||
label: 'Service Alerts',
|
||||
icon: Icons.build_circle_outlined,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
@ -339,7 +346,6 @@ class _AlertCard extends StatelessWidget {
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: visual.color,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -367,7 +373,6 @@ class _AlertCard extends StatelessWidget {
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: visual.color,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@ -6,6 +6,7 @@ import 'package:intl/intl.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/asset_model.dart';
|
||||
import '../../../../shared/models/entity_attachment_model.dart';
|
||||
import '../../../../shared/models/user_management_models.dart';
|
||||
@ -16,13 +17,17 @@ import '../../../../shared/widgets/app_empty_state.dart';
|
||||
import '../../../../shared/widgets/app_loading_view.dart';
|
||||
import '../../../../shared/widgets/app_status_chip.dart';
|
||||
import '../../../../shared/widgets/can_permission.dart';
|
||||
import '../../../../shared/widgets/detail_overview_widgets.dart';
|
||||
import '../../../../shared/widgets/document_preview_dialog.dart';
|
||||
import '../../../../shared/widgets/entity_attachments_card.dart';
|
||||
import '../../../../shared/widgets/error_view.dart';
|
||||
import '../../../../shared/widgets/page_header.dart';
|
||||
import '../../../../shared/widgets/app_side_panel.dart';
|
||||
import '../../../../shared/widgets/app_segmented_tab_bar.dart';
|
||||
import '../providers/assets_provider.dart';
|
||||
import '../providers/asset_form_lookups_provider.dart';
|
||||
import '../widgets/asset_form_panel.dart';
|
||||
import '../utils/maintenance_due_display.dart';
|
||||
import 'asset_form_screen.dart';
|
||||
import '../widgets/asset_maintenance_panel.dart';
|
||||
import '../widgets/asset_side_panels.dart';
|
||||
import '../../../../shared/widgets/app_toast.dart';
|
||||
@ -39,6 +44,7 @@ class AssetDetailScreen extends ConsumerStatefulWidget {
|
||||
class _AssetDetailScreenState extends ConsumerState<AssetDetailScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final TabController _tabController;
|
||||
bool _requestedFreshLoad = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@ -46,6 +52,23 @@ class _AssetDetailScreenState extends ConsumerState<AssetDetailScreen>
|
||||
_tabController = TabController(length: 4, vsync: this);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_requestedFreshLoad) return;
|
||||
_requestedFreshLoad = true;
|
||||
// Always hit GET /assets/{id} (+ related) when opening view.
|
||||
ref.invalidate(assetDetailProvider(widget.assetId));
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant AssetDetailScreen oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.assetId != widget.assetId) {
|
||||
ref.invalidate(assetDetailProvider(widget.assetId));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
@ -76,12 +99,16 @@ class _AssetDetailScreenState extends ConsumerState<AssetDetailScreen>
|
||||
onPressed: () => context.go(RouteConstants.assets),
|
||||
),
|
||||
title: state.asset.assetName,
|
||||
titleTrailing: AppStatusChip(
|
||||
status: state.asset.status ?? 'IN_USE',
|
||||
compact: true,
|
||||
),
|
||||
subtitle: state.asset.assetCode ?? 'Asset ID: ${state.asset.id}',
|
||||
actions: [
|
||||
if (canEdit)
|
||||
OutlinedButton.icon(
|
||||
onPressed: () =>
|
||||
openAssetFormPanel(context, ref, assetId: widget.assetId),
|
||||
openAssetForm(context, ref, assetId: widget.assetId),
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
label: const Text('Edit'),
|
||||
),
|
||||
@ -104,13 +131,25 @@ class _AssetDetailScreenState extends ConsumerState<AssetDetailScreen>
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TabBar(
|
||||
AppSegmentedTabBar(
|
||||
controller: _tabController,
|
||||
tabs: const [
|
||||
Tab(text: 'Overview'),
|
||||
Tab(text: 'AMC'),
|
||||
Tab(text: 'Service Visits'),
|
||||
Tab(text: 'Insurance'),
|
||||
AppSegmentedTab(
|
||||
label: 'Overview',
|
||||
icon: Icons.dashboard_outlined,
|
||||
),
|
||||
AppSegmentedTab(
|
||||
label: 'AMC',
|
||||
icon: Icons.handshake_outlined,
|
||||
),
|
||||
AppSegmentedTab(
|
||||
label: 'Service Visits',
|
||||
icon: Icons.build_outlined,
|
||||
),
|
||||
AppSegmentedTab(
|
||||
label: 'Insurance',
|
||||
icon: Icons.health_and_safety_outlined,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
@ -205,6 +244,16 @@ class _OverviewTab extends ConsumerWidget {
|
||||
asset.maintenanceInchargeUserId,
|
||||
users,
|
||||
);
|
||||
final daysUntilDueDisplay = MaintenanceDueDisplay.fromDaysUntilDue(
|
||||
asset.maintenance?.daysUntilDue,
|
||||
);
|
||||
final retainedPct = _retainedPercentage(
|
||||
currentValue: asset.resolvedCurrentValue,
|
||||
purchaseCost: asset.purchaseCost,
|
||||
);
|
||||
final scheme = theme.colorScheme;
|
||||
final activeColor =
|
||||
asset.isActive ? const Color(0xFF16A34A) : scheme.onSurfaceVariant;
|
||||
|
||||
return SingleChildScrollView(
|
||||
child: Center(
|
||||
@ -221,110 +270,183 @@ class _OverviewTab extends ConsumerWidget {
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'Asset Details',
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Asset Details',
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
TextButton.icon(
|
||||
onPressed: onOpenTransferHistory,
|
||||
icon: const Icon(Icons.history, size: 18),
|
||||
label: const Text('Transfer History'),
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: activeColor,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: activeColor.withValues(alpha: 0.35),
|
||||
blurRadius: 6,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
asset.isActive ? 'Active' : 'Inactive',
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
color: activeColor,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_AssetInfoGrid(
|
||||
items: [
|
||||
_AssetInfo('Asset Name', asset.assetName),
|
||||
_AssetInfo('Asset Code', asset.assetCode ?? '—'),
|
||||
_AssetInfo('Category', asset.assetCategoryName ?? '—'),
|
||||
_AssetInfo(
|
||||
'Subcategory',
|
||||
asset.assetSubcategoryName ?? '—',
|
||||
),
|
||||
_AssetInfo('Location', asset.locationName ?? '—'),
|
||||
_AssetInfo(
|
||||
'Commencement Date',
|
||||
asset.commencementDate != null
|
||||
? dateFormat.format(asset.commencementDate!)
|
||||
: '—',
|
||||
),
|
||||
_AssetInfo(
|
||||
'Maintenance Incharge',
|
||||
maintenanceInchargeLabel,
|
||||
),
|
||||
_AssetInfo(
|
||||
'Maintenance Frequency',
|
||||
asset.maintenanceFrequencyInDays != null
|
||||
? '${asset.maintenanceFrequencyInDays} days'
|
||||
: '—',
|
||||
),
|
||||
if (asset.maintenance != null) ...[
|
||||
_AssetInfo(
|
||||
'Maintenance Due',
|
||||
asset.maintenance!.isDue ? 'Yes' : 'No',
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 1) Identity
|
||||
DetailOverviewSection(
|
||||
title: 'Identity',
|
||||
child: DetailInfoGrid(
|
||||
items: [
|
||||
DetailInfoItem('Asset Name', asset.assetName),
|
||||
DetailInfoItem('Asset Code', asset.assetCode ?? '—'),
|
||||
DetailInfoItem('Location', asset.locationName ?? '—'),
|
||||
DetailInfoItem(
|
||||
'Asset Category',
|
||||
asset.assetCategoryName ?? '—',
|
||||
),
|
||||
_AssetInfo(
|
||||
'Next Due Date',
|
||||
asset.maintenance!.nextDueDate != null
|
||||
? dateFormat
|
||||
.format(asset.maintenance!.nextDueDate!)
|
||||
: '—',
|
||||
DetailInfoItem(
|
||||
'Subcategory',
|
||||
asset.assetSubcategoryName ?? '—',
|
||||
),
|
||||
_AssetInfo(
|
||||
'Days Until Due',
|
||||
asset.maintenance!.daysUntilDue?.toString() ?? '—',
|
||||
DetailInfoItem(
|
||||
'Manufacturer',
|
||||
asset.manufacturer ?? '—',
|
||||
),
|
||||
DetailInfoItem(
|
||||
'Brand / Model',
|
||||
asset.brandModel ?? '—',
|
||||
),
|
||||
DetailInfoItem(
|
||||
'Serial Number',
|
||||
asset.serialNumber ?? '—',
|
||||
),
|
||||
],
|
||||
_AssetInfo('Serial Number', asset.serialNumber ?? '—'),
|
||||
_AssetInfo('Brand / Model', asset.brandModel ?? '—'),
|
||||
_AssetInfo('Manufacturer', asset.manufacturer ?? '—'),
|
||||
_AssetInfo(
|
||||
'Purchase Date',
|
||||
asset.purchaseDate != null
|
||||
? dateFormat.format(asset.purchaseDate!)
|
||||
: '—',
|
||||
),
|
||||
_AssetInfo(
|
||||
'Warranty Expiry',
|
||||
asset.warrantyExpiryDate != null
|
||||
? dateFormat.format(asset.warrantyExpiryDate!)
|
||||
: '—',
|
||||
),
|
||||
_AssetInfo(
|
||||
'Purchase Cost',
|
||||
asset.purchaseCost != null
|
||||
? '₹${asset.purchaseCost}'
|
||||
: '—',
|
||||
),
|
||||
_AssetInfo(
|
||||
'Useful Life',
|
||||
asset.usefulLifeYears != null
|
||||
? '${asset.usefulLifeYears} years'
|
||||
: '—',
|
||||
),
|
||||
_AssetInfo(
|
||||
'Depreciation',
|
||||
asset.depreciationMethod != null
|
||||
? '${asset.depreciationMethod}'
|
||||
'${asset.depreciationRate != null ? ' (${asset.depreciationRate}%)' : ''}'
|
||||
: '—',
|
||||
),
|
||||
_AssetInfo(
|
||||
'Condition',
|
||||
assetConditionLabel(asset.condition),
|
||||
),
|
||||
_AssetInfo.widget(
|
||||
'Status',
|
||||
AppStatusChip(status: asset.status ?? 'IN_USE'),
|
||||
),
|
||||
_AssetInfo('Active', asset.isActive ? 'Yes' : 'No'),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 2) Purchase & Warranty
|
||||
DetailOverviewSection(
|
||||
title: 'Purchase & Warranty',
|
||||
child: DetailInfoGrid(
|
||||
items: [
|
||||
DetailInfoItem(
|
||||
'Purchase Date - Warranty Expiry',
|
||||
_purchaseWarrantyRange(
|
||||
purchaseDate: asset.purchaseDate,
|
||||
warrantyExpiryDate: asset.warrantyExpiryDate,
|
||||
dateFormat: dateFormat,
|
||||
),
|
||||
),
|
||||
DetailInfoItem(
|
||||
'Purchase Cost',
|
||||
asset.purchaseCost != null
|
||||
? CurrencyFormatter.format(asset.purchaseCost)
|
||||
: '—',
|
||||
),
|
||||
DetailInfoItem.widget(
|
||||
'PO Number',
|
||||
_DocumentNumberLink(
|
||||
label: asset.poNumber,
|
||||
enabled: asset.poId != null &&
|
||||
(asset.poNumber?.trim().isNotEmpty ??
|
||||
false),
|
||||
onTap: () {
|
||||
final poId = asset.poId;
|
||||
if (poId == null) return;
|
||||
showPurchaseOrderPreviewDialog(
|
||||
context,
|
||||
purchaseOrderId: poId.toString(),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
DetailInfoItem.widget(
|
||||
'Purchase Receipt Number',
|
||||
_DocumentNumberLink(
|
||||
label: asset.grnNumber,
|
||||
enabled: asset.grnId != null &&
|
||||
(asset.grnNumber?.trim().isNotEmpty ??
|
||||
false),
|
||||
onTap: () {
|
||||
final grnId = asset.grnId;
|
||||
if (grnId == null) return;
|
||||
showGrnPreviewDialog(
|
||||
context,
|
||||
grnId: grnId.toString(),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 4) Valuation & Depreciation
|
||||
DetailOverviewSection(
|
||||
title: 'Valuation & Depreciation',
|
||||
child: _ValuationDepreciationCard(
|
||||
currentValue: asset.resolvedCurrentValue,
|
||||
purchaseCost: asset.purchaseCost,
|
||||
retainedPct: retainedPct,
|
||||
commencementDate: asset.commencementDate,
|
||||
usefulLifeYears: asset.usefulLifeYears,
|
||||
depreciationMethod: asset.depreciationMethod,
|
||||
depreciationRate: asset.depreciationRate,
|
||||
annualDepreciation:
|
||||
asset.depreciation?.annualDepreciation,
|
||||
depreciatedAmount: asset.depreciatedAmount,
|
||||
salvageValue: asset.salvageValue,
|
||||
salvagePercentage: asset.salvagePercentage,
|
||||
dateFormat: dateFormat,
|
||||
),
|
||||
),
|
||||
|
||||
// 5) Maintenance
|
||||
DetailOverviewSection(
|
||||
title: 'Maintenance',
|
||||
showDivider: false,
|
||||
child: DetailInfoGrid(
|
||||
items: [
|
||||
DetailInfoItem(
|
||||
'Maintenance Incharge',
|
||||
maintenanceInchargeLabel,
|
||||
),
|
||||
DetailInfoItem(
|
||||
'Maintenance Frequency',
|
||||
asset.maintenanceFrequencyInDays != null
|
||||
? '${asset.maintenanceFrequencyInDays} days'
|
||||
: '—',
|
||||
),
|
||||
DetailInfoItem.widget(
|
||||
'Next Due Date',
|
||||
_nextDueDateValue(
|
||||
theme: theme,
|
||||
nextDueDate: asset.maintenance?.nextDueDate,
|
||||
dateFormat: dateFormat,
|
||||
daysUntilDueDisplay: daysUntilDueDisplay,
|
||||
),
|
||||
),
|
||||
DetailInfoItem(
|
||||
'Condition',
|
||||
assetConditionLabel(asset.condition),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
if (asset.maintenanceFrequencyInDays != null ||
|
||||
asset.maintenanceChecklistJson?.isNotEmpty == true ||
|
||||
asset.maintenance != null) ...[
|
||||
@ -332,26 +454,48 @@ class _OverviewTab extends ConsumerWidget {
|
||||
padding: EdgeInsets.symmetric(vertical: 16),
|
||||
child: Divider(height: 1),
|
||||
),
|
||||
AssetRecentMaintenanceLogsSection(
|
||||
assetId: assetId,
|
||||
leadingActions: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: () async {
|
||||
final saved = await openSubmitMaintenancePanel(
|
||||
context,
|
||||
ref,
|
||||
asset: asset,
|
||||
);
|
||||
if (saved == true && context.mounted) {
|
||||
ref.invalidate(myMaintenanceProvider);
|
||||
ref.invalidate(assetDetailProvider(assetId));
|
||||
showAppToastFromSnackBar(
|
||||
context,
|
||||
const SnackBar(
|
||||
content:
|
||||
Text('Maintenance log submitted'),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.checklist_outlined),
|
||||
label: const Text('Log Maintenance'),
|
||||
),
|
||||
],
|
||||
trailingActions: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: onOpenTransferHistory,
|
||||
icon: const Icon(Icons.history, size: 18),
|
||||
label: const Text('Transfer History'),
|
||||
),
|
||||
],
|
||||
),
|
||||
] else ...[
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () async {
|
||||
final saved = await openSubmitMaintenancePanel(
|
||||
context,
|
||||
ref,
|
||||
asset: asset,
|
||||
);
|
||||
if (saved == true && context.mounted) {
|
||||
showAppToastFromSnackBar(
|
||||
context,
|
||||
const SnackBar(
|
||||
content: Text('Maintenance log submitted'),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.checklist_outlined),
|
||||
label: const Text('Log Maintenance'),
|
||||
onPressed: onOpenTransferHistory,
|
||||
icon: const Icon(Icons.history, size: 18),
|
||||
label: const Text('Transfer History'),
|
||||
),
|
||||
),
|
||||
],
|
||||
@ -360,8 +504,8 @@ class _OverviewTab extends ConsumerWidget {
|
||||
padding: EdgeInsets.symmetric(vertical: 20),
|
||||
child: Divider(height: 1),
|
||||
),
|
||||
_AssetInfoGrid(
|
||||
items: [_AssetInfo('Remarks', asset.remarks!)],
|
||||
DetailInfoGrid(
|
||||
items: [DetailInfoItem('Remarks', asset.remarks!)],
|
||||
),
|
||||
],
|
||||
],
|
||||
@ -398,86 +542,352 @@ class _OverviewTab extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _AssetInfoGrid extends StatelessWidget {
|
||||
const _AssetInfoGrid({required this.items});
|
||||
|
||||
final List<_AssetInfo> items;
|
||||
static const int _columns = 3;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxWidth = constraints.maxWidth;
|
||||
final cols = maxWidth < 600
|
||||
? 1
|
||||
: maxWidth < 900
|
||||
? 2
|
||||
: _columns;
|
||||
const spacing = 16.0;
|
||||
final colWidth = (maxWidth - spacing * (cols - 1)) / cols;
|
||||
|
||||
return Wrap(
|
||||
spacing: spacing,
|
||||
runSpacing: 16,
|
||||
children: items
|
||||
.map(
|
||||
(item) => SizedBox(
|
||||
width: colWidth,
|
||||
child: _AssetDetailTile(
|
||||
label: item.label,
|
||||
value: item.value,
|
||||
valueWidget: item.valueWidget,
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
String _purchaseWarrantyRange({
|
||||
required DateTime? purchaseDate,
|
||||
required DateTime? warrantyExpiryDate,
|
||||
required DateFormat dateFormat,
|
||||
}) {
|
||||
final purchase =
|
||||
purchaseDate != null ? dateFormat.format(purchaseDate) : '—';
|
||||
final warranty =
|
||||
warrantyExpiryDate != null ? dateFormat.format(warrantyExpiryDate) : '—';
|
||||
return '$purchase - $warranty';
|
||||
}
|
||||
|
||||
class _AssetDetailTile extends StatelessWidget {
|
||||
const _AssetDetailTile({
|
||||
class _DocumentNumberLink extends StatelessWidget {
|
||||
const _DocumentNumberLink({
|
||||
required this.label,
|
||||
this.value,
|
||||
this.valueWidget,
|
||||
required this.enabled,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String? value;
|
||||
final Widget? valueWidget;
|
||||
final String? label;
|
||||
final bool enabled;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
final text = label?.trim().isNotEmpty == true ? label!.trim() : '—';
|
||||
if (!enabled) {
|
||||
return Text(
|
||||
text,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
valueWidget ?? Text(value ?? '—', style: theme.textTheme.bodyLarge),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Text(
|
||||
text,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: theme.colorScheme.primary.withValues(alpha: 0.45),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AssetInfo {
|
||||
const _AssetInfo(this.label, this.value) : valueWidget = null;
|
||||
Widget _nextDueDateValue({
|
||||
required ThemeData theme,
|
||||
required DateTime? nextDueDate,
|
||||
required DateFormat dateFormat,
|
||||
required MaintenanceDueDisplay? daysUntilDueDisplay,
|
||||
}) {
|
||||
final dateLabel =
|
||||
nextDueDate != null ? dateFormat.format(nextDueDate) : '—';
|
||||
final valueStyle = theme.textTheme.titleSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w600,
|
||||
);
|
||||
|
||||
const _AssetInfo.widget(this.label, this.valueWidget) : value = null;
|
||||
if (daysUntilDueDisplay == null) {
|
||||
return Text(dateLabel, style: valueStyle);
|
||||
}
|
||||
|
||||
final String label;
|
||||
final String? value;
|
||||
final Widget? valueWidget;
|
||||
return Text.rich(
|
||||
TextSpan(
|
||||
style: valueStyle,
|
||||
children: [
|
||||
TextSpan(text: dateLabel),
|
||||
TextSpan(
|
||||
text: ' (${daysUntilDueDisplay.label})',
|
||||
style: valueStyle?.copyWith(
|
||||
color: daysUntilDueDisplay.color,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
double? _retainedPercentage({
|
||||
required double? currentValue,
|
||||
required double? purchaseCost,
|
||||
}) {
|
||||
if (currentValue == null || purchaseCost == null || purchaseCost <= 0) {
|
||||
return null;
|
||||
}
|
||||
return (currentValue / purchaseCost * 100).clamp(0, 100);
|
||||
}
|
||||
|
||||
class _ValuationDepreciationCard extends StatelessWidget {
|
||||
const _ValuationDepreciationCard({
|
||||
required this.currentValue,
|
||||
required this.purchaseCost,
|
||||
required this.retainedPct,
|
||||
required this.commencementDate,
|
||||
required this.usefulLifeYears,
|
||||
required this.depreciationMethod,
|
||||
required this.depreciationRate,
|
||||
required this.annualDepreciation,
|
||||
required this.depreciatedAmount,
|
||||
required this.salvageValue,
|
||||
required this.salvagePercentage,
|
||||
required this.dateFormat,
|
||||
});
|
||||
|
||||
final double? currentValue;
|
||||
final double? purchaseCost;
|
||||
final double? retainedPct;
|
||||
final DateTime? commencementDate;
|
||||
final int? usefulLifeYears;
|
||||
final String? depreciationMethod;
|
||||
final double? depreciationRate;
|
||||
final double? annualDepreciation;
|
||||
final double? depreciatedAmount;
|
||||
final double? salvageValue;
|
||||
final double? salvagePercentage;
|
||||
final DateFormat dateFormat;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final onCard = scheme.onPrimary;
|
||||
final muted = onCard.withValues(alpha: 0.72);
|
||||
final progress = retainedPct == null ? 0.0 : retainedPct! / 100;
|
||||
|
||||
final subtitleParts = <String>[];
|
||||
if (purchaseCost != null) {
|
||||
subtitleParts.add(
|
||||
'of ${CurrencyFormatter.format(purchaseCost)} purchase cost',
|
||||
);
|
||||
}
|
||||
if (commencementDate != null) {
|
||||
subtitleParts.add('commenced ${dateFormat.format(commencementDate!)}');
|
||||
}
|
||||
|
||||
final methodLabel = depreciationMethod == null
|
||||
? '—'
|
||||
: '$depreciationMethod'
|
||||
'${depreciationRate != null ? ' ($depreciationRate%)' : ''}';
|
||||
|
||||
final detailItems = <(String, String)>[
|
||||
(
|
||||
'Commencement Date',
|
||||
commencementDate != null ? dateFormat.format(commencementDate!) : '—',
|
||||
),
|
||||
(
|
||||
'Useful Life',
|
||||
usefulLifeYears != null ? '$usefulLifeYears years' : '—',
|
||||
),
|
||||
('Depreciation Method', methodLabel),
|
||||
(
|
||||
'Annual Depreciation',
|
||||
annualDepreciation != null
|
||||
? CurrencyFormatter.format(annualDepreciation)
|
||||
: '—',
|
||||
),
|
||||
(
|
||||
'Depreciated Amount',
|
||||
depreciatedAmount != null
|
||||
? CurrencyFormatter.format(depreciatedAmount)
|
||||
: '—',
|
||||
),
|
||||
(
|
||||
'Salvage Value',
|
||||
salvageValue != null ? CurrencyFormatter.format(salvageValue) : '—',
|
||||
),
|
||||
(
|
||||
'Salvage Percentage',
|
||||
salvagePercentage != null ? '$salvagePercentage%' : '—',
|
||||
),
|
||||
];
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
scheme.primary,
|
||||
Color.lerp(scheme.primary, scheme.secondary, 0.55)!,
|
||||
scheme.secondary,
|
||||
],
|
||||
stops: const [0, 0.55, 1],
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: scheme.primary.withValues(alpha: 0.28),
|
||||
blurRadius: 18,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 88,
|
||||
height: 88,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 88,
|
||||
height: 88,
|
||||
child: CircularProgressIndicator(
|
||||
value: progress,
|
||||
strokeWidth: 7,
|
||||
backgroundColor: onCard.withValues(alpha: 0.18),
|
||||
color: Color.lerp(scheme.secondary, Colors.white, 0.45)!,
|
||||
strokeCap: StrokeCap.round,
|
||||
),
|
||||
),
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
retainedPct == null
|
||||
? '—'
|
||||
: '${retainedPct!.toStringAsFixed(2)}%',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
color: onCard,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'RETAINED',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: muted,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.6,
|
||||
fontSize: 9,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 18),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'CURRENT VALUE',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: muted,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.8,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
currentValue != null
|
||||
? CurrencyFormatter.format(currentValue)
|
||||
: '—',
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
color: onCard,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
if (subtitleParts.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
subtitleParts.join(' · '),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: muted,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: onCard.withValues(alpha: 0.10),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: onCard.withValues(alpha: 0.12)),
|
||||
),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final cols = constraints.maxWidth < 560
|
||||
? 2
|
||||
: constraints.maxWidth < 820
|
||||
? 3
|
||||
: 4;
|
||||
const spacing = 16.0;
|
||||
final colWidth = (constraints.maxWidth - spacing * (cols - 1)) /
|
||||
cols;
|
||||
return Wrap(
|
||||
spacing: spacing,
|
||||
runSpacing: 14,
|
||||
children: [
|
||||
for (final item in detailItems)
|
||||
SizedBox(
|
||||
width: colWidth,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.$1,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: muted,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
item.$2,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: onCard,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _userLabel(int? userId, List<FilterOptionModel> users) {
|
||||
|
||||
1966
lib/modules/assets/presentation/screens/asset_form_screen.dart
Normal file
1966
lib/modules/assets/presentation/screens/asset_form_screen.dart
Normal file
File diff suppressed because it is too large
Load Diff
@ -22,14 +22,16 @@ import '../../../../shared/widgets/app_responsive_filter_bar.dart';
|
||||
import '../../../../shared/widgets/app_search_filter_toggle.dart';
|
||||
import '../../../../shared/widgets/app_status_chip.dart';
|
||||
import '../../../../shared/widgets/app_table_action_icon.dart';
|
||||
import '../../../../shared/widgets/app_table_column_selector.dart';
|
||||
import '../../../../shared/widgets/can_permission.dart';
|
||||
import '../../../../shared/providers/table_column_prefs_provider.dart';
|
||||
import '../../../../shared/widgets/error_view.dart';
|
||||
import '../../../../shared/widgets/page_header.dart';
|
||||
import '../../../../shared/utils/file_download_helper.dart';
|
||||
import '../providers/asset_categories_provider.dart';
|
||||
import '../providers/asset_form_lookups_provider.dart';
|
||||
import '../providers/assets_provider.dart';
|
||||
import '../widgets/asset_form_panel.dart';
|
||||
import 'asset_form_screen.dart';
|
||||
import '../../../../shared/widgets/app_toast.dart';
|
||||
|
||||
class AssetListScreen extends ConsumerStatefulWidget {
|
||||
@ -45,6 +47,10 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final assetsAsync = ref.watch(assetsListProvider);
|
||||
final filterLookups =
|
||||
ref.watch(assetListFilterLookupsProvider).valueOrNull;
|
||||
final allCategories =
|
||||
ref.watch(itemCategoriesProvider).valueOrNull ?? [];
|
||||
final canEdit = ref.can('assets', PermissionAction.update);
|
||||
final canDelete = ref.can('assets', PermissionAction.delete);
|
||||
final canExport = ref.can('assets', PermissionAction.export);
|
||||
@ -69,10 +75,8 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
|
||||
onRetry: () => ref.invalidate(assetsListProvider),
|
||||
),
|
||||
data: (state) {
|
||||
final allCategories =
|
||||
ref.watch(itemCategoriesProvider).valueOrNull ?? [];
|
||||
final lookups = ref.watch(assetFormLookupsProvider).valueOrNull;
|
||||
final allLocations = lookups?.locations ?? const [];
|
||||
final allLocations = filterLookups?.locations ?? const [];
|
||||
final statuses = filterLookups?.statuses ?? const [];
|
||||
final notifier = ref.read(assetsListProvider.notifier);
|
||||
|
||||
return Column(
|
||||
@ -89,6 +93,11 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
AppTableColumnSelectorButton(
|
||||
tableId: _AssetDataTable.tableId,
|
||||
columns: _AssetDataTable.columnOptions,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (canExport)
|
||||
OutlinedButton.icon(
|
||||
onPressed: state.isExporting ? null : _exportAssets,
|
||||
@ -112,7 +121,7 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
|
||||
module: 'assets',
|
||||
action: PermissionAction.create,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () => openAssetFormPanel(context, ref),
|
||||
onPressed: () => openAssetForm(context, ref),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Add Asset'),
|
||||
),
|
||||
@ -151,40 +160,27 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
|
||||
query: state.query,
|
||||
categories: allCategories,
|
||||
locations: allLocations,
|
||||
statuses: lookups?.statuses ?? const [],
|
||||
statuses: statuses,
|
||||
onSearch: notifier.setSearch,
|
||||
onCategoryChanged: notifier.setCategoryFilter,
|
||||
onLocationChanged: notifier.setLocationFilter,
|
||||
onStatusChanged: notifier.setStatusFilter,
|
||||
),
|
||||
),
|
||||
if (state.assets.isEmpty)
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Text(
|
||||
'No assets found',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyLarge
|
||||
?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Expanded(
|
||||
child: _AssetDataTable(
|
||||
assets: state.assets,
|
||||
canEdit: canEdit,
|
||||
canDelete: canDelete,
|
||||
onView: _viewAsset,
|
||||
onEdit: _editAsset,
|
||||
onDelete: _deleteAsset,
|
||||
),
|
||||
Expanded(
|
||||
child: _AssetDataTable(
|
||||
assets: state.assets,
|
||||
canEdit: canEdit,
|
||||
canDelete: canDelete,
|
||||
onView: _viewAsset,
|
||||
onEdit: _editAsset,
|
||||
onDelete: _deleteAsset,
|
||||
onEnsureFullDataset: () =>
|
||||
notifier.ensureColumnSearchDataset(),
|
||||
onColumnSearchCleared: () =>
|
||||
notifier.clearColumnSearchDataset(),
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
@ -193,6 +189,7 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
|
||||
totalPages: state.totalPages,
|
||||
totalItems: state.total,
|
||||
pageSize: state.query.limit,
|
||||
itemsOnPage: state.assets.length,
|
||||
itemLabel: 'assets',
|
||||
onPageChanged: notifier.setPage,
|
||||
onPageSizeChanged: notifier.setPageSize,
|
||||
@ -215,7 +212,7 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
|
||||
}
|
||||
|
||||
void _editAsset(AssetModel asset) {
|
||||
openAssetFormPanel(context, ref, assetId: asset.id);
|
||||
openAssetForm(context, ref, assetId: asset.id);
|
||||
}
|
||||
|
||||
Future<void> _exportAssets() async {
|
||||
@ -381,7 +378,7 @@ class _AssetsFilterBar extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _AssetDataTable extends StatelessWidget {
|
||||
class _AssetDataTable extends ConsumerWidget {
|
||||
const _AssetDataTable({
|
||||
required this.assets,
|
||||
required this.canEdit,
|
||||
@ -389,96 +386,177 @@ class _AssetDataTable extends StatelessWidget {
|
||||
required this.onView,
|
||||
required this.onEdit,
|
||||
required this.onDelete,
|
||||
this.onEnsureFullDataset,
|
||||
this.onColumnSearchCleared,
|
||||
});
|
||||
|
||||
static const tableId = 'assets_list';
|
||||
|
||||
static List<AppTableColumnOption> get columnOptions => const [
|
||||
AppTableColumnOption(
|
||||
id: 'asset_code',
|
||||
label: 'Asset Code',
|
||||
required: true,
|
||||
),
|
||||
AppTableColumnOption(id: 'asset_name', label: 'Asset Name'),
|
||||
AppTableColumnOption(id: 'category', label: 'Category'),
|
||||
AppTableColumnOption(id: 'location', label: 'Location'),
|
||||
AppTableColumnOption(id: 'current_value', label: 'Current Value'),
|
||||
AppTableColumnOption(
|
||||
id: 'depreciated_amount',
|
||||
label: 'Depreciated Amount',
|
||||
),
|
||||
AppTableColumnOption(
|
||||
id: 'warranty_validity',
|
||||
label: 'Warranty Validity',
|
||||
),
|
||||
AppTableColumnOption(id: 'status', label: 'Status'),
|
||||
];
|
||||
|
||||
final List<AssetModel> assets;
|
||||
final bool canEdit;
|
||||
final bool canDelete;
|
||||
final void Function(AssetModel asset) onView;
|
||||
final void Function(AssetModel asset) onEdit;
|
||||
final Future<void> Function(AssetModel asset) onDelete;
|
||||
final Future<void> Function()? onEnsureFullDataset;
|
||||
final VoidCallback? onColumnSearchCleared;
|
||||
|
||||
List<AppDataColumn<AssetModel>> _allColumns() {
|
||||
return [
|
||||
AppDataColumn(
|
||||
id: 'asset_code',
|
||||
label: 'Asset Code',
|
||||
sortKey: 'asset_code',
|
||||
locked: true,
|
||||
flex: 1,
|
||||
searchText: (asset) => asset.assetCode ?? '',
|
||||
cellBuilder: (_, asset) {
|
||||
final code = asset.assetCode;
|
||||
if (code == null || code.isEmpty) return const Text('—');
|
||||
return AppTableCell.link(code, onTap: () => onView(asset));
|
||||
},
|
||||
),
|
||||
AppDataColumn(
|
||||
id: 'asset_name',
|
||||
label: 'Asset Name',
|
||||
sortKey: 'asset_name',
|
||||
flex: 2,
|
||||
searchText: (asset) => asset.assetName,
|
||||
cellBuilder: (_, asset) => Text(asset.assetName),
|
||||
),
|
||||
AppDataColumn(
|
||||
id: 'category',
|
||||
label: 'Category',
|
||||
sortKey: 'category',
|
||||
flex: 2,
|
||||
searchText: (asset) => asset.assetCategoryName ?? '',
|
||||
cellBuilder: (_, asset) => Text(asset.assetCategoryName ?? '—'),
|
||||
),
|
||||
AppDataColumn(
|
||||
id: 'location',
|
||||
label: 'Location',
|
||||
sortKey: 'location',
|
||||
flex: 1,
|
||||
searchText: (asset) => asset.locationName ?? '',
|
||||
cellBuilder: (_, asset) => Text(asset.locationName ?? '—'),
|
||||
),
|
||||
AppDataColumn(
|
||||
id: 'current_value',
|
||||
label: 'Current Value',
|
||||
sortKey: 'current_value',
|
||||
flex: 1,
|
||||
searchText: (asset) =>
|
||||
CurrencyFormatter.searchable(asset.resolvedCurrentValue),
|
||||
sortValue: (asset) => asset.resolvedCurrentValue,
|
||||
cellBuilder: (_, asset) => Text(
|
||||
asset.resolvedCurrentValue != null
|
||||
? CurrencyFormatter.format(asset.resolvedCurrentValue)
|
||||
: '—',
|
||||
),
|
||||
),
|
||||
AppDataColumn(
|
||||
id: 'depreciated_amount',
|
||||
label: 'Depreciated Amount',
|
||||
sortKey: 'depreciated_amount',
|
||||
flex: 1,
|
||||
searchText: (asset) =>
|
||||
CurrencyFormatter.searchable(asset.depreciatedAmount),
|
||||
sortValue: (asset) => asset.depreciatedAmount,
|
||||
cellBuilder: (_, asset) => Text(
|
||||
asset.depreciatedAmount != null
|
||||
? CurrencyFormatter.format(asset.depreciatedAmount)
|
||||
: '—',
|
||||
),
|
||||
),
|
||||
AppDataColumn(
|
||||
id: 'warranty_validity',
|
||||
label: 'Warranty Validity',
|
||||
sortKey: 'warranty_validity',
|
||||
flex: 1,
|
||||
searchText: (asset) =>
|
||||
DateFormatter.searchableDate(asset.warrantyExpiryDate),
|
||||
sortValue: (asset) => asset.warrantyExpiryDate,
|
||||
cellBuilder: (_, asset) => Text(
|
||||
DateFormatter.displayDate(asset.warrantyExpiryDate),
|
||||
),
|
||||
),
|
||||
AppDataColumn(
|
||||
id: 'status',
|
||||
label: 'Status',
|
||||
sortKey: 'status',
|
||||
flex: 1,
|
||||
searchText: (asset) =>
|
||||
asset.status?.toLowerCase().replaceAll(' ', '_') ?? 'active',
|
||||
cellBuilder: (_, asset) => AppStatusChip(
|
||||
status: asset.status?.toLowerCase().replaceAll(' ', '_') ?? 'active',
|
||||
compact: true,
|
||||
forTable: true,
|
||||
),
|
||||
),
|
||||
AppDataColumn(
|
||||
id: 'actions',
|
||||
label: 'Actions',
|
||||
flex: 1,
|
||||
alignment: Alignment.centerRight,
|
||||
enableSearch: false,
|
||||
locked: true,
|
||||
includeInColumnSelector: false,
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final prefs = ref.watch(tableColumnPrefsProvider(tableId));
|
||||
final columns = resolveAppDataColumns(_allColumns(), prefs);
|
||||
|
||||
return AppDataTable<AssetModel>(
|
||||
wrapInCard: false,
|
||||
columns: [
|
||||
AppDataColumn(
|
||||
label: 'Asset Code',
|
||||
flex: 1,
|
||||
searchText: (asset) => asset.assetCode ?? '',
|
||||
cellBuilder: (_, asset) {
|
||||
final code = asset.assetCode;
|
||||
if (code == null || code.isEmpty) return const Text('—');
|
||||
return _AssetCodeBadge(code: code);
|
||||
},
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Asset Name',
|
||||
flex: 2,
|
||||
searchText: (asset) => asset.assetName,
|
||||
cellBuilder: (_, asset) => Text(asset.assetName),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Category',
|
||||
flex: 2,
|
||||
searchText: (asset) => asset.assetCategoryName ?? '',
|
||||
cellBuilder: (_, asset) => Text(asset.assetCategoryName ?? '—'),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Location',
|
||||
flex: 1,
|
||||
searchText: (asset) => asset.locationName ?? '',
|
||||
cellBuilder: (_, asset) => Text(asset.locationName ?? '—'),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Warranty',
|
||||
flex: 1,
|
||||
searchText: (asset) =>
|
||||
DateFormatter.displayDate(asset.warrantyExpiryDate),
|
||||
cellBuilder: (_, asset) => Text(
|
||||
DateFormatter.displayDate(asset.warrantyExpiryDate),
|
||||
),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Status',
|
||||
flex: 1,
|
||||
searchText: (asset) =>
|
||||
asset.status?.toLowerCase().replaceAll(' ', '_') ?? 'active',
|
||||
cellBuilder: (_, asset) => AppStatusChip(
|
||||
status: asset.status?.toLowerCase().replaceAll(' ', '_') ?? 'active',
|
||||
compact: true,
|
||||
forTable: true,
|
||||
),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Actions',
|
||||
flex: 1,
|
||||
alignment: Alignment.centerRight,
|
||||
enableSearch: false,
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
onEnsureFullDataset: onEnsureFullDataset,
|
||||
onColumnSearchCleared: onColumnSearchCleared,
|
||||
columns: columns,
|
||||
rows: assets,
|
||||
);
|
||||
}
|
||||
@ -540,7 +618,10 @@ class _AssetMobileList extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
if (asset.assetCode != null && asset.assetCode!.isNotEmpty)
|
||||
_AssetCodeBadge(code: asset.assetCode!)
|
||||
AppTableCell.link(
|
||||
asset.assetCode!,
|
||||
onTap: () => onView(asset),
|
||||
)
|
||||
else
|
||||
const Text('—'),
|
||||
Text('${asset.assetCategoryName ?? '—'} · ${asset.locationName ?? '—'}'),
|
||||
@ -575,28 +656,3 @@ class _AssetMobileList extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AssetCodeBadge extends StatelessWidget {
|
||||
const _AssetCodeBadge({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: AppTableCell.text(
|
||||
code,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,23 +8,44 @@ import '../../../../core/errors/failure.dart';
|
||||
import '../../../../core/utils/formatters.dart';
|
||||
import '../../../../core/utils/responsive_utils.dart';
|
||||
import '../../../../shared/models/asset_model.dart';
|
||||
import '../../../../shared/providers/table_column_prefs_provider.dart';
|
||||
import '../../../../shared/widgets/app_data_table.dart';
|
||||
import '../../../../shared/widgets/app_empty_state.dart';
|
||||
import '../../../../shared/widgets/app_form_toggle_field.dart';
|
||||
import '../../../../shared/widgets/app_loading_view.dart';
|
||||
import '../../../../shared/widgets/app_pagination.dart';
|
||||
import '../../../../shared/widgets/app_table_action_icon.dart';
|
||||
import '../../../../shared/widgets/app_table_column_selector.dart';
|
||||
import '../../../../shared/widgets/app_toast.dart';
|
||||
import '../../../../shared/widgets/error_view.dart';
|
||||
import '../../../../shared/widgets/page_header.dart';
|
||||
import '../providers/assets_provider.dart';
|
||||
import '../utils/maintenance_due_display.dart';
|
||||
import '../widgets/asset_maintenance_panel.dart';
|
||||
|
||||
class AssetMaintenanceScreen extends ConsumerWidget {
|
||||
class AssetMaintenanceScreen extends ConsumerStatefulWidget {
|
||||
const AssetMaintenanceScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ConsumerState<AssetMaintenanceScreen> createState() =>
|
||||
_AssetMaintenanceScreenState();
|
||||
}
|
||||
|
||||
class _AssetMaintenanceScreenState
|
||||
extends ConsumerState<AssetMaintenanceScreen> {
|
||||
bool _requestedFreshLoad = false;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_requestedFreshLoad) return;
|
||||
_requestedFreshLoad = true;
|
||||
// Always refetch My Maintenance when opening this screen.
|
||||
ref.invalidate(myMaintenanceProvider);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final maintenanceAsync = ref.watch(myMaintenanceProvider);
|
||||
|
||||
return Padding(
|
||||
@ -53,7 +74,10 @@ class _MyMaintenanceBody extends ConsumerWidget {
|
||||
AssetModel asset,
|
||||
) async {
|
||||
final saved = await openSubmitMaintenancePanel(context, ref, asset: asset);
|
||||
if (saved == true && context.mounted) {
|
||||
if (!context.mounted) return;
|
||||
if (saved == true) {
|
||||
await ref.read(myMaintenanceProvider.notifier).refresh();
|
||||
if (!context.mounted) return;
|
||||
showAppToastFromSnackBar(
|
||||
context,
|
||||
const SnackBar(content: Text('Maintenance log submitted')),
|
||||
@ -73,6 +97,11 @@ class _MyMaintenanceBody extends ConsumerWidget {
|
||||
title: 'My Maintenance',
|
||||
subtitle: 'Assets assigned to you for checklist-based maintenance',
|
||||
actions: [
|
||||
AppTableColumnSelectorButton(
|
||||
tableId: _MaintenanceTable.tableId,
|
||||
columns: _MaintenanceTable.columnOptions,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => context.go(RouteConstants.assets),
|
||||
icon: const Icon(Icons.inventory_2_outlined),
|
||||
@ -142,6 +171,7 @@ class _MyMaintenanceBody extends ConsumerWidget {
|
||||
totalPages: state.totalPages,
|
||||
totalItems: state.total,
|
||||
pageSize: state.limit,
|
||||
itemsOnPage: state.assets.length,
|
||||
onPageChanged: notifier.setPage,
|
||||
onPageSizeChanged: notifier.setPageSize,
|
||||
itemLabel: 'assets',
|
||||
@ -151,80 +181,116 @@ class _MyMaintenanceBody extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _MaintenanceTable extends StatelessWidget {
|
||||
class _MaintenanceTable extends ConsumerWidget {
|
||||
const _MaintenanceTable({
|
||||
required this.assets,
|
||||
required this.onSubmit,
|
||||
required this.onView,
|
||||
});
|
||||
|
||||
static const tableId = 'my_maintenance';
|
||||
|
||||
static List<AppTableColumnOption> get columnOptions => const [
|
||||
AppTableColumnOption(
|
||||
id: 'asset_code',
|
||||
label: 'Asset Code',
|
||||
required: true,
|
||||
),
|
||||
AppTableColumnOption(id: 'asset_name', label: 'Asset Name'),
|
||||
AppTableColumnOption(id: 'next_due', label: 'Next Due'),
|
||||
AppTableColumnOption(id: 'status', label: 'Status'),
|
||||
];
|
||||
|
||||
final List<AssetModel> assets;
|
||||
final void Function(AssetModel asset) onSubmit;
|
||||
final void Function(AssetModel asset) onView;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
List<AppDataColumn<AssetModel>> _allColumns() {
|
||||
final dateFormat = DateFormat('dd MMM yyyy');
|
||||
|
||||
return [
|
||||
AppDataColumn(
|
||||
id: 'asset_code',
|
||||
label: 'Asset Code',
|
||||
sortKey: 'asset_code',
|
||||
locked: true,
|
||||
flex: 1,
|
||||
searchText: (asset) => asset.assetCode ?? '',
|
||||
cellBuilder: (_, asset) => AppTableCell.link(
|
||||
asset.assetCode,
|
||||
onTap: () => onView(asset),
|
||||
),
|
||||
),
|
||||
AppDataColumn(
|
||||
id: 'asset_name',
|
||||
label: 'Asset Name',
|
||||
sortKey: 'asset_name',
|
||||
flex: 2,
|
||||
searchText: (asset) => asset.assetName,
|
||||
cellBuilder: (_, asset) => Text(asset.assetName),
|
||||
),
|
||||
AppDataColumn(
|
||||
id: 'next_due',
|
||||
label: 'Next Due',
|
||||
sortKey: 'next_due',
|
||||
flex: 1,
|
||||
searchText: (asset) => DateFormatter.displayDate(
|
||||
asset.maintenance?.nextDueDate,
|
||||
),
|
||||
sortValue: (asset) => asset.maintenance?.nextDueDate,
|
||||
cellBuilder: (_, asset) {
|
||||
final nextDue = asset.maintenance?.nextDueDate;
|
||||
return Text(
|
||||
nextDue != null ? dateFormat.format(nextDue) : '—',
|
||||
);
|
||||
},
|
||||
),
|
||||
AppDataColumn(
|
||||
id: 'status',
|
||||
label: 'Status',
|
||||
sortKey: 'status',
|
||||
flex: 1,
|
||||
searchText: (asset) =>
|
||||
asset.maintenance?.isDue == true ? 'due' : 'ok',
|
||||
cellBuilder: (_, asset) => _DueBadge(
|
||||
isDue: asset.maintenance?.isDue == true,
|
||||
daysUntilDue: asset.maintenance?.daysUntilDue,
|
||||
),
|
||||
),
|
||||
AppDataColumn(
|
||||
id: 'actions',
|
||||
label: 'Actions',
|
||||
flex: 1,
|
||||
alignment: Alignment.centerRight,
|
||||
enableSearch: false,
|
||||
locked: true,
|
||||
includeInColumnSelector: false,
|
||||
cellBuilder: (_, asset) => AppTableActions(
|
||||
children: [
|
||||
AppTableActionIcon(
|
||||
tooltip: 'Submit checklist',
|
||||
icon: Icons.checklist_outlined,
|
||||
onPressed: () => onSubmit(asset),
|
||||
),
|
||||
AppTableActionIcon(
|
||||
tooltip: 'View asset',
|
||||
icon: Icons.visibility_outlined,
|
||||
onPressed: () => onView(asset),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final prefs = ref.watch(tableColumnPrefsProvider(tableId));
|
||||
final columns = resolveAppDataColumns(_allColumns(), prefs);
|
||||
|
||||
return AppDataTable<AssetModel>(
|
||||
wrapInCard: false,
|
||||
columns: [
|
||||
AppDataColumn(
|
||||
label: 'Asset Code',
|
||||
flex: 1,
|
||||
searchText: (asset) => asset.assetCode ?? '',
|
||||
cellBuilder: (_, asset) => Text(asset.assetCode ?? '—'),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Asset Name',
|
||||
flex: 2,
|
||||
searchText: (asset) => asset.assetName,
|
||||
cellBuilder: (_, asset) => Text(asset.assetName),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Next Due',
|
||||
flex: 1,
|
||||
searchText: (asset) => DateFormatter.displayDate(
|
||||
asset.maintenance?.nextDueDate,
|
||||
),
|
||||
cellBuilder: (_, asset) {
|
||||
final nextDue = asset.maintenance?.nextDueDate;
|
||||
return Text(
|
||||
nextDue != null ? dateFormat.format(nextDue) : '—',
|
||||
);
|
||||
},
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Status',
|
||||
flex: 1,
|
||||
searchText: (asset) =>
|
||||
asset.maintenance?.isDue == true ? 'due' : 'ok',
|
||||
cellBuilder: (_, asset) => _DueBadge(
|
||||
isDue: asset.maintenance?.isDue == true,
|
||||
daysUntilDue: asset.maintenance?.daysUntilDue,
|
||||
),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Actions',
|
||||
flex: 1,
|
||||
alignment: Alignment.centerRight,
|
||||
enableSearch: false,
|
||||
cellBuilder: (_, asset) => AppTableActions(
|
||||
children: [
|
||||
AppTableActionIcon(
|
||||
tooltip: 'Submit checklist',
|
||||
icon: Icons.checklist_outlined,
|
||||
onPressed: () => onSubmit(asset),
|
||||
),
|
||||
AppTableActionIcon(
|
||||
tooltip: 'View asset',
|
||||
icon: Icons.visibility_outlined,
|
||||
onPressed: () => onView(asset),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
columns: columns,
|
||||
rows: assets,
|
||||
);
|
||||
}
|
||||
@ -326,16 +392,10 @@ class _DueBadge extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final color = isDue
|
||||
? const Color(0xFFDC2626)
|
||||
: const Color(0xFF16A34A);
|
||||
final label = isDue
|
||||
? (daysUntilDue != null && daysUntilDue! < 0
|
||||
? 'Overdue'
|
||||
: 'Due')
|
||||
: (daysUntilDue != null
|
||||
? 'In $daysUntilDue day${daysUntilDue == 1 ? '' : 's'}'
|
||||
: 'On track');
|
||||
final due = MaintenanceDueDisplay.fromDaysUntilDue(daysUntilDue);
|
||||
final color = due?.color ??
|
||||
(isDue ? const Color(0xFFDC2626) : const Color(0xFF16A34A));
|
||||
final label = due?.label ?? (isDue ? 'Due' : 'On track');
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
|
||||
@ -0,0 +1,38 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Display copy + color for maintenance `days_until_due` from the API.
|
||||
class MaintenanceDueDisplay {
|
||||
const MaintenanceDueDisplay({
|
||||
required this.label,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final Color color;
|
||||
|
||||
/// Green/neutral upcoming, amber due today, red overdue.
|
||||
static MaintenanceDueDisplay? fromDaysUntilDue(int? daysUntilDue) {
|
||||
if (daysUntilDue == null) return null;
|
||||
|
||||
if (daysUntilDue > 0) {
|
||||
return MaintenanceDueDisplay(
|
||||
label:
|
||||
'Due in $daysUntilDue day${daysUntilDue == 1 ? '' : 's'}',
|
||||
color: const Color(0xFF16A34A),
|
||||
);
|
||||
}
|
||||
|
||||
if (daysUntilDue == 0) {
|
||||
return const MaintenanceDueDisplay(
|
||||
label: 'Due today',
|
||||
color: Color(0xFFD97706),
|
||||
);
|
||||
}
|
||||
|
||||
final overdueBy = daysUntilDue.abs();
|
||||
return MaintenanceDueDisplay(
|
||||
label: 'Overdue by $overdueBy day${overdueBy == 1 ? '' : 's'}',
|
||||
color: const Color(0xFFDC2626),
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -4,6 +4,7 @@ import 'package:intl/intl.dart';
|
||||
|
||||
import '../../../../core/utils/formatters.dart';
|
||||
import '../../../../shared/models/asset_model.dart';
|
||||
import '../../../../shared/widgets/api_feedback.dart';
|
||||
import '../../../../shared/widgets/app_button.dart';
|
||||
import '../../../../shared/widgets/app_date_popup.dart';
|
||||
import '../../../../shared/widgets/app_dropdown.dart';
|
||||
@ -45,13 +46,14 @@ class SubmitMaintenanceLogPanel extends ConsumerStatefulWidget {
|
||||
|
||||
class _ChecklistRowState {
|
||||
_ChecklistRowState({
|
||||
required this.keyName,
|
||||
required this.label,
|
||||
});
|
||||
required this.required,
|
||||
}) : status = required ? null : 'OK';
|
||||
|
||||
final String keyName;
|
||||
final String label;
|
||||
String status = 'OK';
|
||||
final bool required;
|
||||
/// Null until the user picks a status (required items must choose explicitly).
|
||||
String? status;
|
||||
final TextEditingController remarksController = TextEditingController();
|
||||
|
||||
void dispose() => remarksController.dispose();
|
||||
@ -64,20 +66,17 @@ class _SubmitMaintenanceLogPanelState
|
||||
DateTime _performedDate = DateTime.now();
|
||||
late final List<_ChecklistRowState> _rows;
|
||||
bool _isSubmitting = false;
|
||||
bool _showLogs = false;
|
||||
List<AssetMaintenanceLogModel>? _logs;
|
||||
bool _logsLoading = false;
|
||||
String? _logsError;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final checklist = checklistForAsset(widget.asset);
|
||||
_rows = checklist
|
||||
.where((item) => item.label.trim().isNotEmpty)
|
||||
.map(
|
||||
(item) => _ChecklistRowState(
|
||||
keyName: item.key.isNotEmpty ? item.key : item.label,
|
||||
label: item.label.isNotEmpty ? item.label : item.key,
|
||||
label: item.label.trim(),
|
||||
required: item.required,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
@ -105,27 +104,6 @@ class _SubmitMaintenanceLogPanelState
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadLogs() async {
|
||||
setState(() {
|
||||
_showLogs = true;
|
||||
_logsLoading = true;
|
||||
_logsError = null;
|
||||
});
|
||||
final result = await ref
|
||||
.read(assetRepositoryProvider)
|
||||
.getMaintenanceLogs(widget.asset.id);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_logsLoading = false;
|
||||
if (result.failure != null) {
|
||||
_logsError = result.failure!.message;
|
||||
_logs = null;
|
||||
} else {
|
||||
_logs = result.data ?? [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
if (_rows.isEmpty) {
|
||||
@ -133,17 +111,34 @@ class _SubmitMaintenanceLogPanelState
|
||||
return;
|
||||
}
|
||||
|
||||
final missingRequired = _rows.where((row) {
|
||||
if (!row.required) return false;
|
||||
final statusMissing = row.status == null || row.status!.trim().isEmpty;
|
||||
final remarksMissing = row.remarksController.text.trim().isEmpty;
|
||||
return statusMissing || remarksMissing;
|
||||
}).toList();
|
||||
if (missingRequired.isNotEmpty) {
|
||||
showSidePanelSnackBar(
|
||||
context,
|
||||
'Complete required checklist items: ${missingRequired.map((r) => r.label).join(', ')}',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isSubmitting = true);
|
||||
try {
|
||||
final payload = <String, dynamic>{
|
||||
'performed_date': DateFormatter.toApiDate(_performedDate),
|
||||
'checklist_json': _rows
|
||||
.map(
|
||||
(row) => {
|
||||
'key': row.keyName,
|
||||
'status': row.status,
|
||||
if (row.remarksController.text.trim().isNotEmpty)
|
||||
'remarks': row.remarksController.text.trim(),
|
||||
(row) {
|
||||
final remarks = row.remarksController.text.trim();
|
||||
return <String, dynamic>{
|
||||
'label': row.label,
|
||||
'status': row.status,
|
||||
'remarks': remarks.isEmpty ? null : remarks,
|
||||
'required': row.required,
|
||||
};
|
||||
},
|
||||
)
|
||||
.toList(),
|
||||
@ -162,7 +157,7 @@ class _SubmitMaintenanceLogPanelState
|
||||
if (mounted) Navigator.of(context, rootNavigator: true).pop(true);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
showSidePanelSnackBar(context, e.toString());
|
||||
showSidePanelApiError(context, e);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isSubmitting = false);
|
||||
@ -263,61 +258,7 @@ class _SubmitMaintenanceLogPanelState
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'Recent Logs',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
TextButton.icon(
|
||||
onPressed: _logsLoading
|
||||
? null
|
||||
: () {
|
||||
if (_showLogs && _logs != null) {
|
||||
setState(() => _showLogs = !_showLogs);
|
||||
} else {
|
||||
_loadLogs();
|
||||
}
|
||||
},
|
||||
icon: Icon(
|
||||
_showLogs ? Icons.expand_less : Icons.history,
|
||||
size: 18,
|
||||
),
|
||||
label: Text(_showLogs ? 'Hide' : 'View'),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_showLogs) ...[
|
||||
const SizedBox(height: 8),
|
||||
if (_logsLoading)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
else if (_logsError != null)
|
||||
Text(
|
||||
_logsError!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
)
|
||||
else if (_logs == null || _logs!.isEmpty)
|
||||
const AppEmptyState(
|
||||
title: 'No logs yet',
|
||||
description: 'Submitted maintenance visits will appear here.',
|
||||
icon: Icons.history_toggle_off_outlined,
|
||||
)
|
||||
else
|
||||
..._logs!.take(5).map(
|
||||
(log) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: _MaintenanceLogTile(log: log),
|
||||
),
|
||||
),
|
||||
],
|
||||
AssetRecentMaintenanceLogsSection(assetId: widget.asset.id),
|
||||
],
|
||||
),
|
||||
),
|
||||
@ -325,6 +266,133 @@ class _SubmitMaintenanceLogPanelState
|
||||
}
|
||||
}
|
||||
|
||||
/// Expandable recent maintenance logs (asset detail + submit panel).
|
||||
class AssetRecentMaintenanceLogsSection extends ConsumerStatefulWidget {
|
||||
const AssetRecentMaintenanceLogsSection({
|
||||
super.key,
|
||||
required this.assetId,
|
||||
this.limit = 5,
|
||||
this.leadingActions,
|
||||
this.trailingActions,
|
||||
});
|
||||
|
||||
final String assetId;
|
||||
final int limit;
|
||||
|
||||
/// Optional actions shown before Recent Logs (e.g. Log Maintenance).
|
||||
final List<Widget>? leadingActions;
|
||||
|
||||
/// Optional actions shown after Recent Logs (e.g. Transfer History).
|
||||
final List<Widget>? trailingActions;
|
||||
|
||||
@override
|
||||
ConsumerState<AssetRecentMaintenanceLogsSection> createState() =>
|
||||
_AssetRecentMaintenanceLogsSectionState();
|
||||
}
|
||||
|
||||
class _AssetRecentMaintenanceLogsSectionState
|
||||
extends ConsumerState<AssetRecentMaintenanceLogsSection> {
|
||||
bool _showLogs = false;
|
||||
List<AssetMaintenanceLogModel>? _logs;
|
||||
bool _logsLoading = false;
|
||||
String? _logsError;
|
||||
|
||||
Future<void> _loadLogs() async {
|
||||
setState(() {
|
||||
_showLogs = true;
|
||||
_logsLoading = true;
|
||||
_logsError = null;
|
||||
});
|
||||
final result = await ref
|
||||
.read(assetRepositoryProvider)
|
||||
.getMaintenanceLogs(widget.assetId);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_logsLoading = false;
|
||||
if (result.failure != null) {
|
||||
_logsError = result.failure!.message;
|
||||
_logs = null;
|
||||
} else {
|
||||
_logs = result.data ?? [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _toggle() {
|
||||
if (_showLogs && _logs != null) {
|
||||
setState(() => _showLogs = !_showLogs);
|
||||
return;
|
||||
}
|
||||
_loadLogs();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Refresh open logs after a new maintenance submit updates the asset.
|
||||
ref.listen(assetDetailProvider(widget.assetId), (previous, next) {
|
||||
if (_showLogs && previous != next) {
|
||||
_loadLogs();
|
||||
}
|
||||
});
|
||||
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 8,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
...?widget.leadingActions,
|
||||
OutlinedButton.icon(
|
||||
onPressed: _logsLoading ? null : _toggle,
|
||||
icon: Icon(
|
||||
_showLogs ? Icons.expand_less : Icons.history,
|
||||
size: 18,
|
||||
),
|
||||
label: const Text('Recent Logs'),
|
||||
),
|
||||
...?widget.trailingActions,
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_showLogs) ...[
|
||||
const SizedBox(height: 12),
|
||||
if (_logsLoading)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
else if (_logsError != null)
|
||||
Text(
|
||||
_logsError!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
)
|
||||
else if (_logs == null || _logs!.isEmpty)
|
||||
const AppEmptyState(
|
||||
title: 'No logs yet',
|
||||
description: 'Submitted maintenance visits will appear here.',
|
||||
icon: Icons.history_toggle_off_outlined,
|
||||
)
|
||||
else
|
||||
..._logs!.take(widget.limit).map(
|
||||
(log) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: _MaintenanceLogTile(log: log),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DateField extends StatelessWidget {
|
||||
const _DateField({
|
||||
required this.label,
|
||||
@ -376,27 +444,37 @@ class _ChecklistItemCard extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
row.label,
|
||||
row.required ? '${row.label} *' : row.label,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
AppDropdown<String>(
|
||||
label: 'Status',
|
||||
label: row.required ? 'Status *' : 'Status',
|
||||
isDense: true,
|
||||
value: row.status,
|
||||
hint: 'Select status',
|
||||
options: const [
|
||||
AppDropdownOption(value: 'OK', label: 'OK'),
|
||||
AppDropdownOption(value: 'NOT_OK', label: 'Not OK'),
|
||||
AppDropdownOption(value: 'NA', label: 'N/A'),
|
||||
],
|
||||
onChanged: onStatusChanged,
|
||||
validator: row.required
|
||||
? (v) =>
|
||||
(v == null || v.trim().isEmpty) ? 'Status is required' : null
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
AppTextField(
|
||||
controller: row.remarksController,
|
||||
label: 'Item remarks',
|
||||
label: row.required ? 'Item remarks *' : 'Item remarks',
|
||||
validator: row.required
|
||||
? (v) => (v == null || v.trim().isEmpty)
|
||||
? 'Remarks are required for this checklist item'
|
||||
: null
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -454,12 +532,19 @@ class _MaintenanceLogTile extends StatelessWidget {
|
||||
spacing: 6,
|
||||
runSpacing: 4,
|
||||
children: log.checklistJson.map((item) {
|
||||
final key = item['key']?.toString() ?? 'Item';
|
||||
final status = item['status']?.toString() ?? '—';
|
||||
final label = item['label']?.toString().trim();
|
||||
final rawStatus = item['status']?.toString().trim() ?? '—';
|
||||
final status = rawStatus.replaceAll('_', ' ');
|
||||
final remarks = item['remarks']?.toString().trim();
|
||||
final title =
|
||||
(label != null && label.isNotEmpty) ? label : 'Item';
|
||||
final chipText = (remarks != null && remarks.isNotEmpty)
|
||||
? '$title: $status — $remarks'
|
||||
: '$title: $status';
|
||||
return Chip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
label: Text(
|
||||
'$key: $status',
|
||||
chipText,
|
||||
style: theme.textTheme.labelSmall,
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
|
||||
@ -5,6 +5,8 @@ import '../../../../core/errors/failure.dart';
|
||||
import '../../../../core/utils/formatters.dart';
|
||||
import '../../../../core/utils/validators.dart';
|
||||
import '../../../../shared/models/asset_model.dart';
|
||||
import '../../../../shared/models/user_management_models.dart'
|
||||
show FilterOptionModel;
|
||||
import '../../../../shared/widgets/api_feedback.dart';
|
||||
import '../../../../shared/widgets/app_button.dart';
|
||||
import '../../../../shared/widgets/app_card.dart';
|
||||
@ -16,7 +18,6 @@ import '../../../../shared/widgets/app_searchable_dropdown.dart';
|
||||
import '../../../../shared/widgets/app_side_panel.dart';
|
||||
import '../../../../shared/widgets/app_text_field.dart';
|
||||
import '../../../../shared/widgets/error_view.dart';
|
||||
import '../../../master_data/presentation/widgets/master_quick_add.dart';
|
||||
import '../providers/asset_form_lookups_provider.dart';
|
||||
import '../providers/assets_provider.dart';
|
||||
|
||||
@ -83,7 +84,8 @@ class _AddAmcPanelState extends ConsumerState<AddAmcPanel> {
|
||||
_endDate = contract.endDate;
|
||||
_renewalDate = contract.renewalDate;
|
||||
if (contract.annualCost != null) {
|
||||
_annualCostController.text = contract.annualCost.toString();
|
||||
_annualCostController.text =
|
||||
CurrencyFormatter.formatEditable(contract.annualCost);
|
||||
}
|
||||
_paymentFrequency = contract.paymentFrequency;
|
||||
_serviceFrequency = contract.serviceFrequency;
|
||||
@ -100,7 +102,7 @@ class _AddAmcPanelState extends ConsumerState<AddAmcPanel> {
|
||||
}
|
||||
|
||||
Map<String, dynamic> _buildPayload() {
|
||||
final annualCost = double.tryParse(_annualCostController.text.trim());
|
||||
final annualCost = CurrencyFormatter.tryParse(_annualCostController.text);
|
||||
final visitsPerYear = int.tryParse(_visitsPerYearController.text.trim());
|
||||
return {
|
||||
'vendor_id': _vendorId,
|
||||
@ -136,12 +138,20 @@ class _AddAmcPanelState extends ConsumerState<AddAmcPanel> {
|
||||
Future<void> _pickDate({
|
||||
required DateTime? current,
|
||||
required void Function(DateTime date) onPicked,
|
||||
DateTime? firstDate,
|
||||
DateTime? lastDate,
|
||||
}) async {
|
||||
final first = firstDate ?? DateTime(2000);
|
||||
final last = lastDate ?? DateTime(2100);
|
||||
var initial = current ?? DateTime.now();
|
||||
if (initial.isBefore(first)) initial = first;
|
||||
if (initial.isAfter(last)) initial = last;
|
||||
|
||||
final picked = await showAppDatePopup(
|
||||
context: context,
|
||||
initialDate: current ?? DateTime.now(),
|
||||
firstDate: DateTime(2000),
|
||||
lastDate: DateTime(2100),
|
||||
initialDate: initial,
|
||||
firstDate: first,
|
||||
lastDate: last,
|
||||
helpText: 'Select date',
|
||||
);
|
||||
if (picked != null) {
|
||||
@ -149,12 +159,38 @@ class _AddAmcPanelState extends ConsumerState<AddAmcPanel> {
|
||||
}
|
||||
}
|
||||
|
||||
void _onStartDatePicked(DateTime date) {
|
||||
final clearedEnd = _endDate != null &&
|
||||
DateTime(_endDate!.year, _endDate!.month, _endDate!.day)
|
||||
.isBefore(DateTime(date.year, date.month, date.day));
|
||||
_startDate = date;
|
||||
if (clearedEnd) {
|
||||
_endDate = null;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
showSidePanelSnackBar(
|
||||
context,
|
||||
'End date cleared. Please select an end date on or after the start date.',
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
if (_startDate == null || _endDate == null) {
|
||||
showSidePanelSnackBar(context, 'Please select start and end dates');
|
||||
return;
|
||||
}
|
||||
if (DateTime(_endDate!.year, _endDate!.month, _endDate!.day).isBefore(
|
||||
DateTime(_startDate!.year, _startDate!.month, _startDate!.day),
|
||||
)) {
|
||||
showSidePanelSnackBar(
|
||||
context,
|
||||
'End date cannot be earlier than start date',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_vendorId == null) {
|
||||
showSidePanelSnackBar(context, 'Please select a vendor');
|
||||
@ -173,7 +209,7 @@ class _AddAmcPanelState extends ConsumerState<AddAmcPanel> {
|
||||
if (mounted) Navigator.of(context, rootNavigator: true).pop(true);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
showSidePanelSnackBar(context, e.toString());
|
||||
showSidePanelApiError(context, e);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isSubmitting = false);
|
||||
@ -263,7 +299,7 @@ class _AddAmcPanelState extends ConsumerState<AddAmcPanel> {
|
||||
value: _startDate,
|
||||
onPick: () => _pickDate(
|
||||
current: _startDate,
|
||||
onPicked: (date) => setState(() => _startDate = date),
|
||||
onPicked: _onStartDatePicked,
|
||||
),
|
||||
),
|
||||
right: _SidePanelDateField(
|
||||
@ -272,7 +308,8 @@ class _AddAmcPanelState extends ConsumerState<AddAmcPanel> {
|
||||
value: _endDate,
|
||||
onPick: () => _pickDate(
|
||||
current: _endDate,
|
||||
onPicked: (date) => setState(() => _endDate = date),
|
||||
firstDate: _startDate ?? DateTime(2000),
|
||||
onPicked: (date) => _endDate = date,
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -283,13 +320,18 @@ class _AddAmcPanelState extends ConsumerState<AddAmcPanel> {
|
||||
value: _renewalDate,
|
||||
onPick: () => _pickDate(
|
||||
current: _renewalDate,
|
||||
onPicked: (date) => setState(() => _renewalDate = date),
|
||||
onPicked: (date) => _renewalDate = date,
|
||||
),
|
||||
),
|
||||
right: AppTextField(
|
||||
controller: _annualCostController,
|
||||
label: 'Annual Cost',
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: CurrencyFormatter.amountInput,
|
||||
validator: (v) => Validators.optionalNonNegativeDouble(
|
||||
v,
|
||||
fieldName: 'Annual Cost',
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
@ -386,7 +428,7 @@ class _AddAmcPanelState extends ConsumerState<AddAmcPanel> {
|
||||
footer: _panelFooter(
|
||||
context,
|
||||
isSubmitting: true,
|
||||
saveLabel: 'Edit AMC Contract',
|
||||
saveLabel: 'Update AMC Contract',
|
||||
onSave: () {},
|
||||
),
|
||||
child: const Center(child: CircularProgressIndicator()),
|
||||
@ -402,7 +444,7 @@ class _AddAmcPanelState extends ConsumerState<AddAmcPanel> {
|
||||
footer: _panelFooter(
|
||||
context,
|
||||
isSubmitting: _isSubmitting,
|
||||
saveLabel: 'Edit AMC Contract',
|
||||
saveLabel: 'Update AMC Contract',
|
||||
onSave: _save,
|
||||
),
|
||||
child: _buildForm(),
|
||||
@ -416,7 +458,7 @@ class _AddAmcPanelState extends ConsumerState<AddAmcPanel> {
|
||||
footer: _panelFooter(
|
||||
context,
|
||||
isSubmitting: _isSubmitting,
|
||||
saveLabel: 'Add AMC Contract',
|
||||
saveLabel: 'Save AMC Contract',
|
||||
onSave: _save,
|
||||
),
|
||||
child: _buildForm(),
|
||||
@ -500,9 +542,10 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
|
||||
_downtimeHoursController.text = visit.downtimeHours.toString();
|
||||
}
|
||||
if (visit.serviceCost != null) {
|
||||
_serviceCostController.text = visit.serviceCost.toString();
|
||||
_serviceCostController.text =
|
||||
CurrencyFormatter.formatEditable(visit.serviceCost);
|
||||
}
|
||||
_isUnderAmc = visit.isUnderAmc;
|
||||
_isUnderAmc = visit.isUnderAmc || visit.amcContractId != null;
|
||||
_assetConditionAfter = visit.assetConditionAfter;
|
||||
_remarksController.text = visit.remarks ?? '';
|
||||
}
|
||||
@ -524,12 +567,16 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
|
||||
}
|
||||
|
||||
Map<String, dynamic> _buildPayload() {
|
||||
final downtimeHours = double.tryParse(_downtimeHoursController.text.trim());
|
||||
final serviceCost = double.tryParse(_serviceCostController.text.trim());
|
||||
final downtimeHours =
|
||||
CurrencyFormatter.tryParse(_downtimeHoursController.text);
|
||||
final serviceCost =
|
||||
CurrencyFormatter.tryParse(_serviceCostController.text);
|
||||
return {
|
||||
if (_visitType != null && _visitType!.trim().isNotEmpty) 'visit_type': _visitType,
|
||||
if (_visitType != null && _visitType!.trim().isNotEmpty)
|
||||
'visit_type': _visitType,
|
||||
'visit_date': DateFormatter.toApiDate(_visitDate!),
|
||||
if (_amcContractId != null) 'amc_contract_id': _amcContractId,
|
||||
if (_isUnderAmc && _amcContractId != null)
|
||||
'amc_contract_id': _amcContractId,
|
||||
if (_complaintNoController.text.trim().isNotEmpty)
|
||||
'complaint_no': _complaintNoController.text.trim(),
|
||||
if (_complaintDate != null)
|
||||
@ -551,7 +598,8 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
|
||||
if (downtimeHours != null) 'downtime_hours': downtimeHours,
|
||||
if (serviceCost != null) 'service_cost': serviceCost,
|
||||
'is_under_amc': _isUnderAmc,
|
||||
if (_assetConditionAfter != null && _assetConditionAfter!.trim().isNotEmpty)
|
||||
if (_assetConditionAfter != null &&
|
||||
_assetConditionAfter!.trim().isNotEmpty)
|
||||
'asset_condition_after': _assetConditionAfter,
|
||||
if (_remarksController.text.trim().isNotEmpty)
|
||||
'remarks': _remarksController.text.trim(),
|
||||
@ -577,16 +625,67 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
|
||||
if (mounted) Navigator.of(context, rootNavigator: true).pop(true);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
showSidePanelSnackBar(context, e.toString());
|
||||
showSidePanelApiError(context, e);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isSubmitting = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _setUnderAmc(bool value) {
|
||||
setState(() {
|
||||
_isUnderAmc = value;
|
||||
if (!value) {
|
||||
_amcContractId = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _onAmcContractChanged(
|
||||
int? contractId,
|
||||
List<AmcContractModel> amcContracts,
|
||||
) {
|
||||
setState(() {
|
||||
_amcContractId = contractId;
|
||||
if (contractId == null) return;
|
||||
final contract = amcContracts
|
||||
.where((c) => int.tryParse(c.id) == contractId)
|
||||
.firstOrNull;
|
||||
if (contract?.vendorId != null) {
|
||||
_vendorId = contract!.vendorId;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
List<AppDropdownOption<int>> _vendorOptions({
|
||||
required List<FilterOptionModel> vendors,
|
||||
required List<AmcContractModel> amcContracts,
|
||||
}) {
|
||||
final all = vendors
|
||||
.map(
|
||||
(vendor) => AppDropdownOption(
|
||||
value: int.tryParse(vendor.id) ?? 0,
|
||||
label: vendor.name,
|
||||
),
|
||||
)
|
||||
.where((option) => option.value != 0)
|
||||
.toList();
|
||||
|
||||
if (!_isUnderAmc || _amcContractId == null) return all;
|
||||
|
||||
final contract = amcContracts
|
||||
.where((c) => int.tryParse(c.id) == _amcContractId)
|
||||
.firstOrNull;
|
||||
final contractVendorId = contract?.vendorId;
|
||||
if (contractVendorId == null) return all;
|
||||
|
||||
return all.where((option) => option.value == contractVendorId).toList();
|
||||
}
|
||||
|
||||
Widget _buildForm(List<AmcContractModel> amcContracts) {
|
||||
final lookupsAsync = ref.watch(assetFormLookupsProvider);
|
||||
final options = lookupsAsync.valueOrNull?.options ?? const AssetDropdownOptionsModel();
|
||||
final options =
|
||||
lookupsAsync.valueOrNull?.options ?? const AssetDropdownOptionsModel();
|
||||
|
||||
if (lookupsAsync.hasValue) {
|
||||
final nextVisitType = resolveAssetOptionValue(
|
||||
@ -623,6 +722,7 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// 1) Visit classification
|
||||
SidePanelFormRow(
|
||||
left: AppSearchableDropdown<String>(
|
||||
label: 'Visit Type *',
|
||||
@ -655,24 +755,35 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
|
||||
onPicked: (date) => setState(() => _visitDate = date),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
AppSearchableDropdown<int>(
|
||||
label: 'AMC Contract',
|
||||
value: _amcContractId,
|
||||
searchHint: 'Search AMC contract...',
|
||||
options: amcContracts
|
||||
.map((contract) {
|
||||
final id = int.tryParse(contract.id);
|
||||
if (id == null) return null;
|
||||
final label = contract.contractNo?.trim().isNotEmpty == true
|
||||
? contract.contractNo!
|
||||
: 'AMC #${contract.id}';
|
||||
return AppDropdownOption(value: id, label: label);
|
||||
})
|
||||
.whereType<AppDropdownOption<int>>()
|
||||
.toList(),
|
||||
onChanged: (v) => setState(() => _amcContractId = v),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// 2) Contract & Vendor
|
||||
AppFormToggleField(
|
||||
label: 'Under AMC',
|
||||
value: _isUnderAmc,
|
||||
onChanged: _setUnderAmc,
|
||||
),
|
||||
if (_isUnderAmc) ...[
|
||||
const SizedBox(height: 8),
|
||||
AppSearchableDropdown<int>(
|
||||
label: 'AMC Contract',
|
||||
value: _amcContractId,
|
||||
searchHint: 'Search AMC contract...',
|
||||
options: amcContracts
|
||||
.map((contract) {
|
||||
final id = int.tryParse(contract.id);
|
||||
if (id == null) return null;
|
||||
final label =
|
||||
contract.contractNo?.trim().isNotEmpty == true
|
||||
? contract.contractNo!
|
||||
: 'AMC #${contract.id}';
|
||||
return AppDropdownOption(value: id, label: label);
|
||||
})
|
||||
.whereType<AppDropdownOption<int>>()
|
||||
.toList(),
|
||||
onChanged: (v) => _onAmcContractChanged(v, amcContracts),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
SidePanelFormRow(
|
||||
left: lookupsAsync.when(
|
||||
@ -685,13 +796,10 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
|
||||
label: 'Vendor',
|
||||
value: _vendorId,
|
||||
searchHint: 'Search vendor...',
|
||||
options: lookups.vendors
|
||||
.map((vendor) => AppDropdownOption(
|
||||
value: int.tryParse(vendor.id) ?? 0,
|
||||
label: vendor.name,
|
||||
))
|
||||
.where((option) => option.value != 0)
|
||||
.toList(),
|
||||
options: _vendorOptions(
|
||||
vendors: lookups.vendors,
|
||||
amcContracts: amcContracts,
|
||||
),
|
||||
onChanged: (v) => setState(() => _vendorId = v),
|
||||
),
|
||||
),
|
||||
@ -704,6 +812,8 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
|
||||
onChanged: (v) => setState(() => _assetConditionAfter = v),
|
||||
),
|
||||
),
|
||||
|
||||
// 3) Complaint intake
|
||||
SidePanelFormRow(
|
||||
left: AppTextField(
|
||||
controller: _complaintNoController,
|
||||
@ -724,6 +834,8 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
|
||||
maxLines: 3,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 4) Engineer & work performed
|
||||
SidePanelFormRow(
|
||||
left: AppTextField(
|
||||
controller: _engineerNameController,
|
||||
@ -749,6 +861,8 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
|
||||
maxLines: 3,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 5) Outcome & cost
|
||||
SidePanelFormRow(
|
||||
left: _SidePanelDateField(
|
||||
label: 'Next Service Date',
|
||||
@ -761,7 +875,8 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
|
||||
right: AppTextField(
|
||||
controller: _downtimeHoursController,
|
||||
label: 'Downtime Hours',
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
keyboardType:
|
||||
const TextInputType.numberWithOptions(decimal: true),
|
||||
validator: (v) => Validators.optionalPositiveDouble(
|
||||
v,
|
||||
fieldName: 'Downtime Hours',
|
||||
@ -772,6 +887,7 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
|
||||
controller: _serviceCostController,
|
||||
label: 'Service Cost',
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: CurrencyFormatter.amountInput,
|
||||
validator: (v) => Validators.optionalPositiveDouble(
|
||||
v,
|
||||
fieldName: 'Service Cost',
|
||||
@ -783,12 +899,6 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
|
||||
label: 'Remarks',
|
||||
maxLines: 3,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
AppFormToggleField(
|
||||
label: 'Under AMC',
|
||||
value: _isUnderAmc,
|
||||
onChanged: (value) => setState(() => _isUnderAmc = value),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@ -809,7 +919,7 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
|
||||
footer: _panelFooter(
|
||||
context,
|
||||
isSubmitting: true,
|
||||
saveLabel: 'Edit Service Visit',
|
||||
saveLabel: 'Update Service Visit',
|
||||
onSave: () {},
|
||||
),
|
||||
child: const Center(child: CircularProgressIndicator()),
|
||||
@ -825,7 +935,7 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
|
||||
footer: _panelFooter(
|
||||
context,
|
||||
isSubmitting: _isSubmitting,
|
||||
saveLabel: 'Edit Service Visit',
|
||||
saveLabel: 'Update Service Visit',
|
||||
onSave: _save,
|
||||
),
|
||||
child: _buildForm(widget.amcContracts),
|
||||
@ -911,10 +1021,12 @@ class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
|
||||
_insurerEmailController.text = policy.insurerEmail ?? '';
|
||||
_policyType = policy.policyType;
|
||||
if (policy.sumInsured != null) {
|
||||
_sumInsuredController.text = policy.sumInsured.toString();
|
||||
_sumInsuredController.text =
|
||||
CurrencyFormatter.formatEditable(policy.sumInsured);
|
||||
}
|
||||
if (policy.annualPremium != null) {
|
||||
_annualPremiumController.text = policy.annualPremium.toString();
|
||||
_annualPremiumController.text =
|
||||
CurrencyFormatter.formatEditable(policy.annualPremium);
|
||||
}
|
||||
_startDate = policy.policyStartDate;
|
||||
_endDate = policy.policyEndDate;
|
||||
@ -929,12 +1041,20 @@ class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
|
||||
Future<void> _pickDate({
|
||||
required DateTime? current,
|
||||
required void Function(DateTime date) onPicked,
|
||||
DateTime? firstDate,
|
||||
DateTime? lastDate,
|
||||
}) async {
|
||||
final first = firstDate ?? DateTime(2000);
|
||||
final last = lastDate ?? DateTime(2100);
|
||||
var initial = current ?? DateTime.now();
|
||||
if (initial.isBefore(first)) initial = first;
|
||||
if (initial.isAfter(last)) initial = last;
|
||||
|
||||
final picked = await showAppDatePopup(
|
||||
context: context,
|
||||
initialDate: current ?? DateTime.now(),
|
||||
firstDate: DateTime(2000),
|
||||
lastDate: DateTime(2100),
|
||||
initialDate: initial,
|
||||
firstDate: first,
|
||||
lastDate: last,
|
||||
helpText: 'Select date',
|
||||
);
|
||||
if (picked != null) {
|
||||
@ -942,9 +1062,28 @@ class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
|
||||
}
|
||||
}
|
||||
|
||||
void _onStartDatePicked(DateTime date) {
|
||||
final clearedEnd = _endDate != null &&
|
||||
DateTime(_endDate!.year, _endDate!.month, _endDate!.day)
|
||||
.isBefore(DateTime(date.year, date.month, date.day));
|
||||
_startDate = date;
|
||||
if (clearedEnd) {
|
||||
_endDate = null;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
showSidePanelSnackBar(
|
||||
context,
|
||||
'End date cleared. Please select an end date on or after the start date.',
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> _buildPayload() {
|
||||
final sumInsured = double.tryParse(_sumInsuredController.text.trim());
|
||||
final annualPremium = double.tryParse(_annualPremiumController.text.trim());
|
||||
final sumInsured =
|
||||
CurrencyFormatter.tryParse(_sumInsuredController.text);
|
||||
final annualPremium =
|
||||
CurrencyFormatter.tryParse(_annualPremiumController.text);
|
||||
return {
|
||||
'policy_no': _policyNoController.text.trim(),
|
||||
'insurer_name': _insurerNameController.text.trim(),
|
||||
@ -978,6 +1117,15 @@ class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
|
||||
showSidePanelSnackBar(context, 'Please select start and end dates');
|
||||
return;
|
||||
}
|
||||
if (DateTime(_endDate!.year, _endDate!.month, _endDate!.day).isBefore(
|
||||
DateTime(_startDate!.year, _startDate!.month, _startDate!.day),
|
||||
)) {
|
||||
showSidePanelSnackBar(
|
||||
context,
|
||||
'End date cannot be earlier than start date',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isSubmitting = true);
|
||||
try {
|
||||
@ -991,7 +1139,7 @@ class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
|
||||
if (mounted) Navigator.of(context, rootNavigator: true).pop(true);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
showSidePanelSnackBar(context, e.toString());
|
||||
showSidePanelApiError(context, e);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isSubmitting = false);
|
||||
@ -1073,6 +1221,7 @@ class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
|
||||
controller: _sumInsuredController,
|
||||
label: 'Sum Insured',
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: CurrencyFormatter.amountInput,
|
||||
validator: (v) => Validators.optionalNonNegativeDouble(
|
||||
v,
|
||||
fieldName: 'Sum Insured',
|
||||
@ -1084,6 +1233,7 @@ class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
|
||||
controller: _annualPremiumController,
|
||||
label: 'Annual Premium',
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: CurrencyFormatter.amountInput,
|
||||
validator: (v) => Validators.optionalNonNegativeDouble(
|
||||
v,
|
||||
fieldName: 'Annual Premium',
|
||||
@ -1097,7 +1247,7 @@ class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
|
||||
value: _startDate,
|
||||
onPick: () => _pickDate(
|
||||
current: _startDate,
|
||||
onPicked: (date) => setState(() => _startDate = date),
|
||||
onPicked: _onStartDatePicked,
|
||||
),
|
||||
),
|
||||
right: _SidePanelDateField(
|
||||
@ -1106,7 +1256,8 @@ class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
|
||||
value: _endDate,
|
||||
onPick: () => _pickDate(
|
||||
current: _endDate,
|
||||
onPicked: (date) => setState(() => _endDate = date),
|
||||
firstDate: _startDate ?? DateTime(2000),
|
||||
onPicked: (date) => _endDate = date,
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -1117,7 +1268,7 @@ class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
|
||||
value: _renewalDate,
|
||||
onPick: () => _pickDate(
|
||||
current: _renewalDate,
|
||||
onPicked: (date) => setState(() => _renewalDate = date),
|
||||
onPicked: (date) => _renewalDate = date,
|
||||
),
|
||||
),
|
||||
right: _SidePanelDateField(
|
||||
@ -1125,7 +1276,7 @@ class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
|
||||
value: _premiumPaidDate,
|
||||
onPick: () => _pickDate(
|
||||
current: _premiumPaidDate,
|
||||
onPicked: (date) => setState(() => _premiumPaidDate = date),
|
||||
onPicked: (date) => _premiumPaidDate = date,
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -1171,7 +1322,7 @@ class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
|
||||
footer: _panelFooter(
|
||||
context,
|
||||
isSubmitting: true,
|
||||
saveLabel: 'Edit Insurance Policy',
|
||||
saveLabel: 'Update Insurance Policy',
|
||||
onSave: () {},
|
||||
),
|
||||
child: const Center(child: CircularProgressIndicator()),
|
||||
@ -1187,7 +1338,7 @@ class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
|
||||
footer: _panelFooter(
|
||||
context,
|
||||
isSubmitting: _isSubmitting,
|
||||
saveLabel: 'Edit Insurance Policy',
|
||||
saveLabel: 'Update Insurance Policy',
|
||||
onSave: _save,
|
||||
),
|
||||
child: _buildForm(),
|
||||
@ -1201,7 +1352,7 @@ class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
|
||||
footer: _panelFooter(
|
||||
context,
|
||||
isSubmitting: _isSubmitting,
|
||||
saveLabel: 'Add Insurance Policy',
|
||||
saveLabel: 'Save Insurance Policy',
|
||||
onSave: _save,
|
||||
),
|
||||
child: _buildForm(),
|
||||
@ -1223,7 +1374,6 @@ class _TransferAssetPanelState extends ConsumerState<TransferAssetPanel> {
|
||||
final _reasonController = TextEditingController();
|
||||
DateTime _transferDate = DateTime.now();
|
||||
int? _toLocationId;
|
||||
int? _toDepartmentId;
|
||||
int? _toUserId;
|
||||
bool _isSubmitting = false;
|
||||
|
||||
@ -1249,10 +1399,7 @@ class _TransferAssetPanelState extends ConsumerState<TransferAssetPanel> {
|
||||
Future<void> _save() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
final hasDestination =
|
||||
_toLocationId != null ||
|
||||
_toDepartmentId != null ||
|
||||
_toUserId != null;
|
||||
final hasDestination = _toLocationId != null || _toUserId != null;
|
||||
if (!hasDestination) {
|
||||
showSidePanelSnackBar(
|
||||
context,
|
||||
@ -1266,7 +1413,6 @@ class _TransferAssetPanelState extends ConsumerState<TransferAssetPanel> {
|
||||
await ref.read(assetDetailProvider(widget.assetId).notifier).transferAsset({
|
||||
'transfer_date': DateFormatter.toApiDate(_transferDate),
|
||||
if (_toLocationId != null) 'to_location_id': _toLocationId,
|
||||
if (_toDepartmentId != null) 'to_department_id': _toDepartmentId,
|
||||
if (_toUserId != null) 'to_user_id': _toUserId,
|
||||
'reason': _reasonController.text.trim(),
|
||||
});
|
||||
@ -1323,25 +1469,6 @@ class _TransferAssetPanelState extends ConsumerState<TransferAssetPanel> {
|
||||
onChanged: (v) => setState(() => _toLocationId = v),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
MasterQuickAddDropdown<int>(
|
||||
masterId: 'departments',
|
||||
label: 'To Department',
|
||||
value: _toDepartmentId,
|
||||
searchHint: 'Search department...',
|
||||
options: lookups.departments
|
||||
.map((option) {
|
||||
final id = int.tryParse(option.id);
|
||||
if (id == null) return null;
|
||||
return AppDropdownOption(value: id, label: option.name);
|
||||
})
|
||||
.whereType<AppDropdownOption<int>>()
|
||||
.toList(),
|
||||
refreshLookups: () =>
|
||||
ref.invalidate(assetFormLookupsProvider),
|
||||
parseCreatedId: int.tryParse,
|
||||
onChanged: (v) => setState(() => _toDepartmentId = v),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
AppSearchableDropdown<int>(
|
||||
label: 'To User',
|
||||
value: _toUserId,
|
||||
@ -1423,36 +1550,11 @@ class _TransferHistoryEntry extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _TransferLocationBlock(
|
||||
label: 'From',
|
||||
location: item.fromLocationName,
|
||||
department: item.fromDepartmentName,
|
||||
user: item.fromUserName,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Icon(
|
||||
Icons.arrow_forward_rounded,
|
||||
size: 18,
|
||||
color: theme.colorScheme.outline,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _TransferLocationBlock(
|
||||
label: 'To',
|
||||
location: item.toLocationName,
|
||||
department: item.toDepartmentName,
|
||||
user: item.toUserName,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
_TransferFromToGrid(
|
||||
fromLocation: item.fromLocationName,
|
||||
fromUser: item.fromUserName,
|
||||
toLocation: item.toLocationName,
|
||||
toUser: item.toUserName,
|
||||
),
|
||||
if (showReason) ...[
|
||||
const SizedBox(height: 16),
|
||||
@ -1484,46 +1586,86 @@ class _TransferHistoryEntry extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _TransferLocationBlock extends StatelessWidget {
|
||||
const _TransferLocationBlock({
|
||||
required this.label,
|
||||
this.location,
|
||||
this.department,
|
||||
this.user,
|
||||
class _TransferFromToGrid extends StatelessWidget {
|
||||
const _TransferFromToGrid({
|
||||
this.fromLocation,
|
||||
this.fromUser,
|
||||
this.toLocation,
|
||||
this.toUser,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String? location;
|
||||
final String? department;
|
||||
final String? user;
|
||||
final String? fromLocation;
|
||||
final String? fromUser;
|
||||
final String? toLocation;
|
||||
final String? toUser;
|
||||
|
||||
static const _arrowWidth = 34.0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final labelStyle = theme.textTheme.labelSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.6,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.6,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: Text('From', style: labelStyle)),
|
||||
const SizedBox(width: _arrowWidth),
|
||||
Expanded(child: Text('To', style: labelStyle)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_TransferLocationRow(
|
||||
icon: Icons.place_outlined,
|
||||
value: location,
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _TransferLocationRow(
|
||||
icon: Icons.place_outlined,
|
||||
value: fromLocation,
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: _arrowWidth,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 1),
|
||||
child: Icon(
|
||||
Icons.arrow_forward_rounded,
|
||||
size: 18,
|
||||
color: theme.colorScheme.outline,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _TransferLocationRow(
|
||||
icon: Icons.place_outlined,
|
||||
value: toLocation,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
_TransferLocationRow(
|
||||
icon: Icons.apartment_outlined,
|
||||
value: department,
|
||||
),
|
||||
_TransferLocationRow(
|
||||
icon: Icons.person_outline,
|
||||
value: user,
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _TransferLocationRow(
|
||||
icon: Icons.person_outline,
|
||||
value: fromUser,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: _arrowWidth),
|
||||
Expanded(
|
||||
child: _TransferLocationRow(
|
||||
icon: Icons.person_outline,
|
||||
value: toUser,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../../../core/constants/api_endpoints.dart';
|
||||
import '../../../../core/utils/pagination_meta.dart';
|
||||
import '../../../../shared/models/audit_log_model.dart';
|
||||
import '../../../../shared/models/export_file_result.dart';
|
||||
|
||||
@ -32,20 +33,23 @@ class AuditRemoteDataSource {
|
||||
: <AuditLogEntryModel>[];
|
||||
|
||||
final meta = body['meta'] as Map<String, dynamic>? ?? {};
|
||||
final page = (meta['page'] as num?)?.toInt() ?? query.page;
|
||||
final limit = (meta['limit'] as num?)?.toInt() ?? query.limit;
|
||||
final total = (meta['total'] as num?)?.toInt() ?? items.length;
|
||||
final totalPages = limit > 0
|
||||
? ((total + limit - 1) ~/ limit).clamp(1, 999999)
|
||||
: 1;
|
||||
final pagination = parsePagination(
|
||||
body: body,
|
||||
fallbackPage: query.page,
|
||||
fallbackLimit: query.limit,
|
||||
itemCount: items.length,
|
||||
);
|
||||
final filtersRequired = meta['filters_required'] == true;
|
||||
|
||||
return AuditLogListResult(
|
||||
items: items,
|
||||
page: page,
|
||||
limit: limit,
|
||||
total: total,
|
||||
totalPages: totalPages,
|
||||
page: pagination.page,
|
||||
limit: query.limit,
|
||||
total: pagination.total,
|
||||
totalPages: resolveTotalPages(
|
||||
total: pagination.total,
|
||||
limit: query.limit,
|
||||
),
|
||||
filtersRequired: filtersRequired,
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/utils/column_search_paging.dart';
|
||||
import '../../../../core/utils/table_search.dart';
|
||||
|
||||
import '../../../../shared/models/audit_log_model.dart';
|
||||
@ -82,6 +83,8 @@ final auditLogsListProvider =
|
||||
);
|
||||
|
||||
class AuditLogsListNotifier extends AsyncNotifier<AuditLogsListState> {
|
||||
final _columnSearch = ColumnSearchPaging();
|
||||
|
||||
@override
|
||||
Future<AuditLogsListState> build() async {
|
||||
ref.keepAlive();
|
||||
@ -148,6 +151,27 @@ class AuditLogsListNotifier extends AsyncNotifier<AuditLogsListState> {
|
||||
applyQuery(current.query.copyWith(search: TableSearch.normalize(search), page: 1));
|
||||
}
|
||||
|
||||
Future<void> ensureColumnSearchDataset() async {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
final limit = _columnSearch.beginFullDataset(
|
||||
currentLimit: current.query.limit,
|
||||
total: current.total,
|
||||
);
|
||||
if (limit == null) return;
|
||||
await applyQuery(
|
||||
current.query.copyWith(search: null, page: 1, limit: limit),
|
||||
);
|
||||
}
|
||||
|
||||
void clearColumnSearchDataset() {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
final limit = _columnSearch.endFullDataset();
|
||||
if (limit == null) return;
|
||||
applyQuery(current.query.copyWith(search: null, page: 1, limit: limit));
|
||||
}
|
||||
|
||||
void setPage(int page) {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
|
||||
@ -23,7 +23,9 @@ import '../../../../shared/widgets/app_searchable_dropdown.dart';
|
||||
import '../../../../shared/widgets/app_side_panel.dart';
|
||||
import '../../../../shared/widgets/app_status_chip.dart';
|
||||
import '../../../../shared/widgets/app_table_action_icon.dart';
|
||||
import '../../../../shared/widgets/app_table_column_selector.dart';
|
||||
import '../../../../shared/widgets/app_table_shell.dart';
|
||||
import '../../../../shared/providers/table_column_prefs_provider.dart';
|
||||
import '../../../../shared/widgets/error_view.dart';
|
||||
import '../../../../shared/widgets/page_header.dart';
|
||||
import '../providers/audit_provider.dart';
|
||||
@ -143,6 +145,11 @@ class _AuditLogsScreenState extends ConsumerState<AuditLogsScreen> {
|
||||
() => _filtersExpanded = !_filtersExpanded,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
AppTableColumnSelectorButton(
|
||||
tableId: _AuditDataTable.tableId,
|
||||
columns: _AuditDataTable.columnOptions,
|
||||
),
|
||||
if (canExport) ...[
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton.icon(
|
||||
@ -184,6 +191,7 @@ class _AuditLogsScreenState extends ConsumerState<AuditLogsScreen> {
|
||||
totalPages: state.totalPages,
|
||||
totalItems: state.total,
|
||||
pageSize: state.query.limit,
|
||||
itemsOnPage: state.items.length,
|
||||
itemLabel: 'audit logs',
|
||||
onPageChanged: notifier.setPage,
|
||||
onPageSizeChanged: notifier.setPageSize,
|
||||
@ -205,30 +213,35 @@ class _AuditLogsScreenState extends ConsumerState<AuditLogsScreen> {
|
||||
),
|
||||
],
|
||||
)
|
||||
: state.items.isEmpty
|
||||
? ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: const [
|
||||
SizedBox(
|
||||
height: 260,
|
||||
child: AppEmptyState(
|
||||
title: 'No audit logs found',
|
||||
description:
|
||||
'Try adjusting filters or expanding the date range.',
|
||||
icon: Icons.history_outlined,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: context.isMobile
|
||||
? _AuditCardList(
|
||||
items: state.items,
|
||||
onView: _viewLog,
|
||||
: context.isMobile
|
||||
? (state.items.isEmpty
|
||||
? ListView(
|
||||
physics:
|
||||
const AlwaysScrollableScrollPhysics(),
|
||||
children: const [
|
||||
SizedBox(
|
||||
height: 260,
|
||||
child: AppEmptyState(
|
||||
title: 'No audit logs found',
|
||||
description:
|
||||
'Try adjusting filters or expanding the date range.',
|
||||
icon: Icons.history_outlined,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: _AuditDataTable(
|
||||
: _AuditCardList(
|
||||
items: state.items,
|
||||
onView: _viewLog,
|
||||
),
|
||||
))
|
||||
: _AuditDataTable(
|
||||
items: state.items,
|
||||
onView: _viewLog,
|
||||
onEnsureFullDataset: () =>
|
||||
notifier.ensureColumnSearchDataset(),
|
||||
onColumnSearchCleared: () =>
|
||||
notifier.clearColumnSearchDataset(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -291,7 +304,10 @@ class _FiltersBar extends StatelessWidget {
|
||||
options: [
|
||||
const AppDropdownOption(value: null, label: 'All Tables'),
|
||||
...filters.tableNames.map(
|
||||
(name) => AppDropdownOption(value: name, label: name),
|
||||
(name) => AppDropdownOption(
|
||||
value: name,
|
||||
label: humanizeLabel(name),
|
||||
),
|
||||
),
|
||||
],
|
||||
onChanged: onTableChanged,
|
||||
@ -339,7 +355,7 @@ class _FiltersBar extends StatelessWidget {
|
||||
);
|
||||
|
||||
final theme = Theme.of(context);
|
||||
final iconColor = theme.colorScheme.primary;
|
||||
final iconColor = theme.colorScheme.secondary;
|
||||
final resetButton = IconButton(
|
||||
tooltip: 'Reset',
|
||||
color: iconColor,
|
||||
@ -361,100 +377,143 @@ class _FiltersBar extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _AuditDataTable extends StatelessWidget {
|
||||
class _AuditDataTable extends ConsumerWidget {
|
||||
const _AuditDataTable({
|
||||
required this.items,
|
||||
required this.onView,
|
||||
this.onEnsureFullDataset,
|
||||
this.onColumnSearchCleared,
|
||||
});
|
||||
|
||||
static const tableId = 'audit_logs';
|
||||
|
||||
static List<AppTableColumnOption> get columnOptions => const [
|
||||
AppTableColumnOption(id: 'when', label: 'When', required: true),
|
||||
AppTableColumnOption(id: 'action', label: 'Action'),
|
||||
AppTableColumnOption(id: 'table', label: 'Table'),
|
||||
AppTableColumnOption(id: 'record', label: 'Record'),
|
||||
AppTableColumnOption(id: 'performed_by', label: 'Performed By'),
|
||||
AppTableColumnOption(id: 'request_id', label: 'Request ID'),
|
||||
AppTableColumnOption(id: 'changes', label: 'Changes'),
|
||||
];
|
||||
|
||||
final List<AuditLogEntryModel> items;
|
||||
final void Function(AuditLogEntryModel log) onView;
|
||||
final Future<void> Function()? onEnsureFullDataset;
|
||||
final VoidCallback? onColumnSearchCleared;
|
||||
|
||||
static String _changesText(AuditLogEntryModel row) {
|
||||
final parts = <String>[];
|
||||
if (row.hasOldValue) parts.add('old');
|
||||
if (row.hasNewValue) parts.add('new');
|
||||
return parts.isEmpty ? '' : parts.join(' / ');
|
||||
}
|
||||
|
||||
List<AppDataColumn<AuditLogEntryModel>> _allColumns() {
|
||||
return [
|
||||
AppDataColumn(
|
||||
id: 'when',
|
||||
label: 'When',
|
||||
sortKey: 'when',
|
||||
locked: true,
|
||||
flex: 2,
|
||||
searchText: (row) => DateFormatter.searchableDate(row.performedAt),
|
||||
sortValue: (row) => row.performedAt,
|
||||
cellBuilder: (_, row) => AppTableCell.text(
|
||||
DateFormatter.displayDateTime(row.performedAt),
|
||||
),
|
||||
),
|
||||
AppDataColumn(
|
||||
id: 'action',
|
||||
label: 'Action',
|
||||
sortKey: 'action',
|
||||
flex: 1,
|
||||
searchText: (row) => row.action,
|
||||
cellBuilder: (_, row) => AppTableCell.child(
|
||||
AppStatusChip(
|
||||
status: row.action,
|
||||
compact: true,
|
||||
forTable: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
AppDataColumn(
|
||||
id: 'table',
|
||||
label: 'Table',
|
||||
sortKey: 'table',
|
||||
flex: 2,
|
||||
searchText: (row) => humanizeLabel(row.tableName),
|
||||
cellBuilder: (_, row) =>
|
||||
AppTableCell.text(humanizeLabel(row.tableName)),
|
||||
),
|
||||
AppDataColumn(
|
||||
id: 'record',
|
||||
label: 'Record',
|
||||
sortKey: 'record',
|
||||
flex: 1,
|
||||
searchText: (row) => row.recordId ?? '',
|
||||
cellBuilder: (_, row) => AppTableCell.text(row.recordId),
|
||||
),
|
||||
AppDataColumn(
|
||||
id: 'performed_by',
|
||||
label: 'Performed By',
|
||||
sortKey: 'performed_by',
|
||||
flex: 2,
|
||||
searchText: (row) => row.performerLabel,
|
||||
cellBuilder: (_, row) => AppTableCell.text(row.performerLabel),
|
||||
),
|
||||
AppDataColumn(
|
||||
id: 'request_id',
|
||||
label: 'Request ID',
|
||||
sortKey: 'request_id',
|
||||
flex: 2,
|
||||
searchText: (row) => row.requestId ?? '',
|
||||
cellBuilder: (_, row) => AppTableCell.text(row.requestId),
|
||||
),
|
||||
AppDataColumn(
|
||||
id: 'changes',
|
||||
label: 'Changes',
|
||||
sortKey: 'changes',
|
||||
flex: 1,
|
||||
searchText: _changesText,
|
||||
cellBuilder: (_, row) {
|
||||
final text = _changesText(row);
|
||||
return AppTableCell.text(text.isEmpty ? '—' : text);
|
||||
},
|
||||
),
|
||||
AppDataColumn(
|
||||
id: 'actions',
|
||||
label: 'Actions',
|
||||
flex: 1,
|
||||
alignment: Alignment.centerRight,
|
||||
enableSearch: false,
|
||||
locked: true,
|
||||
includeInColumnSelector: false,
|
||||
cellBuilder: (_, row) => AppTableActions(
|
||||
children: [
|
||||
AppTableActionIcon(
|
||||
tooltip: 'View',
|
||||
icon: Icons.visibility_outlined,
|
||||
onPressed: () => onView(row),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final prefs = ref.watch(tableColumnPrefsProvider(tableId));
|
||||
final columns = resolveAppDataColumns(_allColumns(), prefs);
|
||||
|
||||
return AppDataTable<AuditLogEntryModel>(
|
||||
wrapInCard: false,
|
||||
rows: items,
|
||||
emptyMessage: 'No audit logs found',
|
||||
columns: [
|
||||
AppDataColumn(
|
||||
label: 'When',
|
||||
flex: 2,
|
||||
searchText: (row) => DateFormatter.displayDateTime(row.performedAt),
|
||||
cellBuilder: (_, row) => AppTableCell.text(
|
||||
DateFormatter.displayDateTime(row.performedAt),
|
||||
),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Action',
|
||||
flex: 1,
|
||||
searchText: (row) => row.action,
|
||||
cellBuilder: (_, row) => AppTableCell.child(
|
||||
AppStatusChip(
|
||||
status: row.action,
|
||||
compact: true,
|
||||
forTable: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Table',
|
||||
flex: 2,
|
||||
searchText: (row) => row.tableName,
|
||||
cellBuilder: (_, row) => AppTableCell.text(row.tableName),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Record',
|
||||
flex: 1,
|
||||
searchText: (row) => row.recordId ?? '',
|
||||
cellBuilder: (_, row) => AppTableCell.text(row.recordId),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Performed By',
|
||||
flex: 2,
|
||||
searchText: (row) => row.performerLabel,
|
||||
cellBuilder: (_, row) => AppTableCell.text(row.performerLabel),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Request ID',
|
||||
flex: 2,
|
||||
searchText: (row) => row.requestId ?? '',
|
||||
cellBuilder: (_, row) => AppTableCell.text(row.requestId),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Changes',
|
||||
flex: 1,
|
||||
searchText: (row) {
|
||||
final parts = <String>[];
|
||||
if (row.hasOldValue) parts.add('old');
|
||||
if (row.hasNewValue) parts.add('new');
|
||||
return parts.isEmpty ? '' : parts.join(' / ');
|
||||
},
|
||||
cellBuilder: (_, row) {
|
||||
final parts = <String>[];
|
||||
if (row.hasOldValue) parts.add('old');
|
||||
if (row.hasNewValue) parts.add('new');
|
||||
return AppTableCell.text(
|
||||
parts.isEmpty ? '—' : parts.join(' / '),
|
||||
);
|
||||
},
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Actions',
|
||||
flex: 1,
|
||||
alignment: Alignment.centerRight,
|
||||
enableSearch: false,
|
||||
cellBuilder: (_, row) => AppTableActions(
|
||||
children: [
|
||||
AppTableActionIcon(
|
||||
tooltip: 'View',
|
||||
icon: Icons.visibility_outlined,
|
||||
onPressed: () => onView(row),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
onEnsureFullDataset: onEnsureFullDataset,
|
||||
onColumnSearchCleared: onColumnSearchCleared,
|
||||
columns: columns,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -485,7 +544,7 @@ class _AuditCardList extends StatelessWidget {
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
log.tableName,
|
||||
humanizeLabel(log.tableName),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
|
||||
@ -57,7 +57,7 @@ class _DetailBody extends StatelessWidget {
|
||||
label: 'Action',
|
||||
child: AppStatusChip(status: detail.action, compact: true),
|
||||
),
|
||||
_DetailRow(label: 'Table', value: _humanizeKey(detail.tableName)),
|
||||
_DetailRow(label: 'Table', value: humanizeLabel(detail.tableName)),
|
||||
_DetailRow(label: 'Record ID', value: detail.recordId ?? '—'),
|
||||
_DetailRow(
|
||||
label: 'Performed At',
|
||||
@ -96,6 +96,10 @@ class _DetailRow extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final labelStyle = theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Row(
|
||||
@ -103,22 +107,20 @@ class _DetailRow extends StatelessWidget {
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 140,
|
||||
child: Text(
|
||||
label,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
child: Text(label, style: labelStyle),
|
||||
),
|
||||
if (child != null)
|
||||
// Keep badge left-aligned with text values (do not expand Chip).
|
||||
child!
|
||||
else
|
||||
Expanded(
|
||||
child: SelectableText(
|
||||
value ?? '—',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: child ??
|
||||
SelectableText(
|
||||
value ?? '—',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@ -269,15 +271,15 @@ List<_FieldRow> _flattenFields(
|
||||
for (final entry in entries) {
|
||||
final key = entry.key.toString();
|
||||
final label = prefix == null
|
||||
? _humanizeKey(key)
|
||||
: '${_humanizeKey(prefix)} › ${_humanizeKey(key)}';
|
||||
? humanizeLabel(key)
|
||||
: '${humanizeLabel(prefix)} › ${humanizeLabel(key)}';
|
||||
final value = entry.value;
|
||||
|
||||
if (value is Map) {
|
||||
final nested = Map<String, dynamic>.from(value);
|
||||
final summary = _nestedSummary(nested);
|
||||
if (summary != null) {
|
||||
rows.add(_FieldRow(label: _humanizeKey(key), value: summary));
|
||||
rows.add(_FieldRow(label: humanizeLabel(key), value: summary));
|
||||
} else {
|
||||
rows.addAll(
|
||||
_flattenFields(
|
||||
@ -330,7 +332,7 @@ String? _nestedSummary(Map<String, dynamic> nested) {
|
||||
return nested.entries
|
||||
.map(
|
||||
(e) =>
|
||||
'${_humanizeKey(e.key.toString())}: ${_formatValue(e.key.toString(), e.value)}',
|
||||
'${humanizeLabel(e.key.toString())}: ${_formatValue(e.key.toString(), e.value)}',
|
||||
)
|
||||
.join(', ');
|
||||
}
|
||||
@ -393,30 +395,3 @@ String _formatValue(String key, Object? value) {
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
String _humanizeKey(String key) {
|
||||
final cleaned = key.trim().replaceAll(RegExp(r'[_\-.]+'), ' ');
|
||||
if (cleaned.isEmpty) return key;
|
||||
|
||||
const acronyms = {
|
||||
'id': 'ID',
|
||||
'amc': 'AMC',
|
||||
'gst': 'GST',
|
||||
'hsn': 'HSN',
|
||||
'uom': 'UOM',
|
||||
'po': 'PO',
|
||||
'grn': 'GRN',
|
||||
'url': 'URL',
|
||||
'api': 'API',
|
||||
};
|
||||
|
||||
return cleaned
|
||||
.split(RegExp(r'\s+'))
|
||||
.where((part) => part.isNotEmpty)
|
||||
.map((part) {
|
||||
final lower = part.toLowerCase();
|
||||
if (acronyms.containsKey(lower)) return acronyms[lower]!;
|
||||
return '${lower[0].toUpperCase()}${lower.substring(1)}';
|
||||
})
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
@ -64,10 +64,59 @@ class AuthRemoteDataSource {
|
||||
return _mapProfileToUser(profile, permissions: permissions, roleId: roleId);
|
||||
}
|
||||
|
||||
Future<UserModel> getCurrentUser() async {
|
||||
Future<UserModel> getCurrentUser({String? accessToken}) async {
|
||||
final response = await dio.get<Map<String, dynamic>>(ApiEndpoints.me);
|
||||
final data = ApiEnvelope.data(response);
|
||||
return UserModel.fromLoginJson(data);
|
||||
final user = UserModel.fromLoginJson(data);
|
||||
return withRolePermissionsIfMissing(
|
||||
user,
|
||||
accessToken: accessToken,
|
||||
mePayload: data,
|
||||
);
|
||||
}
|
||||
|
||||
/// When `/auth/me` omits permissions, load them from the role matrix.
|
||||
Future<UserModel> withRolePermissionsIfMissing(
|
||||
UserModel user, {
|
||||
required String? accessToken,
|
||||
Map<String, dynamic>? mePayload,
|
||||
}) async {
|
||||
if (user.permissions.isNotEmpty) return user;
|
||||
|
||||
final roleId = (mePayload != null ? _roleIdFromMe(mePayload) : null) ??
|
||||
(accessToken == null || accessToken.isEmpty
|
||||
? null
|
||||
: JwtUtils.roleId(accessToken));
|
||||
if (roleId == null || roleId.isEmpty) return user;
|
||||
|
||||
final permissions = await _fetchRolePermissions(roleId);
|
||||
if (permissions.isEmpty) return user;
|
||||
return user.copyWith(permissions: permissions);
|
||||
}
|
||||
|
||||
String? _roleIdFromMe(Map<String, dynamic> data) {
|
||||
final direct = data['role_id'] ?? data['roleId'];
|
||||
if (direct != null && direct.toString().trim().isNotEmpty) {
|
||||
return direct.toString().trim();
|
||||
}
|
||||
final role = data['role'];
|
||||
if (role is Map) {
|
||||
final id = role['id'] ?? role['role_id'];
|
||||
if (id != null && id.toString().trim().isNotEmpty) {
|
||||
return id.toString().trim();
|
||||
}
|
||||
}
|
||||
final roles = data['roles'];
|
||||
if (roles is List && roles.isNotEmpty) {
|
||||
final first = roles.first;
|
||||
if (first is Map) {
|
||||
final id = first['id'] ?? first['role_id'];
|
||||
if (id != null && id.toString().trim().isNotEmpty) {
|
||||
return id.toString().trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> forgotPassword(ForgotPasswordRequest request) async {
|
||||
|
||||
@ -33,7 +33,9 @@ class AuthRepositoryImpl implements AuthRepository {
|
||||
return safeApiCall(() async {
|
||||
final loginResponse = await remote.login(request);
|
||||
await _persistSession(loginResponse);
|
||||
final user = await remote.getCurrentUser();
|
||||
final user = await remote.getCurrentUser(
|
||||
accessToken: loginResponse.tokens.accessToken,
|
||||
);
|
||||
return loginResponse.copyWith(user: user);
|
||||
});
|
||||
}
|
||||
@ -56,7 +58,10 @@ class AuthRepositoryImpl implements AuthRepository {
|
||||
|
||||
@override
|
||||
Future<Result<UserModel>> getCurrentUser() async {
|
||||
return safeApiCall(() => remote.getCurrentUser());
|
||||
return safeApiCall(() async {
|
||||
final accessToken = await tokenStorage.getAccessToken();
|
||||
return remote.getCurrentUser(accessToken: accessToken);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
@ -91,7 +96,8 @@ class AuthRepositoryImpl implements AuthRepository {
|
||||
final updated = await remote.updateProfile(request);
|
||||
// Prefer fresh /auth/me so avatar + role/department stay in sync.
|
||||
try {
|
||||
return await remote.getCurrentUser();
|
||||
final accessToken = await tokenStorage.getAccessToken();
|
||||
return await remote.getCurrentUser(accessToken: accessToken);
|
||||
} catch (_) {
|
||||
return updated;
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import '../../../../core/theme/app_typography.dart';
|
||||
|
||||
import '../../../../core/constants/route_constants.dart';
|
||||
import '../../../../core/utils/validators.dart';
|
||||
@ -78,7 +78,7 @@ class _ChangePasswordScreenState extends ConsumerState<ChangePasswordScreen> {
|
||||
if (result.failure != null) {
|
||||
setState(
|
||||
() => _errorMessage =
|
||||
result.failure?.message ?? result.failure.toString(),
|
||||
result.failure?.message ?? 'Unable to change password.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
@ -108,13 +108,9 @@ class _ChangePasswordScreenState extends ConsumerState<ChangePasswordScreen> {
|
||||
return InputDecoration(
|
||||
labelText: label,
|
||||
floatingLabelBehavior: FloatingLabelBehavior.auto,
|
||||
labelStyle: GoogleFonts.inter(
|
||||
fontSize: 14.5,
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
floatingLabelStyle: GoogleFonts.inter(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
labelStyle: AppTypography.label2(color: colors.onSurfaceVariant),
|
||||
floatingLabelStyle: AppTypography.label3(
|
||||
weight: AppTypography.semiBold,
|
||||
color: colors.primary,
|
||||
),
|
||||
prefixIcon: Icon(icon, color: colors.iconMuted, size: 18),
|
||||
@ -212,9 +208,8 @@ class _ChangePasswordScreenState extends ConsumerState<ChangePasswordScreen> {
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Change Password',
|
||||
style: GoogleFonts.manrope(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
style: AppTypography.body1(
|
||||
weight: AppTypography.bold,
|
||||
color: colors.headingColor,
|
||||
),
|
||||
),
|
||||
@ -285,21 +280,17 @@ class _ChangePasswordScreenState extends ConsumerState<ChangePasswordScreen> {
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
'Update your password',
|
||||
style: GoogleFonts.manrope(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.3,
|
||||
style: AppTypography.heading5(
|
||||
weight: AppTypography.bold,
|
||||
color: colors.headingColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
'Enter your current password and choose a new one. You’ll need to sign in again afterward.',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 13.5,
|
||||
height: 1.45,
|
||||
style: AppTypography.body3(
|
||||
color: colors.subtitleColor,
|
||||
),
|
||||
).copyWith(height: 1.45),
|
||||
),
|
||||
const SizedBox(height: 26),
|
||||
TextFormField(
|
||||
@ -312,8 +303,8 @@ class _ChangePasswordScreenState extends ConsumerState<ChangePasswordScreen> {
|
||||
v,
|
||||
fieldName: 'Current password',
|
||||
),
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 14.5,
|
||||
style: AppTypography.body3(
|
||||
weight: AppTypography.medium,
|
||||
color: colors.headingColor,
|
||||
),
|
||||
decoration: _fieldDecoration(
|
||||
@ -338,8 +329,8 @@ class _ChangePasswordScreenState extends ConsumerState<ChangePasswordScreen> {
|
||||
AutofillHints.newPassword
|
||||
],
|
||||
validator: Validators.password,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 14.5,
|
||||
style: AppTypography.body3(
|
||||
weight: AppTypography.medium,
|
||||
color: colors.headingColor,
|
||||
),
|
||||
decoration: _fieldDecoration(
|
||||
@ -368,8 +359,8 @@ class _ChangePasswordScreenState extends ConsumerState<ChangePasswordScreen> {
|
||||
}
|
||||
return Validators.password(v);
|
||||
},
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 14.5,
|
||||
style: AppTypography.body3(
|
||||
weight: AppTypography.medium,
|
||||
color: colors.headingColor,
|
||||
),
|
||||
decoration: _fieldDecoration(
|
||||
@ -390,8 +381,7 @@ class _ChangePasswordScreenState extends ConsumerState<ChangePasswordScreen> {
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_errorMessage!,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 13,
|
||||
style: AppTypography.body3(
|
||||
color: colors.colorScheme.error,
|
||||
),
|
||||
),
|
||||
@ -415,10 +405,8 @@ class _ChangePasswordScreenState extends ConsumerState<ChangePasswordScreen> {
|
||||
borderRadius:
|
||||
BorderRadius.circular(12),
|
||||
),
|
||||
textStyle: GoogleFonts.inter(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.2,
|
||||
textStyle: AppTypography.label1(
|
||||
weight: AppTypography.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -446,11 +434,9 @@ class _ChangePasswordScreenState extends ConsumerState<ChangePasswordScreen> {
|
||||
),
|
||||
child: Text(
|
||||
'SECURE ACCESS',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 11.5,
|
||||
letterSpacing: 0.6,
|
||||
style: AppTypography.caption1(
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
).copyWith(letterSpacing: 0.6),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
@ -473,8 +459,7 @@ class _ChangePasswordScreenState extends ConsumerState<ChangePasswordScreen> {
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'Your data is protected and encrypted',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 12,
|
||||
style: AppTypography.body4(
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
|
||||
@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../../core/constants/route_constants.dart';
|
||||
import '../../../../core/theme/app_typography.dart';
|
||||
import '../../../../core/utils/validators.dart';
|
||||
import '../../../../shared/widgets/app_button.dart';
|
||||
import '../../../../shared/widgets/app_text_field.dart';
|
||||
@ -47,7 +48,10 @@ class _ForgotPasswordScreenState extends ConsumerState<ForgotPasswordScreen> {
|
||||
setState(() => _isLoading = false);
|
||||
|
||||
if (result.failure != null) {
|
||||
setState(() => _errorMessage = result.failure.toString());
|
||||
setState(
|
||||
() => _errorMessage =
|
||||
result.failure?.message ?? 'Unable to send reset email.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -86,7 +90,9 @@ class _ForgotPasswordScreenState extends ConsumerState<ForgotPasswordScreen> {
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_errorMessage!,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
style: AppTypography.body3(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
@ -94,7 +100,9 @@ class _ForgotPasswordScreenState extends ConsumerState<ForgotPasswordScreen> {
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_successMessage!,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.primary),
|
||||
style: AppTypography.body3(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
|
||||
@ -3,10 +3,9 @@ import 'dart:math' as math;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import '../../../../core/theme/app_typography.dart';
|
||||
|
||||
import '../../../../core/config/dev_config.dart';
|
||||
import '../../../../core/constants/app_constants.dart';
|
||||
import '../../../../core/constants/enums.dart';
|
||||
import '../../../../core/constants/route_constants.dart';
|
||||
import '../../../../core/constants/storage_keys.dart';
|
||||
@ -172,7 +171,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
setState(() => _isForgotLoading = false);
|
||||
|
||||
if (result.failure != null) {
|
||||
setState(() => _forgotErrorMessage = result.failure.toString());
|
||||
setState(
|
||||
() => _forgotErrorMessage =
|
||||
result.failure?.message ?? 'Unable to send reset email.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -214,7 +216,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
if (result.failure != null) {
|
||||
setState(
|
||||
() => _resetErrorMessage =
|
||||
result.failure?.message ?? result.failure.toString(),
|
||||
result.failure?.message ?? 'Unable to reset password.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
@ -296,13 +298,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
return InputDecoration(
|
||||
labelText: label,
|
||||
floatingLabelBehavior: FloatingLabelBehavior.auto,
|
||||
labelStyle: GoogleFonts.inter(
|
||||
fontSize: 14.5,
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
floatingLabelStyle: GoogleFonts.inter(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
labelStyle: AppTypography.label2(color: colors.onSurfaceVariant),
|
||||
floatingLabelStyle: AppTypography.label3(
|
||||
weight: AppTypography.semiBold,
|
||||
color: colors.primary,
|
||||
),
|
||||
prefixIcon: Icon(icon, color: colors.iconMuted, size: 18),
|
||||
@ -355,14 +353,24 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLogo(String logoUrl) {
|
||||
Widget _buildLogo(String logoUrl, LoginColors colors) {
|
||||
return Center(
|
||||
child: SidebarLogo(
|
||||
logoUrl: logoUrl,
|
||||
height: 64,
|
||||
width: 220,
|
||||
fit: BoxFit.contain,
|
||||
showBackground: false,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
// Light: blend into the card. Dark: keep white so the logo stays readable.
|
||||
color: colors.isDark ? Colors.white : colors.cardBackground,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
child: SidebarLogo(
|
||||
logoUrl: logoUrl,
|
||||
height: 64,
|
||||
width: 220,
|
||||
fit: BoxFit.contain,
|
||||
showBackground: false,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -378,11 +386,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
child: Text(
|
||||
'SECURE ACCESS',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 11.5,
|
||||
letterSpacing: 0.6,
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
style: AppTypography.caption1(color: colors.onSurfaceVariant)
|
||||
.copyWith(letterSpacing: 0.6),
|
||||
),
|
||||
),
|
||||
Expanded(child: Divider(color: colors.outlineSoft, height: 1)),
|
||||
@ -400,10 +405,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'Your data is protected and encrypted',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 12,
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
style: AppTypography.body4(color: colors.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -411,18 +413,6 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
);
|
||||
}
|
||||
|
||||
Widget _versionLabel(LoginColors colors) {
|
||||
return Text(
|
||||
'${AppConstants.appName} · v${AppConstants.appVersion}',
|
||||
textAlign: TextAlign.center,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 11,
|
||||
letterSpacing: 0.3,
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _backToSignInButton(LoginColors colors) {
|
||||
return Center(
|
||||
child: TextButton(
|
||||
@ -433,10 +423,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
),
|
||||
child: Text(
|
||||
'Back to Sign In',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 13.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
style: AppTypography.body3(weight: AppTypography.semiBold),
|
||||
),
|
||||
),
|
||||
);
|
||||
@ -459,11 +446,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
textStyle: GoogleFonts.inter(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.2,
|
||||
),
|
||||
textStyle: AppTypography.label1(weight: AppTypography.bold),
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -477,117 +460,109 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
}) {
|
||||
return Form(
|
||||
key: _loginFormKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildLogo(logoUrl),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
'Welcome back',
|
||||
style: GoogleFonts.manrope(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.3,
|
||||
color: colors.headingColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
'Sign in to continue to your BCPL workspace.',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 13.5,
|
||||
color: colors.subtitleColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 26),
|
||||
TextFormField(
|
||||
controller: _emailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
autofillHints: const [AutofillHints.email],
|
||||
validator: Validators.email,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 14.5,
|
||||
color: colors.headingColor,
|
||||
),
|
||||
decoration: _fieldDecoration(
|
||||
colors: colors,
|
||||
label: 'Email address',
|
||||
icon: Icons.mail_outline_rounded,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
obscureText: _obscurePassword,
|
||||
autofillHints: const [AutofillHints.password],
|
||||
validator: (v) => Validators.required(v, fieldName: 'Password'),
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 14.5,
|
||||
color: colors.headingColor,
|
||||
),
|
||||
decoration: _fieldDecoration(
|
||||
colors: colors,
|
||||
label: 'Password',
|
||||
icon: Icons.lock_outline_rounded,
|
||||
suffix: IconButton(
|
||||
tooltip: _obscurePassword ? 'Show password' : 'Hide password',
|
||||
icon: Icon(
|
||||
_obscurePassword
|
||||
? Icons.visibility_outlined
|
||||
: Icons.visibility_off_outlined,
|
||||
color: colors.iconMuted,
|
||||
size: 18,
|
||||
),
|
||||
onPressed: () =>
|
||||
setState(() => _obscurePassword = !_obscurePassword),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildLogo(logoUrl, colors),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
'Welcome',
|
||||
style: AppTypography.heading5(
|
||||
weight: AppTypography.bold,
|
||||
color: colors.headingColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 34,
|
||||
width: 34,
|
||||
child: Checkbox(
|
||||
value: _rememberMe,
|
||||
activeColor: colors.primary,
|
||||
checkColor: colors.onPrimary,
|
||||
side: BorderSide(color: colors.outline, width: 1.5),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
'Sign in to access your BCPL workspace.',
|
||||
style: AppTypography.body3(color: colors.subtitleColor),
|
||||
),
|
||||
const SizedBox(height: 26),
|
||||
TextFormField(
|
||||
controller: _emailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
autofillHints: const [AutofillHints.email],
|
||||
validator: Validators.email,
|
||||
style: AppTypography.body3(
|
||||
weight: AppTypography.medium,
|
||||
color: colors.headingColor,
|
||||
),
|
||||
decoration: _fieldDecoration(
|
||||
colors: colors,
|
||||
label: 'Email address',
|
||||
icon: Icons.mail_outline_rounded,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
obscureText: _obscurePassword,
|
||||
autofillHints: const [AutofillHints.password],
|
||||
validator: (v) => Validators.required(v, fieldName: 'Password'),
|
||||
style: AppTypography.body3(
|
||||
weight: AppTypography.medium,
|
||||
color: colors.headingColor,
|
||||
),
|
||||
decoration: _fieldDecoration(
|
||||
colors: colors,
|
||||
label: 'Password',
|
||||
icon: Icons.lock_outline_rounded,
|
||||
suffix: IconButton(
|
||||
tooltip: _obscurePassword ? 'Show password' : 'Hide password',
|
||||
icon: Icon(
|
||||
_obscurePassword
|
||||
? Icons.visibility_outlined
|
||||
: Icons.visibility_off_outlined,
|
||||
color: colors.iconMuted,
|
||||
size: 18,
|
||||
),
|
||||
onChanged: (v) => setState(() => _rememberMe = v ?? false),
|
||||
onPressed: () =>
|
||||
setState(() => _obscurePassword = !_obscurePassword),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Remember me',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 13,
|
||||
color: colors.labelColor,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
TextButton(
|
||||
onPressed: _flipToForgot,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: colors.linkColor,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
child: Text(
|
||||
'Forgot password?',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 34,
|
||||
width: 34,
|
||||
child: Checkbox(
|
||||
value: _rememberMe,
|
||||
activeColor: colors.primary,
|
||||
checkColor: colors.onPrimary,
|
||||
side: BorderSide(color: colors.outline, width: 1.5),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
onChanged: (v) => setState(() => _rememberMe = v ?? false),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Text(
|
||||
'Remember me',
|
||||
style: AppTypography.body3(color: colors.labelColor),
|
||||
),
|
||||
const Spacer(),
|
||||
TextButton(
|
||||
onPressed: _flipToForgot,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: colors.linkColor,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
child: Text(
|
||||
'Forgot password?',
|
||||
style: AppTypography.body3(
|
||||
weight: AppTypography.semiBold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
_primaryButtonTheme(
|
||||
colors: colors,
|
||||
child: AppButton(
|
||||
@ -598,45 +573,41 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
),
|
||||
const SizedBox(height: 26),
|
||||
_secureAccessFooter(colors),
|
||||
const SizedBox(height: 22),
|
||||
_versionLabel(colors),
|
||||
if (DevConfig.screenPreviewEnabled) ...[
|
||||
const SizedBox(height: 20),
|
||||
Divider(color: colors.outlineSoft),
|
||||
const SizedBox(height: 14),
|
||||
Text(
|
||||
'Login API unavailable? Browse all screens without signing in:',
|
||||
textAlign: TextAlign.center,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 12,
|
||||
color: colors.subtitleColor,
|
||||
const SizedBox(height: 20),
|
||||
Divider(color: colors.outlineSoft),
|
||||
const SizedBox(height: 14),
|
||||
Text(
|
||||
'Login API unavailable? Browse all screens without signing in:',
|
||||
textAlign: TextAlign.center,
|
||||
style: AppTypography.body4(color: colors.subtitleColor),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: colors.primary,
|
||||
side: BorderSide(color: colors.outlineSoft),
|
||||
minimumSize: const Size(double.infinity, 46),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
const SizedBox(height: 12),
|
||||
Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: colors.primary,
|
||||
side: BorderSide(color: colors.outlineSoft),
|
||||
minimumSize: const Size(double.infinity, 46),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: AppButton(
|
||||
label: 'Explore All Screens',
|
||||
isOutlined: true,
|
||||
onPressed: () {
|
||||
ref.read(authStateProvider.notifier).loginAsDemo();
|
||||
context.go(RouteConstants.screenGallery);
|
||||
},
|
||||
),
|
||||
),
|
||||
child: AppButton(
|
||||
label: 'Explore All Screens',
|
||||
isOutlined: true,
|
||||
onPressed: () {
|
||||
ref.read(authStateProvider.notifier).loginAsDemo();
|
||||
context.go(RouteConstants.screenGallery);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -652,25 +623,20 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
mainAxisSize: fillHeight ? MainAxisSize.max : MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildLogo(logoUrl),
|
||||
_buildLogo(logoUrl, colors),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
'Forgot Password',
|
||||
style: GoogleFonts.manrope(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.3,
|
||||
style: AppTypography.heading5(
|
||||
weight: AppTypography.bold,
|
||||
color: colors.headingColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
'Enter your registered email address to receive password reset instructions.',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 13.5,
|
||||
height: 1.45,
|
||||
color: colors.subtitleColor,
|
||||
),
|
||||
style: AppTypography.body3(color: colors.subtitleColor)
|
||||
.copyWith(height: 1.45),
|
||||
),
|
||||
const SizedBox(height: 26),
|
||||
TextFormField(
|
||||
@ -678,8 +644,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
autofillHints: const [AutofillHints.email],
|
||||
validator: Validators.email,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 14.5,
|
||||
style: AppTypography.body3(
|
||||
weight: AppTypography.medium,
|
||||
color: colors.headingColor,
|
||||
),
|
||||
decoration: _fieldDecoration(
|
||||
@ -692,20 +658,14 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_forgotErrorMessage!,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 13,
|
||||
color: colors.colorScheme.error,
|
||||
),
|
||||
style: AppTypography.body3(color: colors.colorScheme.error),
|
||||
),
|
||||
],
|
||||
if (_forgotSuccessMessage != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_forgotSuccessMessage!,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 13,
|
||||
color: colors.primary,
|
||||
),
|
||||
style: AppTypography.body3(color: colors.primary),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 18),
|
||||
@ -724,8 +684,6 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
_backToSignInButton(colors),
|
||||
const SizedBox(height: 18),
|
||||
_secureAccessFooter(colors),
|
||||
const SizedBox(height: 22),
|
||||
_versionLabel(colors),
|
||||
],
|
||||
),
|
||||
);
|
||||
@ -742,25 +700,20 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
mainAxisSize: fillHeight ? MainAxisSize.max : MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildLogo(logoUrl),
|
||||
_buildLogo(logoUrl, colors),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
'Reset Password',
|
||||
style: GoogleFonts.manrope(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.3,
|
||||
style: AppTypography.heading5(
|
||||
weight: AppTypography.bold,
|
||||
color: colors.headingColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
'Choose a new password for your account.',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 13.5,
|
||||
height: 1.45,
|
||||
color: colors.subtitleColor,
|
||||
),
|
||||
style: AppTypography.body3(color: colors.subtitleColor)
|
||||
.copyWith(height: 1.45),
|
||||
),
|
||||
const SizedBox(height: 26),
|
||||
TextFormField(
|
||||
@ -768,8 +721,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
obscureText: _obscureNewPassword,
|
||||
autofillHints: const [AutofillHints.newPassword],
|
||||
validator: Validators.password,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 14.5,
|
||||
style: AppTypography.body3(
|
||||
weight: AppTypography.medium,
|
||||
color: colors.headingColor,
|
||||
),
|
||||
decoration: _fieldDecoration(
|
||||
@ -801,10 +754,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
if (v != _newPasswordController.text) {
|
||||
return 'Passwords do not match';
|
||||
}
|
||||
return Validators.required(v, fieldName: 'Confirm password');
|
||||
return Validators.password(v);
|
||||
},
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 14.5,
|
||||
style: AppTypography.body3(
|
||||
weight: AppTypography.medium,
|
||||
color: colors.headingColor,
|
||||
),
|
||||
decoration: _fieldDecoration(
|
||||
@ -832,20 +785,14 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_resetErrorMessage!,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 13,
|
||||
color: colors.colorScheme.error,
|
||||
),
|
||||
style: AppTypography.body3(color: colors.colorScheme.error),
|
||||
),
|
||||
],
|
||||
if ((_resetToken ?? '').isEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'This reset link is invalid or has expired. Request a new one from Forgot Password.',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 13,
|
||||
color: colors.colorScheme.error,
|
||||
),
|
||||
style: AppTypography.body3(color: colors.colorScheme.error),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 18),
|
||||
@ -864,8 +811,6 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
_backToSignInButton(colors),
|
||||
const SizedBox(height: 18),
|
||||
_secureAccessFooter(colors),
|
||||
const SizedBox(height: 22),
|
||||
_versionLabel(colors),
|
||||
],
|
||||
),
|
||||
);
|
||||
@ -944,6 +889,14 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
);
|
||||
|
||||
if (_cardHeight == null) return flipped;
|
||||
|
||||
// Lock height only while flipping / on the back face so the two
|
||||
// faces match. Keep the front face unconstrained so validation
|
||||
// errors can expand without overflowing.
|
||||
final lockHeight =
|
||||
_flipController.isAnimating || !_flipController.isDismissed;
|
||||
if (!lockHeight) return flipped;
|
||||
|
||||
return SizedBox(
|
||||
height: _cardHeight,
|
||||
width: double.infinity,
|
||||
|
||||
@ -52,7 +52,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
||||
obscureText: true,
|
||||
validator: (v) {
|
||||
if (v != _passwordController.text) return 'Passwords do not match';
|
||||
return Validators.required(v, fieldName: 'Confirm Password');
|
||||
return Validators.password(v);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../core/theme/app_typography.dart';
|
||||
import 'login_colors.dart';
|
||||
|
||||
/// BCPL wordmark with blue and green swooshes, matching the login design.
|
||||
@ -26,8 +27,9 @@ class BcplLogo extends StatelessWidget {
|
||||
child: Text(
|
||||
'BCPL',
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontFamily,
|
||||
fontSize: height * 0.58,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: AppTypography.bold,
|
||||
fontStyle: FontStyle.italic,
|
||||
letterSpacing: -0.5,
|
||||
color: colors.headingColor,
|
||||
|
||||
@ -3,6 +3,7 @@ import 'dart:math' as math;
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'login_colors.dart';
|
||||
import '../../../../core/theme/app_typography.dart';
|
||||
|
||||
/// Orbiting ERP module illustration for the login brand panel.
|
||||
class LoginHeroIllustration extends StatefulWidget {
|
||||
@ -200,12 +201,10 @@ class _Hub extends StatelessWidget {
|
||||
),
|
||||
child: Text(
|
||||
'ERP',
|
||||
style: TextStyle(
|
||||
style: AppTypography.body2(
|
||||
weight: AppTypography.bold,
|
||||
color: colors.panelText,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 17,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
).copyWith(letterSpacing: 0.5),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import '../../../../core/theme/app_typography.dart';
|
||||
|
||||
import 'login_colors.dart';
|
||||
import 'login_hero_illustration.dart';
|
||||
@ -40,24 +40,26 @@ class LoginHeroPanel extends StatelessWidget {
|
||||
SizedBox(height: compact ? 20 : 28),
|
||||
Text(
|
||||
'Smart. Integrated.\nEfficient.',
|
||||
style: GoogleFonts.manrope(
|
||||
fontSize: compact ? 28 : 40,
|
||||
fontWeight: FontWeight.w800,
|
||||
height: 1.12,
|
||||
letterSpacing: -0.5,
|
||||
color: colors.panelText,
|
||||
),
|
||||
style: (compact
|
||||
? AppTypography.heading4(
|
||||
weight: AppTypography.bold,
|
||||
color: colors.panelText,
|
||||
)
|
||||
: AppTypography.heading3(
|
||||
weight: AppTypography.bold,
|
||||
color: colors.panelText,
|
||||
))
|
||||
.copyWith(height: 1.12),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
child: Text(
|
||||
'One workspace for inventory, sales, purchases, accounts and reporting — built for teams who move fast.',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: compact ? 13.5 : 15,
|
||||
height: 1.6,
|
||||
color: colors.panelTextDim,
|
||||
),
|
||||
style: (compact
|
||||
? AppTypography.body3(color: colors.panelTextDim)
|
||||
: AppTypography.body2(color: colors.panelTextDim))
|
||||
.copyWith(height: 1.6),
|
||||
),
|
||||
),
|
||||
SizedBox(height: compact ? 16 : 24),
|
||||
@ -84,50 +86,20 @@ class _BrandMark extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.panelText.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: colors.panelText.withValues(alpha: 0.18),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'BC',
|
||||
style: GoogleFonts.manrope(
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 16,
|
||||
color: colors.panelText,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'BCPL',
|
||||
style: AppTypography.heading6(
|
||||
weight: AppTypography.bold,
|
||||
color: colors.panelText,
|
||||
).copyWith(letterSpacing: 0.5),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'BCPL',
|
||||
style: GoogleFonts.manrope(
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 20,
|
||||
letterSpacing: 0.5,
|
||||
color: colors.panelText,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'BHARAT ERP',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 11,
|
||||
letterSpacing: 1.5,
|
||||
color: colors.panelTextDim,
|
||||
),
|
||||
),
|
||||
],
|
||||
Text(
|
||||
'BHARAT ERP',
|
||||
style: AppTypography.caption1(color: colors.panelTextDim)
|
||||
.copyWith(letterSpacing: 1.5),
|
||||
),
|
||||
],
|
||||
);
|
||||
@ -142,74 +114,64 @@ class _FeatureRow extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final items = [
|
||||
(
|
||||
Icons.layers_outlined,
|
||||
'Unified data',
|
||||
'One source of truth across every module',
|
||||
),
|
||||
(
|
||||
Icons.bolt_outlined,
|
||||
'Real-time sync',
|
||||
'Every team sees the same live numbers',
|
||||
),
|
||||
(
|
||||
Icons.bar_chart_rounded,
|
||||
'Clear reporting',
|
||||
'Dashboards built for daily decisions',
|
||||
),
|
||||
(Icons.layers_outlined, 'Unified data'),
|
||||
(Icons.bolt_outlined, 'Real-time sync'),
|
||||
(Icons.bar_chart_rounded, 'Clear reporting'),
|
||||
];
|
||||
|
||||
return Wrap(
|
||||
spacing: 28,
|
||||
runSpacing: 16,
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
for (final item in items)
|
||||
SizedBox(
|
||||
width: 160,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 30,
|
||||
height: 30,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.panelText.withValues(alpha: 0.10),
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
border: Border.all(
|
||||
color: colors.panelText.withValues(alpha: 0.16),
|
||||
),
|
||||
),
|
||||
child: Icon(item.$1, size: 15, color: colors.panelText),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.$2,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 12.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colors.panelText,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
item.$3,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 11.5,
|
||||
height: 1.4,
|
||||
color: colors.panelTextDim,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
for (var i = 0; i < items.length; i++) ...[
|
||||
if (i > 0) const SizedBox(width: 28),
|
||||
_FeatureItem(
|
||||
colors: colors,
|
||||
icon: items[i].$1,
|
||||
title: items[i].$2,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FeatureItem extends StatelessWidget {
|
||||
const _FeatureItem({
|
||||
required this.colors,
|
||||
required this.icon,
|
||||
required this.title,
|
||||
});
|
||||
|
||||
final LoginColors colors;
|
||||
final IconData icon;
|
||||
final String title;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 30,
|
||||
height: 30,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.panelText.withValues(alpha: 0.10),
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
border: Border.all(
|
||||
color: colors.panelText.withValues(alpha: 0.16),
|
||||
),
|
||||
),
|
||||
child: Icon(icon, size: 15, color: colors.panelText),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
title,
|
||||
style: AppTypography.label3(
|
||||
weight: AppTypography.semiBold,
|
||||
color: colors.panelText,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@ -61,7 +61,7 @@ class _BranchFormScreenState extends State<BranchFormScreen> {
|
||||
AppTextField(controller: _managerController, label: 'Manager'),
|
||||
const SizedBox(height: 24),
|
||||
AppButton(
|
||||
label: isEditing ? 'Update Branch' : 'Create Branch',
|
||||
label: isEditing ? 'Update Branch' : 'Save Branch',
|
||||
onPressed: () {
|
||||
if (_formKey.currentState!.validate()) Navigator.of(context).pop();
|
||||
},
|
||||
|
||||
@ -89,7 +89,7 @@ class _CompanyFormScreenState extends State<CompanyFormScreen> {
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
AppButton(
|
||||
label: isEditing ? 'Update Company' : 'Create Company',
|
||||
label: isEditing ? 'Update Company' : 'Save Company',
|
||||
onPressed: () {
|
||||
if (_formKey.currentState!.validate()) {
|
||||
Navigator.of(context).pop();
|
||||
|
||||
@ -1,10 +1,7 @@
|
||||
import '../../../../shared/widgets/app_card.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../../core/constants/route_constants.dart';
|
||||
import '../../../../core/constants/app_constants.dart';
|
||||
import '../../../../core/utils/responsive_utils.dart';
|
||||
import '../../../../shared/widgets/kpi_card.dart';
|
||||
import '../../../../shared/widgets/page_header.dart';
|
||||
|
||||
class DashboardScreen extends StatelessWidget {
|
||||
@ -12,83 +9,52 @@ class DashboardScreen extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SingleChildScrollView(
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: context.contentMaxWidth),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const PageHeader(
|
||||
title: 'Dashboard',
|
||||
subtitle: 'Asset management overview',
|
||||
subtitle: 'Overview',
|
||||
),
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final crossAxisCount = constraints.maxWidth > 900 ? 3 : (constraints.maxWidth > 600 ? 2 : 1);
|
||||
return GridView.count(
|
||||
crossAxisCount: crossAxisCount,
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
mainAxisSpacing: 16,
|
||||
crossAxisSpacing: 16,
|
||||
childAspectRatio: 1.8,
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
KpiCard(
|
||||
title: 'Total Assets',
|
||||
value: '—',
|
||||
icon: Icons.inventory_2_outlined,
|
||||
onTap: () => context.go(RouteConstants.assets),
|
||||
Image.asset(
|
||||
AppConstants.defaultLogoAsset,
|
||||
height: 72,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (_, __, ___) => Icon(
|
||||
Icons.dashboard_outlined,
|
||||
size: 72,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
KpiCard(
|
||||
title: 'Allocated',
|
||||
value: '—',
|
||||
icon: Icons.assignment_ind_outlined,
|
||||
color: Colors.blue,
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Dashboard coming soon',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
KpiCard(
|
||||
title: 'Available',
|
||||
value: '—',
|
||||
icon: Icons.check_circle_outline,
|
||||
color: Colors.green,
|
||||
),
|
||||
KpiCard(
|
||||
title: 'Under Maintenance',
|
||||
value: '—',
|
||||
icon: Icons.build_outlined,
|
||||
color: Colors.orange,
|
||||
),
|
||||
KpiCard(
|
||||
title: 'Disposed',
|
||||
value: '—',
|
||||
icon: Icons.delete_outline,
|
||||
color: Colors.red,
|
||||
),
|
||||
KpiCard(
|
||||
title: 'Warranty Expiring',
|
||||
value: '—',
|
||||
icon: Icons.warning_amber_outlined,
|
||||
color: Colors.amber,
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'This space is empty for now. Insights and charts will appear here later.',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Text('Charts', style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 16),
|
||||
AppCard(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(48),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Charts will load from API\n(Assets by Category, Branch, Allocation Trend)',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@ -77,7 +77,7 @@ final _entries = [
|
||||
),
|
||||
// Assets
|
||||
_GalleryEntry(title: 'Asset List', route: RouteConstants.assets, group: 'Assets'),
|
||||
_GalleryEntry(title: 'Asset Detail', route: '/assets/demo-asset', group: 'Assets'),
|
||||
_GalleryEntry(title: 'Asset Detail', route: '${RouteConstants.assets}/demo-asset', group: 'Assets'),
|
||||
_GalleryEntry(title: 'Alerts', route: RouteConstants.assetAlerts, group: 'Assets'),
|
||||
// Master data
|
||||
_GalleryEntry(title: 'Master Data Hub', route: RouteConstants.masterData, group: 'Master Data'),
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../../../core/constants/api_endpoints.dart';
|
||||
import '../../../../core/utils/pagination_meta.dart';
|
||||
import '../../../../core/utils/export_file_name.dart';
|
||||
import '../../../../shared/models/api_response.dart';
|
||||
import '../../../../shared/models/export_file_result.dart';
|
||||
@ -16,7 +17,12 @@ class GrnRemoteDataSource {
|
||||
ApiEndpoints.grn,
|
||||
queryParameters: _queryToMap(query),
|
||||
);
|
||||
return _parsePaginated(response.data, GrnModel.fromJson);
|
||||
return _parsePaginated(
|
||||
response.data,
|
||||
GrnModel.fromJson,
|
||||
fallbackPage: query.page,
|
||||
fallbackLimit: query.limit,
|
||||
);
|
||||
}
|
||||
|
||||
/// Form-dropdown loader: all active GRNs (`dropdown_call=true`).
|
||||
@ -158,58 +164,45 @@ class GrnRemoteDataSource {
|
||||
|
||||
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).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).clamp(1, 999999) : 1,
|
||||
);
|
||||
T Function(Map<String, dynamic>) fromJson, {
|
||||
int fallbackPage = 1,
|
||||
int fallbackLimit = 20,
|
||||
}) {
|
||||
var items = <T>[];
|
||||
if (body is Map) {
|
||||
final raw = body['data'];
|
||||
if (raw is List) {
|
||||
items = raw
|
||||
.whereType<Map>()
|
||||
.map((e) => fromJson(Map<String, dynamic>.from(e)))
|
||||
.toList();
|
||||
} else if (raw is Map) {
|
||||
final list = raw['items'];
|
||||
if (list is List) {
|
||||
items = list
|
||||
.whereType<Map>()
|
||||
.map((e) => fromJson(Map<String, dynamic>.from(e)))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return const PaginatedResponse(
|
||||
items: [],
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total: 0,
|
||||
totalPages: 1,
|
||||
final pagination = parsePagination(
|
||||
body: body,
|
||||
fallbackPage: fallbackPage,
|
||||
fallbackLimit: fallbackLimit,
|
||||
itemCount: items.length,
|
||||
);
|
||||
|
||||
return PaginatedResponse(
|
||||
items: items,
|
||||
page: pagination.page,
|
||||
limit: fallbackLimit,
|
||||
total: pagination.total,
|
||||
totalPages: resolveTotalPages(
|
||||
total: pagination.total,
|
||||
limit: fallbackLimit,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,10 +1,15 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/utils/column_search_paging.dart';
|
||||
import '../../../../core/utils/table_search.dart';
|
||||
|
||||
import '../../../../shared/models/export_file_result.dart';
|
||||
import '../../../../shared/models/grn_model.dart';
|
||||
import '../../../../shared/models/purchase_order_model.dart';
|
||||
import '../../../purchase_orders/data/repositories/purchase_order_repository_impl.dart';
|
||||
import '../../../purchase_orders/presentation/providers/purchase_order_lookups_provider.dart';
|
||||
import '../../data/repositories/grn_repository_impl.dart';
|
||||
import 'grn_lookups_provider.dart';
|
||||
|
||||
class GrnListState {
|
||||
const GrnListState({
|
||||
@ -57,6 +62,8 @@ final grnListProvider =
|
||||
);
|
||||
|
||||
class GrnListNotifier extends AutoDisposeAsyncNotifier<GrnListState> {
|
||||
final _columnSearch = ColumnSearchPaging();
|
||||
|
||||
@override
|
||||
Future<GrnListState> build() async {
|
||||
return _load(const GrnListQuery(limit: 20));
|
||||
@ -67,8 +74,73 @@ class GrnListNotifier extends AutoDisposeAsyncNotifier<GrnListState> {
|
||||
final result = await repository.getGrns(query);
|
||||
if (result.failure != null) throw result.failure!;
|
||||
final page = result.data!;
|
||||
var items = List<GrnModel>.from(page.items);
|
||||
final search = TableSearch.normalize(query.search);
|
||||
|
||||
// API search often ignores PO number / vendor name — enrich via filters.
|
||||
if (search.isNotEmpty &&
|
||||
query.poId == null &&
|
||||
query.vendorId == null) {
|
||||
try {
|
||||
final grnLookups = await ref.read(grnLookupsProvider.future);
|
||||
final poCandidates = <PurchaseOrderModel>[
|
||||
...grnLookups.receivablePurchaseOrders,
|
||||
];
|
||||
// Include broader PO dropdown options so search works beyond receivable.
|
||||
final allPoOptions = await ref
|
||||
.read(purchaseOrderRepositoryProvider)
|
||||
.listPurchaseOrderOptions();
|
||||
if (allPoOptions.failure == null && allPoOptions.data != null) {
|
||||
poCandidates.addAll(allPoOptions.data!);
|
||||
}
|
||||
|
||||
final seenPoIds = <String>{};
|
||||
var poMatches = 0;
|
||||
for (final po in poCandidates) {
|
||||
if (!seenPoIds.add(po.id)) continue;
|
||||
if (!TableSearch.matches(search, [po.poNo, po.vendorName])) {
|
||||
continue;
|
||||
}
|
||||
if (++poMatches > 5) break;
|
||||
final poId = int.tryParse(po.id);
|
||||
if (poId == null) continue;
|
||||
final byPo = await repository.getGrns(
|
||||
query.copyWith(search: null, poId: poId),
|
||||
);
|
||||
if (byPo.failure == null && byPo.data != null) {
|
||||
items = TableSearch.mergeById(
|
||||
items,
|
||||
byPo.data!.items,
|
||||
(grn) => grn.id,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final poLookups = await ref.read(purchaseOrderLookupsProvider.future);
|
||||
var vendorMatches = 0;
|
||||
for (final vendor in poLookups.vendors) {
|
||||
if (!TableSearch.matches(search, [vendor.name])) continue;
|
||||
if (++vendorMatches > 5) break;
|
||||
final vendorId = int.tryParse(vendor.id);
|
||||
if (vendorId == null) continue;
|
||||
final byVendor = await repository.getGrns(
|
||||
query.copyWith(search: null, vendorId: vendorId),
|
||||
);
|
||||
if (byVendor.failure == null && byVendor.data != null) {
|
||||
items = TableSearch.mergeById(
|
||||
items,
|
||||
byVendor.data!.items,
|
||||
(grn) => grn.id,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// Lookups enrichment is best-effort.
|
||||
}
|
||||
}
|
||||
|
||||
return GrnListState(
|
||||
grns: page.items,
|
||||
grns: items,
|
||||
query: query,
|
||||
total: page.total,
|
||||
totalPages: page.totalPages,
|
||||
@ -103,6 +175,27 @@ class GrnListNotifier extends AutoDisposeAsyncNotifier<GrnListState> {
|
||||
applyQuery(current.query.copyWith(search: TableSearch.normalize(search), page: 1));
|
||||
}
|
||||
|
||||
Future<void> ensureColumnSearchDataset() async {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
final limit = _columnSearch.beginFullDataset(
|
||||
currentLimit: current.query.limit,
|
||||
total: current.total,
|
||||
);
|
||||
if (limit == null) return;
|
||||
await applyQuery(
|
||||
current.query.copyWith(search: null, page: 1, limit: limit),
|
||||
);
|
||||
}
|
||||
|
||||
void clearColumnSearchDataset() {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
final limit = _columnSearch.endFullDataset();
|
||||
if (limit == null) return;
|
||||
applyQuery(current.query.copyWith(search: null, page: 1, limit: limit));
|
||||
}
|
||||
|
||||
void setStatusFilter(String? status) {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
|
||||
@ -12,12 +12,14 @@ import '../../../../shared/providers/permissions_provider.dart';
|
||||
import '../../../../shared/utils/file_download_helper.dart';
|
||||
import '../../../../shared/widgets/app_text_field.dart';
|
||||
import '../../../../shared/widgets/app_loading_view.dart';
|
||||
import '../../../../shared/widgets/detail_overview_widgets.dart';
|
||||
import '../../../../shared/widgets/error_view.dart';
|
||||
import '../providers/grn_lookups_provider.dart';
|
||||
import '../providers/grn_provider.dart';
|
||||
import '../widgets/grn_attachments_card.dart';
|
||||
import '../widgets/grn_line_items_editor.dart';
|
||||
import '../widgets/grn_status_chip.dart';
|
||||
import '../../../../shared/widgets/api_feedback.dart';
|
||||
import '../../../../shared/widgets/app_toast.dart';
|
||||
|
||||
class GrnDetailScreen extends ConsumerStatefulWidget {
|
||||
@ -32,6 +34,24 @@ class GrnDetailScreen extends ConsumerStatefulWidget {
|
||||
class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> {
|
||||
bool _isWorking = false;
|
||||
bool _isDownloadingPdf = false;
|
||||
bool _requestedFreshLoad = false;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_requestedFreshLoad) return;
|
||||
_requestedFreshLoad = true;
|
||||
// Always hit GET /grn/{id} when opening view.
|
||||
ref.invalidate(grnDetailProvider(widget.grnId));
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant GrnDetailScreen oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.grnId != widget.grnId) {
|
||||
ref.invalidate(grnDetailProvider(widget.grnId));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -44,7 +64,7 @@ class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> {
|
||||
return Scaffold(
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
body: detailAsync.when(
|
||||
loading: () => const AppLoadingView(message: 'Loading GRN...'),
|
||||
loading: () => const AppLoadingView(message: 'Loading Purchase Receipt...'),
|
||||
error: (e, _) => ErrorView.fromFailure(
|
||||
e is Failure ? e : Failure.unknown(message: e.toString()),
|
||||
onRetry: () => ref.invalidate(grnDetailProvider(widget.grnId)),
|
||||
@ -52,13 +72,10 @@ class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> {
|
||||
data: (grn) {
|
||||
final lookups = lookupsAsync.asData?.value;
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 32),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 1200),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_DetailHeader(
|
||||
grn: grn,
|
||||
isWorking: _isWorking,
|
||||
@ -100,8 +117,6 @@ class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> {
|
||||
const SizedBox(height: 20),
|
||||
_DetailFooter(grn: grn),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
@ -121,7 +136,10 @@ class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> {
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
showAppToastFromSnackBar(context, SnackBar(content: Text(e.toString())));
|
||||
showAppToastFromSnackBar(
|
||||
context,
|
||||
SnackBar(content: Text(errorDisplayMessage(e))),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isWorking = false);
|
||||
@ -133,7 +151,7 @@ class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Cancel GRN'),
|
||||
title: const Text('Cancel Purchase Receipt'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
@ -158,7 +176,7 @@ class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> {
|
||||
if (reasonController.text.trim().isEmpty) return;
|
||||
Navigator.pop(context, true);
|
||||
},
|
||||
child: const Text('Cancel GRN'),
|
||||
child: const Text('Cancel Purchase Receipt'),
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -169,7 +187,7 @@ class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> {
|
||||
() => ref.read(grnDetailProvider(widget.grnId).notifier).cancel(
|
||||
cancellationReason: reasonController.text.trim(),
|
||||
),
|
||||
'GRN cancelled',
|
||||
'Purchase Receipt cancelled',
|
||||
);
|
||||
reasonController.dispose();
|
||||
}
|
||||
@ -182,14 +200,17 @@ class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> {
|
||||
.downloadPdf();
|
||||
await downloadFile(
|
||||
bytes: bytes,
|
||||
fileName: '${grn.grnNumber ?? 'GRN-${grn.id}'}.pdf',
|
||||
fileName: '${grn.grnNumber ?? 'PurchaseReceipt-${grn.id}'}.pdf',
|
||||
);
|
||||
if (mounted) {
|
||||
showAppToastFromSnackBar(context, const SnackBar(content: Text('PDF downloaded')));
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
showAppToastFromSnackBar(context, SnackBar(content: Text(e.toString())));
|
||||
showAppToastFromSnackBar(
|
||||
context,
|
||||
SnackBar(content: Text(errorDisplayMessage(e))),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isDownloadingPdf = false);
|
||||
@ -280,7 +301,7 @@ class _DetailHeader extends StatelessWidget {
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
grn.grnNumber ?? 'GRN #${grn.id}',
|
||||
grn.grnNumber ?? 'Purchase Receipt #${grn.id}',
|
||||
style: theme.textTheme.headlineSmall,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
@ -508,124 +529,113 @@ class _ReceiptDetailsCard extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final locationDisplay = _displayOrDash(
|
||||
[
|
||||
if (grn.locationName?.trim().isNotEmpty == true)
|
||||
grn.locationName!.trim(),
|
||||
if (grn.locationType?.trim().isNotEmpty == true)
|
||||
'(${grn.locationType!.trim()})',
|
||||
].join(' ').trim(),
|
||||
);
|
||||
|
||||
return _SectionCard(
|
||||
title: 'RECEIPT DETAILS',
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final cols = constraints.maxWidth < 600
|
||||
? 1
|
||||
: constraints.maxWidth < 900
|
||||
? 2
|
||||
: 4;
|
||||
const spacing = 20.0;
|
||||
final width = (constraints.maxWidth - spacing * (cols - 1)) / cols;
|
||||
final items = [
|
||||
_DetailField(
|
||||
label: 'GRN Date',
|
||||
value: DateFormatter.displayDate(grn.grnDate),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
DetailOverviewSection(
|
||||
title: 'Summary',
|
||||
child: DetailSummaryStrip(
|
||||
metrics: [
|
||||
DetailSummaryMetric(
|
||||
icon: Icons.flag_outlined,
|
||||
label: 'Status',
|
||||
accent: scheme.secondary,
|
||||
child: GrnStatusChip(status: grn.status, compact: true),
|
||||
),
|
||||
DetailSummaryMetric(
|
||||
icon: Icons.calendar_today_outlined,
|
||||
label: 'Purchase Receipt Date',
|
||||
child: Text(
|
||||
DateFormatter.displayDate(grn.grnDate),
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
DetailSummaryMetric(
|
||||
icon: Icons.receipt_long_outlined,
|
||||
label: 'PO Number',
|
||||
child: Text(
|
||||
_displayOrDash(grn.poNumber),
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
DetailSummaryMetric(
|
||||
icon: Icons.payments_outlined,
|
||||
label: 'Invoice Amount',
|
||||
accent: scheme.primary,
|
||||
child: Text(
|
||||
grn.vendorInvoiceAmount != null
|
||||
? CurrencyFormatter.format(grn.vendorInvoiceAmount!)
|
||||
: '—',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
_DetailField(
|
||||
label: 'PO Number',
|
||||
value: _displayOrDash(grn.poNumber),
|
||||
),
|
||||
DetailOverviewSection(
|
||||
title: 'Receipt',
|
||||
child: DetailInfoGrid(
|
||||
items: [
|
||||
DetailInfoItem('Vendor', _displayOrDash(grn.vendorName)),
|
||||
DetailInfoItem('Location', locationDisplay),
|
||||
DetailInfoItem(
|
||||
'Vendor Invoice No',
|
||||
_displayOrDash(grn.vendorInvoiceNo),
|
||||
),
|
||||
DetailInfoItem(
|
||||
'Vendor Invoice Date',
|
||||
DateFormatter.displayDate(grn.vendorInvoiceDate),
|
||||
),
|
||||
DetailInfoItem(
|
||||
'Received By',
|
||||
_userLabel(lookups?.users, grn.receivedBy),
|
||||
),
|
||||
DetailInfoItem(
|
||||
'Quality Checked By',
|
||||
_userLabel(lookups?.users, grn.qualityCheckedBy),
|
||||
),
|
||||
],
|
||||
),
|
||||
_DetailField(
|
||||
label: 'Vendor',
|
||||
value: _displayOrDash(grn.vendorName),
|
||||
),
|
||||
DetailOverviewSection(
|
||||
title: 'Transport',
|
||||
showDivider: false,
|
||||
child: DetailInfoGrid(
|
||||
items: [
|
||||
DetailInfoItem('Vehicle No', _displayOrDash(grn.vehicleNo)),
|
||||
DetailInfoItem('LR No', _displayOrDash(grn.lrNo)),
|
||||
DetailInfoItem(
|
||||
'LR Date',
|
||||
DateFormatter.displayDate(grn.lrDate),
|
||||
),
|
||||
],
|
||||
),
|
||||
_DetailField(
|
||||
label: 'Location',
|
||||
value: _displayOrDash(
|
||||
[
|
||||
if (grn.locationName?.trim().isNotEmpty == true)
|
||||
grn.locationName!.trim(),
|
||||
if (grn.locationType?.trim().isNotEmpty == true)
|
||||
'(${grn.locationType!.trim()})',
|
||||
].join(' ').trim(),
|
||||
),
|
||||
),
|
||||
_DetailField(
|
||||
label: 'Vendor Invoice No',
|
||||
value: _displayOrDash(grn.vendorInvoiceNo),
|
||||
),
|
||||
_DetailField(
|
||||
label: 'Vendor Invoice Date',
|
||||
value: DateFormatter.displayDate(grn.vendorInvoiceDate),
|
||||
),
|
||||
_DetailField(
|
||||
label: 'Vendor Invoice Amount',
|
||||
value: grn.vendorInvoiceAmount != null
|
||||
? CurrencyFormatter.format(grn.vendorInvoiceAmount!)
|
||||
: '—',
|
||||
),
|
||||
_DetailField(
|
||||
label: 'Vehicle No',
|
||||
value: _displayOrDash(grn.vehicleNo),
|
||||
),
|
||||
_DetailField(
|
||||
label: 'LR No',
|
||||
value: _displayOrDash(grn.lrNo),
|
||||
),
|
||||
_DetailField(
|
||||
label: 'LR Date',
|
||||
value: DateFormatter.displayDate(grn.lrDate),
|
||||
),
|
||||
_DetailField(
|
||||
label: 'Received By',
|
||||
value: _userLabel(lookups?.users, grn.receivedBy),
|
||||
),
|
||||
_DetailField(
|
||||
label: 'Quality Checked By',
|
||||
value: _userLabel(lookups?.users, grn.qualityCheckedBy),
|
||||
),
|
||||
];
|
||||
|
||||
return Wrap(
|
||||
spacing: spacing,
|
||||
runSpacing: 16,
|
||||
children: items
|
||||
.map((item) => SizedBox(width: width, child: item))
|
||||
.toList(),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DetailField extends StatelessWidget {
|
||||
const _DetailField({
|
||||
required this.label,
|
||||
required this.value,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
value,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LineItemsCard extends StatelessWidget {
|
||||
const _LineItemsCard({required this.grn});
|
||||
|
||||
|
||||
@ -5,26 +5,27 @@ 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 '../../../../core/utils/validators.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/api_feedback.dart';
|
||||
import '../../../../shared/widgets/app_button.dart';
|
||||
import '../../../../shared/widgets/app_date_popup.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_side_panel.dart';
|
||||
import '../../../../shared/widgets/app_sticky_form_layout.dart';
|
||||
import '../../../../shared/widgets/app_text_field.dart';
|
||||
import '../../../../shared/widgets/error_view.dart';
|
||||
import '../../../../shared/widgets/app_toast.dart';
|
||||
import '../providers/grn_lookups_provider.dart';
|
||||
import '../providers/grn_provider.dart';
|
||||
import '../widgets/grn_line_items_editor.dart';
|
||||
import '../widgets/grn_status_chip.dart';
|
||||
import '../../../../shared/widgets/app_toast.dart';
|
||||
|
||||
class GrnFormScreen extends ConsumerStatefulWidget {
|
||||
const GrnFormScreen({super.key, this.grnId});
|
||||
@ -56,18 +57,29 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
|
||||
final List<GrnLineItemDraft> _lines = [];
|
||||
bool _isSubmitting = false;
|
||||
String? _populatedSignature;
|
||||
bool _requestedFreshLoad = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (!widget.isEditing) {
|
||||
_grnDate = DateTime.now();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) ref.invalidate(grnLookupsProvider);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_requestedFreshLoad) return;
|
||||
_requestedFreshLoad = true;
|
||||
if (!widget.isEditing) {
|
||||
ref.invalidate(grnLookupsProvider);
|
||||
return;
|
||||
}
|
||||
// Always hit GET /grn/{id} when opening edit.
|
||||
ref.invalidate(grnFormProvider(widget.grnId));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
@ -277,7 +289,7 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
|
||||
if (!mounted) return;
|
||||
showAppToastFromSnackBar(context,
|
||||
SnackBar(
|
||||
content: Text(widget.isEditing ? 'GRN updated' : 'GRN created'),
|
||||
content: Text(widget.isEditing ? 'Purchase Receipt updated' : 'Purchase Receipt created'),
|
||||
),
|
||||
);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
@ -289,9 +301,10 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
final message =
|
||||
e is Failure ? validationErrorMessage(e) : e.toString();
|
||||
showAppToastFromSnackBar(context, SnackBar(content: Text(message)));
|
||||
showAppToastFromSnackBar(
|
||||
context,
|
||||
SnackBar(content: Text(errorDisplayMessage(e))),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _isSubmitting = false);
|
||||
}
|
||||
@ -350,7 +363,7 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
|
||||
lookups: lookups,
|
||||
existing: existingAsync.valueOrNull,
|
||||
)
|
||||
: const AppLoadingView(message: 'Loading GRN...'),
|
||||
: const AppLoadingView(message: 'Loading Purchase Receipt...'),
|
||||
error: (e, _) => ErrorView.fromFailure(
|
||||
e is Failure ? e : Failure.unknown(message: e.toString()),
|
||||
onRetry: () => ref.invalidate(grnFormProvider(widget.grnId)),
|
||||
@ -378,7 +391,7 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
|
||||
|
||||
if (widget.isEditing && existing != null && !existing.canEdit) {
|
||||
return ErrorView.fromFailure(
|
||||
const Failure.validation(message: 'This GRN cannot be edited'),
|
||||
const Failure.validation(message: 'This Purchase Receipt cannot be edited'),
|
||||
onRetry: () => context.go('${RouteConstants.grn}/${existing.id}'),
|
||||
);
|
||||
}
|
||||
@ -395,16 +408,16 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
|
||||
.toList();
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return SingleChildScrollView(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 32),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
return Form(
|
||||
key: _formKey,
|
||||
child: AppStickyFormLayout(
|
||||
scrollController: _scrollController,
|
||||
headerPadding: const EdgeInsets.fromLTRB(24, 12, 24, 12),
|
||||
bodyPadding: const EdgeInsets.fromLTRB(24, 16, 24, 32),
|
||||
header: _buildHeader(existing),
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildHeader(existing),
|
||||
const SizedBox(height: 16),
|
||||
_SectionCard(
|
||||
title: 'RECEIPT DETAILS',
|
||||
child: Column(
|
||||
@ -412,7 +425,7 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
|
||||
FormRowFour(
|
||||
children: [
|
||||
_DateField(
|
||||
label: 'GRN Date *',
|
||||
label: 'Purchase Receipt Date *',
|
||||
value: _grnDate,
|
||||
enabled: !widget.isEditing,
|
||||
onTap: widget.isEditing
|
||||
@ -451,8 +464,11 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
showAppToastFromSnackBar(context,
|
||||
SnackBar(content: Text(e.toString())),
|
||||
showAppToastFromSnackBar(
|
||||
context,
|
||||
SnackBar(
|
||||
content: Text(errorDisplayMessage(e)),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
@ -625,8 +641,8 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
|
||||
Widget _buildHeader(GrnModel? existing) {
|
||||
final theme = Theme.of(context);
|
||||
final title = widget.isEditing
|
||||
? 'Edit ${existing?.grnNumber ?? 'GRN'}'
|
||||
: 'Create Goods Received Note';
|
||||
? 'Edit ${existing?.grnNumber ?? 'Purchase Receipt'}'
|
||||
: 'Create Purchase Receipt';
|
||||
final subtitle = widget.isEditing
|
||||
? null
|
||||
: 'Select an approved purchase order, enter receipt details, then confirm quantities.';
|
||||
@ -640,7 +656,7 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
AppButton(
|
||||
label: widget.isEditing ? 'Update GRN' : 'Save GRN',
|
||||
label: widget.isEditing ? 'Update Purchase Receipt' : 'Save Purchase Receipt',
|
||||
icon: Icons.check,
|
||||
expand: false,
|
||||
isLoading: _isSubmitting,
|
||||
@ -675,7 +691,7 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
|
||||
);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
padding: EdgeInsets.zero,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final stack = constraints.maxWidth < 720;
|
||||
|
||||
@ -21,9 +21,11 @@ import '../../../../shared/widgets/app_search_filter_toggle.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_column_selector.dart';
|
||||
import '../../../../shared/widgets/app_table_shell.dart';
|
||||
import '../../../../shared/widgets/error_view.dart';
|
||||
import '../../../../shared/widgets/page_header.dart';
|
||||
import '../../../../shared/providers/table_column_prefs_provider.dart';
|
||||
import '../../../../shared/utils/file_download_helper.dart';
|
||||
import '../providers/grn_lookups_provider.dart';
|
||||
import '../providers/grn_provider.dart';
|
||||
@ -64,7 +66,7 @@ class _GrnListScreenState extends ConsumerState<GrnListScreen> {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: listAsync.when(
|
||||
loading: () => const AppLoadingView(message: 'Loading GRNs...'),
|
||||
loading: () => const AppLoadingView(message: 'Loading Purchase Receipts...'),
|
||||
error: (error, _) => ErrorView.fromFailure(
|
||||
error is Failure ? error : Failure.unknown(message: error.toString()),
|
||||
onRetry: () => ref.invalidate(grnListProvider),
|
||||
@ -73,7 +75,7 @@ class _GrnListScreenState extends ConsumerState<GrnListScreen> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
PageHeader(
|
||||
title: 'Goods Received Notes',
|
||||
title: 'Purchase Receipt',
|
||||
subtitle: 'Record and track purchase order receipts',
|
||||
actions: [
|
||||
AppSearchFilterButton(
|
||||
@ -82,6 +84,11 @@ class _GrnListScreenState extends ConsumerState<GrnListScreen> {
|
||||
() => _filtersExpanded = !_filtersExpanded,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
AppTableColumnSelectorButton(
|
||||
tableId: _GrnDataTable.tableId,
|
||||
columns: _GrnDataTable.columnOptions,
|
||||
),
|
||||
if (canExport) ...[
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton.icon(
|
||||
@ -103,7 +110,7 @@ class _GrnListScreenState extends ConsumerState<GrnListScreen> {
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () => context.go(RouteConstants.grnAdd),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Create GRN'),
|
||||
label: const Text('Create Purchase Receipt'),
|
||||
),
|
||||
),
|
||||
],
|
||||
@ -126,38 +133,45 @@ class _GrnListScreenState extends ConsumerState<GrnListScreen> {
|
||||
totalPages: state.totalPages,
|
||||
totalItems: state.total,
|
||||
pageSize: state.query.limit,
|
||||
itemLabel: 'GRNs',
|
||||
itemsOnPage: state.grns.length,
|
||||
itemLabel: 'Purchase Receipts',
|
||||
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,
|
||||
child: context.isMobile
|
||||
? (state.grns.isEmpty
|
||||
? ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: const [
|
||||
SizedBox(
|
||||
height: 240,
|
||||
child: AppEmptyState(
|
||||
title: 'No Purchase Receipts found',
|
||||
description:
|
||||
'Try adjusting filters or create a new purchase receipt.',
|
||||
icon: Icons.inventory_2_outlined,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: _GrnDataTable(
|
||||
: _GrnCardList(
|
||||
grns: state.grns,
|
||||
onView: _viewGrn,
|
||||
onEdit: canEdit ? _editGrn : null,
|
||||
),
|
||||
))
|
||||
: _GrnDataTable(
|
||||
grns: state.grns,
|
||||
onView: _viewGrn,
|
||||
onEdit: canEdit ? _editGrn : null,
|
||||
onEnsureFullDataset: () => ref
|
||||
.read(grnListProvider.notifier)
|
||||
.ensureColumnSearchDataset(),
|
||||
onColumnSearchCleared: () => ref
|
||||
.read(grnListProvider.notifier)
|
||||
.clearColumnSearchDataset(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -202,7 +216,7 @@ class _GrnListScreenState extends ConsumerState<GrnListScreen> {
|
||||
void _editGrn(GrnModel grn) {
|
||||
if (!grn.canEdit) {
|
||||
showAppToastFromSnackBar(context,
|
||||
const SnackBar(content: Text('Only posted GRNs can be edited')),
|
||||
const SnackBar(content: Text('Only posted Purchase Receipts can be edited')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@ -236,7 +250,7 @@ class _FiltersBar extends StatelessWidget {
|
||||
onChanged: onSearch,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Search',
|
||||
hintText: 'Search GRN number, PO, vendor...',
|
||||
hintText: 'Search receipt number, PO, vendor...',
|
||||
prefixIcon: Icon(Icons.search, size: 20),
|
||||
floatingLabelBehavior: FloatingLabelBehavior.always,
|
||||
isDense: true,
|
||||
@ -282,83 +296,138 @@ class _FiltersBar extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _GrnDataTable extends StatelessWidget {
|
||||
class _GrnDataTable extends ConsumerWidget {
|
||||
const _GrnDataTable({
|
||||
required this.grns,
|
||||
required this.onView,
|
||||
this.onEdit,
|
||||
this.onEnsureFullDataset,
|
||||
this.onColumnSearchCleared,
|
||||
});
|
||||
|
||||
static const tableId = 'grn_list';
|
||||
|
||||
static List<AppTableColumnOption> get columnOptions => const [
|
||||
AppTableColumnOption(
|
||||
id: 'grn_number',
|
||||
label: 'Purchase Receipt Number',
|
||||
required: true,
|
||||
),
|
||||
AppTableColumnOption(id: 'date', label: 'Date'),
|
||||
AppTableColumnOption(id: 'po_number', label: 'PO Number'),
|
||||
AppTableColumnOption(id: 'vendor', label: 'Vendor'),
|
||||
AppTableColumnOption(id: 'location', label: 'Location'),
|
||||
AppTableColumnOption(id: 'status', label: 'Status'),
|
||||
];
|
||||
|
||||
final List<GrnModel> grns;
|
||||
final ValueChanged<GrnModel> onView;
|
||||
final ValueChanged<GrnModel>? onEdit;
|
||||
final Future<void> Function()? onEnsureFullDataset;
|
||||
final VoidCallback? onColumnSearchCleared;
|
||||
|
||||
List<AppDataColumn<GrnModel>> _allColumns(BuildContext context) {
|
||||
return [
|
||||
AppDataColumn(
|
||||
id: 'grn_number',
|
||||
label: 'Purchase Receipt Number',
|
||||
sortKey: 'grn_number',
|
||||
locked: true,
|
||||
flex: 2,
|
||||
searchText: (grn) => grn.grnNumber ?? '',
|
||||
cellBuilder: (_, grn) => AppTableCell.link(
|
||||
grn.grnNumber,
|
||||
onTap: () => onView(grn),
|
||||
),
|
||||
),
|
||||
AppDataColumn(
|
||||
id: 'date',
|
||||
label: 'Date',
|
||||
sortKey: 'date',
|
||||
flex: 1,
|
||||
searchText: (grn) => DateFormatter.searchableDate(grn.grnDate),
|
||||
sortValue: (grn) => grn.grnDate,
|
||||
cellBuilder: (_, grn) => Text(DateFormatter.displayDate(grn.grnDate)),
|
||||
),
|
||||
AppDataColumn(
|
||||
id: 'po_number',
|
||||
label: 'PO Number',
|
||||
sortKey: 'po_number',
|
||||
flex: 2,
|
||||
searchText: (grn) => grn.poNumber ?? '',
|
||||
cellBuilder: (_, grn) => AppTableCell.link(
|
||||
grn.poNumber,
|
||||
onTap: grn.poId == null
|
||||
? null
|
||||
: () => context.push(
|
||||
'${RouteConstants.purchaseOrders}/${grn.poId}',
|
||||
),
|
||||
),
|
||||
),
|
||||
AppDataColumn(
|
||||
id: 'vendor',
|
||||
label: 'Vendor',
|
||||
sortKey: 'vendor',
|
||||
flex: 2,
|
||||
searchText: (grn) => grn.vendorName ?? '',
|
||||
cellBuilder: (_, grn) => Text(grn.vendorName ?? '—'),
|
||||
),
|
||||
AppDataColumn(
|
||||
id: 'location',
|
||||
label: 'Location',
|
||||
sortKey: 'location',
|
||||
flex: 2,
|
||||
searchText: (grn) => grn.locationName ?? '',
|
||||
cellBuilder: (_, grn) => Text(grn.locationName ?? '—'),
|
||||
),
|
||||
AppDataColumn(
|
||||
id: 'status',
|
||||
label: 'Status',
|
||||
sortKey: 'status',
|
||||
flex: 1,
|
||||
searchText: (grn) => grn.status,
|
||||
cellBuilder: (_, grn) => GrnStatusChip(
|
||||
status: grn.status,
|
||||
compact: true,
|
||||
forTable: true,
|
||||
),
|
||||
),
|
||||
AppDataColumn(
|
||||
id: 'actions',
|
||||
label: 'Actions',
|
||||
flex: 1,
|
||||
enableSearch: false,
|
||||
locked: true,
|
||||
includeInColumnSelector: false,
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final prefs = ref.watch(tableColumnPrefsProvider(tableId));
|
||||
final columns = resolveAppDataColumns(_allColumns(context), prefs);
|
||||
|
||||
return AppDataTable<GrnModel>(
|
||||
wrapInCard: false,
|
||||
columns: [
|
||||
AppDataColumn(
|
||||
label: 'GRN Number',
|
||||
flex: 2,
|
||||
searchText: (grn) => grn.grnNumber ?? '',
|
||||
cellBuilder: (_, grn) => Text(grn.grnNumber ?? '—'),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Date',
|
||||
flex: 1,
|
||||
searchText: (grn) => DateFormatter.displayDate(grn.grnDate),
|
||||
cellBuilder: (_, grn) => Text(DateFormatter.displayDate(grn.grnDate)),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'PO Number',
|
||||
flex: 2,
|
||||
searchText: (grn) => grn.poNumber ?? '',
|
||||
cellBuilder: (_, grn) => Text(grn.poNumber ?? '—'),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Vendor',
|
||||
flex: 2,
|
||||
searchText: (grn) => grn.vendorName ?? '',
|
||||
cellBuilder: (_, grn) => Text(grn.vendorName ?? '—'),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Location',
|
||||
flex: 2,
|
||||
searchText: (grn) => grn.locationName ?? '',
|
||||
cellBuilder: (_, grn) => Text(grn.locationName ?? '—'),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Status',
|
||||
flex: 1,
|
||||
searchText: (grn) => grn.status,
|
||||
cellBuilder: (_, grn) => GrnStatusChip(
|
||||
status: grn.status,
|
||||
compact: true,
|
||||
forTable: true,
|
||||
),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Actions',
|
||||
flex: 1,
|
||||
enableSearch: false,
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
onEnsureFullDataset: onEnsureFullDataset,
|
||||
onColumnSearchCleared: onColumnSearchCleared,
|
||||
columns: columns,
|
||||
rows: grns,
|
||||
);
|
||||
}
|
||||
@ -391,18 +460,26 @@ class _GrnCardList extends StatelessWidget {
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
grn.grnNumber ?? 'GRN #${grn.id}',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
child: AppTableCell.link(
|
||||
grn.grnNumber ?? 'Purchase Receipt #${grn.id}',
|
||||
onTap: () => onView(grn),
|
||||
),
|
||||
),
|
||||
GrnStatusChip(status: grn.status, compact: true),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text('PO: ${grn.poNumber ?? '—'}'),
|
||||
if (grn.poNumber != null && grn.poNumber!.trim().isNotEmpty)
|
||||
AppTableCell.link(
|
||||
'PO: ${grn.poNumber}',
|
||||
onTap: grn.poId == null
|
||||
? null
|
||||
: () => context.push(
|
||||
'${RouteConstants.purchaseOrders}/${grn.poId}',
|
||||
),
|
||||
)
|
||||
else
|
||||
const Text('PO: —'),
|
||||
Text('Vendor: ${grn.vendorName ?? '—'}'),
|
||||
Text('Location: ${grn.locationName ?? '—'}'),
|
||||
Text('Date: ${DateFormatter.displayDate(grn.grnDate)}'),
|
||||
|
||||
@ -2,11 +2,10 @@ import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.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/utils/file_download_helper.dart';
|
||||
import '../../../../shared/widgets/api_feedback.dart';
|
||||
import '../../../../shared/widgets/app_confirmation_dialog.dart';
|
||||
import '../providers/grn_provider.dart';
|
||||
import '../../../../shared/widgets/app_toast.dart';
|
||||
@ -95,10 +94,9 @@ class _GrnAttachmentsCardState extends ConsumerState<GrnAttachmentsCard> {
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
final message =
|
||||
e is Failure ? validationErrorMessage(e) : e.toString();
|
||||
showAppToastFromSnackBar(context,
|
||||
SnackBar(content: Text(message)),
|
||||
showAppToastFromSnackBar(
|
||||
context,
|
||||
SnackBar(content: Text(errorDisplayMessage(e))),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _isUploading = false);
|
||||
@ -118,8 +116,9 @@ class _GrnAttachmentsCardState extends ConsumerState<GrnAttachmentsCard> {
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
showAppToastFromSnackBar(context,
|
||||
SnackBar(content: Text(e.toString())),
|
||||
showAppToastFromSnackBar(
|
||||
context,
|
||||
SnackBar(content: Text(errorDisplayMessage(e))),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _busyAttachmentId = null);
|
||||
@ -150,10 +149,9 @@ class _GrnAttachmentsCardState extends ConsumerState<GrnAttachmentsCard> {
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
final message =
|
||||
e is Failure ? validationErrorMessage(e) : e.toString();
|
||||
showAppToastFromSnackBar(context,
|
||||
SnackBar(content: Text(message)),
|
||||
showAppToastFromSnackBar(
|
||||
context,
|
||||
SnackBar(content: Text(errorDisplayMessage(e))),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _busyAttachmentId = null);
|
||||
@ -208,8 +206,8 @@ class _GrnAttachmentsCardState extends ConsumerState<GrnAttachmentsCard> {
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
showUpload
|
||||
? 'PDF, JPEG, PNG, or WebP · upload/delete only while GRN is Posted'
|
||||
: 'Supporting documents for this GRN',
|
||||
? 'PDF, JPEG, PNG, or WebP · upload/delete only while Purchase Receipt is Posted'
|
||||
: 'Supporting documents for this Purchase Receipt',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
@ -258,7 +256,7 @@ class _GrnAttachmentsCardState extends ConsumerState<GrnAttachmentsCard> {
|
||||
attachments.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'This GRN is cancelled — attachments are view/download only.',
|
||||
'This Purchase Receipt is cancelled — attachments are view/download only.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
|
||||
@ -332,9 +332,9 @@ class _GrnLineItemCardState extends State<_GrnLineItemCard> {
|
||||
const SizedBox(height: 8),
|
||||
FormRow(
|
||||
columnCount: 12,
|
||||
spans: const [2, 2, 2, 2, 2, 2],
|
||||
spans: const [2, 2, 2, 3, 3],
|
||||
spacing: 8,
|
||||
stackBelowWidth: 1100,
|
||||
stackBelowWidth: 0,
|
||||
children: [
|
||||
AppTextField(
|
||||
key: ValueKey('$lineKey-accepted'),
|
||||
@ -398,14 +398,6 @@ class _GrnLineItemCardState extends State<_GrnLineItemCard> {
|
||||
readOnly: true,
|
||||
fillColor: currentBg,
|
||||
),
|
||||
AppTextField(
|
||||
key: ValueKey('$lineKey-rate'),
|
||||
controller: item.rateController,
|
||||
label: 'Rate',
|
||||
hint: '0.00',
|
||||
isDense: true,
|
||||
readOnly: true,
|
||||
),
|
||||
AppTextField(
|
||||
key: ValueKey('$lineKey-batch'),
|
||||
controller: item.batchNoController,
|
||||
@ -439,7 +431,7 @@ class _GrnLineItemCardState extends State<_GrnLineItemCard> {
|
||||
columnCount: 12,
|
||||
spans: const [3, 3, 3, 3],
|
||||
spacing: 8,
|
||||
stackBelowWidth: 1100,
|
||||
stackBelowWidth: 0,
|
||||
children: [
|
||||
_GrnLineDateField(
|
||||
key: ValueKey('$lineKey-mfg'),
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../core/theme/app_typography.dart';
|
||||
import '../../../../shared/models/grn_model.dart';
|
||||
import '../../../../shared/widgets/app_status_chip.dart';
|
||||
|
||||
@ -17,7 +18,8 @@ class GrnStatusChip extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final (color, label) = _resolveStatus(status);
|
||||
final secondary = Theme.of(context).colorScheme.secondary;
|
||||
final (color, label) = _resolveStatus(status, secondary);
|
||||
if (forTable) {
|
||||
return TableStatusBadge(
|
||||
label: label,
|
||||
@ -30,11 +32,15 @@ class GrnStatusChip extends StatelessWidget {
|
||||
return Chip(
|
||||
label: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: compact ? 11 : 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
style: compact
|
||||
? AppTypography.caption1(
|
||||
weight: AppTypography.semiBold,
|
||||
color: color,
|
||||
)
|
||||
: AppTypography.label3(
|
||||
weight: AppTypography.semiBold,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
backgroundColor: color.withValues(alpha: 0.12),
|
||||
side: BorderSide(color: color.withValues(alpha: 0.3)),
|
||||
@ -43,14 +49,14 @@ class GrnStatusChip extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
(Color, String) _resolveStatus(String raw) {
|
||||
(Color, String) _resolveStatus(String raw, Color secondary) {
|
||||
switch (raw.toUpperCase()) {
|
||||
case 'POSTED':
|
||||
return (Colors.green.shade700, grnStatusLabel(raw));
|
||||
return (secondary, grnStatusLabel(raw));
|
||||
case 'CANCELLED':
|
||||
return (Colors.grey.shade700, grnStatusLabel(raw));
|
||||
default:
|
||||
return (Colors.blueGrey, grnStatusLabel(raw));
|
||||
return (secondary, grnStatusLabel(raw));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/network/dio_client.dart';
|
||||
import '../../../../core/utils/active_option.dart';
|
||||
import '../../../../core/utils/pagination_meta.dart';
|
||||
import '../../../../shared/models/export_file_result.dart';
|
||||
import '../../domain/entities/master_definition.dart';
|
||||
|
||||
@ -17,18 +18,14 @@ class MasterListResult {
|
||||
required this.total,
|
||||
required this.page,
|
||||
required this.limit,
|
||||
required this.totalPages,
|
||||
});
|
||||
|
||||
final List<Map<String, dynamic>> items;
|
||||
final int total;
|
||||
final int page;
|
||||
final int limit;
|
||||
|
||||
int get totalPages {
|
||||
if (limit <= 0) return 1;
|
||||
final pages = (total / limit).ceil();
|
||||
return pages < 1 ? 1 : pages;
|
||||
}
|
||||
final int totalPages;
|
||||
}
|
||||
|
||||
class MasterCrudRemoteDataSource {
|
||||
@ -65,19 +62,26 @@ class MasterCrudRemoteDataSource {
|
||||
|
||||
final items = list
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map((item) => Map<String, dynamic>.from(item))
|
||||
.map((item) => _sanitizeLocationRow(
|
||||
definition,
|
||||
Map<String, dynamic>.from(item),
|
||||
))
|
||||
.toList();
|
||||
|
||||
final meta = raw is Map<String, dynamic> ? raw : body;
|
||||
final total = _asInt(meta['total']) ?? items.length;
|
||||
final currentPage = _asInt(meta['page']) ?? page;
|
||||
final currentLimit = _asInt(meta['limit']) ?? limit;
|
||||
final pagination = parsePagination(
|
||||
body: body,
|
||||
fallbackPage: page,
|
||||
fallbackLimit: limit,
|
||||
itemCount: items.length,
|
||||
);
|
||||
|
||||
return MasterListResult(
|
||||
items: items,
|
||||
total: total,
|
||||
page: currentPage,
|
||||
limit: currentLimit,
|
||||
total: pagination.total,
|
||||
page: pagination.page,
|
||||
// Keep the requested page size so the UI dropdown matches the next request.
|
||||
limit: limit,
|
||||
totalPages: resolveTotalPages(total: pagination.total, limit: limit),
|
||||
);
|
||||
}
|
||||
|
||||
@ -103,7 +107,12 @@ class MasterCrudRemoteDataSource {
|
||||
|
||||
return list
|
||||
.whereType<Map>()
|
||||
.map((item) => Map<String, dynamic>.from(item))
|
||||
.map(
|
||||
(item) => _sanitizeLocationRow(
|
||||
definition,
|
||||
Map<String, dynamic>.from(item),
|
||||
),
|
||||
)
|
||||
.where(isActiveOptionRow)
|
||||
.toList();
|
||||
}
|
||||
@ -113,7 +122,7 @@ class MasterCrudRemoteDataSource {
|
||||
String id,
|
||||
) async {
|
||||
final response = await dio.get('${definition.apiPath}/$id');
|
||||
return _extractData(response.data);
|
||||
return _sanitizeLocationRow(definition, _extractData(response.data));
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> create(
|
||||
@ -124,7 +133,7 @@ class MasterCrudRemoteDataSource {
|
||||
definition.apiPath,
|
||||
data: payload..removeWhere((_, value) => value == null),
|
||||
);
|
||||
return _extractData(response.data);
|
||||
return _sanitizeLocationRow(definition, _extractData(response.data));
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> update(
|
||||
@ -136,7 +145,7 @@ class MasterCrudRemoteDataSource {
|
||||
'${definition.apiPath}/$id',
|
||||
data: payload..removeWhere((_, value) => value == null),
|
||||
);
|
||||
return _extractData(response.data);
|
||||
return _sanitizeLocationRow(definition, _extractData(response.data));
|
||||
}
|
||||
|
||||
Future<void> delete(MasterDefinition definition, String id) async {
|
||||
@ -197,10 +206,15 @@ class MasterCrudRemoteDataSource {
|
||||
return Map<String, dynamic>.from(body);
|
||||
}
|
||||
|
||||
int? _asInt(Object? value) {
|
||||
if (value is int) return value;
|
||||
if (value is num) return value.toInt();
|
||||
if (value is String) return int.tryParse(value);
|
||||
return null;
|
||||
/// Locations API no longer nests warehouses under plants.
|
||||
Map<String, dynamic> _sanitizeLocationRow(
|
||||
MasterDefinition definition,
|
||||
Map<String, dynamic> row,
|
||||
) {
|
||||
if (definition.id != 'locations') return row;
|
||||
row.remove('parent_id');
|
||||
row.remove('plant_id');
|
||||
row.remove('plant');
|
||||
return row;
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,6 +21,7 @@ class MasterRepositoryImpl implements MasterRepository {
|
||||
int page = 1,
|
||||
int limit = 20,
|
||||
String? search,
|
||||
Map<String, dynamic>? extraQueryParameters,
|
||||
}) =>
|
||||
safeApiCall(
|
||||
() => remote.list(
|
||||
@ -28,6 +29,7 @@ class MasterRepositoryImpl implements MasterRepository {
|
||||
page: page,
|
||||
limit: limit,
|
||||
search: search,
|
||||
extraQueryParameters: extraQueryParameters,
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@ -22,6 +22,7 @@ class MasterFieldDef {
|
||||
this.filterByOptionKey,
|
||||
this.visibleWhenFieldKey,
|
||||
this.visibleWhenValue,
|
||||
this.listNestedKey,
|
||||
});
|
||||
|
||||
final String key;
|
||||
@ -33,7 +34,7 @@ class MasterFieldDef {
|
||||
final bool showInForm;
|
||||
/// When true, shown in the form but not editable (value set by other fields).
|
||||
final bool readOnly;
|
||||
/// Master key used to populate dropdown options (e.g. `plants` for plant_id).
|
||||
/// Master key used to populate dropdown options (e.g. `locations` for FKs).
|
||||
final String? optionsMasterKey;
|
||||
/// Extra query parameters when loading [optionsMasterKey] options.
|
||||
final Map<String, dynamic>? optionsQueryParams;
|
||||
@ -47,6 +48,8 @@ class MasterFieldDef {
|
||||
/// Show this field only when [visibleWhenFieldKey] equals [visibleWhenValue].
|
||||
final String? visibleWhenFieldKey;
|
||||
final String? visibleWhenValue;
|
||||
/// Nested object key on list rows for display (e.g. `gst_rate` for `gst_rate_id`).
|
||||
final String? listNestedKey;
|
||||
|
||||
/// Cache key for dropdown option rows (includes query params when set).
|
||||
String get dropdownLookupKey {
|
||||
@ -223,13 +226,14 @@ const masterDefinitions = <MasterDefinition>[
|
||||
showInList: true,
|
||||
showInForm: false,
|
||||
),
|
||||
MasterFieldDef(key: 'item_name', label: 'Item Name', required: true, showInList: true),
|
||||
MasterFieldDef(
|
||||
key: 'is_asset_item',
|
||||
label: 'Asset Item',
|
||||
type: MasterFieldType.boolean,
|
||||
required: true,
|
||||
showInList: true,
|
||||
),
|
||||
MasterFieldDef(key: 'item_name', label: 'Item Name', required: true, showInList: true),
|
||||
MasterFieldDef(
|
||||
key: 'item_category_id',
|
||||
label: 'Category',
|
||||
@ -272,12 +276,17 @@ const masterDefinitions = <MasterDefinition>[
|
||||
label: 'Min Order Qty',
|
||||
type: MasterFieldType.number,
|
||||
required: true,
|
||||
// Stock items only — hidden when Asset Item is checked.
|
||||
visibleWhenFieldKey: 'is_asset_item',
|
||||
visibleWhenValue: 'false',
|
||||
),
|
||||
MasterFieldDef(
|
||||
key: 'reorder_level',
|
||||
label: 'Reorder Level',
|
||||
type: MasterFieldType.number,
|
||||
required: true,
|
||||
visibleWhenFieldKey: 'is_asset_item',
|
||||
visibleWhenValue: 'false',
|
||||
),
|
||||
MasterFieldDef(
|
||||
key: 'tags',
|
||||
@ -368,31 +377,51 @@ const masterDefinitions = <MasterDefinition>[
|
||||
MasterFieldDef(key: 'code', label: 'Code', required: true, showInList: true),
|
||||
MasterFieldDef(key: 'name', label: 'Name', required: true, showInList: true),
|
||||
MasterFieldDef(
|
||||
key: 'parent_id',
|
||||
label: 'Parent Plant',
|
||||
type: MasterFieldType.dropdown,
|
||||
key: 'gstin',
|
||||
label: 'GSTIN',
|
||||
showInList: true,
|
||||
optionsMasterKey: 'locations',
|
||||
optionsQueryParams: const {'type': 'plant'},
|
||||
visibleWhenFieldKey: 'type',
|
||||
visibleWhenValue: 'warehouse',
|
||||
required: true,
|
||||
visibleWhenValue: 'plant',
|
||||
),
|
||||
MasterFieldDef(
|
||||
key: 'city',
|
||||
label: 'City',
|
||||
showInList: true,
|
||||
visibleWhenFieldKey: 'type',
|
||||
visibleWhenValue: 'plant',
|
||||
),
|
||||
MasterFieldDef(key: 'gstin', label: 'GSTIN', showInList: true),
|
||||
MasterFieldDef(key: 'city', label: 'City', showInList: true),
|
||||
MasterFieldDef(
|
||||
key: 'state',
|
||||
label: 'State',
|
||||
type: MasterFieldType.dropdown,
|
||||
optionsMasterKey: 'location_states',
|
||||
showInList: true,
|
||||
visibleWhenFieldKey: 'type',
|
||||
visibleWhenValue: 'plant',
|
||||
),
|
||||
MasterFieldDef(
|
||||
key: 'address',
|
||||
label: 'Address',
|
||||
multiline: true,
|
||||
visibleWhenFieldKey: 'type',
|
||||
visibleWhenValue: 'plant',
|
||||
),
|
||||
MasterFieldDef(
|
||||
key: 'pincode',
|
||||
label: 'Pincode',
|
||||
visibleWhenFieldKey: 'type',
|
||||
visibleWhenValue: 'plant',
|
||||
),
|
||||
MasterFieldDef(
|
||||
key: 'phone',
|
||||
label: 'Phone',
|
||||
visibleWhenFieldKey: 'type',
|
||||
visibleWhenValue: 'plant',
|
||||
),
|
||||
MasterFieldDef(key: 'address', label: 'Address', multiline: true),
|
||||
MasterFieldDef(key: 'pincode', label: 'Pincode'),
|
||||
MasterFieldDef(key: 'phone', label: 'Phone'),
|
||||
MasterFieldDef(
|
||||
key: 'location',
|
||||
label: 'Location Detail',
|
||||
showInList: true,
|
||||
visibleWhenFieldKey: 'type',
|
||||
visibleWhenValue: 'warehouse',
|
||||
),
|
||||
@ -563,6 +592,10 @@ String masterCellValue(Map<String, dynamic> row, MasterFieldDef field) {
|
||||
final value = row[field.key];
|
||||
if (value == null || value == '') return '—';
|
||||
|
||||
if (field.key == 'is_asset_item') {
|
||||
return masterIsAssetItem(value) ? 'Asset' : 'Stock';
|
||||
}
|
||||
|
||||
if (field.key == 'tags') {
|
||||
if (value is List) {
|
||||
final tags = value
|
||||
@ -606,18 +639,25 @@ String masterCellValue(Map<String, dynamic> row, MasterFieldDef field) {
|
||||
if (explicitName != null && explicitName.toString().trim().isNotEmpty) {
|
||||
return explicitName.toString().trim();
|
||||
}
|
||||
final nested = row[baseKey];
|
||||
if (nested is Map) {
|
||||
for (final nestedKey in ['name', 'code', 'description']) {
|
||||
final nestedValue = nested[nestedKey];
|
||||
if (nestedValue != null &&
|
||||
nestedValue.toString().trim().isNotEmpty) {
|
||||
return nestedValue.toString().trim();
|
||||
|
||||
final nestedKeys = <String>[
|
||||
if (field.listNestedKey != null) field.listNestedKey!,
|
||||
baseKey,
|
||||
];
|
||||
for (final nestedKey in nestedKeys) {
|
||||
final nested = row[nestedKey];
|
||||
if (nested is Map) {
|
||||
for (final nameKey in ['name', 'code', 'description']) {
|
||||
final nestedValue = nested[nameKey];
|
||||
if (nestedValue != null &&
|
||||
nestedValue.toString().trim().isNotEmpty) {
|
||||
return nestedValue.toString().trim();
|
||||
}
|
||||
}
|
||||
} else if (nested != null && nested.toString().trim().isNotEmpty) {
|
||||
// Flat denormalized value (e.g. items.hsn_code string)
|
||||
return nested.toString().trim();
|
||||
}
|
||||
} else if (nested != null && nested.toString().trim().isNotEmpty) {
|
||||
// Flat denormalized value (e.g. items.hsn_code string)
|
||||
return nested.toString().trim();
|
||||
}
|
||||
}
|
||||
|
||||
@ -635,6 +675,13 @@ String masterStatusValue(Map<String, dynamic> row) {
|
||||
return 'active';
|
||||
}
|
||||
|
||||
bool masterIsAssetItem(Object? value) {
|
||||
if (value == true || value == 1) return true;
|
||||
if (value == false || value == 0) return false;
|
||||
final text = value?.toString().trim().toLowerCase() ?? '';
|
||||
return text == 'true' || text == '1' || text == 'yes';
|
||||
}
|
||||
|
||||
/// Category list filter for Items form: ASSET when Asset Item is checked.
|
||||
String itemCategoryTypeForValues(Map<String, dynamic> values) =>
|
||||
values['is_asset_item'] == true ? 'ASSET' : 'STOCK';
|
||||
@ -670,6 +717,28 @@ String masterFieldDropdownLookupKey({
|
||||
return '$masterKey?$query';
|
||||
}
|
||||
|
||||
/// Placeholder / hint casing for master field labels (keeps UOM, HSN, GST, etc.).
|
||||
String masterFieldHintLabel(String label) {
|
||||
const acronyms = {
|
||||
'uom': 'UOM',
|
||||
'hsn': 'HSN',
|
||||
'gst': 'GST',
|
||||
'sac': 'SAC',
|
||||
'po': 'PO',
|
||||
'grn': 'Purchase Receipt',
|
||||
'amc': 'AMC',
|
||||
};
|
||||
return label
|
||||
.trim()
|
||||
.split(RegExp(r'\s+'))
|
||||
.where((part) => part.isNotEmpty)
|
||||
.map((part) {
|
||||
final lower = part.toLowerCase();
|
||||
return acronyms[lower] ?? lower;
|
||||
})
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
/// Display label for GST filled from HSN nested `gst_rate.description`.
|
||||
String? gstRateDisplayFromValues(Map<String, dynamic> values) {
|
||||
final nested = values['gst_rate'];
|
||||
|
||||
@ -9,6 +9,7 @@ abstract class MasterRepository {
|
||||
int page,
|
||||
int limit,
|
||||
String? search,
|
||||
Map<String, dynamic>? extraQueryParameters,
|
||||
});
|
||||
|
||||
Future<Result<List<Map<String, dynamic>>>> listOptions(
|
||||
|
||||
@ -0,0 +1,48 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../assets/presentation/providers/asset_categories_provider.dart';
|
||||
import '../../../assets/presentation/providers/asset_form_lookups_provider.dart';
|
||||
import '../../../grn/presentation/providers/grn_lookups_provider.dart';
|
||||
import '../../../purchase_orders/presentation/providers/purchase_order_lookups_provider.dart';
|
||||
import '../../../rbac/presentation/providers/add_user_form_provider.dart';
|
||||
import '../../../vendors/presentation/providers/vendor_lookups_provider.dart';
|
||||
|
||||
/// Refresh form/list dropdown caches that read this master outside Master Data.
|
||||
///
|
||||
/// Pass [Ref.invalidate] or [WidgetRef.invalidate]. Callers should also refresh
|
||||
/// [masterListProvider] for the same [masterId] when needed.
|
||||
void invalidateMasterConsumerLookups(
|
||||
void Function(ProviderOrFamily provider) invalidate,
|
||||
String masterId,
|
||||
) {
|
||||
switch (masterId) {
|
||||
case 'item_categories':
|
||||
invalidate(itemCategoriesFormProvider);
|
||||
invalidate(itemCategoriesProvider);
|
||||
invalidate(itemSubcategoriesProvider);
|
||||
case 'item_subcategories':
|
||||
invalidate(itemSubcategoriesProvider);
|
||||
case 'locations':
|
||||
invalidate(assetFormLookupsProvider);
|
||||
invalidate(assetListFilterLookupsProvider);
|
||||
invalidate(purchaseOrderLookupsProvider);
|
||||
invalidate(grnLookupsProvider);
|
||||
invalidate(addUserFormProvider);
|
||||
case 'departments':
|
||||
invalidate(assetFormLookupsProvider);
|
||||
invalidate(addUserFormProvider);
|
||||
case 'designations':
|
||||
invalidate(addUserFormProvider);
|
||||
case 'uom':
|
||||
case 'items':
|
||||
case 'hsn_codes':
|
||||
case 'gst_rates':
|
||||
case 'delivery_terms':
|
||||
invalidate(purchaseOrderLookupsProvider);
|
||||
case 'payment_terms':
|
||||
invalidate(purchaseOrderLookupsProvider);
|
||||
invalidate(vendorPaymentTermsProvider);
|
||||
case 'terms_notes':
|
||||
invalidate(poDefaultTermsNotesProvider);
|
||||
}
|
||||
}
|
||||
@ -1,12 +1,15 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/constants/app_constants.dart';
|
||||
import '../../../../core/utils/column_search_paging.dart';
|
||||
import '../../../../core/utils/pagination_meta.dart';
|
||||
import '../../../../core/utils/table_search.dart';
|
||||
import '../../../../shared/models/export_file_result.dart';
|
||||
import '../../../assets/data/repositories/asset_repository_impl.dart';
|
||||
import '../../../masters/data/datasources/master_remote_data_source.dart';
|
||||
import '../../data/repositories/master_repository_impl.dart';
|
||||
import '../../domain/entities/master_definition.dart';
|
||||
import 'master_consumer_invalidation.dart';
|
||||
|
||||
class MasterListState {
|
||||
const MasterListState({
|
||||
@ -94,6 +97,10 @@ final masterListProvider = AsyncNotifierProvider.family<
|
||||
MasterListNotifier, MasterListState, String>(MasterListNotifier.new);
|
||||
|
||||
class MasterListNotifier extends FamilyAsyncNotifier<MasterListState, String> {
|
||||
final _columnSearch = ColumnSearchPaging(
|
||||
defaultLimit: AppConstants.defaultPageSize,
|
||||
);
|
||||
|
||||
MasterDefinition get _definition {
|
||||
final def = masterDefinitionById(arg);
|
||||
if (def == null) throw StateError('Unknown master: $arg');
|
||||
@ -114,45 +121,59 @@ class MasterListNotifier extends FamilyAsyncNotifier<MasterListState, String> {
|
||||
final current = state.valueOrNull;
|
||||
final nextPage = page ?? current?.page ?? 1;
|
||||
final nextLimit = limit ?? current?.limit ?? AppConstants.defaultPageSize;
|
||||
final nextSearch = search ?? current?.search;
|
||||
final nextSearch = TableSearch.normalize(search ?? current?.search);
|
||||
final repository = ref.read(masterRepositoryProvider);
|
||||
|
||||
final result = await ref.read(masterRepositoryProvider).list(
|
||||
_definition,
|
||||
page: nextPage,
|
||||
limit: nextLimit,
|
||||
search: nextSearch,
|
||||
);
|
||||
final result = await repository.list(
|
||||
_definition,
|
||||
page: nextPage,
|
||||
limit: nextLimit,
|
||||
search: nextSearch.isEmpty ? null : nextSearch,
|
||||
);
|
||||
|
||||
if (result.failure != null) throw result.failure!;
|
||||
final data = result.data!;
|
||||
var items = List<Map<String, dynamic>>.from(result.data!.items);
|
||||
|
||||
// API `search` often ignores related/enum fields (e.g. category_type).
|
||||
// Supplement with dedicated filters and client-side multi-field matching.
|
||||
if (nextSearch.isNotEmpty) {
|
||||
if (_definition.id == 'item_categories') {
|
||||
var typeMatches = 0;
|
||||
for (final type in categoryTypeOptions) {
|
||||
if (!TableSearch.matches(nextSearch, [type])) continue;
|
||||
if (++typeMatches > 5) break;
|
||||
final typed = await repository.list(
|
||||
_definition,
|
||||
page: nextPage,
|
||||
limit: nextLimit,
|
||||
extraQueryParameters: {'category_type': type},
|
||||
);
|
||||
if (typed.failure == null && typed.data != null) {
|
||||
items = TableSearch.mergeById(
|
||||
items,
|
||||
typed.data!.items,
|
||||
(row) => row['id']?.toString() ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Keep API page results + enriched matches; pagination stays server-driven.
|
||||
}
|
||||
|
||||
final data = result.data!;
|
||||
return MasterListState(
|
||||
items: data.items,
|
||||
search: nextSearch ?? '',
|
||||
items: items,
|
||||
search: nextSearch,
|
||||
page: data.page,
|
||||
// Keep the requested page size so the /page dropdown stays valid
|
||||
// even if API meta omits or mismatches `limit`.
|
||||
limit: nextLimit,
|
||||
total: data.total,
|
||||
totalPages: _resolveTotalPages(
|
||||
apiTotalPages: data.totalPages,
|
||||
total: data.total,
|
||||
limit: nextLimit,
|
||||
),
|
||||
totalPages: resolveTotalPages(total: data.total, limit: nextLimit),
|
||||
);
|
||||
}
|
||||
|
||||
int _resolveTotalPages({
|
||||
required int apiTotalPages,
|
||||
required int total,
|
||||
required int limit,
|
||||
}) {
|
||||
if (apiTotalPages > 0) return apiTotalPages;
|
||||
if (total <= 0 || limit <= 0) return 1;
|
||||
final pages = (total / limit).ceil();
|
||||
return pages < 1 ? 1 : pages;
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
final previous = state.valueOrNull;
|
||||
if (previous == null) {
|
||||
@ -169,6 +190,32 @@ class MasterListNotifier extends FamilyAsyncNotifier<MasterListState, String> {
|
||||
await _reload(page: 1, search: search);
|
||||
}
|
||||
|
||||
Future<void> ensureColumnSearchDataset() async {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
final limit = _columnSearch.beginFullDataset(
|
||||
currentLimit: current.limit,
|
||||
total: current.total,
|
||||
);
|
||||
if (limit == null) return;
|
||||
await _reload(page: 1, limit: limit, search: '');
|
||||
}
|
||||
|
||||
void clearColumnSearchDataset() {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
final limit = _columnSearch.endFullDataset();
|
||||
if (limit == null) return;
|
||||
_reload(page: 1, limit: limit, search: '');
|
||||
}
|
||||
|
||||
/// Clears generic search and reloads the full list.
|
||||
Future<void> clearSearch() async {
|
||||
final current = state.valueOrNull;
|
||||
if (current != null && current.search.isEmpty) return;
|
||||
await _reload(page: 1, search: '');
|
||||
}
|
||||
|
||||
Future<void> setPage(int page) async {
|
||||
await _reload(page: page);
|
||||
}
|
||||
@ -202,6 +249,7 @@ class MasterListNotifier extends FamilyAsyncNotifier<MasterListState, String> {
|
||||
}
|
||||
|
||||
await refresh();
|
||||
invalidateMasterConsumerLookups(ref.invalidate, _definition.id);
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -279,6 +327,8 @@ class MasterFormNotifier extends FamilyAsyncNotifier<MasterFormState, MasterForm
|
||||
}
|
||||
}
|
||||
|
||||
_sanitizeLocationValues(values);
|
||||
|
||||
// Load after values so Items category options use is_asset_item.
|
||||
final dropdownOptions = await _loadDropdownOptions(values: values);
|
||||
|
||||
@ -433,6 +483,14 @@ class MasterFormNotifier extends FamilyAsyncNotifier<MasterFormState, MasterForm
|
||||
}
|
||||
}
|
||||
|
||||
/// Warehouses are no longer nested under plants — drop obsolete keys.
|
||||
void _sanitizeLocationValues(Map<String, dynamic> values) {
|
||||
if (_definition.id != 'locations') return;
|
||||
values.remove('parent_id');
|
||||
values.remove('plant_id');
|
||||
values.remove('plant');
|
||||
}
|
||||
|
||||
void updateValue(String key, dynamic value) {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
@ -447,10 +505,17 @@ class MasterFormNotifier extends FamilyAsyncNotifier<MasterFormState, MasterForm
|
||||
_clearHiddenFieldValues(values);
|
||||
}
|
||||
|
||||
// Asset Item toggles Items category list between STOCK / ASSET.
|
||||
if (key == 'type' && _definition.id == 'locations') {
|
||||
_clearHiddenFieldValues(values);
|
||||
_sanitizeLocationValues(values);
|
||||
}
|
||||
|
||||
// Asset Item toggles Items category list between STOCK / ASSET
|
||||
// and hides stock-only fields (min order qty / reorder level).
|
||||
if (key == 'is_asset_item' && _definition.id == 'items') {
|
||||
values['item_category_id'] = null;
|
||||
values['item_subcategory_id'] = null;
|
||||
_clearHiddenFieldValues(values);
|
||||
state = AsyncData(current.copyWith(values: values));
|
||||
_reloadItemCategoryOptions(values);
|
||||
return;
|
||||
@ -496,7 +561,11 @@ class MasterFormNotifier extends FamilyAsyncNotifier<MasterFormState, MasterForm
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
final options = await _loadDropdownOptions(values: current.values);
|
||||
state = AsyncData(current.copyWith(dropdownOptions: options));
|
||||
// Re-read latest state so a Quick Add selection applied while we were
|
||||
// loading is not overwritten by the snapshot from the start of this call.
|
||||
final latest = state.valueOrNull;
|
||||
if (latest == null) return;
|
||||
state = AsyncData(latest.copyWith(dropdownOptions: options));
|
||||
}
|
||||
|
||||
Map<String, dynamic> _buildPayload(MasterFormState current) {
|
||||
@ -523,6 +592,14 @@ class MasterFormNotifier extends FamilyAsyncNotifier<MasterFormState, MasterForm
|
||||
MasterFieldType.text => value.toString().trim(),
|
||||
};
|
||||
}
|
||||
|
||||
// Never send plant nesting fields for locations (API breaking change).
|
||||
if (_definition.id == 'locations') {
|
||||
payload.remove('parent_id');
|
||||
payload.remove('plant_id');
|
||||
payload.remove('plant');
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
@ -569,8 +646,30 @@ class MasterFormNotifier extends FamilyAsyncNotifier<MasterFormState, MasterForm
|
||||
}
|
||||
|
||||
state = AsyncData(current.copyWith(isSubmitting: false, clearError: true));
|
||||
final createdId = result.data?['id']?.toString();
|
||||
// Keep Asset/PO/GRN/User/Vendor dropdown caches in sync with Master Data.
|
||||
invalidateMasterConsumerLookups(ref.invalidate, _definition.id);
|
||||
ref.invalidate(masterListProvider(_definition.id));
|
||||
final data = result.data;
|
||||
final createdId = _readCreatedId(data);
|
||||
if (createdId != null && createdId.isNotEmpty) return createdId;
|
||||
return arg.recordId ?? 'created';
|
||||
}
|
||||
|
||||
String? _readCreatedId(Map<String, dynamic>? data) {
|
||||
if (data == null) return null;
|
||||
for (final key in const ['id', 'ID', 'gst_rate_id']) {
|
||||
final value = data[key];
|
||||
if (value != null && value.toString().trim().isNotEmpty) {
|
||||
return value.toString().trim();
|
||||
}
|
||||
}
|
||||
final nested = data['data'];
|
||||
if (nested is Map) {
|
||||
final value = nested['id'];
|
||||
if (value != null && value.toString().trim().isNotEmpty) {
|
||||
return value.toString().trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,21 +7,23 @@ import '../../../../core/constants/route_constants.dart';
|
||||
import '../../../../core/errors/failure.dart';
|
||||
import '../../../../shared/utils/file_download_helper.dart';
|
||||
import '../../../../shared/providers/permissions_provider.dart';
|
||||
import '../../../../shared/providers/table_column_prefs_provider.dart';
|
||||
import '../../../../shared/widgets/app_confirmation_dialog.dart';
|
||||
import '../../../../shared/widgets/app_data_table.dart';
|
||||
import '../../../../shared/widgets/app_empty_state.dart';
|
||||
import '../../../../shared/widgets/app_loading_view.dart';
|
||||
import '../../../../shared/widgets/app_pagination.dart';
|
||||
import '../../../../shared/widgets/app_search_export_bar.dart';
|
||||
import '../../../../shared/widgets/app_search_filter_toggle.dart';
|
||||
import '../../../../shared/widgets/app_status_chip.dart';
|
||||
import '../../../../shared/widgets/app_table_action_icon.dart';
|
||||
import '../../../../shared/widgets/app_table_column_selector.dart';
|
||||
import '../../../../shared/widgets/app_table_shell.dart';
|
||||
import '../../../../shared/widgets/error_view.dart';
|
||||
import '../../../../shared/widgets/app_side_panel.dart';
|
||||
import '../../../../shared/widgets/page_header.dart';
|
||||
import '../../domain/entities/master_definition.dart';
|
||||
import '../providers/master_provider.dart';
|
||||
import '../providers/master_consumer_invalidation.dart';
|
||||
import '../widgets/master_form_panel.dart';
|
||||
import '../../../../shared/widgets/app_toast.dart';
|
||||
|
||||
@ -37,6 +39,7 @@ class MasterListScreen extends ConsumerStatefulWidget {
|
||||
class _MasterListScreenState extends ConsumerState<MasterListScreen> {
|
||||
final _searchController = TextEditingController();
|
||||
bool _filtersExpanded = false;
|
||||
bool _didResetSearchOnOpen = false;
|
||||
|
||||
MasterDefinition get _definition {
|
||||
final def = masterDefinitionById(widget.masterId);
|
||||
@ -44,6 +47,24 @@ class _MasterListScreenState extends ConsumerState<MasterListScreen> {
|
||||
return def;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// keepAlive can restore a previous search after All Masters → another master.
|
||||
// Always open list screens with a clean generic search.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || _didResetSearchOnOpen) return;
|
||||
_didResetSearchOnOpen = true;
|
||||
_searchController.clear();
|
||||
final notifier = ref.read(masterListProvider(widget.masterId).notifier);
|
||||
final existing =
|
||||
ref.read(masterListProvider(widget.masterId)).valueOrNull;
|
||||
if (existing != null && existing.search.isNotEmpty) {
|
||||
notifier.clearSearch();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
@ -107,7 +128,7 @@ class _MasterListScreenState extends ConsumerState<MasterListScreen> {
|
||||
width: 560,
|
||||
);
|
||||
if (saved != null && mounted) {
|
||||
ref.invalidate(masterListProvider(widget.masterId));
|
||||
invalidateMasterConsumerLookups(ref.invalidate, widget.masterId);
|
||||
showAppToastFromSnackBar(context,
|
||||
SnackBar(
|
||||
content: Text(
|
||||
@ -137,6 +158,10 @@ class _MasterListScreenState extends ConsumerState<MasterListScreen> {
|
||||
await ref.read(masterListProvider(widget.masterId).notifier).deleteRecord(id);
|
||||
if (!mounted) return;
|
||||
|
||||
if (success) {
|
||||
invalidateMasterConsumerLookups(ref.invalidate, widget.masterId);
|
||||
}
|
||||
|
||||
showAppToastFromSnackBar(context,
|
||||
SnackBar(
|
||||
content: Text(
|
||||
@ -181,6 +206,11 @@ class _MasterListScreenState extends ConsumerState<MasterListScreen> {
|
||||
() => _filtersExpanded = !_filtersExpanded,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
AppTableColumnSelectorButton(
|
||||
tableId: _MasterListTable.tableIdFor(def),
|
||||
columns: _MasterListTable.columnOptionsFor(def),
|
||||
),
|
||||
if (canExport) ...[
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton.icon(
|
||||
@ -197,7 +227,13 @@ class _MasterListScreenState extends ConsumerState<MasterListScreen> {
|
||||
],
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => context.push(RouteConstants.masterData),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
ref
|
||||
.read(masterListProvider(widget.masterId).notifier)
|
||||
.clearSearch();
|
||||
context.go(RouteConstants.masterData);
|
||||
},
|
||||
icon: const Icon(Icons.grid_view_outlined),
|
||||
label: const Text('All Masters'),
|
||||
),
|
||||
@ -225,36 +261,26 @@ class _MasterListScreenState extends ConsumerState<MasterListScreen> {
|
||||
totalPages: state.totalPages,
|
||||
totalItems: state.total,
|
||||
pageSize: state.limit,
|
||||
itemsOnPage: state.items.length,
|
||||
itemLabel: def.title.toLowerCase(),
|
||||
onPageChanged: notifier.setPage,
|
||||
onPageSizeChanged: notifier.setPageSize,
|
||||
),
|
||||
child: RefreshIndicator(
|
||||
onRefresh: notifier.refresh,
|
||||
child: state.items.isEmpty
|
||||
? ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 240,
|
||||
child: AppEmptyState(
|
||||
title: 'No ${def.title.toLowerCase()} found',
|
||||
description:
|
||||
'Add your first ${def.title.toLowerCase()} record to get started.',
|
||||
icon: def.icon,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: _MasterListTable(
|
||||
definition: def,
|
||||
items: state.items,
|
||||
isDeleting: state.isDeleting,
|
||||
canEdit: canEdit,
|
||||
canDelete: canDelete,
|
||||
onEdit: (id) => _openFormPanel(recordId: id),
|
||||
onDelete: _deleteRecord,
|
||||
),
|
||||
child: _MasterListTable(
|
||||
definition: def,
|
||||
items: state.items,
|
||||
isDeleting: state.isDeleting,
|
||||
canEdit: canEdit,
|
||||
canDelete: canDelete,
|
||||
onEdit: (id) => _openFormPanel(recordId: id),
|
||||
onDelete: _deleteRecord,
|
||||
onEnsureFullDataset: () =>
|
||||
notifier.ensureColumnSearchDataset(),
|
||||
onColumnSearchCleared: () =>
|
||||
notifier.clearColumnSearchDataset(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -266,7 +292,7 @@ class _MasterListScreenState extends ConsumerState<MasterListScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
class _MasterListTable extends StatelessWidget {
|
||||
class _MasterListTable extends ConsumerWidget {
|
||||
const _MasterListTable({
|
||||
required this.definition,
|
||||
required this.items,
|
||||
@ -275,6 +301,8 @@ class _MasterListTable extends StatelessWidget {
|
||||
required this.canDelete,
|
||||
required this.onEdit,
|
||||
required this.onDelete,
|
||||
this.onEnsureFullDataset,
|
||||
this.onColumnSearchCleared,
|
||||
});
|
||||
|
||||
final MasterDefinition definition;
|
||||
@ -284,61 +312,114 @@ class _MasterListTable extends StatelessWidget {
|
||||
final bool canDelete;
|
||||
final ValueChanged<String> onEdit;
|
||||
final ValueChanged<Map<String, dynamic>> onDelete;
|
||||
final Future<void> Function()? onEnsureFullDataset;
|
||||
final VoidCallback? onColumnSearchCleared;
|
||||
|
||||
static String tableIdFor(MasterDefinition definition) =>
|
||||
'master_${definition.id}';
|
||||
|
||||
static String _fieldLabel(MasterFieldDef field) =>
|
||||
field.key == 'is_asset_item' ? 'Type' : field.label;
|
||||
|
||||
static List<AppTableColumnOption> columnOptionsFor(
|
||||
MasterDefinition definition,
|
||||
) {
|
||||
final fields = definition.listFields;
|
||||
return [
|
||||
for (var i = 0; i < fields.length; i++)
|
||||
AppTableColumnOption(
|
||||
id: fields[i].key,
|
||||
label: _fieldLabel(fields[i]),
|
||||
required: i == 0,
|
||||
),
|
||||
const AppTableColumnOption(id: 'status', label: 'Status'),
|
||||
];
|
||||
}
|
||||
|
||||
List<AppDataColumn<Map<String, dynamic>>> _allColumns(ThemeData theme) {
|
||||
final fields = definition.listFields;
|
||||
return [
|
||||
for (var i = 0; i < fields.length; i++)
|
||||
AppDataColumn<Map<String, dynamic>>(
|
||||
id: fields[i].key,
|
||||
label: _fieldLabel(fields[i]),
|
||||
sortKey: fields[i].key,
|
||||
locked: i == 0,
|
||||
flex: _columnFlex(fields[i]),
|
||||
searchText: (row) => masterCellValue(row, fields[i]),
|
||||
cellBuilder: (_, row) {
|
||||
final field = fields[i];
|
||||
if (field.key == 'is_asset_item') {
|
||||
final isAsset = masterIsAssetItem(row['is_asset_item']);
|
||||
return TableStatusBadge(
|
||||
label: isAsset ? 'Asset' : 'Stock',
|
||||
color: isAsset
|
||||
? const Color(0xFF2563EB)
|
||||
: const Color(0xFF64748B),
|
||||
compact: true,
|
||||
);
|
||||
}
|
||||
return Text(masterCellValue(row, field));
|
||||
},
|
||||
),
|
||||
AppDataColumn(
|
||||
id: 'status',
|
||||
label: 'Status',
|
||||
sortKey: 'status',
|
||||
flex: 1,
|
||||
searchText: (row) => masterStatusValue(row),
|
||||
cellBuilder: (_, row) => AppStatusChip(
|
||||
status: masterStatusValue(row),
|
||||
compact: true,
|
||||
forTable: true,
|
||||
),
|
||||
),
|
||||
AppDataColumn(
|
||||
id: 'actions',
|
||||
label: 'Actions',
|
||||
flex: 1,
|
||||
alignment: Alignment.centerRight,
|
||||
enableSearch: false,
|
||||
locked: true,
|
||||
includeInColumnSelector: false,
|
||||
cellBuilder: (_, row) {
|
||||
final id = row['id']?.toString();
|
||||
return AppTableActions(
|
||||
children: [
|
||||
if (canEdit)
|
||||
AppTableActionIcon(
|
||||
tooltip: 'Edit',
|
||||
icon: Icons.edit_outlined,
|
||||
enabled: id != null,
|
||||
onPressed: () => onEdit(id!),
|
||||
),
|
||||
if (canDelete)
|
||||
AppTableActionIcon(
|
||||
tooltip: 'Delete',
|
||||
icon: Icons.delete_outline,
|
||||
color: theme.colorScheme.error,
|
||||
enabled: !isDeleting,
|
||||
onPressed: () => onDelete(row),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
final prefs = ref.watch(tableColumnPrefsProvider(tableIdFor(definition)));
|
||||
final columns = resolveAppDataColumns(_allColumns(theme), prefs);
|
||||
|
||||
return AppDataTable<Map<String, dynamic>>(
|
||||
wrapInCard: false,
|
||||
columns: [
|
||||
...definition.listFields.map(
|
||||
(field) => AppDataColumn<Map<String, dynamic>>(
|
||||
label: field.label,
|
||||
flex: _columnFlex(field),
|
||||
searchText: (row) => masterCellValue(row, field),
|
||||
cellBuilder: (_, row) => Text(masterCellValue(row, field)),
|
||||
),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Status',
|
||||
flex: 1,
|
||||
searchText: (row) => masterStatusValue(row),
|
||||
cellBuilder: (_, row) => AppStatusChip(
|
||||
status: masterStatusValue(row),
|
||||
compact: true,
|
||||
forTable: true,
|
||||
),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Actions',
|
||||
flex: 1,
|
||||
alignment: Alignment.centerRight,
|
||||
enableSearch: false,
|
||||
cellBuilder: (_, row) {
|
||||
final id = row['id']?.toString();
|
||||
return AppTableActions(
|
||||
children: [
|
||||
if (canEdit)
|
||||
AppTableActionIcon(
|
||||
tooltip: 'Edit',
|
||||
icon: Icons.edit_outlined,
|
||||
enabled: id != null,
|
||||
onPressed: () => onEdit(id!),
|
||||
),
|
||||
if (canDelete)
|
||||
AppTableActionIcon(
|
||||
tooltip: 'Delete',
|
||||
icon: Icons.delete_outline,
|
||||
color: theme.colorScheme.error,
|
||||
enabled: !isDeleting,
|
||||
onPressed: () => onDelete(row),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
onEnsureFullDataset: onEnsureFullDataset,
|
||||
onColumnSearchCleared: onColumnSearchCleared,
|
||||
emptyMessage: 'No ${definition.title.toLowerCase()} found',
|
||||
columns: columns,
|
||||
rows: items,
|
||||
);
|
||||
}
|
||||
@ -346,6 +427,7 @@ class _MasterListTable extends StatelessWidget {
|
||||
int _columnFlex(MasterFieldDef field) {
|
||||
return switch (field.key) {
|
||||
'code' || 'item_code' || 'series_code' || 'hsn_code_id' => 1,
|
||||
'is_asset_item' => 1,
|
||||
'name' || 'item_name' || 'description' || 'term_name' => 3,
|
||||
_ => 2,
|
||||
};
|
||||
|
||||
@ -4,8 +4,10 @@ import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../../core/constants/enums.dart';
|
||||
import '../../../../core/constants/route_constants.dart';
|
||||
import '../../../../shared/providers/auth_provider.dart';
|
||||
import '../../../../shared/providers/permissions_provider.dart';
|
||||
import '../../../../shared/widgets/app_hover_effect.dart';
|
||||
import '../../../../shared/widgets/app_loading_view.dart';
|
||||
import '../../domain/entities/master_definition.dart';
|
||||
|
||||
class MastersHubScreen extends ConsumerWidget {
|
||||
@ -16,8 +18,15 @@ class MastersHubScreen extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final authState = ref.watch(authStateProvider);
|
||||
final categories = masterCategories;
|
||||
final canViewMasters = ref.can('masters', PermissionAction.read);
|
||||
final theme = Theme.of(context);
|
||||
|
||||
if (authState.status == AuthStatus.initial ||
|
||||
authState.status == AuthStatus.loading) {
|
||||
return const AppLoadingView(message: 'Loading master data...');
|
||||
}
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
@ -28,64 +37,74 @@ class MastersHubScreen extends ConsumerWidget {
|
||||
children: [
|
||||
Text(
|
||||
'Master Data',
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Browse and manage all master records by category.',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
...categories.map((category) {
|
||||
if (!canViewMasters) return const SizedBox.shrink();
|
||||
final items = masterDefinitions
|
||||
.where((def) => def.category == category)
|
||||
.toList();
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
category.toUpperCase(),
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.8,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate:
|
||||
SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: _tileMaxWidth,
|
||||
mainAxisSpacing: 10,
|
||||
crossAxisSpacing: 10,
|
||||
mainAxisExtent: _tileHeight,
|
||||
if (!canViewMasters)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 48),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'You do not have permission to view master data.',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final def = items[index];
|
||||
return _MasterAppTile(
|
||||
title: def.title,
|
||||
icon: def.icon,
|
||||
color: _masterIconColor(index),
|
||||
onTap: () => context.push(
|
||||
RouteConstants.masterList(def.routeKey),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
],
|
||||
);
|
||||
}),
|
||||
),
|
||||
)
|
||||
else
|
||||
...categories.map((category) {
|
||||
final items = masterDefinitions
|
||||
.where((def) => def.category == category)
|
||||
.toList();
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
category.toUpperCase(),
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.8,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: _tileMaxWidth,
|
||||
mainAxisSpacing: 10,
|
||||
crossAxisSpacing: 10,
|
||||
mainAxisExtent: _tileHeight,
|
||||
),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final def = items[index];
|
||||
return _MasterAppTile(
|
||||
title: def.title,
|
||||
icon: def.icon,
|
||||
color: _masterIconColor(index),
|
||||
onTap: () => context.push(
|
||||
RouteConstants.masterList(def.routeKey),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
],
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
@ -142,7 +161,6 @@ class _MasterAppTile extends StatelessWidget {
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.2,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@ -22,6 +22,7 @@ class MasterFormPanel extends ConsumerStatefulWidget {
|
||||
this.recordId,
|
||||
this.initialValues,
|
||||
this.formSessionId,
|
||||
this.readOnlyFields,
|
||||
});
|
||||
|
||||
final String masterId;
|
||||
@ -33,6 +34,9 @@ class MasterFormPanel extends ConsumerStatefulWidget {
|
||||
/// Unique per open so create forms always start empty.
|
||||
final String? formSessionId;
|
||||
|
||||
/// Field keys rendered as read-only (in addition to [MasterFieldDef.readOnly]).
|
||||
final Set<String>? readOnlyFields;
|
||||
|
||||
bool get isEditing => recordId != null;
|
||||
|
||||
@override
|
||||
@ -105,6 +109,10 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
|
||||
);
|
||||
}
|
||||
|
||||
bool _isFieldReadOnly(MasterFieldDef field) =>
|
||||
field.readOnly ||
|
||||
(widget.readOnlyFields?.contains(field.key) ?? false);
|
||||
|
||||
Widget _buildField(
|
||||
BuildContext context, {
|
||||
required MasterFieldDef field,
|
||||
@ -152,7 +160,7 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
|
||||
);
|
||||
|
||||
case MasterFieldType.dropdown:
|
||||
if (field.readOnly) {
|
||||
if (_isFieldReadOnly(field)) {
|
||||
final display = field.key == 'gst_rate_id'
|
||||
? (gstRateDisplayFromValues(formState.values) ?? '')
|
||||
: (value?.toString() ?? '');
|
||||
@ -226,7 +234,7 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
|
||||
if (filterField != null) {
|
||||
for (final f in _definition.formFields) {
|
||||
if (f.key == filterField) {
|
||||
parentLabel = f.label.toLowerCase();
|
||||
parentLabel = masterFieldHintLabel(f.label);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@ -247,8 +255,8 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
|
||||
? 'Select $parentLabel first'
|
||||
: dropdownOptions.isEmpty
|
||||
? 'No options available'
|
||||
: 'Select ${field.label.toLowerCase()}',
|
||||
searchHint: 'Search ${field.label.toLowerCase()}...',
|
||||
: 'Select ${masterFieldHintLabel(field.label)}',
|
||||
searchHint: 'Search ${masterFieldHintLabel(field.label)}...',
|
||||
enabled: parentSelected,
|
||||
initialValues: () {
|
||||
final values = <String, dynamic>{};
|
||||
@ -263,11 +271,9 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
|
||||
}
|
||||
return values.isEmpty ? null : values;
|
||||
}(),
|
||||
refreshLookups: () {
|
||||
ref
|
||||
.read(masterFormProvider(_args).notifier)
|
||||
.reloadDropdownOptions();
|
||||
},
|
||||
refreshLookups: () => ref
|
||||
.read(masterFormProvider(_args).notifier)
|
||||
.reloadDropdownOptions(),
|
||||
parseCreatedId: (id) => id,
|
||||
onChanged: (selected) => notifier.updateValue(field.key, selected),
|
||||
validator: field.required
|
||||
@ -287,8 +293,8 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
|
||||
? 'Select $parentLabel first'
|
||||
: dropdownOptions.isEmpty
|
||||
? 'No options available'
|
||||
: 'Select ${field.label.toLowerCase()}',
|
||||
searchHint: 'Search ${field.label.toLowerCase()}...',
|
||||
: 'Select ${masterFieldHintLabel(field.label)}',
|
||||
searchHint: 'Search ${masterFieldHintLabel(field.label)}...',
|
||||
enabled: field.staticOptions != null
|
||||
? true
|
||||
: parentSelected && dropdownOptions.isNotEmpty,
|
||||
@ -414,11 +420,13 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
|
||||
final fullWidth = left.multiline || left.key == 'is_asset_item';
|
||||
if (fullWidth) {
|
||||
widgets.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: _buildField(context, field: left, formState: formState),
|
||||
QuickAddBlockable(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: _buildField(context, field: left, formState: formState),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@ -440,11 +448,13 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
|
||||
i += 2;
|
||||
} else {
|
||||
widgets.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: _buildField(context, field: left, formState: formState),
|
||||
QuickAddBlockable(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: _buildField(context, field: left, formState: formState),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@ -454,11 +464,13 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
|
||||
|
||||
if (activeField != null) {
|
||||
widgets.add(
|
||||
SidePanelSection(
|
||||
title: 'STATUS',
|
||||
children: [
|
||||
_buildField(context, field: activeField, formState: formState),
|
||||
],
|
||||
QuickAddBlockable(
|
||||
child: SidePanelSection(
|
||||
title: 'STATUS',
|
||||
children: [
|
||||
_buildField(context, field: activeField, formState: formState),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -485,7 +497,8 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
AppButton(
|
||||
label: widget.isEditing ? 'Edit ${def.title}' : 'Add ${def.title}',
|
||||
label:
|
||||
widget.isEditing ? 'Update ${def.title}' : 'Save ${def.title}',
|
||||
expand: false,
|
||||
icon: Icons.check,
|
||||
isLoading: isSubmitting,
|
||||
|
||||
@ -162,7 +162,7 @@ class _MasterInlineCreateFormState
|
||||
if (filterField != null) {
|
||||
for (final f in _definition.formFields) {
|
||||
if (f.key == filterField) {
|
||||
parentLabel = f.label.toLowerCase();
|
||||
parentLabel = masterFieldHintLabel(f.label);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@ -181,8 +181,8 @@ class _MasterInlineCreateFormState
|
||||
? 'Select $parentLabel first'
|
||||
: dropdownOptions.isEmpty
|
||||
? 'No options available'
|
||||
: 'Select ${field.label.toLowerCase()}',
|
||||
searchHint: 'Search ${field.label.toLowerCase()}...',
|
||||
: 'Select ${masterFieldHintLabel(field.label)}',
|
||||
searchHint: 'Search ${masterFieldHintLabel(field.label)}...',
|
||||
enabled: field.staticOptions != null
|
||||
? true
|
||||
: parentSelected && dropdownOptions.isNotEmpty,
|
||||
@ -497,7 +497,7 @@ class _MasterInlineCreateFormState
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
AppButton(
|
||||
label: 'Add ${def.title}',
|
||||
label: 'Save ${def.title}',
|
||||
expand: false,
|
||||
icon: Icons.check,
|
||||
isLoading: isSubmitting,
|
||||
|
||||
@ -9,15 +9,16 @@ import '../../../../shared/widgets/app_dropdown.dart';
|
||||
import '../../../../shared/widgets/app_searchable_dropdown.dart';
|
||||
import '../../../../shared/widgets/app_side_panel.dart';
|
||||
import '../providers/master_provider.dart';
|
||||
import '../../domain/entities/master_definition.dart';
|
||||
import 'master_form_panel.dart';
|
||||
import 'master_inline_create_form.dart';
|
||||
import '../../../../shared/widgets/app_toast.dart';
|
||||
|
||||
/// Dropdown with inline Quick Add that expands below the field (no dialog).
|
||||
/// Dropdown with Quick Add.
|
||||
///
|
||||
/// When placed inside a [QuickAddInlineHost] (FormRow / SidePanelFormRow), the
|
||||
/// create form is rendered at full row width under the row so every field and
|
||||
/// button stays clickable. Without a host, the form expands directly below the
|
||||
/// dropdown at the available column width.
|
||||
/// By default expands an inline create form below the field (or under a
|
||||
/// [QuickAddInlineHost]). Set [openInSidePanel] to open [MasterFormPanel]
|
||||
/// in a popup side panel instead.
|
||||
class MasterQuickAddDropdown<T> extends ConsumerStatefulWidget {
|
||||
const MasterQuickAddDropdown({
|
||||
super.key,
|
||||
@ -35,6 +36,8 @@ class MasterQuickAddDropdown<T> extends ConsumerStatefulWidget {
|
||||
this.initialValues,
|
||||
this.refreshLookups,
|
||||
this.addNewLabel,
|
||||
this.openInSidePanel = false,
|
||||
this.readOnlyFields,
|
||||
});
|
||||
|
||||
final String masterId;
|
||||
@ -54,6 +57,12 @@ class MasterQuickAddDropdown<T> extends ConsumerStatefulWidget {
|
||||
final FutureOr<void> Function()? refreshLookups;
|
||||
final String? addNewLabel;
|
||||
|
||||
/// When true, Quick Add opens [MasterFormPanel] in a side panel popup.
|
||||
final bool openInSidePanel;
|
||||
|
||||
/// Field keys locked as read-only in the Quick Add form (side panel or inline).
|
||||
final Set<String>? readOnlyFields;
|
||||
|
||||
@override
|
||||
ConsumerState<MasterQuickAddDropdown<T>> createState() =>
|
||||
_MasterQuickAddDropdownState<T>();
|
||||
@ -75,9 +84,10 @@ class _MasterQuickAddDropdownState<T>
|
||||
|
||||
bool get _canQuickAdd => ref.can('masters', PermissionAction.create);
|
||||
|
||||
void _openInlineForm() {
|
||||
Future<void> _onAddNew() async {
|
||||
if (!_canQuickAdd) {
|
||||
showAppToastFromSnackBar(context,
|
||||
showAppToastFromSnackBar(
|
||||
context,
|
||||
const SnackBar(
|
||||
content: Text('You do not have permission to add master data'),
|
||||
),
|
||||
@ -85,6 +95,42 @@ class _MasterQuickAddDropdownState<T>
|
||||
return;
|
||||
}
|
||||
|
||||
if (widget.openInSidePanel) {
|
||||
await _openSidePanelForm();
|
||||
return;
|
||||
}
|
||||
|
||||
_openInlineForm();
|
||||
}
|
||||
|
||||
Future<void> _openSidePanelForm() async {
|
||||
final sessionId =
|
||||
'panel-${widget.masterId}-${DateTime.now().microsecondsSinceEpoch}';
|
||||
ref.invalidate(
|
||||
masterFormProvider((
|
||||
masterId: widget.masterId,
|
||||
recordId: null,
|
||||
initialValues: widget.initialValues,
|
||||
formSessionId: sessionId,
|
||||
)),
|
||||
);
|
||||
|
||||
final createdId = await showSidePanel<String>(
|
||||
context,
|
||||
MasterFormPanel(
|
||||
masterId: widget.masterId,
|
||||
initialValues: widget.initialValues,
|
||||
formSessionId: sessionId,
|
||||
readOnlyFields: widget.readOnlyFields,
|
||||
),
|
||||
width: 560,
|
||||
);
|
||||
|
||||
if (!mounted || createdId == null) return;
|
||||
await _applyCreated(createdId);
|
||||
}
|
||||
|
||||
void _openInlineForm() {
|
||||
final sessionId =
|
||||
'inline-${widget.masterId}-${DateTime.now().microsecondsSinceEpoch}';
|
||||
ref.invalidate(
|
||||
@ -107,8 +153,14 @@ class _MasterQuickAddDropdownState<T>
|
||||
}
|
||||
|
||||
void _collapse() {
|
||||
if (!_expanded) return;
|
||||
_host?.dismiss(this);
|
||||
final host = _host ?? QuickAddInlineScope.maybeOf(context);
|
||||
// Prefer owner dismiss; fall back to dismissAll so Cancel/Close always works
|
||||
// even if the host state was remounted while the form was open.
|
||||
if (host != null) {
|
||||
host.dismiss(this);
|
||||
if (host.isOpen) host.dismissAll();
|
||||
}
|
||||
if (!_expanded && _sessionId == null) return;
|
||||
setState(() {
|
||||
_expanded = false;
|
||||
_sessionId = null;
|
||||
@ -119,30 +171,40 @@ class _MasterQuickAddDropdownState<T>
|
||||
}
|
||||
|
||||
Future<void> _onSaved(String createdId) async {
|
||||
await _applyCreated(createdId);
|
||||
if (mounted) _collapse();
|
||||
}
|
||||
|
||||
Future<void> _applyCreated(String createdId) async {
|
||||
final parsed = (createdId.isNotEmpty && createdId != 'created')
|
||||
? widget.parseCreatedId(createdId)
|
||||
: null;
|
||||
|
||||
// Select first so parent form keeps the new id even if this State is
|
||||
// disposed during the options reload rebuild.
|
||||
if (parsed != null) {
|
||||
widget.onChanged(parsed);
|
||||
}
|
||||
|
||||
try {
|
||||
final refresh = widget.refreshLookups;
|
||||
if (refresh != null) await refresh();
|
||||
} catch (_) {
|
||||
// Still select the created row even if lookup refresh fails.
|
||||
}
|
||||
if (!mounted) return;
|
||||
|
||||
if (createdId != 'created') {
|
||||
final parsed = widget.parseCreatedId(createdId);
|
||||
if (parsed != null) {
|
||||
widget.onChanged(parsed);
|
||||
}
|
||||
// Still keep the created selection even if lookup refresh fails.
|
||||
}
|
||||
|
||||
_collapse();
|
||||
if (!mounted) return;
|
||||
|
||||
// Re-apply after reload in case options refresh cleared dependent values.
|
||||
if (parsed != null) {
|
||||
widget.onChanged(parsed);
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
showAppToastFromSnackBar(
|
||||
showAppToast(
|
||||
context,
|
||||
SnackBar(
|
||||
content:
|
||||
Text('${_titleCase(masterQuickAddNoun(widget.masterId))} added'),
|
||||
),
|
||||
'${_titleCase(masterQuickAddNoun(widget.masterId))} added',
|
||||
type: AppToastType.success,
|
||||
);
|
||||
}
|
||||
|
||||
@ -162,13 +224,15 @@ class _MasterQuickAddDropdownState<T>
|
||||
final host = _host ?? QuickAddInlineScope.maybeOf(context);
|
||||
if (host == null) return;
|
||||
_host = host;
|
||||
host.present(owner: this, form: _buildCreateForm());
|
||||
host.present(owner: this, formBuilder: _buildCreateForm);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final addLabel = widget.addNewLabel ?? masterQuickAddLabel(widget.masterId);
|
||||
_host ??= QuickAddInlineScope.maybeOf(context);
|
||||
if (!widget.openInSidePanel) {
|
||||
_host ??= QuickAddInlineScope.maybeOf(context);
|
||||
}
|
||||
|
||||
final dropdown = Focus(
|
||||
focusNode: _dropdownFocus,
|
||||
@ -179,19 +243,19 @@ class _MasterQuickAddDropdownState<T>
|
||||
onChanged: widget.onChanged,
|
||||
validator: widget.validator,
|
||||
hint: widget.hint,
|
||||
searchHint:
|
||||
widget.searchHint ?? 'Search ${widget.label.toLowerCase()}...',
|
||||
searchHint: widget.searchHint ??
|
||||
'Search ${masterFieldHintLabel(widget.label.replaceAll('*', '').trim())}...',
|
||||
enabled: widget.enabled,
|
||||
isDense: widget.isDense,
|
||||
addNewLabel: _canQuickAdd ? addLabel : null,
|
||||
onAddNew: !_canQuickAdd || !widget.enabled
|
||||
? null
|
||||
: () async => _openInlineForm(),
|
||||
onAddNew: !_canQuickAdd || !widget.enabled ? null : _onAddNew,
|
||||
),
|
||||
);
|
||||
|
||||
// Host renders the form at full row width.
|
||||
if (_host != null || !_expanded || _sessionId == null) {
|
||||
if (widget.openInSidePanel ||
|
||||
_host != null ||
|
||||
!_expanded ||
|
||||
_sessionId == null) {
|
||||
return dropdown;
|
||||
}
|
||||
|
||||
@ -199,7 +263,14 @@ class _MasterQuickAddDropdownState<T>
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
dropdown,
|
||||
IgnorePointer(
|
||||
ignoring: true,
|
||||
child: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
opacity: 0.45,
|
||||
child: dropdown,
|
||||
),
|
||||
),
|
||||
AnimatedSize(
|
||||
duration: const Duration(milliseconds: 280),
|
||||
curve: Curves.easeOutCubic,
|
||||
|
||||
@ -5,6 +5,7 @@ import '../../../../core/constants/api_endpoints.dart';
|
||||
import '../../../../core/constants/app_constants.dart';
|
||||
import '../../../../core/network/dio_client.dart';
|
||||
import '../../../../core/utils/active_option.dart';
|
||||
import '../../../../core/utils/pagination_meta.dart';
|
||||
import '../../../../shared/models/user_management_models.dart';
|
||||
|
||||
final masterRemoteDataSourceProvider = Provider<MasterRemoteDataSource>((ref) {
|
||||
@ -71,6 +72,11 @@ class MasterRemoteDataSource {
|
||||
|
||||
for (final item in rows) {
|
||||
if (!isActiveOptionRow(item)) continue;
|
||||
// Ignore obsolete plant nesting fields if the API still returns them.
|
||||
item.remove('parent_id');
|
||||
item.remove('plant_id');
|
||||
item.remove('plant');
|
||||
|
||||
final id = item['id']?.toString() ?? '';
|
||||
final name = _optionLabel(item);
|
||||
if (id.isEmpty || name.isEmpty) continue;
|
||||
@ -100,6 +106,39 @@ class MasterRemoteDataSource {
|
||||
Future<List<FilterOptionModel>> listPaymentTerms() =>
|
||||
_listOptions(ApiEndpoints.paymentTerms);
|
||||
|
||||
/// Payment terms with `credit_days` for vendor credit-period autofill.
|
||||
Future<
|
||||
({
|
||||
List<FilterOptionModel> options,
|
||||
Map<int, int> creditDaysById,
|
||||
})> listPaymentTermsWithCreditDays() async {
|
||||
final rows = await _listAllMaps(ApiEndpoints.paymentTerms);
|
||||
final options = <FilterOptionModel>[];
|
||||
final creditDaysById = <int, int>{};
|
||||
|
||||
for (final item in rows) {
|
||||
if (!isActiveOptionRow(item)) continue;
|
||||
final id = item['id']?.toString() ?? '';
|
||||
if (id.isEmpty) continue;
|
||||
final name = _optionLabel(item);
|
||||
if (name.isEmpty) continue;
|
||||
|
||||
final idInt = int.tryParse(id);
|
||||
final creditDays = _asInt(
|
||||
item['credit_days'] ??
|
||||
item['credit_period_days'] ??
|
||||
item['days'],
|
||||
);
|
||||
if (idInt != null && creditDays != null) {
|
||||
creditDaysById[idInt] = creditDays;
|
||||
}
|
||||
|
||||
options.add(FilterOptionModel(id: id, name: name));
|
||||
}
|
||||
|
||||
return (options: options, creditDaysById: creditDaysById);
|
||||
}
|
||||
|
||||
Future<List<FilterOptionModel>> listDeliveryTerms() =>
|
||||
_listOptions(ApiEndpoints.deliveryTerms);
|
||||
|
||||
@ -331,20 +370,12 @@ class MasterRemoteDataSource {
|
||||
}
|
||||
|
||||
final raw = body['data'];
|
||||
final meta = body['meta'] is Map
|
||||
? Map<String, dynamic>.from(body['meta'] as Map)
|
||||
: <String, dynamic>{};
|
||||
|
||||
List list;
|
||||
Map<String, dynamic> pageMeta = meta;
|
||||
|
||||
if (raw is List) {
|
||||
list = raw;
|
||||
} else if (raw is Map) {
|
||||
final map = Map<String, dynamic>.from(raw);
|
||||
final items = map['items'];
|
||||
final items = raw['items'];
|
||||
list = items is List ? items : const [];
|
||||
pageMeta = {...meta, ...map};
|
||||
} else {
|
||||
list = const [];
|
||||
}
|
||||
@ -354,14 +385,20 @@ class MasterRemoteDataSource {
|
||||
.map((item) => Map<String, dynamic>.from(item))
|
||||
.toList();
|
||||
|
||||
final total = _asInt(pageMeta['total']) ?? items.length;
|
||||
final limit = _asInt(pageMeta['limit']) ?? fallbackLimit;
|
||||
final explicitTotalPages = _asInt(pageMeta['totalPages']) ??
|
||||
_asInt(pageMeta['total_pages']);
|
||||
final totalPages = explicitTotalPages ??
|
||||
(limit > 0 ? ((total + limit - 1) ~/ limit).clamp(1, 999999) : 1);
|
||||
final pagination = parsePagination(
|
||||
body: body,
|
||||
fallbackPage: 1,
|
||||
fallbackLimit: fallbackLimit,
|
||||
itemCount: items.length,
|
||||
);
|
||||
|
||||
return (items: items, totalPages: totalPages < 1 ? 1 : totalPages);
|
||||
return (
|
||||
items: items,
|
||||
totalPages: resolveTotalPages(
|
||||
total: pagination.total,
|
||||
limit: fallbackLimit,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
int? _asInt(dynamic value) {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../../../core/constants/api_endpoints.dart';
|
||||
import '../../../../core/utils/pagination_meta.dart';
|
||||
import '../../../../core/utils/export_file_name.dart';
|
||||
import '../../../../shared/models/api_response.dart';
|
||||
import '../../../../shared/models/entity_attachment_model.dart';
|
||||
@ -19,7 +20,12 @@ class PurchaseOrderRemoteDataSource {
|
||||
ApiEndpoints.purchaseOrders,
|
||||
queryParameters: _queryToMap(query),
|
||||
);
|
||||
return _parsePaginated(response.data, PurchaseOrderModel.fromJson);
|
||||
return _parsePaginated(
|
||||
response.data,
|
||||
PurchaseOrderModel.fromJson,
|
||||
fallbackPage: query.page,
|
||||
fallbackLimit: query.limit,
|
||||
);
|
||||
}
|
||||
|
||||
/// Approver-only list of POs with status PENDING_APPROVAL.
|
||||
@ -30,7 +36,12 @@ class PurchaseOrderRemoteDataSource {
|
||||
ApiEndpoints.purchaseOrdersPendingApproval,
|
||||
queryParameters: _queryToMap(query),
|
||||
);
|
||||
return _parsePaginated(response.data, PurchaseOrderModel.fromJson);
|
||||
return _parsePaginated(
|
||||
response.data,
|
||||
PurchaseOrderModel.fromJson,
|
||||
fallbackPage: query.page,
|
||||
fallbackLimit: query.limit,
|
||||
);
|
||||
}
|
||||
|
||||
/// Form-dropdown loader (`dropdown_call=true`). Optional status filter.
|
||||
@ -123,11 +134,11 @@ class PurchaseOrderRemoteDataSource {
|
||||
|
||||
Future<PurchaseOrderModel> rejectPurchaseOrder(
|
||||
String id, {
|
||||
required String remarks,
|
||||
required String rejectReason,
|
||||
}) async {
|
||||
final response = await dio.post(
|
||||
ApiEndpoints.purchaseOrderReject(id),
|
||||
data: {'remarks': remarks},
|
||||
data: {'reject_reason': rejectReason},
|
||||
);
|
||||
return PurchaseOrderModel.fromJson(
|
||||
response.data['data'] as Map<String, dynamic>,
|
||||
@ -160,6 +171,25 @@ class PurchaseOrderRemoteDataSource {
|
||||
);
|
||||
}
|
||||
|
||||
/// POST /notifications/trigger — send PO_SUBMIT_APPROVAL emails.
|
||||
Future<String> triggerApprovalNotification(String poId) async {
|
||||
final response = await dio.post(
|
||||
ApiEndpoints.notificationsTrigger,
|
||||
data: {
|
||||
'template_code': 'PO_SUBMIT_APPROVAL',
|
||||
'po_id': int.tryParse(poId) ?? poId,
|
||||
},
|
||||
);
|
||||
final body = response.data;
|
||||
if (body is Map) {
|
||||
final message = body['message'];
|
||||
if (message is String && message.trim().isNotEmpty) {
|
||||
return message.trim();
|
||||
}
|
||||
}
|
||||
return 'Approval notification sent';
|
||||
}
|
||||
|
||||
Future<List<int>> downloadPurchaseOrderPdf(String id) async {
|
||||
final response = await dio.get<List<int>>(
|
||||
ApiEndpoints.purchaseOrderPdf(id),
|
||||
@ -252,58 +282,45 @@ class PurchaseOrderRemoteDataSource {
|
||||
|
||||
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).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).clamp(1, 999999) : 1,
|
||||
);
|
||||
T Function(Map<String, dynamic>) fromJson, {
|
||||
int fallbackPage = 1,
|
||||
int fallbackLimit = 20,
|
||||
}) {
|
||||
var items = <T>[];
|
||||
if (body is Map) {
|
||||
final raw = body['data'];
|
||||
if (raw is List) {
|
||||
items = raw
|
||||
.whereType<Map>()
|
||||
.map((e) => fromJson(Map<String, dynamic>.from(e)))
|
||||
.toList();
|
||||
} else if (raw is Map) {
|
||||
final list = raw['items'];
|
||||
if (list is List) {
|
||||
items = list
|
||||
.whereType<Map>()
|
||||
.map((e) => fromJson(Map<String, dynamic>.from(e)))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return const PaginatedResponse(
|
||||
items: [],
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total: 0,
|
||||
totalPages: 1,
|
||||
final pagination = parsePagination(
|
||||
body: body,
|
||||
fallbackPage: fallbackPage,
|
||||
fallbackLimit: fallbackLimit,
|
||||
itemCount: items.length,
|
||||
);
|
||||
|
||||
return PaginatedResponse(
|
||||
items: items,
|
||||
page: pagination.page,
|
||||
limit: fallbackLimit,
|
||||
total: pagination.total,
|
||||
totalPages: resolveTotalPages(
|
||||
total: pagination.total,
|
||||
limit: fallbackLimit,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -98,10 +98,10 @@ class PurchaseOrderRepositoryImpl implements PurchaseOrderRepository {
|
||||
@override
|
||||
Future<Result<PurchaseOrderModel>> rejectPurchaseOrder(
|
||||
String id, {
|
||||
required String remarks,
|
||||
required String rejectReason,
|
||||
}) {
|
||||
return safeApiCall(
|
||||
() => dataSource.rejectPurchaseOrder(id, remarks: remarks),
|
||||
() => dataSource.rejectPurchaseOrder(id, rejectReason: rejectReason),
|
||||
);
|
||||
}
|
||||
|
||||
@ -121,6 +121,11 @@ class PurchaseOrderRepositoryImpl implements PurchaseOrderRepository {
|
||||
return safeApiCall(() => dataSource.cancelPurchaseOrder(id, remarks: remarks));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<String>> triggerApprovalNotification(String poId) {
|
||||
return safeApiCall(() => dataSource.triggerApprovalNotification(poId));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<List<int>>> downloadPurchaseOrderPdf(String id) {
|
||||
return safeApiCall(() => dataSource.downloadPurchaseOrderPdf(id));
|
||||
|
||||
@ -27,13 +27,18 @@ abstract class PurchaseOrderRepository {
|
||||
Future<Result<PurchaseOrderModel>> approvePurchaseOrder(String id, {String? remarks});
|
||||
Future<Result<PurchaseOrderModel>> rejectPurchaseOrder(
|
||||
String id, {
|
||||
required String remarks,
|
||||
required String rejectReason,
|
||||
});
|
||||
Future<Result<PurchaseOrderModel>> amendPurchaseOrder(
|
||||
String id, {
|
||||
Map<String, dynamic>? data,
|
||||
});
|
||||
Future<Result<PurchaseOrderModel>> cancelPurchaseOrder(String id, {String? remarks});
|
||||
|
||||
/// Triggers `PO_SUBMIT_APPROVAL` email for a pending-approval PO.
|
||||
/// Returns the API success message when available.
|
||||
Future<Result<String>> triggerApprovalNotification(String poId);
|
||||
|
||||
Future<Result<List<int>>> downloadPurchaseOrderPdf(String id);
|
||||
Future<Result<List<EntityAttachmentModel>>> listAttachments(String poId);
|
||||
Future<Result<EntityAttachmentModel>> uploadAttachment(
|
||||
|
||||
@ -1,13 +1,26 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/network/api_handler.dart';
|
||||
import '../../../../core/utils/active_option.dart';
|
||||
import '../../../../core/utils/column_search_paging.dart';
|
||||
import '../../../../core/utils/table_search.dart';
|
||||
|
||||
import '../../../../shared/models/api_response.dart';
|
||||
import '../../../../shared/models/entity_attachment_model.dart';
|
||||
import '../../../../shared/models/export_file_result.dart';
|
||||
import '../../../../shared/models/purchase_order_model.dart';
|
||||
import '../../../../shared/models/vendor_model.dart';
|
||||
import '../../../grn/presentation/providers/grn_lookups_provider.dart';
|
||||
import '../../../vendors/data/repositories/vendor_repository_impl.dart';
|
||||
import '../../data/repositories/purchase_order_repository_impl.dart';
|
||||
|
||||
/// Cached vendor options for list search enrichment only (not full PO lookups).
|
||||
final _poListVendorOptionsProvider =
|
||||
FutureProvider<List<VendorModel>>((ref) async {
|
||||
final result = await ref.watch(vendorRepositoryProvider).listVendorOptions();
|
||||
if (result.failure != null) return const [];
|
||||
return result.data ?? const [];
|
||||
});
|
||||
|
||||
class PurchaseOrdersListState {
|
||||
const PurchaseOrdersListState({
|
||||
this.orders = const [],
|
||||
@ -66,6 +79,8 @@ final pendingApprovalPurchaseOrdersListProvider =
|
||||
|
||||
class PurchaseOrdersListNotifier
|
||||
extends AutoDisposeAsyncNotifier<PurchaseOrdersListState> {
|
||||
final _columnSearch = ColumnSearchPaging();
|
||||
|
||||
@override
|
||||
Future<PurchaseOrdersListState> build() async {
|
||||
return _load(const PurchaseOrderListQuery(limit: 20));
|
||||
@ -76,8 +91,14 @@ class PurchaseOrdersListNotifier
|
||||
final result = await repository.getPurchaseOrders(query);
|
||||
if (result.failure != null) throw result.failure!;
|
||||
final page = result.data!;
|
||||
final orders = await _enrichPurchaseOrderSearch(
|
||||
ref: ref,
|
||||
query: query,
|
||||
items: page.items,
|
||||
load: repository.getPurchaseOrders,
|
||||
);
|
||||
return PurchaseOrdersListState(
|
||||
orders: page.items,
|
||||
orders: orders,
|
||||
query: query,
|
||||
total: page.total,
|
||||
totalPages: page.totalPages,
|
||||
@ -112,6 +133,28 @@ class PurchaseOrdersListNotifier
|
||||
applyQuery(current.query.copyWith(search: TableSearch.normalize(search), page: 1));
|
||||
}
|
||||
|
||||
/// Column search: load all rows once (`limit = total`), then filter client-side.
|
||||
Future<void> ensureColumnSearchDataset() async {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
final limit = _columnSearch.beginFullDataset(
|
||||
currentLimit: current.query.limit,
|
||||
total: current.total,
|
||||
);
|
||||
if (limit == null) return;
|
||||
await applyQuery(
|
||||
current.query.copyWith(search: null, page: 1, limit: limit),
|
||||
);
|
||||
}
|
||||
|
||||
void clearColumnSearchDataset() {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
final limit = _columnSearch.endFullDataset();
|
||||
if (limit == null) return;
|
||||
applyQuery(current.query.copyWith(search: null, page: 1, limit: limit));
|
||||
}
|
||||
|
||||
void setStatusFilter(String? status) {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
@ -173,10 +216,34 @@ class PurchaseOrdersListNotifier
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<bool> approvePurchaseOrder(String id, {String? remarks}) async {
|
||||
final repository = ref.read(purchaseOrderRepositoryProvider);
|
||||
final result = await repository.approvePurchaseOrder(id, remarks: remarks);
|
||||
if (result.failure != null) {
|
||||
final current = state.valueOrNull;
|
||||
if (current != null) {
|
||||
state = AsyncData(current.copyWith(actionError: result.failure!.message));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
ref.invalidate(pendingApprovalPurchaseOrdersListProvider);
|
||||
ref.invalidate(grnLookupsProvider);
|
||||
await refresh();
|
||||
final current = state.valueOrNull;
|
||||
if (current != null) {
|
||||
state = AsyncData(
|
||||
current.copyWith(actionSuccess: 'Purchase order approved'),
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
class PendingApprovalPurchaseOrdersListNotifier
|
||||
extends AutoDisposeAsyncNotifier<PurchaseOrdersListState> {
|
||||
final _columnSearch = ColumnSearchPaging();
|
||||
|
||||
@override
|
||||
Future<PurchaseOrdersListState> build() async {
|
||||
return _load(const PurchaseOrderListQuery(limit: 20));
|
||||
@ -187,8 +254,14 @@ class PendingApprovalPurchaseOrdersListNotifier
|
||||
final result = await repository.getPendingApprovalPurchaseOrders(query);
|
||||
if (result.failure != null) throw result.failure!;
|
||||
final page = result.data!;
|
||||
final orders = await _enrichPurchaseOrderSearch(
|
||||
ref: ref,
|
||||
query: query,
|
||||
items: page.items,
|
||||
load: repository.getPendingApprovalPurchaseOrders,
|
||||
);
|
||||
return PurchaseOrdersListState(
|
||||
orders: page.items,
|
||||
orders: orders,
|
||||
query: query,
|
||||
total: page.total,
|
||||
totalPages: page.totalPages,
|
||||
@ -223,6 +296,28 @@ class PendingApprovalPurchaseOrdersListNotifier
|
||||
applyQuery(current.query.copyWith(search: TableSearch.normalize(search), page: 1));
|
||||
}
|
||||
|
||||
/// Column search: load all rows once (`limit = total`), then filter client-side.
|
||||
Future<void> ensureColumnSearchDataset() async {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
final limit = _columnSearch.beginFullDataset(
|
||||
currentLimit: current.query.limit,
|
||||
total: current.total,
|
||||
);
|
||||
if (limit == null) return;
|
||||
await applyQuery(
|
||||
current.query.copyWith(search: null, page: 1, limit: limit),
|
||||
);
|
||||
}
|
||||
|
||||
void clearColumnSearchDataset() {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
final limit = _columnSearch.endFullDataset();
|
||||
if (limit == null) return;
|
||||
applyQuery(current.query.copyWith(search: null, page: 1, limit: limit));
|
||||
}
|
||||
|
||||
void setPage(int page) {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
@ -300,9 +395,12 @@ class PurchaseOrderDetailNotifier
|
||||
return result.data!;
|
||||
}
|
||||
|
||||
Future<PurchaseOrderModel> reject({required String remarks}) async {
|
||||
Future<PurchaseOrderModel> reject({required String rejectReason}) async {
|
||||
final repository = ref.read(purchaseOrderRepositoryProvider);
|
||||
final result = await repository.rejectPurchaseOrder(arg, remarks: remarks);
|
||||
final result = await repository.rejectPurchaseOrder(
|
||||
arg,
|
||||
rejectReason: rejectReason,
|
||||
);
|
||||
if (result.failure != null) throw result.failure!;
|
||||
state = AsyncData(result.data!);
|
||||
ref.invalidate(purchaseOrdersListProvider);
|
||||
@ -332,6 +430,14 @@ class PurchaseOrderDetailNotifier
|
||||
return result.data!;
|
||||
}
|
||||
|
||||
/// Sends PO_SUBMIT_APPROVAL notification emails to approvers.
|
||||
Future<String> triggerApprovalNotification() async {
|
||||
final repository = ref.read(purchaseOrderRepositoryProvider);
|
||||
final result = await repository.triggerApprovalNotification(arg);
|
||||
if (result.failure != null) throw result.failure!;
|
||||
return result.data!;
|
||||
}
|
||||
|
||||
Future<List<int>> downloadPdf() async {
|
||||
final repository = ref.read(purchaseOrderRepositoryProvider);
|
||||
final result = await repository.downloadPurchaseOrderPdf(arg);
|
||||
@ -425,3 +531,50 @@ class PurchaseOrderFormNotifier extends FamilyAsyncNotifier<PurchaseOrderModel?,
|
||||
return result.data!;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<PurchaseOrderModel>> _enrichPurchaseOrderSearch({
|
||||
required Ref ref,
|
||||
required PurchaseOrderListQuery query,
|
||||
required List<PurchaseOrderModel> items,
|
||||
required Future<Result<PaginatedResponse<PurchaseOrderModel>>> Function(
|
||||
PurchaseOrderListQuery query,
|
||||
) load,
|
||||
}) async {
|
||||
final search = TableSearch.normalize(query.search);
|
||||
if (search.isEmpty || query.vendorId != null) {
|
||||
return items;
|
||||
}
|
||||
|
||||
var merged = List<PurchaseOrderModel>.from(items);
|
||||
try {
|
||||
// Vendor-only cache — do not pull full PO master lookups on every search.
|
||||
final vendors = await ref.read(_poListVendorOptionsProvider.future);
|
||||
var vendorMatches = 0;
|
||||
for (final vendor in vendors) {
|
||||
if (!isActiveVendorOption(
|
||||
isActive: vendor.isActive,
|
||||
status: vendor.status,
|
||||
)) {
|
||||
continue;
|
||||
}
|
||||
if (!TableSearch.matches(search, [vendor.vendorName])) continue;
|
||||
if (++vendorMatches > 5) break;
|
||||
final vendorId = int.tryParse(vendor.id);
|
||||
if (vendorId == null) continue;
|
||||
final byVendor = await load(
|
||||
query.copyWith(search: null, vendorId: vendorId),
|
||||
);
|
||||
if (byVendor.failure == null && byVendor.data != null) {
|
||||
merged = TableSearch.mergeById(
|
||||
merged,
|
||||
byVendor.data!.items,
|
||||
(order) => order.id,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// Vendor enrichment is best-effort.
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
@ -15,6 +15,7 @@ import '../../../../shared/utils/file_download_helper.dart';
|
||||
import '../../../../shared/widgets/app_confirmation_dialog.dart';
|
||||
import '../../../../shared/widgets/app_loading_view.dart';
|
||||
import '../../../../shared/widgets/app_text_field.dart';
|
||||
import '../../../../shared/widgets/detail_overview_widgets.dart';
|
||||
import '../../../../shared/widgets/entity_attachments_card.dart';
|
||||
import '../../../../shared/widgets/error_view.dart';
|
||||
import '../providers/purchase_order_lookups_provider.dart';
|
||||
@ -22,6 +23,7 @@ import '../providers/purchase_orders_provider.dart';
|
||||
import '../../data/repositories/purchase_order_repository_impl.dart';
|
||||
import '../widgets/po_status_chip.dart';
|
||||
import '../widgets/purchase_order_line_items_editor.dart';
|
||||
import '../../../../shared/widgets/api_feedback.dart';
|
||||
import '../../../../shared/widgets/app_toast.dart';
|
||||
|
||||
class PurchaseOrderDetailScreen extends ConsumerStatefulWidget {
|
||||
@ -38,6 +40,26 @@ class _PurchaseOrderDetailScreenState
|
||||
extends ConsumerState<PurchaseOrderDetailScreen> {
|
||||
bool _isWorking = false;
|
||||
bool _isDownloadingPdf = false;
|
||||
bool _requestedFreshLoad = false;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_requestedFreshLoad) return;
|
||||
_requestedFreshLoad = true;
|
||||
// Always hit GET /purchase-orders/{id} when opening view.
|
||||
ref.invalidate(purchaseOrderDetailProvider(widget.purchaseOrderId));
|
||||
ref.invalidate(purchaseOrderAttachmentsProvider(widget.purchaseOrderId));
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant PurchaseOrderDetailScreen oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.purchaseOrderId != widget.purchaseOrderId) {
|
||||
ref.invalidate(purchaseOrderDetailProvider(widget.purchaseOrderId));
|
||||
ref.invalidate(purchaseOrderAttachmentsProvider(widget.purchaseOrderId));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -82,12 +104,17 @@ class _PurchaseOrderDetailScreenState
|
||||
'${RouteConstants.purchaseOrders}/${order.id}/edit',
|
||||
),
|
||||
onSubmit: () => _submit(order),
|
||||
onNotify: () => _notifyApprovers(order),
|
||||
onApprove: () => _approve(order),
|
||||
onReject: () => _reject(order),
|
||||
onAmend: () => _amend(order),
|
||||
onCancel: () => _cancel(order),
|
||||
onDelete: _delete,
|
||||
),
|
||||
if (order.rejectReasonForDisplay != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
_RejectReasonBanner(reason: order.rejectReasonForDisplay!),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
_OrderDetailsCard(order: order, lookups: lookups),
|
||||
const SizedBox(height: 16),
|
||||
@ -146,7 +173,10 @@ class _PurchaseOrderDetailScreenState
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
showAppToastFromSnackBar(context, SnackBar(content: Text(e.toString())));
|
||||
showAppToastFromSnackBar(
|
||||
context,
|
||||
SnackBar(content: Text(errorDisplayMessage(e))),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isWorking = false);
|
||||
@ -158,10 +188,42 @@ class _PurchaseOrderDetailScreenState
|
||||
() => ref
|
||||
.read(purchaseOrderDetailProvider(widget.purchaseOrderId).notifier)
|
||||
.submit(),
|
||||
'Purchase order submitted for approval',
|
||||
order.isRejected
|
||||
? 'Purchase order resubmitted for approval'
|
||||
: 'Purchase order submitted for approval',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _notifyApprovers(PurchaseOrderModel order) async {
|
||||
if (!order.canNotifyApprovers) {
|
||||
showAppToastFromSnackBar(
|
||||
context,
|
||||
const SnackBar(
|
||||
content: Text('Notifications can only be sent for pending approval POs'),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setState(() => _isWorking = true);
|
||||
try {
|
||||
final message = await ref
|
||||
.read(purchaseOrderDetailProvider(widget.purchaseOrderId).notifier)
|
||||
.triggerApprovalNotification();
|
||||
if (mounted) {
|
||||
showAppToastFromSnackBar(context, SnackBar(content: Text(message)));
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
showAppToastFromSnackBar(
|
||||
context,
|
||||
SnackBar(content: Text(errorDisplayMessage(e))),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isWorking = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _approve(PurchaseOrderModel order) async {
|
||||
await _runWorkflow(
|
||||
() => ref
|
||||
@ -172,14 +234,15 @@ class _PurchaseOrderDetailScreenState
|
||||
}
|
||||
|
||||
Future<void> _reject(PurchaseOrderModel order) async {
|
||||
final remarksController = TextEditingController();
|
||||
final reasonController = TextEditingController();
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Reject Purchase Order'),
|
||||
content: AppTextField(
|
||||
controller: remarksController,
|
||||
label: 'Remarks *',
|
||||
controller: reasonController,
|
||||
label: 'Reject reason *',
|
||||
hint: 'Why is this purchase order being rejected?',
|
||||
maxLines: 3,
|
||||
),
|
||||
actions: [
|
||||
@ -195,18 +258,19 @@ class _PurchaseOrderDetailScreenState
|
||||
),
|
||||
);
|
||||
if (confirmed != true || !mounted) return;
|
||||
final remarks = remarksController.text.trim();
|
||||
remarksController.dispose();
|
||||
if (remarks.isEmpty) {
|
||||
showAppToastFromSnackBar(context,
|
||||
const SnackBar(content: Text('Rejection remarks are required')),
|
||||
final rejectReason = reasonController.text.trim();
|
||||
reasonController.dispose();
|
||||
if (rejectReason.isEmpty) {
|
||||
showAppToastFromSnackBar(
|
||||
context,
|
||||
const SnackBar(content: Text('Reject reason is required')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await _runWorkflow(
|
||||
() => ref
|
||||
.read(purchaseOrderDetailProvider(widget.purchaseOrderId).notifier)
|
||||
.reject(remarks: remarks),
|
||||
.reject(rejectReason: rejectReason),
|
||||
'Purchase order rejected',
|
||||
);
|
||||
}
|
||||
@ -233,7 +297,10 @@ class _PurchaseOrderDetailScreenState
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
showAppToastFromSnackBar(context, SnackBar(content: Text(e.toString())));
|
||||
showAppToastFromSnackBar(
|
||||
context,
|
||||
SnackBar(content: Text(errorDisplayMessage(e))),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isWorking = false);
|
||||
@ -282,7 +349,10 @@ class _PurchaseOrderDetailScreenState
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
showAppToastFromSnackBar(context, SnackBar(content: Text(e.toString())));
|
||||
showAppToastFromSnackBar(
|
||||
context,
|
||||
SnackBar(content: Text(errorDisplayMessage(e))),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isWorking = false);
|
||||
@ -302,7 +372,10 @@ class _PurchaseOrderDetailScreenState
|
||||
);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
showAppToastFromSnackBar(context, SnackBar(content: Text(e.toString())));
|
||||
showAppToastFromSnackBar(
|
||||
context,
|
||||
SnackBar(content: Text(errorDisplayMessage(e))),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isDownloadingPdf = false);
|
||||
@ -373,6 +446,56 @@ String _hsnLabel(
|
||||
return _lookupName(lookups?.hsnCodes, item.hsnCodeId);
|
||||
}
|
||||
|
||||
class _RejectReasonBanner extends StatelessWidget {
|
||||
const _RejectReasonBanner({required this.reason});
|
||||
|
||||
final String reason;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.errorContainer.withValues(alpha: 0.35),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.error.withValues(alpha: 0.35),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.info_outline, color: theme.colorScheme.error, size: 20),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Reject reason',
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
color: theme.colorScheme.error,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
reason,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DetailHeader extends StatelessWidget {
|
||||
const _DetailHeader({
|
||||
required this.order,
|
||||
@ -386,6 +509,7 @@ class _DetailHeader extends StatelessWidget {
|
||||
required this.onPdf,
|
||||
required this.onEdit,
|
||||
required this.onSubmit,
|
||||
required this.onNotify,
|
||||
required this.onApprove,
|
||||
required this.onReject,
|
||||
required this.onAmend,
|
||||
@ -404,6 +528,7 @@ class _DetailHeader extends StatelessWidget {
|
||||
final VoidCallback onPdf;
|
||||
final VoidCallback onEdit;
|
||||
final VoidCallback onSubmit;
|
||||
final VoidCallback onNotify;
|
||||
final VoidCallback onApprove;
|
||||
final VoidCallback onReject;
|
||||
final VoidCallback onAmend;
|
||||
@ -441,11 +566,17 @@ class _DetailHeader extends StatelessWidget {
|
||||
),
|
||||
if (canEdit && order.canSubmit)
|
||||
_HeaderActionButton(
|
||||
label: 'Submit',
|
||||
label: order.isRejected ? 'Resubmit' : 'Submit',
|
||||
icon: Icons.send_outlined,
|
||||
filled: true,
|
||||
onPressed: isWorking ? null : onSubmit,
|
||||
),
|
||||
if (canEdit && order.canNotifyApprovers)
|
||||
_HeaderActionButton(
|
||||
label: 'Notify',
|
||||
icon: Icons.notifications_outlined,
|
||||
onPressed: isWorking ? null : onNotify,
|
||||
),
|
||||
if (canApprove && order.canApprove)
|
||||
_HeaderActionButton(
|
||||
label: 'Approve',
|
||||
@ -496,6 +627,41 @@ class _DetailHeader extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
PoStatusChip(status: order.status, compact: true),
|
||||
if (order.isRejected) ...[
|
||||
const SizedBox(width: 4),
|
||||
Tooltip(
|
||||
richMessage: TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: 'Reject reason\n',
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: theme.colorScheme.onInverseSurface,
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: order.rejectReasonForDisplay ??
|
||||
'No reject reason provided',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onInverseSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
waitDuration: const Duration(milliseconds: 150),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
margin: const EdgeInsets.only(top: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.inverseSurface,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.info_outline,
|
||||
size: 18,
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (order.revisionNo != null && order.revisionNo! > 0) ...[
|
||||
const SizedBox(width: 8),
|
||||
PoRevisionChip(revisionNo: order.revisionNo!, compact: true),
|
||||
@ -699,6 +865,8 @@ class _OrderDetailsCard extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final paymentTerm =
|
||||
_lookupName(lookups?.paymentTerms, order.paymentTermId);
|
||||
final deliveryTerm =
|
||||
@ -706,91 +874,89 @@ class _OrderDetailsCard extends StatelessWidget {
|
||||
|
||||
return _SectionCard(
|
||||
title: 'ORDER DETAILS',
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final cols = constraints.maxWidth < 600
|
||||
? 1
|
||||
: constraints.maxWidth < 900
|
||||
? 2
|
||||
: 4;
|
||||
const spacing = 20.0;
|
||||
final width = (constraints.maxWidth - spacing * (cols - 1)) / cols;
|
||||
final items = [
|
||||
_DetailField(
|
||||
label: 'PO Date',
|
||||
value: DateFormatter.displayDate(order.poDate),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
DetailOverviewSection(
|
||||
title: 'Summary',
|
||||
child: DetailSummaryStrip(
|
||||
metrics: [
|
||||
DetailSummaryMetric(
|
||||
icon: Icons.flag_outlined,
|
||||
label: 'Status',
|
||||
accent: scheme.secondary,
|
||||
child: PoStatusChip(status: order.status, compact: true),
|
||||
),
|
||||
DetailSummaryMetric(
|
||||
icon: Icons.payments_outlined,
|
||||
label: 'Total Amount',
|
||||
accent: scheme.primary,
|
||||
child: Text(
|
||||
order.totalAmount != null
|
||||
? CurrencyFormatter.format(order.totalAmount)
|
||||
: '—',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
DetailSummaryMetric(
|
||||
icon: Icons.calendar_today_outlined,
|
||||
label: 'PO Date',
|
||||
child: Text(
|
||||
DateFormatter.displayDate(order.poDate),
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
DetailSummaryMetric(
|
||||
icon: Icons.inventory_2_outlined,
|
||||
label: 'Line Items',
|
||||
child: Text(
|
||||
'${order.items.length}',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
_DetailField(
|
||||
label: 'Expected Delivery',
|
||||
value: DateFormatter.displayDate(order.expectedDeliveryDate),
|
||||
),
|
||||
DetailOverviewSection(
|
||||
title: 'Vendor & Delivery',
|
||||
child: DetailInfoGrid(
|
||||
items: [
|
||||
DetailInfoItem('Vendor', _displayOrDash(order.vendorName)),
|
||||
DetailInfoItem(
|
||||
'Vendor Type',
|
||||
vendorTypeLabel(order.vendorType),
|
||||
),
|
||||
DetailInfoItem(
|
||||
'Expected Delivery',
|
||||
DateFormatter.displayDate(order.expectedDeliveryDate),
|
||||
),
|
||||
DetailInfoItem('Billing', _displayOrDash(order.billingName)),
|
||||
DetailInfoItem('Shipping', _displayOrDash(order.shippingName)),
|
||||
],
|
||||
),
|
||||
_DetailField(
|
||||
label: 'Vendor',
|
||||
value: _displayOrDash(order.vendorName),
|
||||
),
|
||||
DetailOverviewSection(
|
||||
title: 'Terms',
|
||||
showDivider: false,
|
||||
child: DetailInfoGrid(
|
||||
items: [
|
||||
DetailInfoItem('Payment Term', paymentTerm),
|
||||
DetailInfoItem('Delivery Term', deliveryTerm),
|
||||
],
|
||||
),
|
||||
_DetailField(
|
||||
label: 'Vendor Type',
|
||||
value: vendorTypeLabel(order.vendorType),
|
||||
),
|
||||
_DetailField(
|
||||
label: 'Billing',
|
||||
value: _displayOrDash(order.billingName),
|
||||
),
|
||||
_DetailField(
|
||||
label: 'Shipping',
|
||||
value: _displayOrDash(order.shippingName),
|
||||
),
|
||||
_DetailField(label: 'Payment Term', value: paymentTerm),
|
||||
_DetailField(label: 'Delivery Term', value: deliveryTerm),
|
||||
];
|
||||
|
||||
return Wrap(
|
||||
spacing: spacing,
|
||||
runSpacing: 16,
|
||||
children: items
|
||||
.map((item) => SizedBox(width: width, child: item))
|
||||
.toList(),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DetailField extends StatelessWidget {
|
||||
const _DetailField({
|
||||
required this.label,
|
||||
required this.value,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
value,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PoAttachmentsSection extends ConsumerWidget {
|
||||
const _PoAttachmentsSection({
|
||||
required this.poId,
|
||||
@ -1247,7 +1413,7 @@ class _AmountSummaryCard extends StatelessWidget {
|
||||
Text(
|
||||
CurrencyFormatter.format(order.totalAmount),
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.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/network/api_handler.dart';
|
||||
@ -9,6 +11,7 @@ import '../../../../core/theme/app_colors.dart';
|
||||
import '../../../../core/utils/formatters.dart';
|
||||
import '../../../../shared/models/purchase_order_model.dart';
|
||||
import '../../../../shared/models/user_management_models.dart';
|
||||
import '../../../../shared/providers/permissions_provider.dart';
|
||||
import '../../../../shared/utils/navigation_utils.dart';
|
||||
import '../../../../shared/widgets/app_button.dart';
|
||||
import '../../../../shared/widgets/app_date_popup.dart';
|
||||
@ -18,9 +21,11 @@ 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/app_sticky_form_layout.dart';
|
||||
import '../../data/repositories/purchase_order_repository_impl.dart';
|
||||
import '../../../grn/presentation/providers/grn_lookups_provider.dart';
|
||||
import '../../../master_data/presentation/widgets/master_quick_add.dart';
|
||||
import '../../../vendors/presentation/widgets/vendor_form_panel.dart';
|
||||
import '../providers/purchase_order_lookups_provider.dart';
|
||||
import '../providers/purchase_orders_provider.dart';
|
||||
import '../widgets/po_status_chip.dart';
|
||||
@ -59,6 +64,7 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
||||
bool _isSubmitting = false;
|
||||
String? _populatedSignature;
|
||||
bool _defaultTermsApplied = false;
|
||||
bool _requestedFreshLoad = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@ -72,6 +78,15 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
||||
_otherChargesController.addListener(_onChargesChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_requestedFreshLoad || !widget.isEditing) return;
|
||||
_requestedFreshLoad = true;
|
||||
// Always hit GET /purchase-orders/{id} when opening edit.
|
||||
ref.invalidate(purchaseOrderFormProvider(widget.purchaseOrderId));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_discountController.removeListener(_onChargesChanged);
|
||||
@ -203,6 +218,12 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
||||
}
|
||||
|
||||
Map<String, dynamic> _buildPayload() {
|
||||
final discount =
|
||||
(double.tryParse(_discountController.text.trim()) ?? 0).clamp(0, double.infinity);
|
||||
final freight =
|
||||
(double.tryParse(_freightController.text.trim()) ?? 0).clamp(0, double.infinity);
|
||||
final other =
|
||||
(double.tryParse(_otherChargesController.text.trim()) ?? 0).clamp(0, double.infinity);
|
||||
return {
|
||||
'po_date': DateFormatter.toApiDate(_poDate ?? DateTime.now()),
|
||||
'vendor_id': _vendorId,
|
||||
@ -213,12 +234,9 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
||||
if (_expectedDeliveryDate != null)
|
||||
'expected_delivery_date':
|
||||
DateFormatter.toApiDate(_expectedDeliveryDate!),
|
||||
'discount_amount':
|
||||
double.tryParse(_discountController.text.trim()) ?? 0,
|
||||
'freight_charges':
|
||||
double.tryParse(_freightController.text.trim()) ?? 0,
|
||||
'other_charges':
|
||||
double.tryParse(_otherChargesController.text.trim()) ?? 0,
|
||||
'discount_amount': discount,
|
||||
'freight_charges': freight,
|
||||
'other_charges': other,
|
||||
if (_termsController.text.trim().isNotEmpty)
|
||||
'terms_and_conditions': _termsController.text.trim(),
|
||||
if (_remarksController.text.trim().isNotEmpty)
|
||||
@ -239,6 +257,28 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
||||
if (rate == null || rate < 0) {
|
||||
return 'Enter a valid rate for line ${line.lineNo}';
|
||||
}
|
||||
final discPct = double.tryParse(line.discountController.text.trim()) ?? 0;
|
||||
if (discPct < 0 || discPct > 100) {
|
||||
return 'Discount % on line ${line.lineNo} must be between 0 and 100';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _chargesError(PoOrderTotals totals) {
|
||||
final freight = double.tryParse(_freightController.text.trim());
|
||||
if (freight == null) return 'Enter a valid freight amount';
|
||||
if (freight < 0) return 'Freight charges cannot be negative';
|
||||
|
||||
final other = double.tryParse(_otherChargesController.text.trim());
|
||||
if (other == null) return 'Enter a valid other charges amount';
|
||||
if (other < 0) return 'Other charges cannot be negative';
|
||||
|
||||
final discount = double.tryParse(_discountController.text.trim());
|
||||
if (discount == null) return 'Enter a valid discount amount';
|
||||
if (discount < 0) return 'Discount amount cannot be negative';
|
||||
if (discount > totals.maxDiscountAmount) {
|
||||
return 'Discount cannot exceed ${CurrencyFormatter.format(totals.maxDiscountAmount)}';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@ -257,6 +297,20 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
||||
return;
|
||||
}
|
||||
|
||||
if (_poDate != null &&
|
||||
_expectedDeliveryDate != null &&
|
||||
_expectedDeliveryDate!.isBefore(_poDate!)) {
|
||||
showAppToastFromSnackBar(
|
||||
context,
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Expected delivery date cannot be earlier than PO date',
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_vendorId == null || _billingId == null || _shippingId == null) {
|
||||
showAppToastFromSnackBar(context,
|
||||
const SnackBar(content: Text('Please complete all required fields')),
|
||||
@ -279,6 +333,14 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
||||
return;
|
||||
}
|
||||
|
||||
final lookups = ref.read(purchaseOrderLookupsProvider).valueOrNull;
|
||||
final totals = _computeTotals(lookups?.gstRatePctById ?? const {});
|
||||
final chargesError = _chargesError(totals);
|
||||
if (chargesError != null) {
|
||||
showAppToastFromSnackBar(context, SnackBar(content: Text(chargesError)));
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isSubmitting = true);
|
||||
try {
|
||||
final payload = _buildPayload();
|
||||
@ -327,12 +389,14 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
||||
Future<void> _pickDate({
|
||||
required DateTime? current,
|
||||
required ValueChanged<DateTime?> onPicked,
|
||||
DateTime? firstDate,
|
||||
DateTime? lastDate,
|
||||
}) async {
|
||||
final picked = await showAppDatePopup(
|
||||
context: context,
|
||||
initialDate: current ?? DateTime.now(),
|
||||
firstDate: DateTime(2020),
|
||||
lastDate: DateTime(2100),
|
||||
firstDate: firstDate ?? DateTime(2020),
|
||||
lastDate: lastDate ?? DateTime(2100),
|
||||
helpText: 'Select date',
|
||||
);
|
||||
if (picked != null) onPicked(picked);
|
||||
@ -376,27 +440,35 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
||||
final totals = _computeTotals(lookups.gstRatePctById);
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return SingleChildScrollView(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 32),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
return Form(
|
||||
key: _formKey,
|
||||
child: AppStickyFormLayout(
|
||||
scrollController: _scrollController,
|
||||
headerPadding: const EdgeInsets.fromLTRB(24, 12, 24, 12),
|
||||
bodyPadding: const EdgeInsets.fromLTRB(24, 16, 24, 32),
|
||||
header: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildHeader(existing),
|
||||
if (_showReapprovalWarning(existing)) ...[
|
||||
const SizedBox(height: 8),
|
||||
_ReapprovalBanner(),
|
||||
],
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildHeader(existing),
|
||||
if (_showReapprovalWarning(existing)) ...[
|
||||
const SizedBox(height: 8),
|
||||
_ReapprovalBanner(),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
_SectionCard(
|
||||
title: 'ORDER DETAILS',
|
||||
child: QuickAddInlineHost(
|
||||
child: ResponsiveFormGrid(
|
||||
child: QuickAddBlockable(
|
||||
child: ResponsiveFormGrid(
|
||||
xsColumns: 1,
|
||||
smallColumns: 1,
|
||||
mediumColumns: 2,
|
||||
largeColumns: 4,
|
||||
smallBreakpoint: 640,
|
||||
mediumBreakpoint: 640,
|
||||
largeBreakpoint: 1100,
|
||||
children: [
|
||||
@ -417,24 +489,74 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
||||
onChanged: (v) => setState(() => _vendorId = v),
|
||||
validator: (v) =>
|
||||
v == null ? 'Vendor is required' : null,
|
||||
addNewLabel: ref.can(
|
||||
'vendors',
|
||||
PermissionAction.create,
|
||||
)
|
||||
? 'Add vendor'
|
||||
: null,
|
||||
onAddNew: !ref.can(
|
||||
'vendors',
|
||||
PermissionAction.create,
|
||||
)
|
||||
? null
|
||||
: () async {
|
||||
final createdId =
|
||||
await openVendorFormPanel(
|
||||
context,
|
||||
ref,
|
||||
);
|
||||
if (!mounted || createdId == null) {
|
||||
return;
|
||||
}
|
||||
ref.invalidate(
|
||||
purchaseOrderLookupsProvider,
|
||||
);
|
||||
await ref.read(
|
||||
purchaseOrderLookupsProvider.future,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(
|
||||
() => _vendorId =
|
||||
int.tryParse(createdId),
|
||||
);
|
||||
},
|
||||
),
|
||||
AppSearchableDropdown<int>(
|
||||
MasterQuickAddDropdown<int>(
|
||||
masterId: 'locations',
|
||||
label: 'Billing *',
|
||||
value: _dropdownValue(_billingId, locationIds),
|
||||
hint: 'Select billing location',
|
||||
searchHint: 'Search plant or warehouse...',
|
||||
options: _intOptions(lookups.locations),
|
||||
openInSidePanel: true,
|
||||
refreshLookups: () async {
|
||||
ref.invalidate(purchaseOrderLookupsProvider);
|
||||
await ref.read(
|
||||
purchaseOrderLookupsProvider.future,
|
||||
);
|
||||
},
|
||||
parseCreatedId: int.tryParse,
|
||||
onChanged: (v) =>
|
||||
setState(() => _billingId = v),
|
||||
validator: (v) =>
|
||||
v == null ? 'Billing is required' : null,
|
||||
),
|
||||
AppSearchableDropdown<int>(
|
||||
MasterQuickAddDropdown<int>(
|
||||
masterId: 'locations',
|
||||
label: 'Shipping *',
|
||||
value: _dropdownValue(_shippingId, locationIds),
|
||||
hint: 'Select shipping location',
|
||||
searchHint: 'Search plant or warehouse...',
|
||||
options: _intOptions(lookups.locations),
|
||||
openInSidePanel: true,
|
||||
refreshLookups: () async {
|
||||
ref.invalidate(purchaseOrderLookupsProvider);
|
||||
await ref.read(
|
||||
purchaseOrderLookupsProvider.future,
|
||||
);
|
||||
},
|
||||
parseCreatedId: int.tryParse,
|
||||
onChanged: (v) =>
|
||||
setState(() => _shippingId = v),
|
||||
validator: (v) =>
|
||||
@ -448,6 +570,7 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
||||
searchHint: 'Search payment term...',
|
||||
options:
|
||||
_nullableIntOptions(lookups.paymentTerms),
|
||||
openInSidePanel: true,
|
||||
refreshLookups: () =>
|
||||
ref.invalidate(purchaseOrderLookupsProvider),
|
||||
parseCreatedId: int.tryParse,
|
||||
@ -462,6 +585,7 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
||||
searchHint: 'Search delivery term...',
|
||||
options:
|
||||
_nullableIntOptions(lookups.deliveryTerms),
|
||||
openInSidePanel: true,
|
||||
refreshLookups: () =>
|
||||
ref.invalidate(purchaseOrderLookupsProvider),
|
||||
parseCreatedId: int.tryParse,
|
||||
@ -473,6 +597,7 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
||||
value: _expectedDeliveryDate,
|
||||
onTap: () => _pickDate(
|
||||
current: _expectedDeliveryDate,
|
||||
firstDate: _poDate ?? DateTime(2020),
|
||||
onPicked: (d) => setState(
|
||||
() => _expectedDeliveryDate = d,
|
||||
),
|
||||
@ -480,6 +605,7 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
@ -563,7 +689,7 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
||||
const Spacer(),
|
||||
Text(
|
||||
widget.isEditing
|
||||
? '${_lines.length} line item${_lines.length == 1 ? '' : 's'} · editing existing draft'
|
||||
? '${_lines.length} line item${_lines.length == 1 ? '' : 's'} · editing ${existing?.isRejected == true ? 'rejected' : 'existing draft'} order'
|
||||
: '${_lines.length} line item${_lines.length == 1 ? '' : 's'} · draft not yet saved',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
@ -638,7 +764,7 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
||||
);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
padding: EdgeInsets.zero,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final stack = constraints.maxWidth < 720;
|
||||
@ -795,6 +921,15 @@ class _AmountSummaryCard extends StatelessWidget {
|
||||
final bool isEditing;
|
||||
final bool isInterState;
|
||||
|
||||
String? _validateNonNegativeAmount(String? value, String fieldName) {
|
||||
final text = value?.trim() ?? '';
|
||||
if (text.isEmpty) return null;
|
||||
final amount = double.tryParse(text);
|
||||
if (amount == null) return 'Enter a valid amount';
|
||||
if (amount < 0) return 'Cannot be negative';
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _validateDiscount(String? value) {
|
||||
final text = value?.trim() ?? '';
|
||||
if (text.isEmpty) return null;
|
||||
@ -858,10 +993,14 @@ class _AmountSummaryCard extends StatelessWidget {
|
||||
_SummaryInputRow(
|
||||
label: 'Freight Charges',
|
||||
controller: freightController,
|
||||
validator: (v) => _validateNonNegativeAmount(v, 'Freight'),
|
||||
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||
),
|
||||
_SummaryInputRow(
|
||||
label: 'Other Charges',
|
||||
controller: otherChargesController,
|
||||
validator: (v) => _validateNonNegativeAmount(v, 'Other charges'),
|
||||
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||
),
|
||||
_SummaryInputRow(
|
||||
label: 'Discount Amount',
|
||||
@ -902,7 +1041,7 @@ class _AmountSummaryCard extends StatelessWidget {
|
||||
Text(
|
||||
CurrencyFormatter.format(totals.grandTotal),
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
@ -1000,6 +1139,9 @@ class _SummaryInputRow extends StatelessWidget {
|
||||
controller: controller,
|
||||
keyboardType:
|
||||
const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*')),
|
||||
],
|
||||
textAlign: TextAlign.right,
|
||||
autovalidateMode: autovalidateMode,
|
||||
validator: validator,
|
||||
@ -1017,9 +1159,8 @@ class _SummaryInputRow extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
errorMaxLines: 2,
|
||||
errorStyle: theme.textTheme.bodySmall?.copyWith(
|
||||
errorStyle: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.error,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@ -23,10 +23,13 @@ import '../../../../shared/widgets/app_search_filter_toggle.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_column_selector.dart';
|
||||
import '../../../../shared/widgets/app_table_shell.dart';
|
||||
import '../../../../shared/widgets/error_view.dart';
|
||||
import '../../../../shared/widgets/page_header.dart';
|
||||
import '../../../../shared/providers/table_column_prefs_provider.dart';
|
||||
import '../../../../shared/utils/file_download_helper.dart';
|
||||
import '../../data/repositories/purchase_order_repository_impl.dart';
|
||||
import '../providers/purchase_orders_provider.dart';
|
||||
import '../widgets/po_status_chip.dart';
|
||||
import '../../../../shared/widgets/app_toast.dart';
|
||||
@ -76,8 +79,10 @@ class _PurchaseOrderListScreenState extends ConsumerState<PurchaseOrderListScree
|
||||
!pendingOnly && ref.can('purchase_orders', PermissionAction.delete);
|
||||
final canExport =
|
||||
!pendingOnly && ref.can('purchase_orders', PermissionAction.export);
|
||||
final canApprove =
|
||||
pendingOnly && ref.can('purchase_orders', PermissionAction.approve);
|
||||
final canApprove = ref.can('purchase_orders', PermissionAction.approve);
|
||||
// API: notifications/trigger requires PURCHASE_ORDER edit.
|
||||
final canNotify =
|
||||
ref.can('purchase_orders', PermissionAction.update);
|
||||
|
||||
void listenListMessages(
|
||||
AsyncValue<PurchaseOrdersListState>? prev,
|
||||
@ -130,6 +135,11 @@ class _PurchaseOrderListScreenState extends ConsumerState<PurchaseOrderListScree
|
||||
() => _filtersExpanded = !_filtersExpanded,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
AppTableColumnSelectorButton(
|
||||
tableId: _PoDataTable.tableId,
|
||||
columns: _PoDataTable.columnOptions,
|
||||
),
|
||||
if (canExport) ...[
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton.icon(
|
||||
@ -185,6 +195,7 @@ class _PurchaseOrderListScreenState extends ConsumerState<PurchaseOrderListScree
|
||||
totalPages: state.totalPages,
|
||||
totalItems: state.total,
|
||||
pageSize: state.query.limit,
|
||||
itemsOnPage: state.orders.length,
|
||||
itemLabel: pendingOnly
|
||||
? 'pending approvals'
|
||||
: 'purchase orders',
|
||||
@ -213,41 +224,71 @@ class _PurchaseOrderListScreenState extends ConsumerState<PurchaseOrderListScree
|
||||
)
|
||||
.refresh()
|
||||
: ref.read(purchaseOrdersListProvider.notifier).refresh(),
|
||||
child: state.orders.isEmpty
|
||||
? ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 240,
|
||||
child: AppEmptyState(
|
||||
title: pendingOnly
|
||||
? 'No pending approvals'
|
||||
: 'No purchase orders found',
|
||||
description: pendingOnly
|
||||
? 'There are no purchase orders waiting for approval.'
|
||||
: 'Try adjusting filters or create a new purchase order.',
|
||||
icon: pendingOnly
|
||||
? Icons.pending_actions_outlined
|
||||
: Icons.receipt_long_outlined,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: context.isMobile
|
||||
? _PoCardList(
|
||||
orders: state.orders,
|
||||
onView: _viewOrder,
|
||||
onEdit: canEdit ? _editOrder : null,
|
||||
onDelete: canDelete ? _deleteOrder : null,
|
||||
onApprove: canApprove ? _approveOrder : null,
|
||||
child: context.isMobile
|
||||
? (state.orders.isEmpty
|
||||
? ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 240,
|
||||
child: AppEmptyState(
|
||||
title: pendingOnly
|
||||
? 'No pending approvals'
|
||||
: 'No purchase orders found',
|
||||
description: pendingOnly
|
||||
? 'There are no purchase orders waiting for approval.'
|
||||
: 'Try adjusting filters or create a new purchase order.',
|
||||
icon: pendingOnly
|
||||
? Icons.pending_actions_outlined
|
||||
: Icons.receipt_long_outlined,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: _PoDataTable(
|
||||
: _PoCardList(
|
||||
orders: state.orders,
|
||||
onView: _viewOrder,
|
||||
onEdit: canEdit ? _editOrder : null,
|
||||
onDelete: canDelete ? _deleteOrder : null,
|
||||
onApprove: canApprove ? _approveOrder : null,
|
||||
),
|
||||
onNotify:
|
||||
canNotify ? _notifyApprovers : null,
|
||||
))
|
||||
: _PoDataTable(
|
||||
orders: state.orders,
|
||||
onView: _viewOrder,
|
||||
onEdit: canEdit ? _editOrder : null,
|
||||
onDelete: canDelete ? _deleteOrder : null,
|
||||
onApprove: canApprove ? _approveOrder : null,
|
||||
onNotify: canNotify ? _notifyApprovers : null,
|
||||
onEnsureFullDataset: () {
|
||||
if (pendingOnly) {
|
||||
return ref
|
||||
.read(
|
||||
pendingApprovalPurchaseOrdersListProvider
|
||||
.notifier,
|
||||
)
|
||||
.ensureColumnSearchDataset();
|
||||
}
|
||||
return ref
|
||||
.read(purchaseOrdersListProvider.notifier)
|
||||
.ensureColumnSearchDataset();
|
||||
},
|
||||
onColumnSearchCleared: () {
|
||||
if (pendingOnly) {
|
||||
ref
|
||||
.read(
|
||||
pendingApprovalPurchaseOrdersListProvider
|
||||
.notifier,
|
||||
)
|
||||
.clearColumnSearchDataset();
|
||||
} else {
|
||||
ref
|
||||
.read(purchaseOrdersListProvider.notifier)
|
||||
.clearColumnSearchDataset();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -259,8 +300,6 @@ class _PurchaseOrderListScreenState extends ConsumerState<PurchaseOrderListScree
|
||||
}
|
||||
|
||||
void _viewOrder(PurchaseOrderModel order) {
|
||||
// Always hit GET /purchase-orders/{id} for the detail screen.
|
||||
ref.invalidate(purchaseOrderDetailProvider(order.id));
|
||||
context.push('${RouteConstants.purchaseOrders}/${order.id}');
|
||||
}
|
||||
|
||||
@ -321,9 +360,40 @@ class _PurchaseOrderListScreenState extends ConsumerState<PurchaseOrderListScree
|
||||
confirmLabel: 'Approve',
|
||||
);
|
||||
if (confirmed != true || !mounted) return;
|
||||
await ref
|
||||
.read(pendingApprovalPurchaseOrdersListProvider.notifier)
|
||||
.approvePurchaseOrder(order.id);
|
||||
if (widget.pendingApprovalOnly) {
|
||||
await ref
|
||||
.read(pendingApprovalPurchaseOrdersListProvider.notifier)
|
||||
.approvePurchaseOrder(order.id);
|
||||
return;
|
||||
}
|
||||
await ref.read(purchaseOrdersListProvider.notifier).approvePurchaseOrder(order.id);
|
||||
}
|
||||
|
||||
Future<void> _notifyApprovers(PurchaseOrderModel order) async {
|
||||
if (!order.canNotifyApprovers) {
|
||||
showAppToastFromSnackBar(
|
||||
context,
|
||||
const SnackBar(
|
||||
content: Text('Notifications can only be sent for pending approval POs'),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
final result = await ref
|
||||
.read(purchaseOrderRepositoryProvider)
|
||||
.triggerApprovalNotification(order.id);
|
||||
if (!mounted) return;
|
||||
if (result.failure != null) {
|
||||
showAppToastFromSnackBar(
|
||||
context,
|
||||
SnackBar(content: Text(result.failure!.message)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
showAppToastFromSnackBar(
|
||||
context,
|
||||
SnackBar(content: Text(result.data ?? 'Approval notification sent')),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _deleteOrder(PurchaseOrderModel order) async {
|
||||
@ -419,102 +489,164 @@ class _FiltersBar extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _PoDataTable extends StatelessWidget {
|
||||
class _PoDataTable extends ConsumerWidget {
|
||||
const _PoDataTable({
|
||||
required this.orders,
|
||||
required this.onView,
|
||||
this.onEdit,
|
||||
this.onDelete,
|
||||
this.onApprove,
|
||||
this.onNotify,
|
||||
this.onEnsureFullDataset,
|
||||
this.onColumnSearchCleared,
|
||||
});
|
||||
|
||||
static const tableId = 'purchase_orders_list';
|
||||
|
||||
static List<AppTableColumnOption> get columnOptions => const [
|
||||
AppTableColumnOption(
|
||||
id: 'po_number',
|
||||
label: 'PO Number',
|
||||
required: true,
|
||||
),
|
||||
AppTableColumnOption(id: 'date', label: 'Date'),
|
||||
AppTableColumnOption(id: 'vendor', label: 'Vendor'),
|
||||
AppTableColumnOption(id: 'total', label: 'Total'),
|
||||
AppTableColumnOption(id: 'status', label: 'Status'),
|
||||
];
|
||||
|
||||
final List<PurchaseOrderModel> orders;
|
||||
final ValueChanged<PurchaseOrderModel> onView;
|
||||
final ValueChanged<PurchaseOrderModel>? onEdit;
|
||||
final ValueChanged<PurchaseOrderModel>? onDelete;
|
||||
final ValueChanged<PurchaseOrderModel>? onApprove;
|
||||
final ValueChanged<PurchaseOrderModel>? onNotify;
|
||||
final Future<void> Function()? onEnsureFullDataset;
|
||||
final VoidCallback? onColumnSearchCleared;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return AppDataTable<PurchaseOrderModel>(
|
||||
wrapInCard: false,
|
||||
columns: [
|
||||
AppDataColumn(
|
||||
label: 'PO Number',
|
||||
flex: 2,
|
||||
searchText: (order) => order.poNo ?? '',
|
||||
cellBuilder: (_, order) => Text(order.poNo ?? '—'),
|
||||
List<AppDataColumn<PurchaseOrderModel>> _allColumns(ThemeData theme) {
|
||||
return [
|
||||
AppDataColumn(
|
||||
id: 'po_number',
|
||||
label: 'PO Number',
|
||||
sortKey: 'po_number',
|
||||
locked: true,
|
||||
flex: 2,
|
||||
searchText: (order) => order.poNo ?? '',
|
||||
cellBuilder: (_, order) => AppTableCell.link(
|
||||
order.poNo,
|
||||
onTap: () => onView(order),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Date',
|
||||
flex: 1,
|
||||
searchText: (order) => DateFormatter.displayDate(order.poDate),
|
||||
cellBuilder: (_, order) => Text(DateFormatter.displayDate(order.poDate)),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Vendor',
|
||||
flex: 2,
|
||||
searchText: (order) => order.vendorName ?? '',
|
||||
cellBuilder: (_, order) => Text(order.vendorName ?? '—'),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Total',
|
||||
flex: 1,
|
||||
searchText: (order) => CurrencyFormatter.format(order.totalAmount),
|
||||
cellBuilder: (_, order) => SizedBox(
|
||||
width: double.infinity,
|
||||
child: AppTableCell.text(
|
||||
CurrencyFormatter.format(order.totalAmount),
|
||||
textAlign: TextAlign.right,
|
||||
),
|
||||
),
|
||||
AppDataColumn(
|
||||
id: 'date',
|
||||
label: 'Date',
|
||||
sortKey: 'date',
|
||||
flex: 1,
|
||||
searchText: (order) => DateFormatter.searchableDate(order.poDate),
|
||||
sortValue: (order) => order.poDate,
|
||||
cellBuilder: (_, order) => Text(DateFormatter.displayDate(order.poDate)),
|
||||
),
|
||||
AppDataColumn(
|
||||
id: 'vendor',
|
||||
label: 'Vendor',
|
||||
sortKey: 'vendor',
|
||||
flex: 2,
|
||||
searchText: (order) => order.vendorName ?? '',
|
||||
cellBuilder: (_, order) => Text(order.vendorName ?? '—'),
|
||||
),
|
||||
AppDataColumn(
|
||||
id: 'total',
|
||||
label: 'Total',
|
||||
sortKey: 'total',
|
||||
flex: 1,
|
||||
searchText: (order) => CurrencyFormatter.searchable(order.totalAmount),
|
||||
sortValue: (order) => order.totalAmount,
|
||||
cellBuilder: (_, order) => SizedBox(
|
||||
width: double.infinity,
|
||||
child: AppTableCell.text(
|
||||
CurrencyFormatter.format(order.totalAmount),
|
||||
textAlign: TextAlign.right,
|
||||
),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Status',
|
||||
flex: 1,
|
||||
searchText: (order) => order.status,
|
||||
cellBuilder: (_, order) => PoStatusChip(
|
||||
),
|
||||
AppDataColumn(
|
||||
id: 'status',
|
||||
label: 'Status',
|
||||
sortKey: 'status',
|
||||
flex: 1,
|
||||
searchText: (order) => order.status,
|
||||
cellBuilder: (_, order) {
|
||||
final chip = PoStatusChip(
|
||||
status: order.status,
|
||||
compact: true,
|
||||
forTable: true,
|
||||
),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Actions',
|
||||
flex: onApprove != null ? 2 : 1,
|
||||
alignment: Alignment.centerRight,
|
||||
enableSearch: false,
|
||||
cellBuilder: (_, order) => AppTableActions(
|
||||
children: [
|
||||
);
|
||||
final reason = order.rejectReasonForDisplay;
|
||||
if (reason == null) return chip;
|
||||
return Tooltip(
|
||||
message: 'Reject reason: $reason',
|
||||
child: chip,
|
||||
);
|
||||
},
|
||||
),
|
||||
AppDataColumn(
|
||||
id: 'actions',
|
||||
label: 'Actions',
|
||||
width: 88,
|
||||
alignment: Alignment.centerRight,
|
||||
enableSearch: false,
|
||||
locked: true,
|
||||
includeInColumnSelector: false,
|
||||
cellBuilder: (_, order) => AppTableActions(
|
||||
children: [
|
||||
AppTableActionIcon(
|
||||
tooltip: 'View',
|
||||
icon: Icons.visibility_outlined,
|
||||
onPressed: () => onView(order),
|
||||
),
|
||||
if (onNotify != null && order.canNotifyApprovers)
|
||||
AppTableActionIcon(
|
||||
tooltip: 'View',
|
||||
icon: Icons.visibility_outlined,
|
||||
onPressed: () => onView(order),
|
||||
tooltip: 'Notify approvers',
|
||||
icon: Icons.notifications_outlined,
|
||||
onPressed: () => onNotify!(order),
|
||||
),
|
||||
if (onApprove != null && order.canApprove)
|
||||
AppTableActionIcon(
|
||||
tooltip: 'Approve',
|
||||
icon: Icons.check_circle_outline,
|
||||
color: theme.colorScheme.primary,
|
||||
onPressed: () => onApprove!(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),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (onApprove != null && order.canApprove)
|
||||
AppTableActionIcon(
|
||||
tooltip: 'Approve',
|
||||
icon: Icons.check_circle_outline,
|
||||
color: theme.colorScheme.primary,
|
||||
onPressed: () => onApprove!(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),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
final prefs = ref.watch(tableColumnPrefsProvider(tableId));
|
||||
final columns = resolveAppDataColumns(_allColumns(theme), prefs);
|
||||
|
||||
return AppDataTable<PurchaseOrderModel>(
|
||||
wrapInCard: false,
|
||||
onEnsureFullDataset: onEnsureFullDataset,
|
||||
onColumnSearchCleared: onColumnSearchCleared,
|
||||
columns: columns,
|
||||
rows: orders,
|
||||
);
|
||||
}
|
||||
@ -527,6 +659,7 @@ class _PoCardList extends StatelessWidget {
|
||||
this.onEdit,
|
||||
this.onDelete,
|
||||
this.onApprove,
|
||||
this.onNotify,
|
||||
});
|
||||
|
||||
final List<PurchaseOrderModel> orders;
|
||||
@ -534,6 +667,7 @@ class _PoCardList extends StatelessWidget {
|
||||
final ValueChanged<PurchaseOrderModel>? onEdit;
|
||||
final ValueChanged<PurchaseOrderModel>? onDelete;
|
||||
final ValueChanged<PurchaseOrderModel>? onApprove;
|
||||
final ValueChanged<PurchaseOrderModel>? onNotify;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -544,14 +678,26 @@ class _PoCardList extends StatelessWidget {
|
||||
final order = orders[index];
|
||||
return AppCard(
|
||||
child: ListTile(
|
||||
title: Text(order.poNo ?? 'PO #${order.id}'),
|
||||
title: AppTableCell.link(
|
||||
order.poNo ?? 'PO #${order.id}',
|
||||
onTap: () => onView(order),
|
||||
),
|
||||
subtitle: Text(
|
||||
'${order.vendorName ?? '—'} · ${vendorTypeLabel(order.vendorType)}',
|
||||
),
|
||||
onTap: () => onView(order),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
PoStatusChip(status: order.status, compact: true),
|
||||
if (onNotify != null && order.canNotifyApprovers) ...[
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
tooltip: 'Notify approvers',
|
||||
icon: const Icon(Icons.notifications_outlined),
|
||||
onPressed: () => onNotify!(order),
|
||||
),
|
||||
],
|
||||
if (onApprove != null && order.canApprove) ...[
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
@ -565,7 +711,6 @@ class _PoCardList extends StatelessWidget {
|
||||
],
|
||||
],
|
||||
),
|
||||
onTap: () => onView(order),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../core/theme/app_typography.dart';
|
||||
import '../../../../shared/models/purchase_order_model.dart';
|
||||
import '../../../../shared/widgets/app_status_chip.dart';
|
||||
|
||||
@ -17,7 +18,8 @@ class PoStatusChip extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final (color, label) = _resolveStatus(status);
|
||||
final secondary = Theme.of(context).colorScheme.secondary;
|
||||
final (color, label) = _resolveStatus(status, secondary);
|
||||
if (forTable) {
|
||||
return TableStatusBadge(
|
||||
label: label,
|
||||
@ -29,7 +31,7 @@ class PoStatusChip extends StatelessWidget {
|
||||
return _PoBadge(label: label, color: color, compact: compact);
|
||||
}
|
||||
|
||||
(Color, String) _resolveStatus(String raw) {
|
||||
(Color, String) _resolveStatus(String raw, Color secondary) {
|
||||
switch (raw.toUpperCase()) {
|
||||
case 'DRAFT':
|
||||
return (const Color(0xFF546E7A), poStatusLabel(raw));
|
||||
@ -38,17 +40,17 @@ class PoStatusChip extends StatelessWidget {
|
||||
case 'PENDING':
|
||||
return (const Color(0xFFE65100), poStatusLabel(raw));
|
||||
case 'APPROVED':
|
||||
return (const Color(0xFF2E7D32), poStatusLabel(raw));
|
||||
return (secondary, poStatusLabel(raw));
|
||||
case 'REJECTED':
|
||||
return (const Color(0xFFC62828), poStatusLabel(raw));
|
||||
case 'CANCELLED':
|
||||
return (const Color(0xFF616161), poStatusLabel(raw));
|
||||
case 'PARTIALLY_RECEIVED':
|
||||
return (const Color(0xFF00695C), poStatusLabel(raw));
|
||||
return (secondary, poStatusLabel(raw));
|
||||
case 'FULLY_RECEIVED':
|
||||
return (const Color(0xFF283593), poStatusLabel(raw));
|
||||
return (secondary, poStatusLabel(raw));
|
||||
default:
|
||||
return (const Color(0xFF546E7A), poStatusLabel(raw));
|
||||
return (secondary, poStatusLabel(raw));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -67,7 +69,7 @@ class PoRevisionChip extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return _PoBadge(
|
||||
label: 'Revision $revisionNo',
|
||||
color: const Color(0xFF607D8B),
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
compact: compact,
|
||||
);
|
||||
}
|
||||
@ -100,13 +102,16 @@ class _PoBadge extends StatelessWidget {
|
||||
widthFactor: 1,
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: compact ? 11 : 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1,
|
||||
letterSpacing: 0.1,
|
||||
),
|
||||
style: (compact
|
||||
? AppTypography.caption1(
|
||||
weight: AppTypography.semiBold,
|
||||
color: color,
|
||||
)
|
||||
: AppTypography.label3(
|
||||
weight: AppTypography.semiBold,
|
||||
color: color,
|
||||
))
|
||||
.copyWith(height: 1, letterSpacing: 0.1),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/utils/formatters.dart';
|
||||
@ -41,15 +42,19 @@ class PoLineCalculation {
|
||||
required double discPct,
|
||||
required double gstPct,
|
||||
}) {
|
||||
final baseAmount = qty * rate;
|
||||
final discountAmount = baseAmount * discPct / 100;
|
||||
final qtySafe = qty < 0 ? 0.0 : qty;
|
||||
final rateSafe = rate < 0 ? 0.0 : rate;
|
||||
final discSafe = discPct < 0 ? 0.0 : (discPct > 100 ? 100.0 : discPct);
|
||||
final gstSafe = gstPct < 0 ? 0.0 : gstPct;
|
||||
final baseAmount = qtySafe * rateSafe;
|
||||
final discountAmount = baseAmount * discSafe / 100;
|
||||
final lineAmount = baseAmount - discountAmount;
|
||||
final gstAmount = lineAmount * gstPct / 100;
|
||||
final gstAmount = lineAmount * gstSafe / 100;
|
||||
return PoLineCalculation(
|
||||
baseAmount: baseAmount,
|
||||
discountAmount: discountAmount,
|
||||
lineAmount: lineAmount,
|
||||
gstAmount: gstAmount,
|
||||
lineAmount: lineAmount < 0 ? 0 : lineAmount,
|
||||
gstAmount: gstAmount < 0 ? 0 : gstAmount,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -72,7 +77,7 @@ class PoOrderTotals {
|
||||
final double taxAmount;
|
||||
final double grandTotal;
|
||||
|
||||
/// Sub Total + Tax + Freight + Other (discount cannot exceed this).
|
||||
/// Sub Total + Freight + Other (discount cannot exceed this).
|
||||
final double maxDiscountAmount;
|
||||
|
||||
static const zero = PoOrderTotals(
|
||||
@ -85,7 +90,7 @@ class PoOrderTotals {
|
||||
|
||||
/// Sub Total = sum of line amounts
|
||||
/// Taxable = Sub Total + Freight + Other − Discount
|
||||
/// Tax = sum of line GST amounts
|
||||
/// Tax = Taxable × effective GST rate (from line GST ÷ Sub Total)
|
||||
/// Grand Total = Taxable + Tax
|
||||
factory PoOrderTotals.compute({
|
||||
required Iterable<PoLineCalculation> lines,
|
||||
@ -94,22 +99,29 @@ class PoOrderTotals {
|
||||
required double discountAmount,
|
||||
}) {
|
||||
var subTotal = 0.0;
|
||||
var tax = 0.0;
|
||||
var lineTax = 0.0;
|
||||
for (final line in lines) {
|
||||
subTotal += line.lineAmount;
|
||||
tax += line.gstAmount;
|
||||
lineTax += line.gstAmount;
|
||||
}
|
||||
// Keep order-level money fields non-negative.
|
||||
final subSafe = subTotal < 0 ? 0.0 : subTotal;
|
||||
final lineTaxSafe = lineTax < 0 ? 0.0 : lineTax;
|
||||
final freightSafe = freight < 0 ? 0.0 : freight;
|
||||
final otherSafe = otherCharges < 0 ? 0.0 : otherCharges;
|
||||
final clampedDiscount = discountAmount < 0 ? 0.0 : discountAmount;
|
||||
final taxableRaw = subTotal + freightSafe + otherSafe - clampedDiscount;
|
||||
final taxable = taxableRaw < 0 ? 0.0 : taxableRaw;
|
||||
final maxDiscount = subTotal + tax + freightSafe + otherSafe;
|
||||
final maxDiscount = subSafe + freightSafe + otherSafe;
|
||||
final clampedDiscount = discountAmount < 0
|
||||
? 0.0
|
||||
: (discountAmount > maxDiscount ? maxDiscount : discountAmount);
|
||||
final taxable = subSafe + freightSafe + otherSafe - clampedDiscount;
|
||||
// Apply the blended line GST rate to Taxable Amount (not Sub Total),
|
||||
// so freight / other / discount are included in the tax base.
|
||||
final tax = subSafe > 0 ? lineTaxSafe * (taxable / subSafe) : 0.0;
|
||||
final grandTotal = taxable + tax;
|
||||
return PoOrderTotals(
|
||||
subTotal: subTotal,
|
||||
taxableAmount: taxable,
|
||||
taxAmount: tax,
|
||||
subTotal: subSafe,
|
||||
taxableAmount: taxable < 0 ? 0 : taxable,
|
||||
taxAmount: tax < 0 ? 0 : tax,
|
||||
grandTotal: grandTotal < 0 ? 0 : grandTotal,
|
||||
maxDiscountAmount: maxDiscount < 0 ? 0 : maxDiscount,
|
||||
);
|
||||
@ -119,9 +131,12 @@ class PoOrderTotals {
|
||||
class PoLineItemDraft {
|
||||
PoLineItemDraft({
|
||||
this.itemId,
|
||||
this.itemName,
|
||||
this.itemCode,
|
||||
required this.lineNo,
|
||||
TextEditingController? qtyController,
|
||||
this.uomId,
|
||||
this.uomName,
|
||||
TextEditingController? rateController,
|
||||
TextEditingController? discountController,
|
||||
this.gstRateId,
|
||||
@ -132,9 +147,14 @@ class PoLineItemDraft {
|
||||
discountController ?? TextEditingController(text: '0');
|
||||
|
||||
int? itemId;
|
||||
/// Kept so edit can show the label even if the item is inactive / missing
|
||||
/// from the active dropdown lookups.
|
||||
String? itemName;
|
||||
String? itemCode;
|
||||
int lineNo;
|
||||
final TextEditingController qtyController;
|
||||
int? uomId;
|
||||
String? uomName;
|
||||
final TextEditingController rateController;
|
||||
final TextEditingController discountController;
|
||||
int? gstRateId;
|
||||
@ -143,10 +163,13 @@ class PoLineItemDraft {
|
||||
factory PoLineItemDraft.fromModel(PurchaseOrderItemModel item) {
|
||||
return PoLineItemDraft(
|
||||
itemId: item.itemId,
|
||||
itemName: item.itemName,
|
||||
itemCode: item.itemCode,
|
||||
lineNo: item.lineNo ?? 1,
|
||||
qtyController:
|
||||
TextEditingController(text: item.orderedQty?.toString() ?? ''),
|
||||
uomId: item.uomId,
|
||||
uomName: item.uomName,
|
||||
rateController: TextEditingController(text: item.rate?.toString() ?? ''),
|
||||
discountController:
|
||||
TextEditingController(text: item.discountPct?.toString() ?? '0'),
|
||||
@ -448,7 +471,22 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
|
||||
void _onItemChanged(int? itemId) {
|
||||
_updateLine(() {
|
||||
widget.line.itemId = itemId;
|
||||
if (itemId == null) return;
|
||||
if (itemId == null) {
|
||||
widget.line.itemName = null;
|
||||
widget.line.itemCode = null;
|
||||
return;
|
||||
}
|
||||
FilterOptionModel? selected;
|
||||
for (final e in widget.items) {
|
||||
if (_parseId(e.id) == itemId) {
|
||||
selected = e;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (selected != null) {
|
||||
widget.line.itemName = selected.name;
|
||||
widget.line.itemCode = selected.slug;
|
||||
}
|
||||
final key = itemId.toString();
|
||||
final defaultUom = _itemUomById[key];
|
||||
if (defaultUom != null) {
|
||||
@ -468,6 +506,61 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
|
||||
});
|
||||
}
|
||||
|
||||
List<AppDropdownOption<int>> _itemOptionsWithSelected() {
|
||||
final options = widget.items
|
||||
.map((e) {
|
||||
final id = _parseId(e.id);
|
||||
if (id == null) return null;
|
||||
final code = e.slug?.trim();
|
||||
return AppDropdownOption(
|
||||
value: id,
|
||||
label: e.name,
|
||||
subtitle: (code == null || code.isEmpty) ? null : code,
|
||||
);
|
||||
})
|
||||
.whereType<AppDropdownOption<int>>()
|
||||
.toList();
|
||||
|
||||
final selectedId = widget.line.itemId;
|
||||
if (selectedId == null) return options;
|
||||
if (options.any((o) => o.value == selectedId)) return options;
|
||||
|
||||
final name = widget.line.itemName?.trim();
|
||||
final code = widget.line.itemCode?.trim();
|
||||
return [
|
||||
AppDropdownOption(
|
||||
value: selectedId,
|
||||
label: (name != null && name.isNotEmpty) ? name : 'Item #$selectedId',
|
||||
subtitle: (code == null || code.isEmpty) ? null : code,
|
||||
),
|
||||
...options,
|
||||
];
|
||||
}
|
||||
|
||||
List<AppDropdownOption<int>> _uomOptionsWithSelected() {
|
||||
final options = widget.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 selectedId = widget.line.uomId;
|
||||
if (selectedId == null) return options;
|
||||
if (options.any((o) => o.value == selectedId)) return options;
|
||||
|
||||
final name = widget.line.uomName?.trim();
|
||||
return [
|
||||
AppDropdownOption(
|
||||
value: selectedId,
|
||||
label: (name != null && name.isNotEmpty) ? name : 'UOM #$selectedId',
|
||||
),
|
||||
...options,
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _LineItemCard oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
@ -497,29 +590,10 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
|
||||
alpha: isDark ? 0.18 : 0.08,
|
||||
);
|
||||
|
||||
final itemOptions = widget.items
|
||||
.map((e) {
|
||||
final id = _parseId(e.id);
|
||||
if (id == null) return null;
|
||||
final code = e.slug?.trim();
|
||||
return AppDropdownOption(
|
||||
value: id,
|
||||
label: e.name,
|
||||
subtitle: (code == null || code.isEmpty) ? null : code,
|
||||
);
|
||||
})
|
||||
.whereType<AppDropdownOption<int>>()
|
||||
.toList();
|
||||
final uomOptions = widget.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 itemOptions = _itemOptionsWithSelected();
|
||||
final uomOptions = _uomOptionsWithSelected();
|
||||
final gstOptions = [
|
||||
const AppDropdownOption<int?>(value: null, label: 'Select GST Rate'),
|
||||
const AppDropdownOption<int?>(value: null, label: 'Select'),
|
||||
...widget.gstRates.map((e) {
|
||||
final id = _parseId(e.id);
|
||||
if (id == null) return null;
|
||||
@ -527,9 +601,24 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
|
||||
final label = pct != null
|
||||
? (pct % 1 == 0 ? '${pct.toInt()}%' : '$pct%')
|
||||
: e.name.split(' — ').first.trim();
|
||||
if (label.isEmpty) return null;
|
||||
return AppDropdownOption<int?>(value: id, label: label);
|
||||
}),
|
||||
].whereType<AppDropdownOption<int?>>().toList();
|
||||
final selectedGstId = line.gstRateId;
|
||||
if (selectedGstId != null &&
|
||||
!gstOptions.any((o) => o.value == selectedGstId)) {
|
||||
final pct = widget.gstRatePctById[selectedGstId.toString()];
|
||||
gstOptions.insert(
|
||||
1,
|
||||
AppDropdownOption<int?>(
|
||||
value: selectedGstId,
|
||||
label: pct != null
|
||||
? (pct % 1 == 0 ? '${pct.toInt()}%' : '$pct%')
|
||||
: 'GST #$selectedGstId',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const spacing = 8.0;
|
||||
|
||||
@ -541,6 +630,7 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
|
||||
hint: 'Select item',
|
||||
searchHint: 'Search item name or code...',
|
||||
options: itemOptions,
|
||||
openInSidePanel: true,
|
||||
refreshLookups: () async {
|
||||
ref.invalidate(purchaseOrderLookupsProvider);
|
||||
await ref.read(purchaseOrderLookupsProvider.future);
|
||||
@ -554,7 +644,11 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
|
||||
controller: line.qtyController,
|
||||
label: 'Qty *',
|
||||
hint: '0',
|
||||
isDense: true,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*')),
|
||||
],
|
||||
validator: (v) {
|
||||
if (v == null || v.trim().isEmpty) return 'Required';
|
||||
final qty = double.tryParse(v);
|
||||
@ -570,6 +664,7 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
|
||||
hint: 'Select UOM',
|
||||
searchHint: 'Search UOM...',
|
||||
options: uomOptions,
|
||||
openInSidePanel: true,
|
||||
refreshLookups: () async {
|
||||
ref.invalidate(purchaseOrderLookupsProvider);
|
||||
await ref.read(purchaseOrderLookupsProvider.future);
|
||||
@ -584,6 +679,9 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
|
||||
label: 'Rate *',
|
||||
hint: '0.00',
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*')),
|
||||
],
|
||||
validator: (v) {
|
||||
if (v == null || v.trim().isEmpty) return 'Required';
|
||||
final rate = double.tryParse(v);
|
||||
@ -596,7 +694,19 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
|
||||
controller: line.discountController,
|
||||
label: 'Disc %',
|
||||
hint: '0',
|
||||
isDense: true,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*')),
|
||||
],
|
||||
validator: (v) {
|
||||
if (v == null || v.trim().isEmpty) return null;
|
||||
final disc = double.tryParse(v);
|
||||
if (disc == null) return 'Invalid';
|
||||
if (disc < 0) return 'Cannot be negative';
|
||||
if (disc > 100) return 'Max 100%';
|
||||
return null;
|
||||
},
|
||||
);
|
||||
final gstField = MasterQuickAddDropdown<int?>(
|
||||
key: ValueKey('$lineKey-gst'),
|
||||
@ -606,6 +716,8 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
|
||||
hint: 'Select',
|
||||
searchHint: 'Search GST %...',
|
||||
options: gstOptions,
|
||||
isDense: true,
|
||||
openInSidePanel: true,
|
||||
refreshLookups: () async {
|
||||
ref.invalidate(purchaseOrderLookupsProvider);
|
||||
await ref.read(purchaseOrderLookupsProvider.future);
|
||||
@ -613,110 +725,64 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
|
||||
parseCreatedId: int.tryParse,
|
||||
onChanged: (v) => _updateLine(() => line.gstRateId = v),
|
||||
);
|
||||
final amountField = _AmountWithRemove(
|
||||
amount: CurrencyFormatter.format(calc.lineAmount),
|
||||
final amountField = _AmountDisplay(
|
||||
label: 'Amount',
|
||||
value: CurrencyFormatter.format(calc.lineAmount),
|
||||
backgroundColor: amountBg,
|
||||
onRemove: widget.onRemove,
|
||||
);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: borderColor),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: QuickAddInlineHost(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final width = constraints.maxWidth;
|
||||
|
||||
// Wide: single flex row
|
||||
if (width >= 1100) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(flex: 3, child: itemField),
|
||||
const SizedBox(width: spacing),
|
||||
Expanded(flex: 1, child: qtyField),
|
||||
const SizedBox(width: spacing),
|
||||
Expanded(flex: 2, child: uomField),
|
||||
const SizedBox(width: spacing),
|
||||
Expanded(flex: 1, child: rateField),
|
||||
const SizedBox(width: spacing),
|
||||
Expanded(flex: 1, child: discField),
|
||||
const SizedBox(width: spacing),
|
||||
Expanded(flex: 2, child: gstField),
|
||||
const SizedBox(width: spacing),
|
||||
Expanded(flex: 2, child: amountField),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Medium / narrow: wrapping grid (2–3 columns)
|
||||
return ResponsiveFormGrid(
|
||||
spacing: spacing,
|
||||
smallColumns: 1,
|
||||
mediumColumns: 2,
|
||||
largeColumns: 3,
|
||||
mediumBreakpoint: 520,
|
||||
largeBreakpoint: 800,
|
||||
child: QuickAddBlockable(
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
itemField,
|
||||
qtyField,
|
||||
uomField,
|
||||
rateField,
|
||||
discField,
|
||||
gstField,
|
||||
amountField,
|
||||
Expanded(flex: 3, child: itemField),
|
||||
const SizedBox(width: spacing),
|
||||
// Wider than flex 1 so floating labels ("Qty *", "Disc %") aren't clipped.
|
||||
Expanded(flex: 2, child: qtyField),
|
||||
const SizedBox(width: spacing),
|
||||
Expanded(flex: 2, child: uomField),
|
||||
const SizedBox(width: spacing),
|
||||
// Rate and Amount share the same flex so widths match.
|
||||
Expanded(flex: 2, child: rateField),
|
||||
const SizedBox(width: spacing),
|
||||
Expanded(flex: 2, child: discField),
|
||||
const SizedBox(width: spacing),
|
||||
Expanded(flex: 2, child: gstField),
|
||||
const SizedBox(width: spacing),
|
||||
Expanded(flex: 2, child: amountField),
|
||||
if (widget.onRemove != null) ...[
|
||||
const SizedBox(width: 4),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 20),
|
||||
child: IconButton(
|
||||
tooltip: 'Remove line',
|
||||
onPressed: widget.onRemove,
|
||||
icon: const Icon(Icons.delete_outline, size: 20),
|
||||
color: theme.colorScheme.error,
|
||||
visualDensity: VisualDensity.compact,
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 36,
|
||||
minHeight: 36,
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AmountWithRemove extends StatelessWidget {
|
||||
const _AmountWithRemove({
|
||||
required this.amount,
|
||||
required this.backgroundColor,
|
||||
this.onRemove,
|
||||
});
|
||||
|
||||
final String amount;
|
||||
final Color backgroundColor;
|
||||
final VoidCallback? onRemove;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _AmountDisplay(
|
||||
label: 'Amount',
|
||||
value: amount,
|
||||
backgroundColor: backgroundColor,
|
||||
),
|
||||
),
|
||||
if (onRemove != null) ...[
|
||||
const SizedBox(width: 4),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 20),
|
||||
child: IconButton(
|
||||
tooltip: 'Remove line',
|
||||
onPressed: onRemove,
|
||||
icon: const Icon(Icons.delete_outline, size: 20),
|
||||
color: theme.colorScheme.error,
|
||||
visualDensity: VisualDensity.compact,
|
||||
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -753,11 +819,17 @@ class _AmountDisplay extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
value,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: theme.colorScheme.onSurface,
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
value,
|
||||
maxLines: 1,
|
||||
softWrap: false,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@ -62,7 +62,7 @@ const rbacModules = [
|
||||
),
|
||||
RbacModule(
|
||||
key: 'grn',
|
||||
label: 'GRN / PO Receipt',
|
||||
label: 'Purchase Receipt',
|
||||
icon: Icons.inventory_outlined,
|
||||
color: Color(0xFF0891B2),
|
||||
),
|
||||
@ -288,7 +288,7 @@ List<ManagedRole> defaultRoles = [
|
||||
ManagedRole(
|
||||
id: 'store_manager',
|
||||
name: 'Store Manager',
|
||||
description: 'GRN, warehouse receipts, stock view',
|
||||
description: 'Purchase Receipt, warehouse receipts, stock view',
|
||||
icon: Icons.warehouse_outlined,
|
||||
color: const Color(0xFF0891B2),
|
||||
userCount: 12,
|
||||
@ -297,7 +297,7 @@ List<ManagedRole> defaultRoles = [
|
||||
ManagedRole(
|
||||
id: 'accounts',
|
||||
name: 'Accounts',
|
||||
description: 'View PO, GRN, vendor financial details',
|
||||
description: 'View PO, Purchase Receipt, vendor financial details',
|
||||
icon: Icons.account_balance_wallet_outlined,
|
||||
color: const Color(0xFF7C3AED),
|
||||
userCount: 8,
|
||||
|
||||
@ -6,6 +6,7 @@ import '../../../../core/constants/route_constants.dart';
|
||||
import '../../../../core/errors/failure.dart';
|
||||
import '../../../../core/theme/theme_provider.dart';
|
||||
import '../../../../core/utils/responsive_utils.dart';
|
||||
import '../../../../core/utils/validators.dart';
|
||||
import '../../../../shared/models/permission_matrix_models.dart';
|
||||
import '../../../../shared/models/user_management_models.dart';
|
||||
import '../../../../shared/utils/file_download_helper.dart';
|
||||
@ -26,6 +27,7 @@ import '../providers/add_user_form_provider.dart';
|
||||
import '../providers/role_form_provider.dart';
|
||||
import '../providers/rbac_provider.dart';
|
||||
import '../../../../shared/widgets/app_hover_effect.dart';
|
||||
import '../../../../shared/widgets/app_segmented_tab_bar.dart';
|
||||
import '../../../../shared/widgets/app_side_panel.dart';
|
||||
import '../../../roles/presentation/providers/roles_provider.dart';
|
||||
import '../widgets/add_user_panel.dart';
|
||||
@ -395,37 +397,45 @@ class _TabBar extends ConsumerWidget {
|
||||
final canExport = ref.can('users', PermissionAction.export);
|
||||
final isExporting = usersState?.isExporting ?? false;
|
||||
|
||||
final tabEntries = <(RbacTab, AppSegmentedTab)>[
|
||||
if (canViewUsers)
|
||||
(
|
||||
RbacTab.users,
|
||||
AppSegmentedTab(
|
||||
label: 'Users ($userCount)',
|
||||
icon: Icons.people_outline,
|
||||
),
|
||||
),
|
||||
if (canViewRoles)
|
||||
(
|
||||
RbacTab.roles,
|
||||
AppSegmentedTab(
|
||||
label: 'Roles ($roleCount)',
|
||||
icon: Icons.shield_outlined,
|
||||
),
|
||||
),
|
||||
if (canEditRoles)
|
||||
(
|
||||
RbacTab.permissions,
|
||||
const AppSegmentedTab(
|
||||
label: 'Permission Matrix',
|
||||
icon: Icons.vpn_key_outlined,
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
final selectedIndex = tabEntries
|
||||
.indexWhere((entry) => entry.$1 == state.selectedTab)
|
||||
.clamp(0, tabEntries.isEmpty ? 0 : tabEntries.length - 1);
|
||||
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
if (canViewUsers)
|
||||
_TabButton(
|
||||
label: 'Users ($userCount)',
|
||||
icon: Icons.people_outline,
|
||||
selected: isUsersTab,
|
||||
onTap: () => onSelectTab(RbacTab.users),
|
||||
),
|
||||
if (canViewRoles)
|
||||
_TabButton(
|
||||
label: 'Roles ($roleCount)',
|
||||
icon: Icons.shield_outlined,
|
||||
selected: state.selectedTab == RbacTab.roles,
|
||||
onTap: () => onSelectTab(RbacTab.roles),
|
||||
),
|
||||
if (canEditRoles)
|
||||
_TabButton(
|
||||
label: 'Permission Matrix',
|
||||
icon: Icons.vpn_key_outlined,
|
||||
selected: state.selectedTab == RbacTab.permissions,
|
||||
onTap: () => onSelectTab(RbacTab.permissions),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: AppSegmentedTabBar(
|
||||
selectedIndex: selectedIndex,
|
||||
onChanged: (index) => onSelectTab(tabEntries[index].$1),
|
||||
tabs: [for (final entry in tabEntries) entry.$2],
|
||||
),
|
||||
),
|
||||
if (isUsersTab) ...[
|
||||
@ -482,59 +492,6 @@ Future<void> _exportUsersFromTabBar(BuildContext context, WidgetRef ref) async {
|
||||
);
|
||||
}
|
||||
|
||||
class _TabButton extends StatelessWidget {
|
||||
const _TabButton({
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final primary = Theme.of(context).colorScheme.primary;
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: selected ? primary : Colors.transparent,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 18,
|
||||
color: selected ? primary : Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: selected
|
||||
? primary
|
||||
: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _UsersTab extends ConsumerStatefulWidget {
|
||||
const _UsersTab({
|
||||
required this.filtersExpanded,
|
||||
@ -639,17 +596,22 @@ class _UsersTabState extends ConsumerState<_UsersTab> {
|
||||
|
||||
Future<void> _resetPassword(ManagedUserModel user) async {
|
||||
final controller = TextEditingController();
|
||||
final formKey = GlobalKey<FormState>();
|
||||
final password = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('Reset Password'),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
obscureText: true,
|
||||
autofocus: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'New Temporary Password',
|
||||
hintText: 'Min. 8 characters',
|
||||
content: Form(
|
||||
key: formKey,
|
||||
child: TextFormField(
|
||||
controller: controller,
|
||||
obscureText: true,
|
||||
autofocus: true,
|
||||
validator: Validators.password,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'New Temporary Password',
|
||||
hintText: '8+ chars, upper, lower, digit, special',
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
@ -659,9 +621,8 @@ class _UsersTabState extends ConsumerState<_UsersTab> {
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
final value = controller.text.trim();
|
||||
if (value.length < 8) return;
|
||||
Navigator.of(dialogContext).pop(value);
|
||||
if (!(formKey.currentState?.validate() ?? false)) return;
|
||||
Navigator.of(dialogContext).pop(controller.text.trim());
|
||||
},
|
||||
child: const Text('Reset'),
|
||||
),
|
||||
@ -833,39 +794,34 @@ class _UsersTabState extends ConsumerState<_UsersTab> {
|
||||
},
|
||||
),
|
||||
),
|
||||
if (usersState.users.isEmpty)
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Text(
|
||||
'No users found',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Expanded(
|
||||
child: UserRichDataTable(
|
||||
wrapInCard: false,
|
||||
users: usersState.users,
|
||||
actionsBuilder: (_, user) => UserTableActions(
|
||||
user: user,
|
||||
canEdit: canEditUser,
|
||||
canResetPassword: canEditUser,
|
||||
canDeactivate: canDeleteUser,
|
||||
onEdit: () => _editUser(user),
|
||||
onResetPassword: () => _resetPassword(user),
|
||||
onDeactivate: () => _deactivateUser(user),
|
||||
),
|
||||
Expanded(
|
||||
child: UserRichDataTable(
|
||||
wrapInCard: false,
|
||||
users: usersState.users,
|
||||
onEnsureFullDataset: () => ref
|
||||
.read(usersListProvider.notifier)
|
||||
.ensureColumnSearchDataset(),
|
||||
onColumnSearchCleared: () => ref
|
||||
.read(usersListProvider.notifier)
|
||||
.clearColumnSearchDataset(),
|
||||
actionsBuilder: (_, user) => UserTableActions(
|
||||
user: user,
|
||||
canEdit: canEditUser,
|
||||
canResetPassword: canEditUser,
|
||||
canDeactivate: canDeleteUser,
|
||||
onEdit: () => _editUser(user),
|
||||
onResetPassword: () => _resetPassword(user),
|
||||
onDeactivate: () => _deactivateUser(user),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
AppPagination(
|
||||
currentPage: page,
|
||||
totalPages: usersState.totalPages,
|
||||
totalItems: total,
|
||||
pageSize: pageSize,
|
||||
itemsOnPage: usersState.users.length,
|
||||
itemLabel: 'users',
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
onPageChanged:
|
||||
@ -1061,7 +1017,7 @@ class _RolesTab extends ConsumerWidget {
|
||||
key: ValueKey('$themeMode-$brightness-${rolesState.page}'),
|
||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 320,
|
||||
mainAxisExtent: 168,
|
||||
mainAxisExtent: 176,
|
||||
crossAxisSpacing: 16,
|
||||
mainAxisSpacing: 16,
|
||||
),
|
||||
@ -1126,31 +1082,51 @@ class _RolesTab extends ConsumerWidget {
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: appearance.color.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
appearance.icon,
|
||||
color: appearance.color,
|
||||
size: 20,
|
||||
Tooltip(
|
||||
message: role.name,
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
appearance.color.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
appearance.icon,
|
||||
color: appearance.color,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (canEditRole)
|
||||
IconButton(
|
||||
tooltip: 'Edit role',
|
||||
icon: const Icon(Icons.edit_outlined, size: 18),
|
||||
onPressed: () => onEditRole(role),
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 32,
|
||||
minHeight: 32,
|
||||
),
|
||||
),
|
||||
if (canDeleteRole && !isProtectedRole(role))
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline, size: 18),
|
||||
tooltip: 'Delete role',
|
||||
icon: Icon(
|
||||
Icons.delete_outline,
|
||||
size: 18,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
onPressed: () => onDeleteRole(role),
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 32,
|
||||
minHeight: 32,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -1165,56 +1141,88 @@ class _RolesTab extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Tooltip(
|
||||
message: displayDescription == '—' ? '' : displayDescription,
|
||||
waitDuration: const Duration(milliseconds: 300),
|
||||
child: Text(
|
||||
displayDescription,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
height: 1.35,
|
||||
),
|
||||
Expanded(
|
||||
child: Tooltip(
|
||||
message: displayDescription == '—'
|
||||
? ''
|
||||
: displayDescription,
|
||||
waitDuration: const Duration(milliseconds: 300),
|
||||
child: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: Text(
|
||||
displayDescription,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
height: 1.35,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.people_outline,
|
||||
size: 14,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${role.userCount} users',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
Tooltip(
|
||||
message: 'Assigned users',
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.people_outline,
|
||||
size: 14,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${role.userCount} users',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Icon(
|
||||
Icons.vpn_key_outlined,
|
||||
size: 14,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${role.permissionCount} permissions',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
Tooltip(
|
||||
message: 'Granted permissions',
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.vpn_key_outlined,
|
||||
size: 14,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${role.permissionCount} permissions',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -1231,6 +1239,7 @@ class _RolesTab extends ConsumerWidget {
|
||||
totalPages: rolesState.totalPages,
|
||||
totalItems: rolesState.total,
|
||||
pageSize: rolesState.limit,
|
||||
itemsOnPage: rolesState.pagedRoles.length,
|
||||
itemLabel: 'roles',
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
onPageChanged: notifier.setPage,
|
||||
@ -1468,9 +1477,12 @@ class _PermissionMatrixTable extends ConsumerWidget {
|
||||
wrapInCard: false,
|
||||
columns: [
|
||||
AppDataColumn(
|
||||
id: 'module',
|
||||
label: 'Module',
|
||||
sortKey: 'module',
|
||||
flex: 3,
|
||||
enableSearch: false,
|
||||
searchText: (module) => module.name,
|
||||
cellBuilder: (context, module) {
|
||||
final index = matrix.modules.indexOf(module);
|
||||
final appearance =
|
||||
@ -1511,18 +1523,34 @@ class _PermissionMatrixTable extends ConsumerWidget {
|
||||
alignment: Alignment.center,
|
||||
enableSearch: false,
|
||||
cellBuilder: (context, module) {
|
||||
final moduleCatalog = catalog
|
||||
?.where(
|
||||
(entry) =>
|
||||
entry.id == module.moduleId ||
|
||||
normalizePermissionModuleCode(entry.code) ==
|
||||
normalizePermissionModuleCode(module.code),
|
||||
)
|
||||
.firstOrNull;
|
||||
final applicable = isPermissionActionApplicable(
|
||||
module.code,
|
||||
action,
|
||||
catalogActions: moduleCatalog?.actions,
|
||||
columnActions: actionColumns,
|
||||
);
|
||||
final checked = module.granted[action] ?? false;
|
||||
return Checkbox(
|
||||
value: checked,
|
||||
value: applicable ? checked : false,
|
||||
visualDensity: VisualDensity.compact,
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
onChanged: (value) => ref
|
||||
.read(permissionMatrixProvider(roleId).notifier)
|
||||
.toggleAction(
|
||||
module.moduleId,
|
||||
action,
|
||||
value ?? false,
|
||||
),
|
||||
onChanged: applicable
|
||||
? (value) => ref
|
||||
.read(permissionMatrixProvider(roleId).notifier)
|
||||
.toggleAction(
|
||||
module.moduleId,
|
||||
action,
|
||||
value ?? false,
|
||||
)
|
||||
: null,
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@ -41,6 +41,7 @@ class _AddUserPanelState extends ConsumerState<AddUserPanel> {
|
||||
String? _selectedDesignationId;
|
||||
String? _selectedReportingToId;
|
||||
bool _prefilled = false;
|
||||
bool _obscurePassword = true;
|
||||
|
||||
static const _statusOptions = [
|
||||
AppDropdownOption(value: 'Active', label: 'Active'),
|
||||
@ -243,7 +244,7 @@ class _AddUserPanelState extends ConsumerState<AddUserPanel> {
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
AppButton(
|
||||
label: widget.isEditing ? 'Update user' : 'Save user',
|
||||
label: widget.isEditing ? 'Update User' : 'Save User',
|
||||
expand: false,
|
||||
icon: Icons.check,
|
||||
isLoading: isSubmitting,
|
||||
@ -384,11 +385,25 @@ class _AddUserPanelState extends ConsumerState<AddUserPanel> {
|
||||
label: widget.isEditing
|
||||
? 'New Password'
|
||||
: 'Temporary Password *',
|
||||
hint: 'Min. 8 characters',
|
||||
obscureText: true,
|
||||
hint: '8+ chars, upper, lower, digit, special',
|
||||
obscureText: _obscurePassword,
|
||||
suffixIcon: IconButton(
|
||||
tooltip: _obscurePassword
|
||||
? 'Show password'
|
||||
: 'Hide password',
|
||||
icon: Icon(
|
||||
_obscurePassword
|
||||
? Icons.visibility_outlined
|
||||
: Icons.visibility_off_outlined,
|
||||
size: 20,
|
||||
),
|
||||
onPressed: () => setState(
|
||||
() => _obscurePassword = !_obscurePassword,
|
||||
),
|
||||
),
|
||||
validator: widget.isEditing
|
||||
? null
|
||||
: (v) => Validators.required(v, fieldName: 'Password'),
|
||||
? Validators.optionalPassword
|
||||
: Validators.password,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
|
||||
@ -113,7 +113,7 @@ class _RoleFormPanelState extends ConsumerState<RoleFormPanel> {
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: AppButton(
|
||||
label: widget.isEditing ? 'Update role' : 'Create role',
|
||||
label: widget.isEditing ? 'Update Role' : 'Save Role',
|
||||
expand: true,
|
||||
isLoading: isSubmitting,
|
||||
onPressed: isSubmitting ? null : _save,
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../core/theme/app_typography.dart';
|
||||
import '../../../../shared/widgets/app_data_table.dart';
|
||||
import '../../../../shared/widgets/app_table_action_icon.dart';
|
||||
|
||||
@ -126,11 +127,11 @@ class RoleBadge extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final primary = Theme.of(context).colorScheme.primary;
|
||||
final secondary = Theme.of(context).colorScheme.secondary;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: primary.withValues(alpha: 0.08),
|
||||
color: secondary.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
@ -139,7 +140,7 @@ class RoleBadge extends StatelessWidget {
|
||||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(color: primary, shape: BoxShape.circle),
|
||||
decoration: BoxDecoration(color: secondary, shape: BoxShape.circle),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
@ -148,7 +149,7 @@ class RoleBadge extends StatelessWidget {
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: primary,
|
||||
color: secondary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
@ -278,7 +279,7 @@ class UserTableUserCell extends StatelessWidget {
|
||||
children: [
|
||||
AppTableCell.text(
|
||||
user.fullName,
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
style: AppTypography.label2(weight: AppTypography.semiBold),
|
||||
),
|
||||
AppTableCell.text(
|
||||
user.email,
|
||||
@ -325,8 +326,9 @@ class UserAvatarChip extends StatelessWidget {
|
||||
child: Text(
|
||||
display.length > 2 ? display.substring(0, 2) : display,
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontFamily,
|
||||
color: color,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: AppTypography.bold,
|
||||
fontSize: radius * 0.6,
|
||||
),
|
||||
),
|
||||
@ -605,6 +607,7 @@ class _ScrollArrowButton extends StatelessWidget {
|
||||
child: IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
visualDensity: VisualDensity.compact,
|
||||
tooltip: icon == Icons.chevron_left ? 'Scroll left' : 'Scroll right',
|
||||
onPressed: enabled ? onPressed : null,
|
||||
icon: Icon(
|
||||
icon,
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../../../core/constants/api_endpoints.dart';
|
||||
import '../../../../core/utils/pagination_meta.dart';
|
||||
import '../../../../core/utils/formatters.dart';
|
||||
import '../../../../shared/models/export_file_result.dart';
|
||||
import '../../domain/entities/depreciation_report.dart';
|
||||
@ -47,12 +48,16 @@ class ReportsRemoteDataSource {
|
||||
)
|
||||
.toList();
|
||||
|
||||
final page = (meta['page'] as num?)?.toInt() ?? query.page;
|
||||
final limit = (meta['limit'] as num?)?.toInt() ?? query.limit;
|
||||
final total = (meta['total'] as num?)?.toInt() ?? items.length;
|
||||
final totalPages = limit > 0
|
||||
? ((total + limit - 1) ~/ limit).clamp(1, 999999)
|
||||
: 1;
|
||||
final pagination = parsePagination(
|
||||
body: body,
|
||||
fallbackPage: query.page,
|
||||
fallbackLimit: query.limit,
|
||||
itemCount: items.length,
|
||||
);
|
||||
final page = pagination.page;
|
||||
final limit = query.limit;
|
||||
final total = pagination.total;
|
||||
final totalPages = resolveTotalPages(total: total, limit: limit);
|
||||
|
||||
final summaryRaw = meta['summary'];
|
||||
final summary = summaryRaw is Map
|
||||
|
||||
@ -170,6 +170,7 @@ class DepreciationReportRow {
|
||||
this.usefulLifeYears,
|
||||
this.yearsElapsed,
|
||||
this.salvageValue,
|
||||
this.salvagePercentage,
|
||||
this.annualDepreciation,
|
||||
this.accumulatedDepreciation,
|
||||
this.bookValue,
|
||||
@ -190,6 +191,7 @@ class DepreciationReportRow {
|
||||
final int? usefulLifeYears;
|
||||
final double? yearsElapsed;
|
||||
final double? salvageValue;
|
||||
final double? salvagePercentage;
|
||||
final double? annualDepreciation;
|
||||
final double? accumulatedDepreciation;
|
||||
final double? bookValue;
|
||||
@ -300,6 +302,10 @@ class DepreciationReportRow {
|
||||
json['salvage_value'] ?? json['salvageValue'],
|
||||
) ??
|
||||
toDouble(depMap['salvage_value']),
|
||||
salvagePercentage: toDouble(
|
||||
json['salvage_percentage'] ?? json['salvagePercentage'],
|
||||
) ??
|
||||
toDouble(depMap['salvage_percentage']),
|
||||
annualDepreciation: toDouble(
|
||||
json['annual_depreciation'] ?? json['annualDepreciation'],
|
||||
) ??
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/constants/app_constants.dart';
|
||||
import '../../../../core/utils/table_search.dart';
|
||||
import '../../../../core/utils/column_search_paging.dart';
|
||||
import '../../../../shared/models/export_file_result.dart';
|
||||
import '../../data/repositories/reports_repository_impl.dart';
|
||||
import '../../domain/entities/depreciation_report.dart';
|
||||
@ -73,6 +73,10 @@ final depreciationReportProvider = AsyncNotifierProvider<
|
||||
|
||||
class DepreciationReportNotifier
|
||||
extends AsyncNotifier<DepreciationReportState> {
|
||||
final _columnSearch = ColumnSearchPaging(
|
||||
defaultLimit: AppConstants.defaultPageSize,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<DepreciationReportState> build() async {
|
||||
ref.keepAlive();
|
||||
@ -145,6 +149,39 @@ class DepreciationReportNotifier
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> ensureColumnSearchDataset() async {
|
||||
final currentState = state.valueOrNull;
|
||||
final current = currentState?.query ??
|
||||
const DepreciationReportQuery(limit: AppConstants.defaultPageSize);
|
||||
final limit = _columnSearch.beginFullDataset(
|
||||
currentLimit: current.limit,
|
||||
total: currentState?.total ?? 0,
|
||||
);
|
||||
if (limit == null) return;
|
||||
await applyQuery(
|
||||
current.copyWith(
|
||||
page: 1,
|
||||
limit: limit,
|
||||
clearSearch: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void clearColumnSearchDataset() {
|
||||
final currentState = state.valueOrNull;
|
||||
final current = currentState?.query ??
|
||||
const DepreciationReportQuery(limit: AppConstants.defaultPageSize);
|
||||
final limit = _columnSearch.endFullDataset();
|
||||
if (limit == null) return;
|
||||
applyQuery(
|
||||
current.copyWith(
|
||||
page: 1,
|
||||
limit: limit,
|
||||
clearSearch: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setLocationId(String? value) async {
|
||||
final current = state.valueOrNull?.query ??
|
||||
const DepreciationReportQuery(limit: AppConstants.defaultPageSize);
|
||||
|
||||
@ -10,6 +10,7 @@ import '../../../../core/utils/responsive_utils.dart';
|
||||
import '../../../../shared/providers/permissions_provider.dart';
|
||||
import '../../../../shared/utils/file_download_helper.dart';
|
||||
import '../../../rbac/presentation/widgets/rbac_widgets.dart';
|
||||
import '../../../../shared/providers/table_column_prefs_provider.dart';
|
||||
import '../../../../shared/widgets/app_data_table.dart';
|
||||
import '../../../../shared/widgets/app_date_popup.dart';
|
||||
import '../../../../shared/widgets/app_date_range_popup.dart';
|
||||
@ -21,6 +22,7 @@ import '../../../../shared/widgets/app_pagination.dart';
|
||||
import '../../../../shared/widgets/app_responsive_filter_bar.dart';
|
||||
import '../../../../shared/widgets/app_searchable_dropdown.dart';
|
||||
import '../../../../shared/widgets/app_search_filter_toggle.dart';
|
||||
import '../../../../shared/widgets/app_table_column_selector.dart';
|
||||
import '../../../../shared/widgets/app_table_shell.dart';
|
||||
import '../../../../shared/widgets/error_view.dart';
|
||||
import '../../../../shared/widgets/page_header.dart';
|
||||
@ -157,6 +159,11 @@ class _DepreciationReportScreenState
|
||||
() => _filtersExpanded = !_filtersExpanded,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
AppTableColumnSelectorButton(
|
||||
tableId: _ReportTable.tableId,
|
||||
columns: _ReportTable.columnOptions,
|
||||
),
|
||||
if (canExport) ...[
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton.icon(
|
||||
@ -208,30 +215,37 @@ class _DepreciationReportScreenState
|
||||
totalPages: state.totalPages,
|
||||
totalItems: state.total,
|
||||
pageSize: state.query.limit,
|
||||
itemsOnPage: state.items.length,
|
||||
itemLabel: 'assets',
|
||||
onPageChanged: notifier.setPage,
|
||||
onPageSizeChanged: notifier.setPageSize,
|
||||
),
|
||||
child: RefreshIndicator(
|
||||
onRefresh: notifier.refresh,
|
||||
child: state.items.isEmpty
|
||||
? ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: const [
|
||||
SizedBox(
|
||||
height: 260,
|
||||
child: AppEmptyState(
|
||||
title: 'No depreciation data',
|
||||
description:
|
||||
'Try adjusting filters or the as-of date.',
|
||||
icon: Icons.trending_down_outlined,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: context.isMobile
|
||||
? _MobileList(items: state.items)
|
||||
: _ReportTable(items: state.items),
|
||||
child: context.isMobile
|
||||
? (state.items.isEmpty
|
||||
? ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: const [
|
||||
SizedBox(
|
||||
height: 260,
|
||||
child: AppEmptyState(
|
||||
title: 'No depreciation data',
|
||||
description:
|
||||
'Try adjusting filters or the as-of date.',
|
||||
icon: Icons.trending_down_outlined,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: _MobileList(items: state.items))
|
||||
: _ReportTable(
|
||||
items: state.items,
|
||||
onEnsureFullDataset: () =>
|
||||
notifier.ensureColumnSearchDataset(),
|
||||
onColumnSearchCleared: () =>
|
||||
notifier.clearColumnSearchDataset(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -515,7 +529,7 @@ class _FiltersBarState extends State<_FiltersBar> {
|
||||
? (_moreOpen ? 'Less filters ($moreCount)' : 'More filters ($moreCount)')
|
||||
: (_moreOpen ? 'Less filters' : 'More filters');
|
||||
|
||||
final iconColor = theme.colorScheme.primary;
|
||||
final iconColor = theme.colorScheme.secondary;
|
||||
|
||||
final moreButton = IconButton(
|
||||
tooltip: moreTooltip,
|
||||
@ -579,91 +593,153 @@ class _FiltersBarState extends State<_FiltersBar> {
|
||||
}
|
||||
}
|
||||
|
||||
class _ReportTable extends StatelessWidget {
|
||||
const _ReportTable({required this.items});
|
||||
class _ReportTable extends ConsumerWidget {
|
||||
const _ReportTable({
|
||||
required this.items,
|
||||
this.onEnsureFullDataset,
|
||||
this.onColumnSearchCleared,
|
||||
});
|
||||
|
||||
static const tableId = 'depreciation_report';
|
||||
|
||||
static List<AppTableColumnOption> get columnOptions => const [
|
||||
AppTableColumnOption(
|
||||
id: 'asset_code',
|
||||
label: 'Asset Code',
|
||||
required: true,
|
||||
),
|
||||
AppTableColumnOption(id: 'asset_name', label: 'Asset Name'),
|
||||
AppTableColumnOption(id: 'category', label: 'Category'),
|
||||
AppTableColumnOption(id: 'location', label: 'Location'),
|
||||
AppTableColumnOption(id: 'purchase_date', label: 'Purchase Date'),
|
||||
AppTableColumnOption(id: 'purchase_cost', label: 'Purchase Cost'),
|
||||
AppTableColumnOption(id: 'annual', label: 'Annual'),
|
||||
AppTableColumnOption(id: 'accumulated', label: 'Accumulated'),
|
||||
AppTableColumnOption(id: 'book_value', label: 'Book Value'),
|
||||
];
|
||||
|
||||
final List<DepreciationReportRow> items;
|
||||
final Future<void> Function()? onEnsureFullDataset;
|
||||
final VoidCallback? onColumnSearchCleared;
|
||||
|
||||
List<AppDataColumn<DepreciationReportRow>> _allColumns(BuildContext context) {
|
||||
return [
|
||||
AppDataColumn(
|
||||
id: 'asset_code',
|
||||
label: 'Asset Code',
|
||||
sortKey: 'asset_code',
|
||||
locked: true,
|
||||
flex: 2,
|
||||
searchText: (row) => row.assetCode ?? '',
|
||||
cellBuilder: (_, row) => AppTableCell.link(
|
||||
row.assetCode,
|
||||
onTap: row.id.trim().isEmpty
|
||||
? null
|
||||
: () => context.push('${RouteConstants.assets}/${row.id}'),
|
||||
),
|
||||
),
|
||||
AppDataColumn(
|
||||
id: 'asset_name',
|
||||
label: 'Asset Name',
|
||||
sortKey: 'asset_name',
|
||||
flex: 3,
|
||||
searchText: (row) => row.assetName ?? '',
|
||||
cellBuilder: (_, row) => AppTableCell.text(row.assetName),
|
||||
),
|
||||
AppDataColumn(
|
||||
id: 'category',
|
||||
label: 'Category',
|
||||
sortKey: 'category',
|
||||
flex: 2,
|
||||
searchText: (row) => row.categoryName ?? '',
|
||||
cellBuilder: (_, row) => AppTableCell.text(row.categoryName),
|
||||
),
|
||||
AppDataColumn(
|
||||
id: 'location',
|
||||
label: 'Location',
|
||||
sortKey: 'location',
|
||||
flex: 2,
|
||||
searchText: (row) => row.locationName ?? '',
|
||||
cellBuilder: (_, row) => AppTableCell.text(row.locationName),
|
||||
),
|
||||
AppDataColumn(
|
||||
id: 'purchase_date',
|
||||
label: 'Purchase Date',
|
||||
sortKey: 'purchase_date',
|
||||
flex: 2,
|
||||
searchText: (row) => DateFormatter.searchableDate(row.purchaseDate),
|
||||
sortValue: (row) => row.purchaseDate,
|
||||
cellBuilder: (_, row) =>
|
||||
AppTableCell.text(DateFormatter.displayDate(row.purchaseDate)),
|
||||
),
|
||||
AppDataColumn(
|
||||
id: 'purchase_cost',
|
||||
label: 'Purchase Cost',
|
||||
sortKey: 'purchase_cost',
|
||||
flex: 2,
|
||||
alignment: Alignment.centerRight,
|
||||
searchText: (row) => CurrencyFormatter.searchable(row.purchaseCost),
|
||||
sortValue: (row) => row.purchaseCost,
|
||||
cellBuilder: (_, row) => AppTableCell.text(
|
||||
CurrencyFormatter.format(row.purchaseCost),
|
||||
textAlign: TextAlign.right,
|
||||
),
|
||||
),
|
||||
AppDataColumn(
|
||||
id: 'annual',
|
||||
label: 'Annual',
|
||||
sortKey: 'annual',
|
||||
flex: 2,
|
||||
alignment: Alignment.centerRight,
|
||||
searchText: (row) =>
|
||||
CurrencyFormatter.searchable(row.annualDepreciation),
|
||||
sortValue: (row) => row.annualDepreciation,
|
||||
cellBuilder: (_, row) => AppTableCell.text(
|
||||
CurrencyFormatter.format(row.annualDepreciation),
|
||||
textAlign: TextAlign.right,
|
||||
),
|
||||
),
|
||||
AppDataColumn(
|
||||
id: 'accumulated',
|
||||
label: 'Accumulated',
|
||||
sortKey: 'accumulated',
|
||||
flex: 2,
|
||||
alignment: Alignment.centerRight,
|
||||
searchText: (row) =>
|
||||
CurrencyFormatter.searchable(row.accumulatedDepreciation),
|
||||
sortValue: (row) => row.accumulatedDepreciation,
|
||||
cellBuilder: (_, row) => AppTableCell.text(
|
||||
CurrencyFormatter.format(row.accumulatedDepreciation),
|
||||
textAlign: TextAlign.right,
|
||||
),
|
||||
),
|
||||
AppDataColumn(
|
||||
id: 'book_value',
|
||||
label: 'Book Value',
|
||||
sortKey: 'book_value',
|
||||
flex: 2,
|
||||
alignment: Alignment.centerRight,
|
||||
searchText: (row) => CurrencyFormatter.searchable(row.bookValue),
|
||||
sortValue: (row) => row.bookValue,
|
||||
cellBuilder: (_, row) => AppTableCell.text(
|
||||
CurrencyFormatter.format(row.bookValue),
|
||||
textAlign: TextAlign.right,
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final prefs = ref.watch(tableColumnPrefsProvider(tableId));
|
||||
final columns = resolveAppDataColumns(_allColumns(context), prefs);
|
||||
|
||||
return AppDataTable<DepreciationReportRow>(
|
||||
wrapInCard: false,
|
||||
rows: items,
|
||||
columns: [
|
||||
AppDataColumn(
|
||||
label: 'Asset Code',
|
||||
flex: 2,
|
||||
searchText: (row) => row.assetCode ?? '',
|
||||
cellBuilder: (_, row) => AppTableCell.text(row.assetCode),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Asset Name',
|
||||
flex: 3,
|
||||
searchText: (row) => row.assetName ?? '',
|
||||
cellBuilder: (_, row) => AppTableCell.text(row.assetName),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Category',
|
||||
flex: 2,
|
||||
searchText: (row) => row.categoryName ?? '',
|
||||
cellBuilder: (_, row) => AppTableCell.text(row.categoryName),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Location',
|
||||
flex: 2,
|
||||
searchText: (row) => row.locationName ?? '',
|
||||
cellBuilder: (_, row) => AppTableCell.text(row.locationName),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Purchase Date',
|
||||
flex: 2,
|
||||
searchText: (row) => DateFormatter.displayDate(row.purchaseDate),
|
||||
cellBuilder: (_, row) =>
|
||||
AppTableCell.text(DateFormatter.displayDate(row.purchaseDate)),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Purchase Cost',
|
||||
flex: 2,
|
||||
alignment: Alignment.centerRight,
|
||||
searchText: (row) => CurrencyFormatter.format(row.purchaseCost),
|
||||
cellBuilder: (_, row) => AppTableCell.text(
|
||||
CurrencyFormatter.format(row.purchaseCost),
|
||||
textAlign: TextAlign.right,
|
||||
),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Annual',
|
||||
flex: 2,
|
||||
alignment: Alignment.centerRight,
|
||||
searchText: (row) =>
|
||||
CurrencyFormatter.format(row.annualDepreciation),
|
||||
cellBuilder: (_, row) => AppTableCell.text(
|
||||
CurrencyFormatter.format(row.annualDepreciation),
|
||||
textAlign: TextAlign.right,
|
||||
),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Accumulated',
|
||||
flex: 2,
|
||||
alignment: Alignment.centerRight,
|
||||
searchText: (row) =>
|
||||
CurrencyFormatter.format(row.accumulatedDepreciation),
|
||||
cellBuilder: (_, row) => AppTableCell.text(
|
||||
CurrencyFormatter.format(row.accumulatedDepreciation),
|
||||
textAlign: TextAlign.right,
|
||||
),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Book Value',
|
||||
flex: 2,
|
||||
alignment: Alignment.centerRight,
|
||||
searchText: (row) => CurrencyFormatter.format(row.bookValue),
|
||||
cellBuilder: (_, row) => AppTableCell.text(
|
||||
CurrencyFormatter.format(row.bookValue),
|
||||
textAlign: TextAlign.right,
|
||||
),
|
||||
),
|
||||
],
|
||||
onEnsureFullDataset: onEnsureFullDataset,
|
||||
onColumnSearchCleared: onColumnSearchCleared,
|
||||
columns: columns,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/utils/column_search_paging.dart';
|
||||
import '../../../../core/utils/table_search.dart';
|
||||
|
||||
import '../../../../shared/models/permission_matrix_models.dart';
|
||||
@ -21,15 +22,8 @@ class RolesListState {
|
||||
final int limit;
|
||||
|
||||
List<RoleCardModel> get filteredRoles {
|
||||
if (search.isEmpty) return roles;
|
||||
final q = search.toLowerCase();
|
||||
return roles
|
||||
.where(
|
||||
(role) =>
|
||||
role.name.toLowerCase().contains(q) ||
|
||||
(role.description?.toLowerCase().contains(q) ?? false),
|
||||
)
|
||||
.toList();
|
||||
// Roles are already filtered by the API when [search] is set.
|
||||
return roles;
|
||||
}
|
||||
|
||||
int get total => filteredRoles.length;
|
||||
@ -93,6 +87,8 @@ final rolesListProvider =
|
||||
AsyncNotifierProvider<RolesListNotifier, RolesListState>(RolesListNotifier.new);
|
||||
|
||||
class RolesListNotifier extends AsyncNotifier<RolesListState> {
|
||||
final _columnSearch = ColumnSearchPaging(defaultLimit: 10);
|
||||
|
||||
@override
|
||||
Future<RolesListState> build() async {
|
||||
ref.keepAlive();
|
||||
@ -124,10 +120,47 @@ class RolesListNotifier extends AsyncNotifier<RolesListState> {
|
||||
return true;
|
||||
}
|
||||
|
||||
void setSearch(String search) {
|
||||
Future<void> setSearch(String search) async {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
state = AsyncData(current.copyWith(search: TableSearch.normalize(search), page: 1));
|
||||
final normalized = TableSearch.normalize(search);
|
||||
state = const AsyncLoading();
|
||||
try {
|
||||
final loaded = await _load(search: normalized);
|
||||
state = AsyncData(loaded.copyWith(page: 1, limit: current.limit));
|
||||
} catch (e, st) {
|
||||
state = AsyncError(e, st);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> ensureColumnSearchDataset() async {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
final limit = _columnSearch.beginFullDataset(
|
||||
currentLimit: current.limit,
|
||||
total: current.total,
|
||||
);
|
||||
if (limit == null) return;
|
||||
state = const AsyncLoading();
|
||||
try {
|
||||
final loaded = await _load(search: '');
|
||||
state = AsyncData(
|
||||
loaded.copyWith(
|
||||
page: 1,
|
||||
limit: loaded.total > 0 ? loaded.total : limit,
|
||||
),
|
||||
);
|
||||
} catch (e, st) {
|
||||
state = AsyncError(e, st);
|
||||
}
|
||||
}
|
||||
|
||||
void clearColumnSearchDataset() {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
final limit = _columnSearch.endFullDataset();
|
||||
if (limit == null) return;
|
||||
state = AsyncData(current.copyWith(page: 1, limit: limit, search: ''));
|
||||
}
|
||||
|
||||
void setPage(int page) {
|
||||
@ -162,10 +195,37 @@ class PermissionMatrixNotifier
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
|
||||
final normalizedAction = action.toLowerCase();
|
||||
final updated = current.modules.map((row) {
|
||||
if (row.moduleId != moduleId) return row;
|
||||
if (!isPermissionActionApplicable(
|
||||
row.code,
|
||||
action,
|
||||
columnActions: current.actionColumns,
|
||||
)) {
|
||||
return row;
|
||||
}
|
||||
final granted = Map<String, bool>.from(row.granted);
|
||||
granted[action] = value;
|
||||
granted[normalizedAction] = value;
|
||||
|
||||
// CREATE / EDIT / DELETE / APPROVE / EXPORT imply VIEW.
|
||||
const impliesView = {
|
||||
'create',
|
||||
'edit',
|
||||
'delete',
|
||||
'approve',
|
||||
'export',
|
||||
};
|
||||
if (value &&
|
||||
impliesView.contains(normalizedAction) &&
|
||||
isPermissionActionApplicable(
|
||||
row.code,
|
||||
'view',
|
||||
columnActions: current.actionColumns,
|
||||
)) {
|
||||
granted['view'] = true;
|
||||
}
|
||||
|
||||
return row.copyWith(granted: granted);
|
||||
}).toList();
|
||||
|
||||
|
||||
@ -89,9 +89,12 @@ class _MatrixGrid extends ConsumerWidget {
|
||||
wrapInCard: true,
|
||||
columns: [
|
||||
AppDataColumn(
|
||||
id: 'module',
|
||||
label: 'Module',
|
||||
sortKey: 'module',
|
||||
flex: 3,
|
||||
enableSearch: false,
|
||||
searchText: (row) => row.name,
|
||||
cellBuilder: (_, row) => AppTableCell.text(row.name),
|
||||
),
|
||||
...matrix.actionColumns.map(
|
||||
@ -100,14 +103,23 @@ class _MatrixGrid extends ConsumerWidget {
|
||||
flex: 1,
|
||||
alignment: Alignment.center,
|
||||
enableSearch: false,
|
||||
cellBuilder: (_, row) => Checkbox(
|
||||
value: row.granted[action] ?? false,
|
||||
visualDensity: VisualDensity.compact,
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
onChanged: (checked) => ref
|
||||
.read(permissionMatrixProvider(roleId).notifier)
|
||||
.toggleAction(row.moduleId, action, checked ?? false),
|
||||
),
|
||||
cellBuilder: (_, row) {
|
||||
final applicable = isPermissionActionApplicable(
|
||||
row.code,
|
||||
action,
|
||||
columnActions: matrix.actionColumns,
|
||||
);
|
||||
return Checkbox(
|
||||
value: applicable ? (row.granted[action] ?? false) : false,
|
||||
visualDensity: VisualDensity.compact,
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
onChanged: applicable
|
||||
? (checked) => ref
|
||||
.read(permissionMatrixProvider(roleId).notifier)
|
||||
.toggleAction(row.moduleId, action, checked ?? false)
|
||||
: null,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
@ -134,13 +146,22 @@ class _MatrixCardList extends ConsumerWidget {
|
||||
title: Text(row.name),
|
||||
children: matrix.actionColumns
|
||||
.map(
|
||||
(action) => _PermissionSwitch(
|
||||
label: permissionActionLabel(action),
|
||||
value: row.granted[action] ?? false,
|
||||
onChanged: (v) => ref
|
||||
.read(permissionMatrixProvider(roleId).notifier)
|
||||
.toggleAction(row.moduleId, action, v),
|
||||
),
|
||||
(action) {
|
||||
final applicable = isPermissionActionApplicable(
|
||||
row.code,
|
||||
action,
|
||||
columnActions: matrix.actionColumns,
|
||||
);
|
||||
return _PermissionSwitch(
|
||||
label: permissionActionLabel(action),
|
||||
value: applicable ? (row.granted[action] ?? false) : false,
|
||||
onChanged: applicable
|
||||
? (v) => ref
|
||||
.read(permissionMatrixProvider(roleId).notifier)
|
||||
.toggleAction(row.moduleId, action, v)
|
||||
: null,
|
||||
);
|
||||
},
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
@ -159,7 +180,7 @@ class _PermissionSwitch extends StatelessWidget {
|
||||
|
||||
final String label;
|
||||
final bool value;
|
||||
final ValueChanged<bool> onChanged;
|
||||
final ValueChanged<bool>? onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
@ -6,12 +6,14 @@ import 'package:go_router/go_router.dart';
|
||||
import '../../../../core/errors/failure.dart';
|
||||
import '../../../../core/utils/responsive_utils.dart';
|
||||
import '../../../../shared/models/user_management_models.dart';
|
||||
import '../../../../shared/providers/table_column_prefs_provider.dart';
|
||||
import '../../../../shared/widgets/app_data_table.dart';
|
||||
import '../../../../shared/widgets/app_empty_state.dart';
|
||||
import '../../../../shared/widgets/app_loading_view.dart';
|
||||
import '../../../../shared/widgets/app_pagination.dart';
|
||||
import '../../../../shared/widgets/app_search_field.dart';
|
||||
import '../../../../shared/widgets/app_search_filter_toggle.dart';
|
||||
import '../../../../shared/widgets/app_table_column_selector.dart';
|
||||
import '../../../../shared/widgets/app_table_shell.dart';
|
||||
import '../../../../shared/widgets/app_table_action_icon.dart';
|
||||
import '../../../../shared/widgets/error_view.dart';
|
||||
@ -63,6 +65,11 @@ class _RoleListScreenState extends ConsumerState<RoleListScreen> {
|
||||
() => _filtersExpanded = !_filtersExpanded,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
AppTableColumnSelectorButton(
|
||||
tableId: _RoleDataTable.tableId,
|
||||
columns: _RoleDataTable.columnOptions,
|
||||
),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
@ -81,30 +88,38 @@ class _RoleListScreenState extends ConsumerState<RoleListScreen> {
|
||||
totalPages: state.totalPages,
|
||||
totalItems: state.total,
|
||||
pageSize: state.limit,
|
||||
itemsOnPage: state.roles.length,
|
||||
itemLabel: 'roles',
|
||||
onPageChanged: notifier.setPage,
|
||||
onPageSizeChanged: notifier.setPageSize,
|
||||
),
|
||||
child: RefreshIndicator(
|
||||
onRefresh: notifier.refresh,
|
||||
child: roles.isEmpty
|
||||
? ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: const [
|
||||
SizedBox(
|
||||
height: 240,
|
||||
child: AppEmptyState(
|
||||
title: 'No roles found',
|
||||
description:
|
||||
'Roles from the API will appear here.',
|
||||
icon: Icons.security_outlined,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: context.isMobile
|
||||
? _RoleCardList(roles: roles, onOpen: _openRole)
|
||||
: _RoleDataTable(roles: roles, onOpen: _openRole),
|
||||
child: context.isMobile
|
||||
? (roles.isEmpty
|
||||
? ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: const [
|
||||
SizedBox(
|
||||
height: 240,
|
||||
child: AppEmptyState(
|
||||
title: 'No roles found',
|
||||
description:
|
||||
'Roles from the API will appear here.',
|
||||
icon: Icons.security_outlined,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: _RoleCardList(roles: roles, onOpen: _openRole))
|
||||
: _RoleDataTable(
|
||||
roles: roles,
|
||||
onOpen: _openRole,
|
||||
onEnsureFullDataset: () =>
|
||||
notifier.ensureColumnSearchDataset(),
|
||||
onColumnSearchCleared: () =>
|
||||
notifier.clearColumnSearchDataset(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -120,46 +135,90 @@ class _RoleListScreenState extends ConsumerState<RoleListScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
class _RoleDataTable extends StatelessWidget {
|
||||
const _RoleDataTable({required this.roles, required this.onOpen});
|
||||
class _RoleDataTable extends ConsumerWidget {
|
||||
const _RoleDataTable({
|
||||
required this.roles,
|
||||
required this.onOpen,
|
||||
this.onEnsureFullDataset,
|
||||
this.onColumnSearchCleared,
|
||||
});
|
||||
|
||||
static const tableId = 'roles_list';
|
||||
|
||||
static List<AppTableColumnOption> get columnOptions => const [
|
||||
AppTableColumnOption(
|
||||
id: 'role_name',
|
||||
label: 'Role Name',
|
||||
required: true,
|
||||
),
|
||||
AppTableColumnOption(id: 'description', label: 'Description'),
|
||||
AppTableColumnOption(id: 'users_count', label: 'Users Count'),
|
||||
];
|
||||
|
||||
final List<RoleCardModel> roles;
|
||||
final void Function(RoleCardModel role) onOpen;
|
||||
final Future<void> Function()? onEnsureFullDataset;
|
||||
final VoidCallback? onColumnSearchCleared;
|
||||
|
||||
List<AppDataColumn<RoleCardModel>> _allColumns() {
|
||||
return [
|
||||
AppDataColumn(
|
||||
id: 'role_name',
|
||||
label: 'Role Name',
|
||||
sortKey: 'role_name',
|
||||
locked: true,
|
||||
flex: 2,
|
||||
searchText: (r) => r.name,
|
||||
cellBuilder: (_, r) => Text(r.name),
|
||||
),
|
||||
AppDataColumn(
|
||||
id: 'description',
|
||||
label: 'Description',
|
||||
sortKey: 'description',
|
||||
flex: 3,
|
||||
searchText: (r) => r.description ?? '',
|
||||
cellBuilder: (_, r) => Text(r.description ?? '—'),
|
||||
),
|
||||
AppDataColumn(
|
||||
id: 'users_count',
|
||||
label: 'Users Count',
|
||||
sortKey: 'users_count',
|
||||
flex: 1,
|
||||
searchText: (r) => '${r.userCount}',
|
||||
sortValue: (r) => r.userCount,
|
||||
cellBuilder: (_, r) => Text('${r.userCount}'),
|
||||
),
|
||||
AppDataColumn(
|
||||
id: 'actions',
|
||||
label: 'Actions',
|
||||
flex: 1,
|
||||
alignment: Alignment.centerRight,
|
||||
enableSearch: false,
|
||||
locked: true,
|
||||
includeInColumnSelector: false,
|
||||
cellBuilder: (_, r) => AppTableActions(
|
||||
children: [
|
||||
AppTableActionIcon(
|
||||
tooltip: 'View Matrix',
|
||||
icon: Icons.grid_view_outlined,
|
||||
onPressed: () => onOpen(r),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final prefs = ref.watch(tableColumnPrefsProvider(tableId));
|
||||
final columns = resolveAppDataColumns(_allColumns(), prefs);
|
||||
|
||||
return AppDataTable<RoleCardModel>(
|
||||
wrapInCard: false,
|
||||
columns: [
|
||||
AppDataColumn(label: 'Role Name', flex: 2, searchText: (r) => r.name, cellBuilder: (_, r) => Text(r.name)),
|
||||
AppDataColumn(
|
||||
label: 'Description',
|
||||
flex: 3,
|
||||
searchText: (r) => r.description ?? '',
|
||||
cellBuilder: (_, r) => Text(r.description ?? '—'),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Users Count',
|
||||
flex: 1,
|
||||
searchText: (r) => '${r.userCount}',
|
||||
cellBuilder: (_, r) => Text('${r.userCount}'),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Actions',
|
||||
flex: 1,
|
||||
alignment: Alignment.centerRight,
|
||||
enableSearch: false,
|
||||
cellBuilder: (_, r) => AppTableActions(
|
||||
children: [
|
||||
AppTableActionIcon(
|
||||
tooltip: 'View Matrix',
|
||||
icon: Icons.grid_view_outlined,
|
||||
onPressed: () => onOpen(r),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
onEnsureFullDataset: onEnsureFullDataset,
|
||||
onColumnSearchCleared: onColumnSearchCleared,
|
||||
columns: columns,
|
||||
rows: roles,
|
||||
);
|
||||
}
|
||||
|
||||
@ -140,6 +140,15 @@ class AppSettingsNotifier extends StateNotifier<AppSettings> {
|
||||
return result.failure;
|
||||
}
|
||||
|
||||
/// Fetches Company Profile + Email Settings after login / session restore
|
||||
/// and applies them app-wide (logo, favicon, company name, email config).
|
||||
Future<void> syncCompanyAndEmailFromServer() async {
|
||||
await Future.wait([
|
||||
refreshCompanyProfile(),
|
||||
refreshEmailSettings(),
|
||||
]);
|
||||
}
|
||||
|
||||
Future<void> _persist(AppSettings settings) async {
|
||||
state = settings;
|
||||
final result = await _saveSettings(settings);
|
||||
|
||||
@ -9,6 +9,75 @@ import '../providers/settings_provider.dart';
|
||||
import '../widgets/settings_widgets.dart';
|
||||
import '../../../../shared/widgets/app_toast.dart';
|
||||
|
||||
class _BrandThemePreset {
|
||||
const _BrandThemePreset({
|
||||
required this.name,
|
||||
required this.primary,
|
||||
required this.secondary,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final int primary;
|
||||
final int secondary;
|
||||
|
||||
bool matches(BrandingConfig branding) =>
|
||||
branding.primaryColorValue == primary &&
|
||||
branding.secondaryColorValue == secondary;
|
||||
}
|
||||
|
||||
const _brandThemePresets = <_BrandThemePreset>[
|
||||
_BrandThemePreset(
|
||||
name: 'Ocean Blue',
|
||||
primary: 0xFF2563EB,
|
||||
secondary: 0xFF0891B2,
|
||||
),
|
||||
_BrandThemePreset(
|
||||
name: 'Royal Purple',
|
||||
primary: 0xFF6D28D9,
|
||||
secondary: 0xFF0F766E,
|
||||
),
|
||||
_BrandThemePreset(
|
||||
name: 'Emerald Green',
|
||||
primary: 0xFF15803D,
|
||||
secondary: 0xFF0F766E,
|
||||
),
|
||||
_BrandThemePreset(
|
||||
name: 'Sunset Orange',
|
||||
primary: 0xFFEA580C,
|
||||
secondary: 0xFFD97706,
|
||||
),
|
||||
_BrandThemePreset(
|
||||
name: 'Crimson Red',
|
||||
primary: 0xFFDC2626,
|
||||
secondary: 0xFFB45309,
|
||||
),
|
||||
_BrandThemePreset(
|
||||
name: 'Indigo Sky',
|
||||
primary: 0xFF4F46E5,
|
||||
secondary: 0xFF0284C7,
|
||||
),
|
||||
_BrandThemePreset(
|
||||
name: 'Slate Blue',
|
||||
primary: 0xFF334155,
|
||||
secondary: 0xFF2563EB,
|
||||
),
|
||||
_BrandThemePreset(
|
||||
name: 'Teal Mint',
|
||||
primary: 0xFF0F766E,
|
||||
secondary: 0xFF059669,
|
||||
),
|
||||
_BrandThemePreset(
|
||||
name: 'Rose Pink',
|
||||
primary: 0xFFDB2777,
|
||||
secondary: 0xFF7C3AED,
|
||||
),
|
||||
_BrandThemePreset(
|
||||
name: 'Charcoal Gold',
|
||||
primary: 0xFF374151,
|
||||
secondary: 0xFFD97706,
|
||||
),
|
||||
];
|
||||
|
||||
class AppearanceSettingsScreen extends ConsumerWidget {
|
||||
const AppearanceSettingsScreen({super.key});
|
||||
|
||||
@ -17,6 +86,9 @@ class AppearanceSettingsScreen extends ConsumerWidget {
|
||||
final themeMode = ref.watch(themeModeProvider);
|
||||
final branding = ref.watch(brandingProvider);
|
||||
final uiPrefs = ref.watch(appSettingsProvider).uiPreferences;
|
||||
final selectedPreset = _brandThemePresets
|
||||
.where((preset) => preset.matches(branding))
|
||||
.firstOrNull;
|
||||
|
||||
return SettingsPageLayout(
|
||||
title: 'Appearance',
|
||||
@ -43,58 +115,66 @@ class AppearanceSettingsScreen extends ConsumerWidget {
|
||||
const SizedBox(height: 16),
|
||||
SettingsFormCard(
|
||||
title: 'Branding',
|
||||
subtitle: 'Primary and secondary colors for the application',
|
||||
subtitle:
|
||||
'Professionally curated primary and secondary color pairs',
|
||||
children: [
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: CircleAvatar(backgroundColor: branding.primaryColor),
|
||||
title: const Text('Primary Color'),
|
||||
subtitle: Text(
|
||||
'#${branding.primaryColorValue.toRadixString(16).padLeft(8, '0').substring(2)}',
|
||||
),
|
||||
_BrandingPreviewCard(
|
||||
primary: branding.primaryColor,
|
||||
secondary: branding.secondaryColor,
|
||||
themeName: selectedPreset?.name ?? 'Custom',
|
||||
primaryHex: _hex(branding.primaryColorValue),
|
||||
secondaryHex: _hex(branding.secondaryColorValue),
|
||||
),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: CircleAvatar(backgroundColor: branding.secondaryColor),
|
||||
title: const Text('Secondary Color'),
|
||||
subtitle: Text(
|
||||
'#${branding.secondaryColorValue.toRadixString(16).padLeft(8, '0').substring(2)}',
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
'Color themes',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_ColorPreset(
|
||||
label: 'Blue',
|
||||
primary: 0xFF1565C0,
|
||||
secondary: 0xFF00897B,
|
||||
branding: branding,
|
||||
ref: ref,
|
||||
),
|
||||
_ColorPreset(
|
||||
label: 'Purple',
|
||||
primary: 0xFF6A1B9A,
|
||||
secondary: 0xFF00838F,
|
||||
branding: branding,
|
||||
ref: ref,
|
||||
),
|
||||
_ColorPreset(
|
||||
label: 'Green',
|
||||
primary: 0xFF2E7D32,
|
||||
secondary: 0xFF558B2F,
|
||||
branding: branding,
|
||||
ref: ref,
|
||||
),
|
||||
_ColorPreset(
|
||||
label: 'Orange',
|
||||
primary: 0xFFE65100,
|
||||
secondary: 0xFFF57C00,
|
||||
branding: branding,
|
||||
ref: ref,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Choose a cohesive pair optimized for light and dark modes.',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final width = constraints.maxWidth;
|
||||
final crossAxisCount = width >= 720
|
||||
? 3
|
||||
: width >= 480
|
||||
? 2
|
||||
: 1;
|
||||
final spacing = 12.0;
|
||||
final itemWidth =
|
||||
(width - spacing * (crossAxisCount - 1)) / crossAxisCount;
|
||||
|
||||
return Wrap(
|
||||
spacing: spacing,
|
||||
runSpacing: spacing,
|
||||
children: [
|
||||
for (final preset in _brandThemePresets)
|
||||
SizedBox(
|
||||
width: itemWidth,
|
||||
child: _ThemePresetCard(
|
||||
preset: preset,
|
||||
selected: preset.matches(branding),
|
||||
onTap: () {
|
||||
ref.read(brandingProvider.notifier).updateBranding(
|
||||
branding.copyWith(
|
||||
primaryColorValue: preset.primary,
|
||||
secondaryColorValue: preset.secondary,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -112,10 +192,13 @@ class AppearanceSettingsScreen extends ConsumerWidget {
|
||||
: 'Horizontal menu bar at the top',
|
||||
),
|
||||
value: layout,
|
||||
groupValue: NavigationLayout.fromValue(uiPrefs.navigationLayout),
|
||||
groupValue:
|
||||
NavigationLayout.fromValue(uiPrefs.navigationLayout),
|
||||
onChanged: (v) {
|
||||
if (v != null) {
|
||||
ref.read(appSettingsProvider.notifier).updateUiPreferences(
|
||||
ref
|
||||
.read(appSettingsProvider.notifier)
|
||||
.updateUiPreferences(
|
||||
uiPrefs.copyWith(navigationLayout: v.value),
|
||||
);
|
||||
}
|
||||
@ -183,8 +266,11 @@ class AppearanceSettingsScreen extends ConsumerWidget {
|
||||
AppButton(
|
||||
label: 'Settings Auto-Saved',
|
||||
onPressed: () {
|
||||
showAppToastFromSnackBar(context,
|
||||
const SnackBar(content: Text('Appearance settings are saved automatically')),
|
||||
showAppToastFromSnackBar(
|
||||
context,
|
||||
const SnackBar(
|
||||
content: Text('Appearance settings are saved automatically'),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
@ -198,42 +284,348 @@ class AppearanceSettingsScreen extends ConsumerWidget {
|
||||
ThemeModeOption.dark => 'Dark Theme',
|
||||
ThemeModeOption.system => 'System Theme',
|
||||
};
|
||||
|
||||
static String _hex(int value) =>
|
||||
'#${value.toRadixString(16).padLeft(8, '0').substring(2).toUpperCase()}';
|
||||
}
|
||||
|
||||
class _ColorPreset extends StatelessWidget {
|
||||
const _ColorPreset({
|
||||
required this.label,
|
||||
class _BrandingPreviewCard extends StatelessWidget {
|
||||
const _BrandingPreviewCard({
|
||||
required this.primary,
|
||||
required this.secondary,
|
||||
required this.branding,
|
||||
required this.ref,
|
||||
required this.themeName,
|
||||
required this.primaryHex,
|
||||
required this.secondaryHex,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final int primary;
|
||||
final int secondary;
|
||||
final BrandingConfig branding;
|
||||
final WidgetRef ref;
|
||||
final Color primary;
|
||||
final Color secondary;
|
||||
final String themeName;
|
||||
final String primaryHex;
|
||||
final String secondaryHex;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return OutlinedButton(
|
||||
onPressed: () {
|
||||
ref.read(brandingProvider.notifier).updateBranding(
|
||||
branding.copyWith(
|
||||
primaryColorValue: primary,
|
||||
secondaryColorValue: secondary,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
final theme = Theme.of(context);
|
||||
final onPrimary =
|
||||
ThemeData.estimateBrightnessForColor(primary) == Brightness.dark
|
||||
? Colors.white
|
||||
: Colors.black87;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.outline.withValues(alpha: 0.2),
|
||||
),
|
||||
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.35),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
CircleAvatar(radius: 8, backgroundColor: Color(primary)),
|
||||
const SizedBox(width: 6),
|
||||
CircleAvatar(radius: 8, backgroundColor: Color(secondary)),
|
||||
const SizedBox(width: 8),
|
||||
Text(label),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
color: primary,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.dashboard_outlined, color: onPrimary, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Bharat ERP · $themeName',
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: onPrimary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: secondary,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
'Accent',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: ThemeData.estimateBrightnessForColor(secondary) ==
|
||||
Brightness.dark
|
||||
? Colors.white
|
||||
: Colors.black87,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Live preview',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'How headers, primary actions, and secondary accents will look.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: () {},
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: primary,
|
||||
foregroundColor: onPrimary,
|
||||
),
|
||||
icon: const Icon(Icons.add, size: 18),
|
||||
label: const Text('Add'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () {},
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: secondary,
|
||||
side: BorderSide(
|
||||
color: secondary.withValues(alpha: 0.55),
|
||||
),
|
||||
),
|
||||
child: const Text('Export'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Row(
|
||||
children: [
|
||||
_ColorSwatchLabel(
|
||||
color: primary,
|
||||
label: 'Primary',
|
||||
hex: primaryHex,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
_ColorSwatchLabel(
|
||||
color: secondary,
|
||||
label: 'Secondary',
|
||||
hex: secondaryHex,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ColorSwatchLabel extends StatelessWidget {
|
||||
const _ColorSwatchLabel({
|
||||
required this.color,
|
||||
required this.label,
|
||||
required this.hex,
|
||||
});
|
||||
|
||||
final Color color;
|
||||
final String label;
|
||||
final String hex;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.outline.withValues(alpha: 0.25),
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: color.withValues(alpha: 0.28),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
hex,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ThemePresetCard extends StatelessWidget {
|
||||
const _ThemePresetCard({
|
||||
required this.preset,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final _BrandThemePreset preset;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final primary = Color(preset.primary);
|
||||
final secondary = Color(preset.secondary);
|
||||
final borderColor = selected
|
||||
? primary
|
||||
: theme.colorScheme.outline.withValues(alpha: 0.28);
|
||||
|
||||
return Material(
|
||||
color: selected
|
||||
? primary.withValues(alpha: 0.06)
|
||||
: theme.colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
curve: Curves.easeOut,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: borderColor,
|
||||
width: selected ? 2 : 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
_StackedColorCircles(
|
||||
primary: primary,
|
||||
secondary: secondary,
|
||||
),
|
||||
const Spacer(),
|
||||
if (selected)
|
||||
Container(
|
||||
width: 22,
|
||||
height: 22,
|
||||
decoration: BoxDecoration(
|
||||
color: primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.check,
|
||||
size: 14,
|
||||
color: ThemeData.estimateBrightnessForColor(primary) ==
|
||||
Brightness.dark
|
||||
? Colors.white
|
||||
: Colors.black87,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
preset.name,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'${AppearanceSettingsScreen._hex(preset.primary)} · '
|
||||
'${AppearanceSettingsScreen._hex(preset.secondary)}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StackedColorCircles extends StatelessWidget {
|
||||
const _StackedColorCircles({
|
||||
required this.primary,
|
||||
required this.secondary,
|
||||
});
|
||||
|
||||
final Color primary;
|
||||
final Color secondary;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final outline = Theme.of(context)
|
||||
.colorScheme
|
||||
.outline
|
||||
.withValues(alpha: 0.2);
|
||||
|
||||
return SizedBox(
|
||||
width: 56,
|
||||
height: 32,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
left: 0,
|
||||
child: Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: primary,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: outline, width: 2),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 22,
|
||||
child: Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: secondary,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: outline, width: 2),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../../../core/constants/api_endpoints.dart';
|
||||
import '../../../../core/utils/pagination_meta.dart';
|
||||
import '../../../../core/utils/active_option.dart';
|
||||
import '../../../../shared/models/api_response.dart';
|
||||
import '../../../../shared/models/export_file_result.dart';
|
||||
@ -80,19 +81,22 @@ class UserRemoteDataSource {
|
||||
(json) => ManagedUserModel.fromJson(json! as Map<String, dynamic>),
|
||||
).items;
|
||||
|
||||
final meta = body['meta'] as Map<String, dynamic>? ?? {};
|
||||
final page = (meta['page'] as num?)?.toInt() ?? query.page;
|
||||
final limit = (meta['limit'] as num?)?.toInt() ?? query.limit;
|
||||
final total = (meta['total'] as num?)?.toInt() ?? items.length;
|
||||
final totalPages =
|
||||
limit > 0 ? ((total + limit - 1) ~/ limit).clamp(1, 999999) : 1;
|
||||
final pagination = parsePagination(
|
||||
body: body,
|
||||
fallbackPage: query.page,
|
||||
fallbackLimit: query.limit,
|
||||
itemCount: items.length,
|
||||
);
|
||||
|
||||
return PaginatedResponse<ManagedUserModel>(
|
||||
items: items,
|
||||
page: page,
|
||||
limit: limit,
|
||||
total: total,
|
||||
totalPages: totalPages,
|
||||
page: pagination.page,
|
||||
limit: query.limit,
|
||||
total: pagination.total,
|
||||
totalPages: resolveTotalPages(
|
||||
total: pagination.total,
|
||||
limit: query.limit,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/utils/column_search_paging.dart';
|
||||
import '../../../../core/utils/table_search.dart';
|
||||
|
||||
import '../../../../shared/models/export_file_result.dart';
|
||||
@ -100,6 +101,8 @@ final usersListProvider =
|
||||
AsyncNotifierProvider<UsersListNotifier, UsersListState>(UsersListNotifier.new);
|
||||
|
||||
class UsersListNotifier extends AsyncNotifier<UsersListState> {
|
||||
final _columnSearch = ColumnSearchPaging(defaultLimit: 10);
|
||||
|
||||
@override
|
||||
Future<UsersListState> build() async {
|
||||
ref.keepAlive();
|
||||
@ -152,6 +155,27 @@ class UsersListNotifier extends AsyncNotifier<UsersListState> {
|
||||
applyQuery(current.query.copyWith(search: TableSearch.normalize(search), page: 1));
|
||||
}
|
||||
|
||||
Future<void> ensureColumnSearchDataset() async {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
final limit = _columnSearch.beginFullDataset(
|
||||
currentLimit: current.query.limit,
|
||||
total: current.total,
|
||||
);
|
||||
if (limit == null) return;
|
||||
await applyQuery(
|
||||
current.query.copyWith(search: null, page: 1, limit: limit),
|
||||
);
|
||||
}
|
||||
|
||||
void clearColumnSearchDataset() {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
final limit = _columnSearch.endFullDataset();
|
||||
if (limit == null) return;
|
||||
applyQuery(current.query.copyWith(search: null, page: 1, limit: limit));
|
||||
}
|
||||
|
||||
void setPage(int page) {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
@ -236,7 +260,9 @@ class UsersListNotifier extends AsyncNotifier<UsersListState> {
|
||||
if (result.failure != null) {
|
||||
final current = state.valueOrNull;
|
||||
if (current != null) {
|
||||
state = AsyncData(current.copyWith(actionError: result.failure.toString()));
|
||||
state = AsyncData(
|
||||
current.copyWith(actionError: result.failure!.message),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@ -257,7 +283,9 @@ class UsersListNotifier extends AsyncNotifier<UsersListState> {
|
||||
if (result.failure != null) {
|
||||
final current = state.valueOrNull;
|
||||
if (current != null) {
|
||||
state = AsyncData(current.copyWith(actionError: result.failure.toString()));
|
||||
state = AsyncData(
|
||||
current.copyWith(actionError: result.failure!.message),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -5,23 +5,49 @@ import 'package:go_router/go_router.dart';
|
||||
import '../../../../core/utils/formatters.dart';
|
||||
|
||||
import '../../../../core/errors/failure.dart';
|
||||
import '../../../../shared/models/user_management_models.dart';
|
||||
import '../../../../shared/widgets/app_button.dart';
|
||||
import '../../../../shared/widgets/app_confirmation_dialog.dart';
|
||||
import '../../../../shared/widgets/app_loading_view.dart';
|
||||
import '../../../../shared/widgets/app_status_chip.dart';
|
||||
import '../../../../shared/widgets/detail_overview_widgets.dart';
|
||||
import '../../../../shared/widgets/error_view.dart';
|
||||
import '../../../../shared/widgets/page_header.dart';
|
||||
import '../../../../shared/models/user_management_models.dart';
|
||||
import '../providers/users_provider.dart';
|
||||
import '../../../../shared/widgets/app_toast.dart';
|
||||
|
||||
class UserDetailScreen extends ConsumerWidget {
|
||||
class UserDetailScreen extends ConsumerStatefulWidget {
|
||||
const UserDetailScreen({super.key, required this.userId});
|
||||
|
||||
final String userId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ConsumerState<UserDetailScreen> createState() => _UserDetailScreenState();
|
||||
}
|
||||
|
||||
class _UserDetailScreenState extends ConsumerState<UserDetailScreen> {
|
||||
bool _requestedFreshLoad = false;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_requestedFreshLoad) return;
|
||||
_requestedFreshLoad = true;
|
||||
// Always hit GET user-by-id when opening view.
|
||||
ref.invalidate(userDetailProvider(widget.userId));
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant UserDetailScreen oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.userId != widget.userId) {
|
||||
ref.invalidate(userDetailProvider(widget.userId));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final userId = widget.userId;
|
||||
final userAsync = ref.watch(userDetailProvider(userId));
|
||||
|
||||
return Padding(
|
||||
@ -48,7 +74,7 @@ class UserDetailScreen extends ConsumerWidget {
|
||||
AppButton(
|
||||
label: 'Deactivate',
|
||||
expand: false,
|
||||
onPressed: () => _deactivate(context, ref),
|
||||
onPressed: () => _deactivate(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -56,27 +82,87 @@ class UserDetailScreen extends ConsumerWidget {
|
||||
AppCard(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
child: DetailOverviewCard(
|
||||
title: 'User Details',
|
||||
children: [
|
||||
_DetailRow(label: 'Email', value: user.email),
|
||||
_DetailRow(label: 'Mobile', value: user.mobile),
|
||||
_DetailRow(label: 'Role', value: user.roleLabel),
|
||||
_DetailRow(label: 'Department', value: user.departmentLabel),
|
||||
_DetailRow(
|
||||
label: 'Status',
|
||||
valueWidget: AppStatusChip(status: user.status),
|
||||
DetailOverviewSection(
|
||||
title: 'Summary',
|
||||
child: DetailSummaryStrip(
|
||||
metrics: [
|
||||
DetailSummaryMetric(
|
||||
icon: Icons.flag_outlined,
|
||||
label: 'Status',
|
||||
accent: Theme.of(context).colorScheme.secondary,
|
||||
child: AppStatusChip(
|
||||
status: user.status,
|
||||
compact: true,
|
||||
),
|
||||
),
|
||||
DetailSummaryMetric(
|
||||
icon: Icons.badge_outlined,
|
||||
label: 'Employee Code',
|
||||
child: Text(
|
||||
user.employeeCode,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
DetailSummaryMetric(
|
||||
icon: Icons.admin_panel_settings_outlined,
|
||||
label: 'Role',
|
||||
child: Text(
|
||||
user.roleLabel,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
DetailSummaryMetric(
|
||||
icon: Icons.apartment_outlined,
|
||||
label: 'Department',
|
||||
child: Text(
|
||||
user.departmentLabel,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (user.createdAt != null)
|
||||
_DetailRow(
|
||||
label: 'Created',
|
||||
value: DateFormatter.displayDateTime(user.createdAt),
|
||||
DetailOverviewSection(
|
||||
title: 'Contact',
|
||||
child: DetailInfoGrid(
|
||||
items: [
|
||||
DetailInfoItem('Email', user.email),
|
||||
DetailInfoItem('Mobile', user.mobile),
|
||||
],
|
||||
),
|
||||
if (user.updatedAt != null)
|
||||
_DetailRow(
|
||||
label: 'Updated',
|
||||
value: DateFormatter.displayDateTime(user.updatedAt),
|
||||
),
|
||||
DetailOverviewSection(
|
||||
title: 'Account',
|
||||
showDivider: false,
|
||||
child: DetailInfoGrid(
|
||||
items: [
|
||||
DetailInfoItem('Role', user.roleLabel),
|
||||
DetailInfoItem('Department', user.departmentLabel),
|
||||
if (user.createdAt != null)
|
||||
DetailInfoItem(
|
||||
'Created',
|
||||
DateFormatter.displayDateTime(user.createdAt),
|
||||
),
|
||||
if (user.updatedAt != null)
|
||||
DetailInfoItem(
|
||||
'Updated',
|
||||
DateFormatter.displayDateTime(user.updatedAt),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@ -87,7 +173,7 @@ class UserDetailScreen extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _deactivate(BuildContext context, WidgetRef ref) async {
|
||||
Future<void> _deactivate(BuildContext context) async {
|
||||
final confirmed = await showAppConfirmationDialog(
|
||||
context: context,
|
||||
title: 'Deactivate user',
|
||||
@ -97,48 +183,16 @@ class UserDetailScreen extends ConsumerWidget {
|
||||
);
|
||||
if (confirmed != true || !context.mounted) return;
|
||||
|
||||
final success = await ref.read(userDetailProvider(userId).notifier).deactivate();
|
||||
final success =
|
||||
await ref.read(userDetailProvider(widget.userId).notifier).deactivate();
|
||||
if (!context.mounted) return;
|
||||
|
||||
showAppToastFromSnackBar(context,
|
||||
SnackBar(content: Text(success ? 'User deactivated' : 'Failed to deactivate')),
|
||||
showAppToastFromSnackBar(
|
||||
context,
|
||||
SnackBar(
|
||||
content: Text(success ? 'User deactivated' : 'Failed to deactivate'),
|
||||
),
|
||||
);
|
||||
if (success) context.pop();
|
||||
}
|
||||
}
|
||||
|
||||
class _DetailRow extends StatelessWidget {
|
||||
const _DetailRow({
|
||||
required this.label,
|
||||
this.value,
|
||||
this.valueWidget,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String? value;
|
||||
final Widget? valueWidget;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 140,
|
||||
child: Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: valueWidget ?? Text(value ?? '—'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,6 +13,7 @@ import '../../../../shared/widgets/app_text_field.dart';
|
||||
import '../../../../shared/widgets/error_view.dart';
|
||||
import '../../../master_data/presentation/widgets/master_quick_add.dart';
|
||||
import '../providers/users_provider.dart';
|
||||
import '../../../../shared/widgets/api_feedback.dart';
|
||||
import '../../../../shared/widgets/app_toast.dart';
|
||||
|
||||
class UserFormScreen extends ConsumerStatefulWidget {
|
||||
@ -37,9 +38,19 @@ class _UserFormScreenState extends ConsumerState<UserFormScreen> {
|
||||
String _selectedStatus = 'active';
|
||||
bool _isSubmitting = false;
|
||||
bool _prefilled = false;
|
||||
bool _requestedFreshLoad = false;
|
||||
|
||||
bool get isEditing => widget.userId != null;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_requestedFreshLoad || !isEditing) return;
|
||||
_requestedFreshLoad = true;
|
||||
// Always hit GET user-by-id when opening edit.
|
||||
ref.invalidate(userFormProvider(widget.userId));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_employeeIdController.dispose();
|
||||
@ -134,8 +145,9 @@ class _UserFormScreenState extends ConsumerState<UserFormScreen> {
|
||||
context.pop();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
showAppToastFromSnackBar(context,
|
||||
SnackBar(content: Text(e.toString())),
|
||||
showAppToastFromSnackBar(
|
||||
context,
|
||||
SnackBar(content: Text(errorDisplayMessage(e))),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _isSubmitting = false);
|
||||
@ -262,7 +274,7 @@ class _UserFormScreenState extends ConsumerState<UserFormScreen> {
|
||||
children: [
|
||||
Expanded(
|
||||
child: AppButton(
|
||||
label: isEditing ? 'Update User' : 'Create User',
|
||||
label: isEditing ? 'Update User' : 'Save User',
|
||||
isLoading: _isSubmitting,
|
||||
onPressed: _submit,
|
||||
),
|
||||
|
||||
@ -21,6 +21,7 @@ import '../../../../shared/widgets/error_view.dart';
|
||||
import '../../../../shared/widgets/kpi_card.dart';
|
||||
import '../../../../shared/widgets/app_table_shell.dart';
|
||||
import '../../../../shared/widgets/app_table_action_icon.dart';
|
||||
import '../../../../shared/widgets/app_table_column_selector.dart';
|
||||
import '../../../../shared/widgets/page_header.dart';
|
||||
import '../widgets/user_rich_data_table.dart';
|
||||
import '../providers/users_provider.dart';
|
||||
@ -69,6 +70,11 @@ class _UserListScreenState extends ConsumerState<UserListScreen> {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
AppTableColumnSelectorButton(
|
||||
tableId: UserRichDataTable.tableId,
|
||||
columns: UserRichDataTable.columnOptions,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => context.push(RouteConstants.userAdd),
|
||||
icon: const Icon(Icons.person_add),
|
||||
@ -101,6 +107,7 @@ class _UserListScreenState extends ConsumerState<UserListScreen> {
|
||||
totalPages: state.totalPages,
|
||||
totalItems: state.total,
|
||||
pageSize: state.query.limit,
|
||||
itemsOnPage: state.users.length,
|
||||
itemLabel: 'users',
|
||||
onPageChanged: ref.read(usersListProvider.notifier).setPage,
|
||||
onPageSizeChanged:
|
||||
@ -108,41 +115,47 @@ class _UserListScreenState extends ConsumerState<UserListScreen> {
|
||||
),
|
||||
child: RefreshIndicator(
|
||||
onRefresh: () => ref.read(usersListProvider.notifier).refresh(),
|
||||
child: state.users.isEmpty
|
||||
? ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: const [
|
||||
SizedBox(
|
||||
height: 240,
|
||||
child: AppEmptyState(
|
||||
title: 'No users found',
|
||||
description:
|
||||
'Try adjusting filters or add a new user.',
|
||||
icon: Icons.people_outline,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: context.isMobile
|
||||
? _UserCardList(
|
||||
users: state.users,
|
||||
onView: _viewUser,
|
||||
onEdit: _editUser,
|
||||
onToggleStatus: _toggleStatus,
|
||||
onDeactivate: _deactivateUser,
|
||||
child: context.isMobile
|
||||
? (state.users.isEmpty
|
||||
? ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: const [
|
||||
SizedBox(
|
||||
height: 240,
|
||||
child: AppEmptyState(
|
||||
title: 'No users found',
|
||||
description:
|
||||
'Try adjusting filters or add a new user.',
|
||||
icon: Icons.people_outline,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: _UserDataTable(
|
||||
: _UserCardList(
|
||||
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,
|
||||
),
|
||||
))
|
||||
: _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,
|
||||
onEnsureFullDataset: () => ref
|
||||
.read(usersListProvider.notifier)
|
||||
.ensureColumnSearchDataset(),
|
||||
onColumnSearchCleared: () => ref
|
||||
.read(usersListProvider.notifier)
|
||||
.clearColumnSearchDataset(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -300,6 +313,8 @@ class _UserDataTable extends StatelessWidget {
|
||||
required this.onEdit,
|
||||
required this.onToggleStatus,
|
||||
required this.onDeactivate,
|
||||
this.onEnsureFullDataset,
|
||||
this.onColumnSearchCleared,
|
||||
});
|
||||
|
||||
final List<ManagedUserModel> users;
|
||||
@ -310,6 +325,8 @@ class _UserDataTable extends StatelessWidget {
|
||||
final void Function(ManagedUserModel user) onEdit;
|
||||
final Future<void> Function(ManagedUserModel user) onToggleStatus;
|
||||
final Future<void> Function(ManagedUserModel user) onDeactivate;
|
||||
final Future<void> Function()? onEnsureFullDataset;
|
||||
final VoidCallback? onColumnSearchCleared;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -319,6 +336,8 @@ class _UserDataTable extends StatelessWidget {
|
||||
sortAscending: sortOrder == 'asc',
|
||||
onSort: onSort,
|
||||
wrapInCard: false,
|
||||
onEnsureFullDataset: onEnsureFullDataset,
|
||||
onColumnSearchCleared: onColumnSearchCleared,
|
||||
actionsBuilder: (_, user) => _UserActions(
|
||||
user: user,
|
||||
onView: onView,
|
||||
|
||||
@ -86,7 +86,7 @@ class _UserProfileScreenState extends ConsumerState<UserProfileScreen> {
|
||||
context,
|
||||
SnackBar(
|
||||
content: Text(
|
||||
uploadResult.failure?.message ?? uploadResult.failure.toString(),
|
||||
uploadResult.failure?.message ?? 'Unable to upload profile picture.',
|
||||
),
|
||||
),
|
||||
);
|
||||
@ -131,7 +131,9 @@ class _UserProfileScreenState extends ConsumerState<UserProfileScreen> {
|
||||
showAppToastFromSnackBar(
|
||||
context,
|
||||
SnackBar(
|
||||
content: Text(result.failure?.message ?? result.failure.toString()),
|
||||
content: Text(
|
||||
result.failure?.message ?? 'Unable to update profile.',
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../core/constants/route_constants.dart';
|
||||
import '../../../../shared/widgets/app_segmented_tab_bar.dart';
|
||||
import '../../../roles/presentation/screens/role_list_screen.dart';
|
||||
import 'user_list_screen.dart';
|
||||
|
||||
@ -33,13 +34,19 @@ class _UsersRolesHubScreenState extends State<UsersRolesHubScreen>
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
Material(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: TabBar(
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||
child: AppSegmentedTabBar(
|
||||
controller: _tabController,
|
||||
tabs: const [
|
||||
Tab(text: 'Users', icon: Icon(Icons.people_outline)),
|
||||
Tab(text: 'Roles', icon: Icon(Icons.security_outlined)),
|
||||
AppSegmentedTab(
|
||||
label: 'Users',
|
||||
icon: Icons.people_outline,
|
||||
),
|
||||
AppSegmentedTab(
|
||||
label: 'Roles',
|
||||
icon: Icons.security_outlined,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@ -1,9 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/utils/formatters.dart';
|
||||
import '../../../../shared/models/user_management_models.dart';
|
||||
import '../../../../shared/providers/table_column_prefs_provider.dart';
|
||||
import '../../../../shared/widgets/app_data_table.dart';
|
||||
import '../../../../shared/widgets/app_status_chip.dart';
|
||||
import '../../../../shared/widgets/app_table_column_selector.dart';
|
||||
import '../../../rbac/presentation/widgets/rbac_widgets.dart';
|
||||
|
||||
typedef UserTableActionsBuilder = Widget Function(
|
||||
@ -11,7 +14,7 @@ typedef UserTableActionsBuilder = Widget Function(
|
||||
ManagedUserModel user,
|
||||
);
|
||||
|
||||
class UserRichDataTable extends StatelessWidget {
|
||||
class UserRichDataTable extends ConsumerWidget {
|
||||
const UserRichDataTable({
|
||||
super.key,
|
||||
required this.users,
|
||||
@ -20,84 +23,119 @@ class UserRichDataTable extends StatelessWidget {
|
||||
this.sortAscending = true,
|
||||
this.onSort,
|
||||
this.wrapInCard = false,
|
||||
this.onServerSearchChanged,
|
||||
this.onEnsureFullDataset,
|
||||
this.onColumnSearchCleared,
|
||||
});
|
||||
|
||||
static const tableId = 'users_list';
|
||||
|
||||
final List<ManagedUserModel> users;
|
||||
final UserTableActionsBuilder actionsBuilder;
|
||||
final String? sortColumn;
|
||||
final bool sortAscending;
|
||||
final void Function(String column, bool ascending)? onSort;
|
||||
final bool wrapInCard;
|
||||
final ValueChanged<String>? onServerSearchChanged;
|
||||
final Future<void> Function()? onEnsureFullDataset;
|
||||
final VoidCallback? onColumnSearchCleared;
|
||||
|
||||
static List<AppTableColumnOption> get columnOptions => const [
|
||||
AppTableColumnOption(id: 'user', label: 'User', required: true),
|
||||
AppTableColumnOption(id: 'employee_code', label: 'Employee Code'),
|
||||
AppTableColumnOption(id: 'role', label: 'Role'),
|
||||
AppTableColumnOption(id: 'department', label: 'Department'),
|
||||
AppTableColumnOption(id: 'last_login', label: 'Last Login'),
|
||||
AppTableColumnOption(id: 'status', label: 'Status'),
|
||||
];
|
||||
|
||||
List<AppDataColumn<ManagedUserModel>> _allColumns(ThemeData theme) {
|
||||
return [
|
||||
AppDataColumn(
|
||||
id: 'user',
|
||||
label: 'User',
|
||||
sortKey: 'full_name',
|
||||
locked: true,
|
||||
flex: 3,
|
||||
searchText: (user) => '${user.fullName} ${user.email}'.trim(),
|
||||
cellBuilder: (_, user) => UserTableUserCell(user: user),
|
||||
),
|
||||
AppDataColumn(
|
||||
id: 'employee_code',
|
||||
label: 'Employee Code',
|
||||
sortKey: 'employee_code',
|
||||
flex: 1,
|
||||
alignment: Alignment.centerRight,
|
||||
searchText: (user) => user.employeeCode,
|
||||
cellBuilder: (_, user) => EmployeeCodeBadge(code: user.employeeCode),
|
||||
),
|
||||
AppDataColumn(
|
||||
id: 'role',
|
||||
label: 'Role',
|
||||
flex: 2,
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
searchText: (user) =>
|
||||
'${user.roleNames.join(' ')} ${user.roleLabel}'.trim(),
|
||||
cellBuilder: (_, user) => UserRolesCell(roles: user.roleNames),
|
||||
),
|
||||
AppDataColumn(
|
||||
id: 'department',
|
||||
label: 'Department',
|
||||
flex: 2,
|
||||
searchText: (user) => user.departmentName ?? '',
|
||||
cellBuilder: (_, user) => Text(user.departmentLabel),
|
||||
),
|
||||
AppDataColumn(
|
||||
id: 'last_login',
|
||||
label: 'Last Login',
|
||||
flex: 2,
|
||||
searchText: (user) => DateFormatter.searchableDate(user.lastLoginAt),
|
||||
cellBuilder: (_, user) => Text(
|
||||
DateFormatter.formatUserLastLogin(user.lastLoginAt),
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
AppDataColumn(
|
||||
id: 'status',
|
||||
label: 'Status',
|
||||
flex: 1,
|
||||
searchText: (user) => user.status,
|
||||
cellBuilder: (_, user) => AppStatusChip(
|
||||
status: user.status,
|
||||
compact: true,
|
||||
forTable: true,
|
||||
),
|
||||
),
|
||||
AppDataColumn(
|
||||
id: 'actions',
|
||||
label: 'Actions',
|
||||
flex: 1,
|
||||
alignment: Alignment.centerRight,
|
||||
enableSearch: false,
|
||||
locked: true,
|
||||
includeInColumnSelector: false,
|
||||
cellBuilder: (context, user) => actionsBuilder(context, user),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
final prefs = ref.watch(tableColumnPrefsProvider(tableId));
|
||||
final columns = resolveAppDataColumns(_allColumns(theme), prefs);
|
||||
|
||||
return AppDataTable<ManagedUserModel>(
|
||||
wrapInCard: wrapInCard,
|
||||
sortColumn: sortColumn,
|
||||
sortAscending: sortAscending,
|
||||
onSort: onSort,
|
||||
columns: [
|
||||
AppDataColumn(
|
||||
label: 'User',
|
||||
sortKey: 'full_name',
|
||||
flex: 3,
|
||||
searchText: (user) =>
|
||||
'${user.fullName} ${user.email}'.trim(),
|
||||
cellBuilder: (_, user) => UserTableUserCell(user: user),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Employee Code',
|
||||
sortKey: 'employee_code',
|
||||
flex: 1,
|
||||
alignment: Alignment.centerRight,
|
||||
searchText: (user) => user.employeeCode,
|
||||
cellBuilder: (_, user) => EmployeeCodeBadge(code: user.employeeCode),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Role',
|
||||
flex: 2,
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
searchText: (user) => user.roleNames.join(' '),
|
||||
cellBuilder: (_, user) => UserRolesCell(roles: user.roleNames),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Department',
|
||||
flex: 2,
|
||||
searchText: (user) => user.departmentLabel,
|
||||
cellBuilder: (_, user) => Text(user.departmentLabel),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Last Login',
|
||||
flex: 2,
|
||||
searchText: (user) =>
|
||||
DateFormatter.formatUserLastLogin(user.lastLoginAt),
|
||||
cellBuilder: (_, user) => Text(
|
||||
DateFormatter.formatUserLastLogin(user.lastLoginAt),
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Status',
|
||||
flex: 1,
|
||||
searchText: (user) => user.status,
|
||||
cellBuilder: (_, user) => AppStatusChip(
|
||||
status: user.status,
|
||||
compact: true,
|
||||
forTable: true,
|
||||
),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Actions',
|
||||
flex: 1,
|
||||
alignment: Alignment.centerRight,
|
||||
enableSearch: false,
|
||||
cellBuilder: (context, user) => actionsBuilder(context, user),
|
||||
),
|
||||
],
|
||||
onServerSearchChanged: onServerSearchChanged,
|
||||
onEnsureFullDataset: onEnsureFullDataset,
|
||||
onColumnSearchCleared: onColumnSearchCleared,
|
||||
columns: columns,
|
||||
rows: users,
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user