Compare commits

...

10 Commits

Author SHA1 Message Date
f2d5b49dac master screen refresh issue 2026-08-04 15:01:34 +05:30
ef0f33ecbb review changes done 2026-07-27 15:29:10 +05:30
048ecab961 review changes done 2026-07-27 09:15:35 +05:30
167bd99e0a bug fix 2026-07-24 14:01:50 +05:30
b3a0366848 review changes fix 2026-07-23 17:59:45 +05:30
0978f9bfac bug fix 2026-07-22 18:17:43 +05:30
1d44187942 Location api change 2026-07-20 12:12:20 +05:30
4046633340 PO calculation 2026-07-20 11:58:17 +05:30
09ffc5fb6a review changes 2026-07-20 09:47:39 +05:30
03486ea3f4 theme changes 2026-07-17 14:35:10 +05:30
154 changed files with 14313 additions and 5863 deletions

View File

@ -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`. 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`). 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 | | Flavor | Entry point | API URL (auto) | Web base-href |
|--------|-------------|----------------|---------|---------------| |--------|-------------|----------------|---------------|
| **dev** | `lib/config/main_dev.dart` | `https://demo.venbait.in/api/v1` | `https://bharatconsumerproducts.com/erp/login` | `/erp/` | | **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` | — | `/` | | **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/` | | **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 ### Web builds
```bash ```bash
# Dev # 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 # UAT
flutter build web -t lib/config/main_uat.dart --release --base-href / flutter build web -t lib/config/main_uat.dart --release --base-href /
# Prod # 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 ### Android APK / AAB

View File

@ -4,8 +4,10 @@ import 'package:responsive_framework/responsive_framework.dart';
import 'core/constants/app_constants.dart'; import 'core/constants/app_constants.dart';
import 'core/theme/theme_provider.dart'; import 'core/theme/theme_provider.dart';
import 'modules/settings/presentation/providers/settings_provider.dart';
import 'shared/routes/app_router.dart'; import 'shared/routes/app_router.dart';
import 'shared/widgets/app_toast.dart'; import 'shared/widgets/app_toast.dart';
import 'shared/widgets/sidebar_logo.dart';
class BharatErpApp extends ConsumerWidget { class BharatErpApp extends ConsumerWidget {
const BharatErpApp({super.key}); const BharatErpApp({super.key});
@ -15,9 +17,16 @@ class BharatErpApp extends ConsumerWidget {
final router = ref.watch(routerProvider); final router = ref.watch(routerProvider);
final themeMode = ref.watch(themeModeProvider); final themeMode = ref.watch(themeModeProvider);
final branding = ref.watch(brandingProvider); 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( return MaterialApp.router(
title: AppConstants.appName, title: appTitle,
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: buildLightTheme(branding), theme: buildLightTheme(branding),
darkTheme: buildDarkTheme(branding), darkTheme: buildDarkTheme(branding),

View File

@ -17,20 +17,6 @@ class Environment {
Flavor.prod => 'production', 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) { static String get envFileName => switch (flavor) {
Flavor.dev => '.env.development', Flavor.dev => '.env.development',
Flavor.uat => '.env.uat', Flavor.uat => '.env.uat',

View File

@ -213,4 +213,5 @@ class ApiEndpoints {
// Notifications // Notifications
static const String notifications = '/notifications'; static const String notifications = '/notifications';
static const String notificationsTrigger = '/notifications/trigger';
} }

View File

@ -59,13 +59,13 @@ class RouteConstants {
static const String grnEdit = '/grn/:id/edit'; static const String grnEdit = '/grn/:id/edit';
static const String grnDetail = '/grn/:id'; static const String grnDetail = '/grn/:id';
// Assets // Assets (Asset Master)
static const String assets = '/assets'; static const String assets = '/assetsmaster';
static const String assetAdd = '/assets/add'; static const String assetAdd = '/assetsmaster/add';
static const String assetEdit = '/assets/:id/edit'; static const String assetEdit = '/assetsmaster/:id/edit';
static const String assetDetail = '/assets/:id'; static const String assetDetail = '/assetsmaster/:id';
static const String assetAlerts = '/assets/alerts'; static const String assetAlerts = '/assetsmaster/alerts';
static const String assetMaintenance = '/assets/maintenance'; static const String assetMaintenance = '/assetsmaster/maintenance';
// Master Data // Master Data
static const String masterData = '/master-data'; static const String masterData = '/master-data';

View File

@ -13,4 +13,9 @@ class StorageKeys {
static const String appSettings = 'app_settings'; static const String appSettings = 'app_settings';
static const String rememberMe = 'remember_me'; static const String rememberMe = 'remember_me';
static const String rememberedEmail = 'remembered_email'; 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';
} }

View File

@ -3,8 +3,8 @@ import 'package:flutter/material.dart';
class AppColors { class AppColors {
AppColors._(); AppColors._();
static const Color primary = Color(0xFF1565C0); static const Color primary = Color(0xFF2563EB);
static const Color secondary = Color(0xFF00897B); static const Color secondary = Color(0xFF0891B2);
static const Color error = Color(0xFFD32F2F); static const Color error = Color(0xFFD32F2F);
static const Color warning = Color(0xFFF57C00); static const Color warning = Color(0xFFF57C00);
static const Color success = Color(0xFF388E3C); static const Color success = Color(0xFF388E3C);

View File

@ -12,8 +12,9 @@ class AppTheme {
final primary = branding?.primaryColor ?? AppColors.primary; final primary = branding?.primaryColor ?? AppColors.primary;
final secondary = branding?.secondaryColor ?? AppColors.secondary; final secondary = branding?.secondaryColor ?? AppColors.secondary;
final colorScheme = ColorScheme.fromSeed( final colorScheme = _brandedColorScheme(
seedColor: primary, seed: primary,
primary: primary,
secondary: secondary, secondary: secondary,
brightness: Brightness.light, brightness: Brightness.light,
surface: AppColors.lightSurface, surface: AppColors.lightSurface,
@ -26,8 +27,9 @@ class AppTheme {
final primary = branding?.primaryColor ?? AppColors.primary; final primary = branding?.primaryColor ?? AppColors.primary;
final secondary = branding?.secondaryColor ?? AppColors.secondary; final secondary = branding?.secondaryColor ?? AppColors.secondary;
final colorScheme = ColorScheme.fromSeed( final colorScheme = _brandedColorScheme(
seedColor: primary, seed: primary,
primary: primary,
secondary: secondary, secondary: secondary,
brightness: Brightness.dark, brightness: Brightness.dark,
surface: AppColors.darkSurface, surface: AppColors.darkSurface,
@ -36,12 +38,56 @@ class AppTheme {
return _buildTheme(colorScheme, Brightness.dark); 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). /// Theme for white cards, form fields, and picker sheets (unchanged in dark mode).
static ThemeData cardContentTheme(ThemeData theme) { static ThemeData cardContentTheme(ThemeData theme) {
final scheme = theme.brightness == Brightness.light final scheme = theme.brightness == Brightness.light
? theme.colorScheme ? theme.colorScheme
: ColorScheme.fromSeed( : _brandedColorScheme(
seedColor: theme.colorScheme.primary, seed: theme.colorScheme.primary,
primary: theme.colorScheme.primary,
secondary: theme.colorScheme.secondary, secondary: theme.colorScheme.secondary,
brightness: Brightness.light, brightness: Brightness.light,
surface: AppColors.card, surface: AppColors.card,
@ -155,13 +201,27 @@ class AppTheme {
borderSide: BorderSide(color: colorScheme.primary, width: 2), borderSide: BorderSide(color: colorScheme.primary, width: 2),
), ),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
labelStyle: textTheme.bodyMedium, labelStyle: textTheme.labelLarge,
hintStyle: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant), hintStyle: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
helperStyle: textTheme.bodySmall, helperStyle: textTheme.bodySmall,
errorStyle: textTheme.bodySmall?.copyWith(color: colorScheme.error), errorStyle: textTheme.labelSmall?.copyWith(color: colorScheme.error),
), ),
elevatedButtonTheme: ElevatedButtonThemeData( elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom( 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), minimumSize: const Size(0, 48),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
@ -170,18 +230,30 @@ class AppTheme {
), ),
outlinedButtonTheme: OutlinedButtonThemeData( outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
foregroundColor: colorScheme.secondary,
minimumSize: const Size(0, 48), minimumSize: const Size(0, 48),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
side: BorderSide(color: colorScheme.secondary.withValues(alpha: 0.55)),
textStyle: textTheme.labelLarge, textStyle: textTheme.labelLarge,
), ),
), ),
textButtonTheme: TextButtonThemeData( textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom( style: TextButton.styleFrom(
foregroundColor: colorScheme.secondary,
minimumSize: const Size(0, 48), minimumSize: const Size(0, 48),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
textStyle: textTheme.labelLarge, textStyle: textTheme.labelLarge,
), ),
), ),
iconButtonTheme: IconButtonThemeData(
style: IconButton.styleFrom(
foregroundColor: colorScheme.secondary,
),
),
floatingActionButtonTheme: FloatingActionButtonThemeData(
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
),
navigationRailTheme: NavigationRailThemeData( navigationRailTheme: NavigationRailThemeData(
backgroundColor: isDark ? AppColors.darkSurface : AppColors.lightSurface, backgroundColor: isDark ? AppColors.darkSurface : AppColors.lightSurface,
selectedIconTheme: IconThemeData(color: colorScheme.primary), selectedIconTheme: IconThemeData(color: colorScheme.primary),
@ -215,7 +287,17 @@ class AppTheme {
dataTextStyle: textTheme.bodyMedium, dataTextStyle: textTheme.bodyMedium,
), ),
chipTheme: ChipThemeData( 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( dialogTheme: DialogThemeData(
titleTextStyle: textTheme.titleLarge, titleTextStyle: textTheme.titleLarge,

View File

@ -1,57 +1,189 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
/// ERP typography Figtree (Google Font). /// ERP typography Figtree design tokens.
/// Weights: Regular 400, Medium 500, SemiBold 600. ///
/// | Token | Sizes | Weights |
/// |--------------|-------|----------------------------------|
/// | Heading 16 | 5620 | Medium, SemiBold, Bold |
/// | Body 14 | 1812 | Regular, Medium |
/// | Label 13 | 1612 | Regular, Medium, SemiBold, Bold |
/// | Caption 12 | 109 | Medium, SemiBold |
class AppTypography { class AppTypography {
AppTypography._(); AppTypography._();
static const FontWeight regular = FontWeight.w400; static const FontWeight regular = FontWeight.w400;
static const FontWeight medium = FontWeight.w500; static const FontWeight medium = FontWeight.w500;
static const FontWeight semiBold = FontWeight.w600; static const FontWeight semiBold = FontWeight.w600;
static const FontWeight bold = FontWeight.w700;
static String get fontFamily => GoogleFonts.figtree().fontFamily ?? 'Figtree'; static String get fontFamily => GoogleFonts.figtree().fontFamily ?? 'Figtree';
static TextTheme textTheme(ColorScheme colorScheme) { // Design tokens
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,
);
}
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 onSurface = colorScheme.onSurface;
final onSurfaceVariant = colorScheme.onSurfaceVariant; final onSurfaceVariant = colorScheme.onSurfaceVariant;
return TextTheme( return TextTheme(
// Display displayLarge: heading1(color: onSurface),
displayLarge: token(size: 57, weight: semiBold, color: onSurface), displayMedium: heading2(color: onSurface),
displayMedium: token(size: 45, weight: semiBold, color: onSurface), displaySmall: heading3(color: onSurface),
displaySmall: token(size: 36, weight: semiBold, color: onSurface), headlineLarge: heading4(color: onSurface),
// Headline headlineMedium: heading5(color: onSurface),
headlineLarge: token(size: 20, weight: semiBold, color: onSurface), headlineSmall: heading6(color: onSurface),
headlineMedium: token(size: 16, weight: regular, color: onSurface), titleLarge: heading5(color: onSurface),
headlineSmall: token(size: 18, weight: semiBold, color: onSurface), titleMedium: label1(color: onSurface),
// Title titleSmall: label2(color: onSurface),
titleLarge: token(size: 22, weight: semiBold, color: onSurface), bodyLarge: body1(color: onSurface),
titleMedium: token(size: 16, weight: medium, color: onSurface), bodyMedium: body2(color: onSurface),
titleSmall: token(size: 14, weight: medium, color: onSurface), bodySmall: body3(color: onSurfaceVariant),
// Body labelLarge: label2(color: onSurface),
bodyLarge: token(size: 16, weight: regular, color: onSurface), labelMedium: label3(color: onSurfaceVariant),
bodyMedium: token(size: 16, weight: regular, color: onSurface), labelSmall: caption1(color: onSurfaceVariant),
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), static TextStyle _token(
labelSmall: token(size: 10, weight: regular, color: onSurfaceVariant), double size,
FontWeight weight,
Color? color,
double? height,
) {
return GoogleFonts.figtree(
fontSize: size,
fontWeight: weight,
letterSpacing: 0,
height: height,
color: color,
); );
} }
} }

View File

@ -8,8 +8,8 @@ part 'branding_config.g.dart';
class BrandingConfig with _$BrandingConfig { class BrandingConfig with _$BrandingConfig {
const factory BrandingConfig({ const factory BrandingConfig({
String? logoUrl, String? logoUrl,
@Default(0xFF1565C0) int primaryColorValue, @Default(0xFF2563EB) int primaryColorValue,
@Default(0xFF00897B) int secondaryColorValue, @Default(0xFF0891B2) int secondaryColorValue,
String? companyName, String? companyName,
}) = _BrandingConfig; }) = _BrandingConfig;

View File

@ -159,8 +159,8 @@ class __$$BrandingConfigImplCopyWithImpl<$Res>
class _$BrandingConfigImpl implements _BrandingConfig { class _$BrandingConfigImpl implements _BrandingConfig {
const _$BrandingConfigImpl({ const _$BrandingConfigImpl({
this.logoUrl, this.logoUrl,
this.primaryColorValue = 0xFF1565C0, this.primaryColorValue = 0xFF2563EB,
this.secondaryColorValue = 0xFF00897B, this.secondaryColorValue = 0xFF0891B2,
this.companyName, this.companyName,
}); });

View File

@ -10,9 +10,9 @@ _$BrandingConfigImpl _$$BrandingConfigImplFromJson(Map<String, dynamic> json) =>
_$BrandingConfigImpl( _$BrandingConfigImpl(
logoUrl: json['logoUrl'] as String?, logoUrl: json['logoUrl'] as String?,
primaryColorValue: primaryColorValue:
(json['primaryColorValue'] as num?)?.toInt() ?? 0xFF1565C0, (json['primaryColorValue'] as num?)?.toInt() ?? 0xFF2563EB,
secondaryColorValue: secondaryColorValue:
(json['secondaryColorValue'] as num?)?.toInt() ?? 0xFF00897B, (json['secondaryColorValue'] as num?)?.toInt() ?? 0xFF0891B2,
companyName: json['companyName'] as String?, companyName: json['companyName'] as String?,
); );

View 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),
);
}
}

View File

@ -1,3 +1,4 @@
import 'package:flutter/services.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
class DateFormatter { class DateFormatter {
@ -42,13 +43,31 @@ class DateFormatter {
if (dayDiff < 7) return '$dayDiff days ago'; if (dayDiff < 7) return '$dayDiff days ago';
return displayDateTime(local); 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 { class CurrencyFormatter {
CurrencyFormatter._(); CurrencyFormatter._();
static const locale = 'en_IN';
static final _formatter = NumberFormat.currency( static final _formatter = NumberFormat.currency(
locale: 'en_IN', locale: locale,
symbol: '', symbol: '',
decimalDigits: 2, decimalDigits: 2,
); );
@ -57,4 +76,150 @@ class CurrencyFormatter {
if (amount == null) return '-'; if (amount == null) return '-';
return _formatter.format(amount); 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(' ');
} }

View 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,
);
}

View File

@ -19,6 +19,8 @@ const Map<String, String> permissionModuleAliases = {
'purchase_orders': 'PURCHASE_ORDER', 'purchase_orders': 'PURCHASE_ORDER',
'purchase_order': 'PURCHASE_ORDER', 'purchase_order': 'PURCHASE_ORDER',
'grn': 'GRN', 'grn': 'GRN',
'purchase_receipt': 'GRN',
'purchase_receipts': 'GRN',
'reports': 'REPORTS', 'reports': 'REPORTS',
'audit_logs': 'AUDIT_LOGS', 'audit_logs': 'AUDIT_LOGS',
'audit': 'AUDIT_LOGS', 'audit': 'AUDIT_LOGS',

View File

@ -8,6 +8,15 @@ class AppBreakpoints {
static const double tablet = 600; static const double tablet = 600;
static const double desktop = 1024; static const double desktop = 1024;
static const double wide = 1440; 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 { extension ResponsiveContext on BuildContext {
@ -22,23 +31,27 @@ extension ResponsiveContext on BuildContext {
return double.infinity; 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 { int get formGridColumns {
final width = MediaQuery.sizeOf(this).width; final width = MediaQuery.sizeOf(this).width;
return formGridColumnsForWidth(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( int formGridColumnsForWidth(
double width, { double width, {
int xsColumns = 1,
int smallColumns = 2, int smallColumns = 2,
int mediumColumns = 4, int mediumColumns = 3,
int largeColumns = 4, int largeColumns = 4,
double mediumBreakpoint = AppBreakpoints.tablet, double smallBreakpoint = AppBreakpoints.formSmall,
double largeBreakpoint = AppBreakpoints.desktop, double mediumBreakpoint = AppBreakpoints.formMedium,
double largeBreakpoint = AppBreakpoints.formLarge,
}) { }) {
if (width >= largeBreakpoint) return largeColumns; if (width >= largeBreakpoint) return largeColumns;
if (width >= mediumBreakpoint) return mediumColumns; if (width >= mediumBreakpoint) return mediumColumns;
return smallColumns; if (width >= smallBreakpoint) return smallColumns;
return xsColumns;
} }

View File

@ -36,6 +36,26 @@ class TableSearch {
} }
return items.where((item) => matches(q, valuesOf(item))).toList(); 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. /// Debounces search input so API-backed lists are not hit on every keystroke.

View File

@ -1,5 +1,7 @@
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'formatters.dart';
class Validators { class Validators {
Validators._(); Validators._();
@ -137,15 +139,35 @@ class Validators {
return null; 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) { static String? password(String? value) {
if (value == null || value.isEmpty) return 'Password is required'; if (value == null || value.isEmpty) return 'Password is required';
if (value.length < 8) return 'Password must be at least 8 characters'; if (value.length < 8) {
if (!RegExp(r'[A-Z]').hasMatch(value)) return 'Must contain an uppercase letter'; return 'Password must be at least 8 characters';
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 (!RegExp(r'[A-Z]').hasMatch(value)) {
return 'Password must contain at least one uppercase letter (AZ)';
}
if (!RegExp(r'[a-z]').hasMatch(value)) {
return 'Password must contain at least one lowercase letter (az)';
}
if (!RegExp(r'[0-9]').hasMatch(value)) {
return 'Password must contain at least one numeric digit (09)';
}
if (!RegExp(r'''[!@#$%^&*(),.?":{}|<>_\-+=\[\]\\;/`'~]''').hasMatch(value)) {
return 'Password must contain at least one special character (e.g. @, #, \$, %, &, !)';
}
return null; 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. /// Required 15-character GSTIN pattern.
static String? gstin(String? value) { static String? gstin(String? value) {
if (value == null || value.trim().isEmpty) { if (value == null || value.trim().isEmpty) {
@ -212,7 +234,7 @@ class Validators {
String fieldName = 'Value', String fieldName = 'Value',
}) { }) {
if (value == null || value.trim().isEmpty) return null; 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 == null) return 'Enter a valid number';
if (parsed <= 0) return '$fieldName must be greater than 0'; if (parsed <= 0) return '$fieldName must be greater than 0';
return null; return null;
@ -224,7 +246,7 @@ class Validators {
String fieldName = 'Value', String fieldName = 'Value',
}) { }) {
if (value == null || value.trim().isEmpty) return null; 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 == null) return 'Enter a valid number';
if (parsed < 0) return '$fieldName cannot be negative'; if (parsed < 0) return '$fieldName cannot be negative';
return null; return null;
@ -248,7 +270,7 @@ class Validators {
String fieldName = 'Percentage', String fieldName = 'Percentage',
}) { }) {
if (value == null || value.trim().isEmpty) return null; 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 == null) return 'Enter a valid percentage';
if (parsed < 0 || parsed > 100) { if (parsed < 0 || parsed > 100) {
return '$fieldName must be between 0 and 100'; return '$fieldName must be between 0 and 100';

View File

@ -1,6 +1,7 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import '../../../../core/constants/api_endpoints.dart'; import '../../../../core/constants/api_endpoints.dart';
import '../../../../core/utils/pagination_meta.dart';
import '../../../../core/utils/export_file_name.dart'; import '../../../../core/utils/export_file_name.dart';
import '../../../../shared/models/api_response.dart'; import '../../../../shared/models/api_response.dart';
import '../../../../shared/models/asset_model.dart'; import '../../../../shared/models/asset_model.dart';
@ -17,7 +18,12 @@ class AssetRemoteDataSource {
ApiEndpoints.assets, ApiEndpoints.assets,
queryParameters: _queryToMap(query), 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 { Future<AssetModel> getAssetById(String id) async {
@ -215,7 +221,12 @@ class AssetRemoteDataSource {
if (type != null) 'type': type, 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 { Future<List<AssetAlertModel>> getServiceAlerts({String? status}) async {
@ -370,7 +381,12 @@ class AssetRemoteDataSource {
if (dueOnly) 'due_only': true, 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 { Future<List<AssetMaintenanceLogModel>> getMaintenanceLogs(String assetId) async {
@ -464,86 +480,45 @@ class AssetRemoteDataSource {
PaginatedResponse<T> _parsePaginated<T>( PaginatedResponse<T> _parsePaginated<T>(
dynamic body, dynamic body,
T Function(Map<String, dynamic>) fromJson, T Function(Map<String, dynamic>) fromJson, {
) { int fallbackPage = 1,
if (body is! Map) { int fallbackLimit = 20,
return const PaginatedResponse( }) {
items: [], var items = <T>[];
page: 1, if (body is Map) {
limit: 20, final raw = body['data'];
total: 0, if (raw is List) {
totalPages: 1, items = raw
);
}
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
.whereType<Map>() .whereType<Map>()
.map((e) => fromJson(Map<String, dynamic>.from(e))) .map((e) => fromJson(Map<String, dynamic>.from(e)))
.toList(); .toList();
final limit = (meta['limit'] as num?)?.toInt() ?? } else if (raw is Map) {
(nested['limit'] as num?)?.toInt() ?? final list = raw['items'];
20; if (list is List) {
final total = (meta['total'] as num?)?.toInt() ?? items = list
(nested['total'] as num?)?.toInt() ?? .whereType<Map>()
items.length; .map((e) => fromJson(Map<String, dynamic>.from(e)))
final explicitTotalPages = .toList();
(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),
);
} }
} }
return const PaginatedResponse( final pagination = parsePagination(
items: [], body: body,
page: 1, fallbackPage: fallbackPage,
limit: 20, fallbackLimit: fallbackLimit,
total: 0, itemCount: items.length,
totalPages: 1, );
return PaginatedResponse(
items: items,
page: pagination.page,
limit: fallbackLimit,
total: pagination.total,
totalPages: resolveTotalPages(
total: pagination.total,
limit: fallbackLimit,
),
); );
} }

View File

@ -2,9 +2,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../data/repositories/asset_repository_impl.dart'; import '../../data/repositories/asset_repository_impl.dart';
import '../../../../shared/models/asset_model.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). /// 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 repository = ref.watch(assetRepositoryProvider);
final result = await repository.getCategories(); final result = await repository.getCategories();
if (result.failure != null) throw result.failure!; 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`). /// Categories for Asset form dropdowns (`dropdown_call=true`).
final itemCategoriesFormProvider = final itemCategoriesFormProvider =
FutureProvider<List<AssetCategoryModel>>((ref) async { FutureProvider.autoDispose<List<AssetCategoryModel>>((ref) async {
final repository = ref.watch(assetRepositoryProvider); final repository = ref.watch(assetRepositoryProvider);
final result = await repository.getCategories(dropdownCall: true); final result = await repository.getCategories(dropdownCall: true);
if (result.failure != null) throw result.failure!; if (result.failure != null) throw result.failure!;
return result.data ?? []; 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') @Deprecated('Use itemCategoriesProvider')
final assetCategoriesProvider = itemCategoriesProvider; final assetCategoriesProvider = itemCategoriesProvider;

View File

@ -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 = final assetDropdownOptionsProvider =
FutureProvider.autoDispose<AssetDropdownOptionsModel>((ref) async { FutureProvider.autoDispose<AssetDropdownOptionsModel>((ref) async {
return _safeAssetOptions(ref); return _safeAssetOptions(ref);
@ -182,7 +203,7 @@ Future<List<FilterOptionModel>> _safeGrnOptions(Ref ref) async {
.map( .map(
(grn) => FilterOptionModel( (grn) => FilterOptionModel(
id: grn.id, id: grn.id,
name: grn.grnNumber ?? 'GRN #${grn.id}', name: grn.grnNumber ?? 'Purchase Receipt #${grn.id}',
), ),
) )
.toList(); .toList();

View File

@ -1,11 +1,13 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/utils/column_search_paging.dart';
import '../../../../core/utils/table_search.dart'; import '../../../../core/utils/table_search.dart';
import '../../../../shared/models/asset_model.dart'; import '../../../../shared/models/asset_model.dart';
import '../../../../shared/models/entity_attachment_model.dart'; import '../../../../shared/models/entity_attachment_model.dart';
import '../../../../shared/models/export_file_result.dart'; import '../../../../shared/models/export_file_result.dart';
import '../../data/repositories/asset_repository_impl.dart'; import '../../data/repositories/asset_repository_impl.dart';
import 'asset_categories_provider.dart';
class AssetsListState { class AssetsListState {
const AssetsListState({ const AssetsListState({
@ -58,6 +60,8 @@ final assetsListProvider =
); );
class AssetsListNotifier extends AutoDisposeAsyncNotifier<AssetsListState> { class AssetsListNotifier extends AutoDisposeAsyncNotifier<AssetsListState> {
final _columnSearch = ColumnSearchPaging();
@override @override
Future<AssetsListState> build() async { Future<AssetsListState> build() async {
return _load(const AssetListQuery(limit: 20)); return _load(const AssetListQuery(limit: 20));
@ -68,8 +72,35 @@ class AssetsListNotifier extends AutoDisposeAsyncNotifier<AssetsListState> {
final result = await repository.getAssets(query); final result = await repository.getAssets(query);
if (result.failure != null) throw result.failure!; if (result.failure != null) throw result.failure!;
final page = result.data!; 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( return AssetsListState(
assets: page.items, assets: items,
query: query, query: query,
total: page.total, total: page.total,
totalPages: page.totalPages, 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) { void setStatusFilter(String? status) {
final current = state.valueOrNull; final current = state.valueOrNull;
if (current == null) return; if (current == null) return;
@ -171,6 +223,7 @@ class AssetsListNotifier extends AutoDisposeAsyncNotifier<AssetsListState> {
return false; return false;
} }
await refresh(); await refresh();
ref.invalidate(myMaintenanceProvider);
final current = state.valueOrNull; final current = state.valueOrNull;
if (current != null) { if (current != null) {
state = AsyncData(current.copyWith(actionSuccess: 'Asset deleted')); state = AsyncData(current.copyWith(actionSuccess: 'Asset deleted'));
@ -303,6 +356,7 @@ class AssetDetailNotifier extends FamilyAsyncNotifier<AssetDetailState, String>
if (result.failure != null) throw result.failure!; if (result.failure != null) throw result.failure!;
await reload(); await reload();
ref.invalidate(assetsListProvider); ref.invalidate(assetsListProvider);
ref.invalidate(myMaintenanceProvider);
return result.data; return result.data;
} }
@ -311,6 +365,7 @@ class AssetDetailNotifier extends FamilyAsyncNotifier<AssetDetailState, String>
final result = await repository.deleteAsset(arg); final result = await repository.deleteAsset(arg);
if (result.failure != null) return false; if (result.failure != null) return false;
ref.invalidate(assetsListProvider); ref.invalidate(assetsListProvider);
ref.invalidate(myMaintenanceProvider);
return true; return true;
} }
@ -448,6 +503,7 @@ class AssetFormNotifier extends AutoDisposeFamilyAsyncNotifier<AssetModel?, Stri
final result = await repository.createAsset(data); final result = await repository.createAsset(data);
if (result.failure != null) throw result.failure!; if (result.failure != null) throw result.failure!;
ref.invalidate(assetsListProvider); ref.invalidate(assetsListProvider);
ref.invalidate(myMaintenanceProvider);
return result.data; return result.data;
} }
@ -457,6 +513,7 @@ class AssetFormNotifier extends AutoDisposeFamilyAsyncNotifier<AssetModel?, Stri
if (result.failure != null) throw result.failure!; if (result.failure != null) throw result.failure!;
ref.invalidate(assetsListProvider); ref.invalidate(assetsListProvider);
ref.invalidate(assetDetailProvider(id)); ref.invalidate(assetDetailProvider(id));
ref.invalidate(myMaintenanceProvider);
final fresh = await repository.getAssetById(id); final fresh = await repository.getAssetById(id);
final asset = fresh.data ?? result.data; final asset = fresh.data ?? result.data;
@ -636,11 +693,11 @@ class MyMaintenanceState {
} }
final myMaintenanceProvider = final myMaintenanceProvider =
AsyncNotifierProvider<MyMaintenanceNotifier, MyMaintenanceState>( AsyncNotifierProvider.autoDispose<MyMaintenanceNotifier, MyMaintenanceState>(
MyMaintenanceNotifier.new, MyMaintenanceNotifier.new,
); );
class MyMaintenanceNotifier extends AsyncNotifier<MyMaintenanceState> { class MyMaintenanceNotifier extends AutoDisposeAsyncNotifier<MyMaintenanceState> {
@override @override
Future<MyMaintenanceState> build() async { Future<MyMaintenanceState> build() async {
return _load(const MyMaintenanceState()); return _load(const MyMaintenanceState());

View File

@ -13,6 +13,7 @@ import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_pagination.dart'; import '../../../../shared/widgets/app_pagination.dart';
import '../../../../shared/widgets/error_view.dart'; import '../../../../shared/widgets/error_view.dart';
import '../../../../shared/widgets/page_header.dart'; import '../../../../shared/widgets/page_header.dart';
import '../../../../shared/widgets/app_segmented_tab_bar.dart';
import '../providers/assets_provider.dart'; import '../providers/assets_provider.dart';
class AssetAlertsScreen extends ConsumerStatefulWidget { class AssetAlertsScreen extends ConsumerStatefulWidget {
@ -64,11 +65,17 @@ class _AssetAlertsScreenState extends ConsumerState<AssetAlertsScreen>
), ),
], ],
), ),
TabBar( AppSegmentedTabBar(
controller: _tabController, controller: _tabController,
tabs: const [ tabs: const [
Tab(text: 'Expiry Alerts'), AppSegmentedTab(
Tab(text: 'Service Alerts'), label: 'Expiry Alerts',
icon: Icons.event_busy_outlined,
),
AppSegmentedTab(
label: 'Service Alerts',
icon: Icons.build_circle_outlined,
),
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@ -339,7 +346,6 @@ class _AlertCard extends StatelessWidget {
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: visual.color, color: visual.color,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
fontSize: 12,
), ),
), ),
), ),
@ -367,7 +373,6 @@ class _AlertCard extends StatelessWidget {
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: visual.color, color: visual.color,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
fontSize: 12,
), ),
), ),
), ),

View File

@ -6,6 +6,7 @@ import 'package:intl/intl.dart';
import '../../../../core/constants/enums.dart'; import '../../../../core/constants/enums.dart';
import '../../../../core/constants/route_constants.dart'; import '../../../../core/constants/route_constants.dart';
import '../../../../core/errors/failure.dart'; import '../../../../core/errors/failure.dart';
import '../../../../core/utils/formatters.dart';
import '../../../../shared/models/asset_model.dart'; import '../../../../shared/models/asset_model.dart';
import '../../../../shared/models/entity_attachment_model.dart'; import '../../../../shared/models/entity_attachment_model.dart';
import '../../../../shared/models/user_management_models.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_loading_view.dart';
import '../../../../shared/widgets/app_status_chip.dart'; import '../../../../shared/widgets/app_status_chip.dart';
import '../../../../shared/widgets/can_permission.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/entity_attachments_card.dart';
import '../../../../shared/widgets/error_view.dart'; import '../../../../shared/widgets/error_view.dart';
import '../../../../shared/widgets/page_header.dart'; import '../../../../shared/widgets/page_header.dart';
import '../../../../shared/widgets/app_side_panel.dart'; import '../../../../shared/widgets/app_side_panel.dart';
import '../../../../shared/widgets/app_segmented_tab_bar.dart';
import '../providers/assets_provider.dart'; import '../providers/assets_provider.dart';
import '../providers/asset_form_lookups_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_maintenance_panel.dart';
import '../widgets/asset_side_panels.dart'; import '../widgets/asset_side_panels.dart';
import '../../../../shared/widgets/app_toast.dart'; import '../../../../shared/widgets/app_toast.dart';
@ -39,6 +44,7 @@ class AssetDetailScreen extends ConsumerStatefulWidget {
class _AssetDetailScreenState extends ConsumerState<AssetDetailScreen> class _AssetDetailScreenState extends ConsumerState<AssetDetailScreen>
with SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin {
late final TabController _tabController; late final TabController _tabController;
bool _requestedFreshLoad = false;
@override @override
void initState() { void initState() {
@ -46,6 +52,23 @@ class _AssetDetailScreenState extends ConsumerState<AssetDetailScreen>
_tabController = TabController(length: 4, vsync: this); _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 @override
void dispose() { void dispose() {
_tabController.dispose(); _tabController.dispose();
@ -76,12 +99,16 @@ class _AssetDetailScreenState extends ConsumerState<AssetDetailScreen>
onPressed: () => context.go(RouteConstants.assets), onPressed: () => context.go(RouteConstants.assets),
), ),
title: state.asset.assetName, title: state.asset.assetName,
titleTrailing: AppStatusChip(
status: state.asset.status ?? 'IN_USE',
compact: true,
),
subtitle: state.asset.assetCode ?? 'Asset ID: ${state.asset.id}', subtitle: state.asset.assetCode ?? 'Asset ID: ${state.asset.id}',
actions: [ actions: [
if (canEdit) if (canEdit)
OutlinedButton.icon( OutlinedButton.icon(
onPressed: () => onPressed: () =>
openAssetFormPanel(context, ref, assetId: widget.assetId), openAssetForm(context, ref, assetId: widget.assetId),
icon: const Icon(Icons.edit_outlined), icon: const Icon(Icons.edit_outlined),
label: const Text('Edit'), label: const Text('Edit'),
), ),
@ -104,13 +131,25 @@ class _AssetDetailScreenState extends ConsumerState<AssetDetailScreen>
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
TabBar( AppSegmentedTabBar(
controller: _tabController, controller: _tabController,
tabs: const [ tabs: const [
Tab(text: 'Overview'), AppSegmentedTab(
Tab(text: 'AMC'), label: 'Overview',
Tab(text: 'Service Visits'), icon: Icons.dashboard_outlined,
Tab(text: 'Insurance'), ),
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), const SizedBox(height: 16),
@ -205,6 +244,16 @@ class _OverviewTab extends ConsumerWidget {
asset.maintenanceInchargeUserId, asset.maintenanceInchargeUserId,
users, 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( return SingleChildScrollView(
child: Center( child: Center(
@ -221,110 +270,183 @@ class _OverviewTab extends ConsumerWidget {
children: [ children: [
Row( Row(
children: [ children: [
Text( Expanded(
'Asset Details', child: Text(
style: theme.textTheme.labelLarge?.copyWith( 'Asset Details',
color: theme.colorScheme.onSurfaceVariant, style: theme.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w600, color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
), ),
), ),
const Spacer(), Container(
TextButton.icon( width: 8,
onPressed: onOpenTransferHistory, height: 8,
icon: const Icon(Icons.history, size: 18), decoration: BoxDecoration(
label: const Text('Transfer History'), 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), const SizedBox(height: 16),
_AssetInfoGrid(
items: [ // 1) Identity
_AssetInfo('Asset Name', asset.assetName), DetailOverviewSection(
_AssetInfo('Asset Code', asset.assetCode ?? ''), title: 'Identity',
_AssetInfo('Category', asset.assetCategoryName ?? ''), child: DetailInfoGrid(
_AssetInfo( items: [
'Subcategory', DetailInfoItem('Asset Name', asset.assetName),
asset.assetSubcategoryName ?? '', DetailInfoItem('Asset Code', asset.assetCode ?? ''),
), DetailInfoItem('Location', asset.locationName ?? ''),
_AssetInfo('Location', asset.locationName ?? ''), DetailInfoItem(
_AssetInfo( 'Asset Category',
'Commencement Date', asset.assetCategoryName ?? '',
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',
), ),
_AssetInfo( DetailInfoItem(
'Next Due Date', 'Subcategory',
asset.maintenance!.nextDueDate != null asset.assetSubcategoryName ?? '',
? dateFormat
.format(asset.maintenance!.nextDueDate!)
: '',
), ),
_AssetInfo( DetailInfoItem(
'Days Until Due', 'Manufacturer',
asset.maintenance!.daysUntilDue?.toString() ?? '', 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 || if (asset.maintenanceFrequencyInDays != null ||
asset.maintenanceChecklistJson?.isNotEmpty == true || asset.maintenanceChecklistJson?.isNotEmpty == true ||
asset.maintenance != null) ...[ asset.maintenance != null) ...[
@ -332,26 +454,48 @@ class _OverviewTab extends ConsumerWidget {
padding: EdgeInsets.symmetric(vertical: 16), padding: EdgeInsets.symmetric(vertical: 16),
child: Divider(height: 1), 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( Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: OutlinedButton.icon( child: OutlinedButton.icon(
onPressed: () async { onPressed: onOpenTransferHistory,
final saved = await openSubmitMaintenancePanel( icon: const Icon(Icons.history, size: 18),
context, label: const Text('Transfer History'),
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'),
), ),
), ),
], ],
@ -360,8 +504,8 @@ class _OverviewTab extends ConsumerWidget {
padding: EdgeInsets.symmetric(vertical: 20), padding: EdgeInsets.symmetric(vertical: 20),
child: Divider(height: 1), child: Divider(height: 1),
), ),
_AssetInfoGrid( DetailInfoGrid(
items: [_AssetInfo('Remarks', asset.remarks!)], items: [DetailInfoItem('Remarks', asset.remarks!)],
), ),
], ],
], ],
@ -398,86 +542,352 @@ class _OverviewTab extends ConsumerWidget {
} }
} }
class _AssetInfoGrid extends StatelessWidget { String _purchaseWarrantyRange({
const _AssetInfoGrid({required this.items}); required DateTime? purchaseDate,
required DateTime? warrantyExpiryDate,
final List<_AssetInfo> items; required DateFormat dateFormat,
static const int _columns = 3; }) {
final purchase =
@override purchaseDate != null ? dateFormat.format(purchaseDate) : '';
Widget build(BuildContext context) { final warranty =
return LayoutBuilder( warrantyExpiryDate != null ? dateFormat.format(warrantyExpiryDate) : '';
builder: (context, constraints) { return '$purchase - $warranty';
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(),
);
},
);
}
} }
class _AssetDetailTile extends StatelessWidget { class _DocumentNumberLink extends StatelessWidget {
const _AssetDetailTile({ const _DocumentNumberLink({
required this.label, required this.label,
this.value, required this.enabled,
this.valueWidget, required this.onTap,
}); });
final String label; final String? label;
final String? value; final bool enabled;
final Widget? valueWidget; final VoidCallback onTap;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final text = label?.trim().isNotEmpty == true ? label!.trim() : '';
return Column( if (!enabled) {
crossAxisAlignment: CrossAxisAlignment.start, return Text(
children: [ text,
Text( style: theme.textTheme.titleSmall?.copyWith(
label, color: theme.colorScheme.onSurface,
style: theme.textTheme.bodySmall?.copyWith( fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
), ),
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 { Widget _nextDueDateValue({
const _AssetInfo(this.label, this.value) : valueWidget = null; 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; return Text.rich(
final String? value; TextSpan(
final Widget? valueWidget; 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) { String _userLabel(int? userId, List<FilterOptionModel> users) {

File diff suppressed because it is too large Load Diff

View File

@ -22,14 +22,16 @@ import '../../../../shared/widgets/app_responsive_filter_bar.dart';
import '../../../../shared/widgets/app_search_filter_toggle.dart'; import '../../../../shared/widgets/app_search_filter_toggle.dart';
import '../../../../shared/widgets/app_status_chip.dart'; import '../../../../shared/widgets/app_status_chip.dart';
import '../../../../shared/widgets/app_table_action_icon.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/widgets/can_permission.dart';
import '../../../../shared/providers/table_column_prefs_provider.dart';
import '../../../../shared/widgets/error_view.dart'; import '../../../../shared/widgets/error_view.dart';
import '../../../../shared/widgets/page_header.dart'; import '../../../../shared/widgets/page_header.dart';
import '../../../../shared/utils/file_download_helper.dart'; import '../../../../shared/utils/file_download_helper.dart';
import '../providers/asset_categories_provider.dart'; import '../providers/asset_categories_provider.dart';
import '../providers/asset_form_lookups_provider.dart'; import '../providers/asset_form_lookups_provider.dart';
import '../providers/assets_provider.dart'; import '../providers/assets_provider.dart';
import '../widgets/asset_form_panel.dart'; import 'asset_form_screen.dart';
import '../../../../shared/widgets/app_toast.dart'; import '../../../../shared/widgets/app_toast.dart';
class AssetListScreen extends ConsumerStatefulWidget { class AssetListScreen extends ConsumerStatefulWidget {
@ -45,6 +47,10 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final assetsAsync = ref.watch(assetsListProvider); 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 canEdit = ref.can('assets', PermissionAction.update);
final canDelete = ref.can('assets', PermissionAction.delete); final canDelete = ref.can('assets', PermissionAction.delete);
final canExport = ref.can('assets', PermissionAction.export); final canExport = ref.can('assets', PermissionAction.export);
@ -69,10 +75,8 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
onRetry: () => ref.invalidate(assetsListProvider), onRetry: () => ref.invalidate(assetsListProvider),
), ),
data: (state) { data: (state) {
final allCategories = final allLocations = filterLookups?.locations ?? const [];
ref.watch(itemCategoriesProvider).valueOrNull ?? []; final statuses = filterLookups?.statuses ?? const [];
final lookups = ref.watch(assetFormLookupsProvider).valueOrNull;
final allLocations = lookups?.locations ?? const [];
final notifier = ref.read(assetsListProvider.notifier); final notifier = ref.read(assetsListProvider.notifier);
return Column( return Column(
@ -89,6 +93,11 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
AppTableColumnSelectorButton(
tableId: _AssetDataTable.tableId,
columns: _AssetDataTable.columnOptions,
),
const SizedBox(width: 8),
if (canExport) if (canExport)
OutlinedButton.icon( OutlinedButton.icon(
onPressed: state.isExporting ? null : _exportAssets, onPressed: state.isExporting ? null : _exportAssets,
@ -112,7 +121,7 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
module: 'assets', module: 'assets',
action: PermissionAction.create, action: PermissionAction.create,
child: ElevatedButton.icon( child: ElevatedButton.icon(
onPressed: () => openAssetFormPanel(context, ref), onPressed: () => openAssetForm(context, ref),
icon: const Icon(Icons.add), icon: const Icon(Icons.add),
label: const Text('Add Asset'), label: const Text('Add Asset'),
), ),
@ -151,40 +160,27 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
query: state.query, query: state.query,
categories: allCategories, categories: allCategories,
locations: allLocations, locations: allLocations,
statuses: lookups?.statuses ?? const [], statuses: statuses,
onSearch: notifier.setSearch, onSearch: notifier.setSearch,
onCategoryChanged: notifier.setCategoryFilter, onCategoryChanged: notifier.setCategoryFilter,
onLocationChanged: notifier.setLocationFilter, onLocationChanged: notifier.setLocationFilter,
onStatusChanged: notifier.setStatusFilter, onStatusChanged: notifier.setStatusFilter,
), ),
), ),
if (state.assets.isEmpty) Expanded(
Expanded( child: _AssetDataTable(
child: Center( assets: state.assets,
child: Text( canEdit: canEdit,
'No assets found', canDelete: canDelete,
style: Theme.of(context) onView: _viewAsset,
.textTheme onEdit: _editAsset,
.bodyLarge onDelete: _deleteAsset,
?.copyWith( onEnsureFullDataset: () =>
color: Theme.of(context) notifier.ensureColumnSearchDataset(),
.colorScheme onColumnSearchCleared: () =>
.onSurfaceVariant, notifier.clearColumnSearchDataset(),
),
),
),
)
else
Expanded(
child: _AssetDataTable(
assets: state.assets,
canEdit: canEdit,
canDelete: canDelete,
onView: _viewAsset,
onEdit: _editAsset,
onDelete: _deleteAsset,
),
), ),
),
const Divider(height: 1), const Divider(height: 1),
Padding( Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
@ -193,6 +189,7 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
totalPages: state.totalPages, totalPages: state.totalPages,
totalItems: state.total, totalItems: state.total,
pageSize: state.query.limit, pageSize: state.query.limit,
itemsOnPage: state.assets.length,
itemLabel: 'assets', itemLabel: 'assets',
onPageChanged: notifier.setPage, onPageChanged: notifier.setPage,
onPageSizeChanged: notifier.setPageSize, onPageSizeChanged: notifier.setPageSize,
@ -215,7 +212,7 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
} }
void _editAsset(AssetModel asset) { void _editAsset(AssetModel asset) {
openAssetFormPanel(context, ref, assetId: asset.id); openAssetForm(context, ref, assetId: asset.id);
} }
Future<void> _exportAssets() async { Future<void> _exportAssets() async {
@ -381,7 +378,7 @@ class _AssetsFilterBar extends StatelessWidget {
} }
} }
class _AssetDataTable extends StatelessWidget { class _AssetDataTable extends ConsumerWidget {
const _AssetDataTable({ const _AssetDataTable({
required this.assets, required this.assets,
required this.canEdit, required this.canEdit,
@ -389,96 +386,177 @@ class _AssetDataTable extends StatelessWidget {
required this.onView, required this.onView,
required this.onEdit, required this.onEdit,
required this.onDelete, 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 List<AssetModel> assets;
final bool canEdit; final bool canEdit;
final bool canDelete; final bool canDelete;
final void Function(AssetModel asset) onView; final void Function(AssetModel asset) onView;
final void Function(AssetModel asset) onEdit; final void Function(AssetModel asset) onEdit;
final Future<void> Function(AssetModel asset) onDelete; 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 @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>( return AppDataTable<AssetModel>(
wrapInCard: false, wrapInCard: false,
columns: [ onEnsureFullDataset: onEnsureFullDataset,
AppDataColumn( onColumnSearchCleared: onColumnSearchCleared,
label: 'Asset Code', columns: columns,
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),
),
],
),
),
],
rows: assets, rows: assets,
); );
} }
@ -540,7 +618,10 @@ class _AssetMobileList extends StatelessWidget {
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
if (asset.assetCode != null && asset.assetCode!.isNotEmpty) if (asset.assetCode != null && asset.assetCode!.isNotEmpty)
_AssetCodeBadge(code: asset.assetCode!) AppTableCell.link(
asset.assetCode!,
onTap: () => onView(asset),
)
else else
const Text(''), const Text(''),
Text('${asset.assetCategoryName ?? ''} · ${asset.locationName ?? ''}'), 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,
),
),
);
}
}

View File

@ -8,23 +8,44 @@ import '../../../../core/errors/failure.dart';
import '../../../../core/utils/formatters.dart'; import '../../../../core/utils/formatters.dart';
import '../../../../core/utils/responsive_utils.dart'; import '../../../../core/utils/responsive_utils.dart';
import '../../../../shared/models/asset_model.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_data_table.dart';
import '../../../../shared/widgets/app_empty_state.dart'; import '../../../../shared/widgets/app_empty_state.dart';
import '../../../../shared/widgets/app_form_toggle_field.dart'; import '../../../../shared/widgets/app_form_toggle_field.dart';
import '../../../../shared/widgets/app_loading_view.dart'; import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_pagination.dart'; import '../../../../shared/widgets/app_pagination.dart';
import '../../../../shared/widgets/app_table_action_icon.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/app_toast.dart';
import '../../../../shared/widgets/error_view.dart'; import '../../../../shared/widgets/error_view.dart';
import '../../../../shared/widgets/page_header.dart'; import '../../../../shared/widgets/page_header.dart';
import '../providers/assets_provider.dart'; import '../providers/assets_provider.dart';
import '../utils/maintenance_due_display.dart';
import '../widgets/asset_maintenance_panel.dart'; import '../widgets/asset_maintenance_panel.dart';
class AssetMaintenanceScreen extends ConsumerWidget { class AssetMaintenanceScreen extends ConsumerStatefulWidget {
const AssetMaintenanceScreen({super.key}); const AssetMaintenanceScreen({super.key});
@override @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); final maintenanceAsync = ref.watch(myMaintenanceProvider);
return Padding( return Padding(
@ -53,7 +74,10 @@ class _MyMaintenanceBody extends ConsumerWidget {
AssetModel asset, AssetModel asset,
) async { ) async {
final saved = await openSubmitMaintenancePanel(context, ref, asset: asset); 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( showAppToastFromSnackBar(
context, context,
const SnackBar(content: Text('Maintenance log submitted')), const SnackBar(content: Text('Maintenance log submitted')),
@ -73,6 +97,11 @@ class _MyMaintenanceBody extends ConsumerWidget {
title: 'My Maintenance', title: 'My Maintenance',
subtitle: 'Assets assigned to you for checklist-based maintenance', subtitle: 'Assets assigned to you for checklist-based maintenance',
actions: [ actions: [
AppTableColumnSelectorButton(
tableId: _MaintenanceTable.tableId,
columns: _MaintenanceTable.columnOptions,
),
const SizedBox(width: 8),
OutlinedButton.icon( OutlinedButton.icon(
onPressed: () => context.go(RouteConstants.assets), onPressed: () => context.go(RouteConstants.assets),
icon: const Icon(Icons.inventory_2_outlined), icon: const Icon(Icons.inventory_2_outlined),
@ -142,6 +171,7 @@ class _MyMaintenanceBody extends ConsumerWidget {
totalPages: state.totalPages, totalPages: state.totalPages,
totalItems: state.total, totalItems: state.total,
pageSize: state.limit, pageSize: state.limit,
itemsOnPage: state.assets.length,
onPageChanged: notifier.setPage, onPageChanged: notifier.setPage,
onPageSizeChanged: notifier.setPageSize, onPageSizeChanged: notifier.setPageSize,
itemLabel: 'assets', itemLabel: 'assets',
@ -151,80 +181,116 @@ class _MyMaintenanceBody extends ConsumerWidget {
} }
} }
class _MaintenanceTable extends StatelessWidget { class _MaintenanceTable extends ConsumerWidget {
const _MaintenanceTable({ const _MaintenanceTable({
required this.assets, required this.assets,
required this.onSubmit, required this.onSubmit,
required this.onView, 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 List<AssetModel> assets;
final void Function(AssetModel asset) onSubmit; final void Function(AssetModel asset) onSubmit;
final void Function(AssetModel asset) onView; final void Function(AssetModel asset) onView;
@override List<AppDataColumn<AssetModel>> _allColumns() {
Widget build(BuildContext context) {
final dateFormat = DateFormat('dd MMM yyyy'); 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>( return AppDataTable<AssetModel>(
wrapInCard: false, wrapInCard: false,
columns: [ columns: 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),
),
],
),
),
],
rows: assets, rows: assets,
); );
} }
@ -326,16 +392,10 @@ class _DueBadge extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final color = isDue final due = MaintenanceDueDisplay.fromDaysUntilDue(daysUntilDue);
? const Color(0xFFDC2626) final color = due?.color ??
: const Color(0xFF16A34A); (isDue ? const Color(0xFFDC2626) : const Color(0xFF16A34A));
final label = isDue final label = due?.label ?? (isDue ? 'Due' : 'On track');
? (daysUntilDue != null && daysUntilDue! < 0
? 'Overdue'
: 'Due')
: (daysUntilDue != null
? 'In $daysUntilDue day${daysUntilDue == 1 ? '' : 's'}'
: 'On track');
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),

View File

@ -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

View File

@ -4,6 +4,7 @@ import 'package:intl/intl.dart';
import '../../../../core/utils/formatters.dart'; import '../../../../core/utils/formatters.dart';
import '../../../../shared/models/asset_model.dart'; import '../../../../shared/models/asset_model.dart';
import '../../../../shared/widgets/api_feedback.dart';
import '../../../../shared/widgets/app_button.dart'; import '../../../../shared/widgets/app_button.dart';
import '../../../../shared/widgets/app_date_popup.dart'; import '../../../../shared/widgets/app_date_popup.dart';
import '../../../../shared/widgets/app_dropdown.dart'; import '../../../../shared/widgets/app_dropdown.dart';
@ -45,13 +46,14 @@ class SubmitMaintenanceLogPanel extends ConsumerStatefulWidget {
class _ChecklistRowState { class _ChecklistRowState {
_ChecklistRowState({ _ChecklistRowState({
required this.keyName,
required this.label, required this.label,
}); required this.required,
}) : status = required ? null : 'OK';
final String keyName;
final String label; 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(); final TextEditingController remarksController = TextEditingController();
void dispose() => remarksController.dispose(); void dispose() => remarksController.dispose();
@ -64,20 +66,17 @@ class _SubmitMaintenanceLogPanelState
DateTime _performedDate = DateTime.now(); DateTime _performedDate = DateTime.now();
late final List<_ChecklistRowState> _rows; late final List<_ChecklistRowState> _rows;
bool _isSubmitting = false; bool _isSubmitting = false;
bool _showLogs = false;
List<AssetMaintenanceLogModel>? _logs;
bool _logsLoading = false;
String? _logsError;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
final checklist = checklistForAsset(widget.asset); final checklist = checklistForAsset(widget.asset);
_rows = checklist _rows = checklist
.where((item) => item.label.trim().isNotEmpty)
.map( .map(
(item) => _ChecklistRowState( (item) => _ChecklistRowState(
keyName: item.key.isNotEmpty ? item.key : item.label, label: item.label.trim(),
label: item.label.isNotEmpty ? item.label : item.key, required: item.required,
), ),
) )
.toList(); .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 { Future<void> _save() async {
if (!_formKey.currentState!.validate()) return; if (!_formKey.currentState!.validate()) return;
if (_rows.isEmpty) { if (_rows.isEmpty) {
@ -133,17 +111,34 @@ class _SubmitMaintenanceLogPanelState
return; 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); setState(() => _isSubmitting = true);
try { try {
final payload = <String, dynamic>{ final payload = <String, dynamic>{
'performed_date': DateFormatter.toApiDate(_performedDate), 'performed_date': DateFormatter.toApiDate(_performedDate),
'checklist_json': _rows 'checklist_json': _rows
.map( .map(
(row) => { (row) {
'key': row.keyName, final remarks = row.remarksController.text.trim();
'status': row.status, return <String, dynamic>{
if (row.remarksController.text.trim().isNotEmpty) 'label': row.label,
'remarks': row.remarksController.text.trim(), 'status': row.status,
'remarks': remarks.isEmpty ? null : remarks,
'required': row.required,
};
}, },
) )
.toList(), .toList(),
@ -162,7 +157,7 @@ class _SubmitMaintenanceLogPanelState
if (mounted) Navigator.of(context, rootNavigator: true).pop(true); if (mounted) Navigator.of(context, rootNavigator: true).pop(true);
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
showSidePanelSnackBar(context, e.toString()); showSidePanelApiError(context, e);
} }
} finally { } finally {
if (mounted) setState(() => _isSubmitting = false); if (mounted) setState(() => _isSubmitting = false);
@ -263,61 +258,7 @@ class _SubmitMaintenanceLogPanelState
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Row( AssetRecentMaintenanceLogsSection(assetId: widget.asset.id),
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),
),
),
],
], ],
), ),
), ),
@ -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 { class _DateField extends StatelessWidget {
const _DateField({ const _DateField({
required this.label, required this.label,
@ -376,27 +444,37 @@ class _ChecklistItemCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Text( Text(
row.label, row.required ? '${row.label} *' : row.label,
style: theme.textTheme.bodyMedium?.copyWith( style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
AppDropdown<String>( AppDropdown<String>(
label: 'Status', label: row.required ? 'Status *' : 'Status',
isDense: true, isDense: true,
value: row.status, value: row.status,
hint: 'Select status',
options: const [ options: const [
AppDropdownOption(value: 'OK', label: 'OK'), AppDropdownOption(value: 'OK', label: 'OK'),
AppDropdownOption(value: 'NOT_OK', label: 'Not OK'), AppDropdownOption(value: 'NOT_OK', label: 'Not OK'),
AppDropdownOption(value: 'NA', label: 'N/A'), AppDropdownOption(value: 'NA', label: 'N/A'),
], ],
onChanged: onStatusChanged, onChanged: onStatusChanged,
validator: row.required
? (v) =>
(v == null || v.trim().isEmpty) ? 'Status is required' : null
: null,
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
AppTextField( AppTextField(
controller: row.remarksController, 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, spacing: 6,
runSpacing: 4, runSpacing: 4,
children: log.checklistJson.map((item) { children: log.checklistJson.map((item) {
final key = item['key']?.toString() ?? 'Item'; final label = item['label']?.toString().trim();
final status = item['status']?.toString() ?? ''; 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( return Chip(
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
label: Text( label: Text(
'$key: $status', chipText,
style: theme.textTheme.labelSmall, style: theme.textTheme.labelSmall,
), ),
padding: EdgeInsets.zero, padding: EdgeInsets.zero,

View File

@ -5,6 +5,8 @@ import '../../../../core/errors/failure.dart';
import '../../../../core/utils/formatters.dart'; import '../../../../core/utils/formatters.dart';
import '../../../../core/utils/validators.dart'; import '../../../../core/utils/validators.dart';
import '../../../../shared/models/asset_model.dart'; import '../../../../shared/models/asset_model.dart';
import '../../../../shared/models/user_management_models.dart'
show FilterOptionModel;
import '../../../../shared/widgets/api_feedback.dart'; import '../../../../shared/widgets/api_feedback.dart';
import '../../../../shared/widgets/app_button.dart'; import '../../../../shared/widgets/app_button.dart';
import '../../../../shared/widgets/app_card.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_side_panel.dart';
import '../../../../shared/widgets/app_text_field.dart'; import '../../../../shared/widgets/app_text_field.dart';
import '../../../../shared/widgets/error_view.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/asset_form_lookups_provider.dart';
import '../providers/assets_provider.dart'; import '../providers/assets_provider.dart';
@ -83,7 +84,8 @@ class _AddAmcPanelState extends ConsumerState<AddAmcPanel> {
_endDate = contract.endDate; _endDate = contract.endDate;
_renewalDate = contract.renewalDate; _renewalDate = contract.renewalDate;
if (contract.annualCost != null) { if (contract.annualCost != null) {
_annualCostController.text = contract.annualCost.toString(); _annualCostController.text =
CurrencyFormatter.formatEditable(contract.annualCost);
} }
_paymentFrequency = contract.paymentFrequency; _paymentFrequency = contract.paymentFrequency;
_serviceFrequency = contract.serviceFrequency; _serviceFrequency = contract.serviceFrequency;
@ -100,7 +102,7 @@ class _AddAmcPanelState extends ConsumerState<AddAmcPanel> {
} }
Map<String, dynamic> _buildPayload() { Map<String, dynamic> _buildPayload() {
final annualCost = double.tryParse(_annualCostController.text.trim()); final annualCost = CurrencyFormatter.tryParse(_annualCostController.text);
final visitsPerYear = int.tryParse(_visitsPerYearController.text.trim()); final visitsPerYear = int.tryParse(_visitsPerYearController.text.trim());
return { return {
'vendor_id': _vendorId, 'vendor_id': _vendorId,
@ -136,12 +138,20 @@ class _AddAmcPanelState extends ConsumerState<AddAmcPanel> {
Future<void> _pickDate({ Future<void> _pickDate({
required DateTime? current, required DateTime? current,
required void Function(DateTime date) onPicked, required void Function(DateTime date) onPicked,
DateTime? firstDate,
DateTime? lastDate,
}) async { }) 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( final picked = await showAppDatePopup(
context: context, context: context,
initialDate: current ?? DateTime.now(), initialDate: initial,
firstDate: DateTime(2000), firstDate: first,
lastDate: DateTime(2100), lastDate: last,
helpText: 'Select date', helpText: 'Select date',
); );
if (picked != null) { 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 { Future<void> _save() async {
if (!_formKey.currentState!.validate()) return; if (!_formKey.currentState!.validate()) return;
if (_startDate == null || _endDate == null) { if (_startDate == null || _endDate == null) {
showSidePanelSnackBar(context, 'Please select start and end dates'); showSidePanelSnackBar(context, 'Please select start and end dates');
return; 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) { if (_vendorId == null) {
showSidePanelSnackBar(context, 'Please select a vendor'); showSidePanelSnackBar(context, 'Please select a vendor');
@ -173,7 +209,7 @@ class _AddAmcPanelState extends ConsumerState<AddAmcPanel> {
if (mounted) Navigator.of(context, rootNavigator: true).pop(true); if (mounted) Navigator.of(context, rootNavigator: true).pop(true);
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
showSidePanelSnackBar(context, e.toString()); showSidePanelApiError(context, e);
} }
} finally { } finally {
if (mounted) setState(() => _isSubmitting = false); if (mounted) setState(() => _isSubmitting = false);
@ -263,7 +299,7 @@ class _AddAmcPanelState extends ConsumerState<AddAmcPanel> {
value: _startDate, value: _startDate,
onPick: () => _pickDate( onPick: () => _pickDate(
current: _startDate, current: _startDate,
onPicked: (date) => setState(() => _startDate = date), onPicked: _onStartDatePicked,
), ),
), ),
right: _SidePanelDateField( right: _SidePanelDateField(
@ -272,7 +308,8 @@ class _AddAmcPanelState extends ConsumerState<AddAmcPanel> {
value: _endDate, value: _endDate,
onPick: () => _pickDate( onPick: () => _pickDate(
current: _endDate, 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, value: _renewalDate,
onPick: () => _pickDate( onPick: () => _pickDate(
current: _renewalDate, current: _renewalDate,
onPicked: (date) => setState(() => _renewalDate = date), onPicked: (date) => _renewalDate = date,
), ),
), ),
right: AppTextField( right: AppTextField(
controller: _annualCostController, controller: _annualCostController,
label: 'Annual Cost', label: 'Annual Cost',
keyboardType: const TextInputType.numberWithOptions(decimal: true), keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: CurrencyFormatter.amountInput,
validator: (v) => Validators.optionalNonNegativeDouble(
v,
fieldName: 'Annual Cost',
),
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@ -386,7 +428,7 @@ class _AddAmcPanelState extends ConsumerState<AddAmcPanel> {
footer: _panelFooter( footer: _panelFooter(
context, context,
isSubmitting: true, isSubmitting: true,
saveLabel: 'Edit AMC Contract', saveLabel: 'Update AMC Contract',
onSave: () {}, onSave: () {},
), ),
child: const Center(child: CircularProgressIndicator()), child: const Center(child: CircularProgressIndicator()),
@ -402,7 +444,7 @@ class _AddAmcPanelState extends ConsumerState<AddAmcPanel> {
footer: _panelFooter( footer: _panelFooter(
context, context,
isSubmitting: _isSubmitting, isSubmitting: _isSubmitting,
saveLabel: 'Edit AMC Contract', saveLabel: 'Update AMC Contract',
onSave: _save, onSave: _save,
), ),
child: _buildForm(), child: _buildForm(),
@ -416,7 +458,7 @@ class _AddAmcPanelState extends ConsumerState<AddAmcPanel> {
footer: _panelFooter( footer: _panelFooter(
context, context,
isSubmitting: _isSubmitting, isSubmitting: _isSubmitting,
saveLabel: 'Add AMC Contract', saveLabel: 'Save AMC Contract',
onSave: _save, onSave: _save,
), ),
child: _buildForm(), child: _buildForm(),
@ -500,9 +542,10 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
_downtimeHoursController.text = visit.downtimeHours.toString(); _downtimeHoursController.text = visit.downtimeHours.toString();
} }
if (visit.serviceCost != null) { 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; _assetConditionAfter = visit.assetConditionAfter;
_remarksController.text = visit.remarks ?? ''; _remarksController.text = visit.remarks ?? '';
} }
@ -524,12 +567,16 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
} }
Map<String, dynamic> _buildPayload() { Map<String, dynamic> _buildPayload() {
final downtimeHours = double.tryParse(_downtimeHoursController.text.trim()); final downtimeHours =
final serviceCost = double.tryParse(_serviceCostController.text.trim()); CurrencyFormatter.tryParse(_downtimeHoursController.text);
final serviceCost =
CurrencyFormatter.tryParse(_serviceCostController.text);
return { return {
if (_visitType != null && _visitType!.trim().isNotEmpty) 'visit_type': _visitType, if (_visitType != null && _visitType!.trim().isNotEmpty)
'visit_type': _visitType,
'visit_date': DateFormatter.toApiDate(_visitDate!), '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) if (_complaintNoController.text.trim().isNotEmpty)
'complaint_no': _complaintNoController.text.trim(), 'complaint_no': _complaintNoController.text.trim(),
if (_complaintDate != null) if (_complaintDate != null)
@ -551,7 +598,8 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
if (downtimeHours != null) 'downtime_hours': downtimeHours, if (downtimeHours != null) 'downtime_hours': downtimeHours,
if (serviceCost != null) 'service_cost': serviceCost, if (serviceCost != null) 'service_cost': serviceCost,
'is_under_amc': _isUnderAmc, 'is_under_amc': _isUnderAmc,
if (_assetConditionAfter != null && _assetConditionAfter!.trim().isNotEmpty) if (_assetConditionAfter != null &&
_assetConditionAfter!.trim().isNotEmpty)
'asset_condition_after': _assetConditionAfter, 'asset_condition_after': _assetConditionAfter,
if (_remarksController.text.trim().isNotEmpty) if (_remarksController.text.trim().isNotEmpty)
'remarks': _remarksController.text.trim(), 'remarks': _remarksController.text.trim(),
@ -577,16 +625,67 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
if (mounted) Navigator.of(context, rootNavigator: true).pop(true); if (mounted) Navigator.of(context, rootNavigator: true).pop(true);
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
showSidePanelSnackBar(context, e.toString()); showSidePanelApiError(context, e);
} }
} finally { } finally {
if (mounted) setState(() => _isSubmitting = false); 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) { Widget _buildForm(List<AmcContractModel> amcContracts) {
final lookupsAsync = ref.watch(assetFormLookupsProvider); final lookupsAsync = ref.watch(assetFormLookupsProvider);
final options = lookupsAsync.valueOrNull?.options ?? const AssetDropdownOptionsModel(); final options =
lookupsAsync.valueOrNull?.options ?? const AssetDropdownOptionsModel();
if (lookupsAsync.hasValue) { if (lookupsAsync.hasValue) {
final nextVisitType = resolveAssetOptionValue( final nextVisitType = resolveAssetOptionValue(
@ -623,6 +722,7 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
// 1) Visit classification
SidePanelFormRow( SidePanelFormRow(
left: AppSearchableDropdown<String>( left: AppSearchableDropdown<String>(
label: 'Visit Type *', label: 'Visit Type *',
@ -655,24 +755,35 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
onPicked: (date) => setState(() => _visitDate = date), onPicked: (date) => setState(() => _visitDate = date),
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 8),
AppSearchableDropdown<int>(
label: 'AMC Contract', // 2) Contract & Vendor
value: _amcContractId, AppFormToggleField(
searchHint: 'Search AMC contract...', label: 'Under AMC',
options: amcContracts value: _isUnderAmc,
.map((contract) { onChanged: _setUnderAmc,
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),
), ),
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), const SizedBox(height: 12),
SidePanelFormRow( SidePanelFormRow(
left: lookupsAsync.when( left: lookupsAsync.when(
@ -685,13 +796,10 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
label: 'Vendor', label: 'Vendor',
value: _vendorId, value: _vendorId,
searchHint: 'Search vendor...', searchHint: 'Search vendor...',
options: lookups.vendors options: _vendorOptions(
.map((vendor) => AppDropdownOption( vendors: lookups.vendors,
value: int.tryParse(vendor.id) ?? 0, amcContracts: amcContracts,
label: vendor.name, ),
))
.where((option) => option.value != 0)
.toList(),
onChanged: (v) => setState(() => _vendorId = v), onChanged: (v) => setState(() => _vendorId = v),
), ),
), ),
@ -704,6 +812,8 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
onChanged: (v) => setState(() => _assetConditionAfter = v), onChanged: (v) => setState(() => _assetConditionAfter = v),
), ),
), ),
// 3) Complaint intake
SidePanelFormRow( SidePanelFormRow(
left: AppTextField( left: AppTextField(
controller: _complaintNoController, controller: _complaintNoController,
@ -724,6 +834,8 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
maxLines: 3, maxLines: 3,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
// 4) Engineer & work performed
SidePanelFormRow( SidePanelFormRow(
left: AppTextField( left: AppTextField(
controller: _engineerNameController, controller: _engineerNameController,
@ -749,6 +861,8 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
maxLines: 3, maxLines: 3,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
// 5) Outcome & cost
SidePanelFormRow( SidePanelFormRow(
left: _SidePanelDateField( left: _SidePanelDateField(
label: 'Next Service Date', label: 'Next Service Date',
@ -761,7 +875,8 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
right: AppTextField( right: AppTextField(
controller: _downtimeHoursController, controller: _downtimeHoursController,
label: 'Downtime Hours', label: 'Downtime Hours',
keyboardType: const TextInputType.numberWithOptions(decimal: true), keyboardType:
const TextInputType.numberWithOptions(decimal: true),
validator: (v) => Validators.optionalPositiveDouble( validator: (v) => Validators.optionalPositiveDouble(
v, v,
fieldName: 'Downtime Hours', fieldName: 'Downtime Hours',
@ -772,6 +887,7 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
controller: _serviceCostController, controller: _serviceCostController,
label: 'Service Cost', label: 'Service Cost',
keyboardType: const TextInputType.numberWithOptions(decimal: true), keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: CurrencyFormatter.amountInput,
validator: (v) => Validators.optionalPositiveDouble( validator: (v) => Validators.optionalPositiveDouble(
v, v,
fieldName: 'Service Cost', fieldName: 'Service Cost',
@ -783,12 +899,6 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
label: 'Remarks', label: 'Remarks',
maxLines: 3, 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( footer: _panelFooter(
context, context,
isSubmitting: true, isSubmitting: true,
saveLabel: 'Edit Service Visit', saveLabel: 'Update Service Visit',
onSave: () {}, onSave: () {},
), ),
child: const Center(child: CircularProgressIndicator()), child: const Center(child: CircularProgressIndicator()),
@ -825,7 +935,7 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
footer: _panelFooter( footer: _panelFooter(
context, context,
isSubmitting: _isSubmitting, isSubmitting: _isSubmitting,
saveLabel: 'Edit Service Visit', saveLabel: 'Update Service Visit',
onSave: _save, onSave: _save,
), ),
child: _buildForm(widget.amcContracts), child: _buildForm(widget.amcContracts),
@ -911,10 +1021,12 @@ class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
_insurerEmailController.text = policy.insurerEmail ?? ''; _insurerEmailController.text = policy.insurerEmail ?? '';
_policyType = policy.policyType; _policyType = policy.policyType;
if (policy.sumInsured != null) { if (policy.sumInsured != null) {
_sumInsuredController.text = policy.sumInsured.toString(); _sumInsuredController.text =
CurrencyFormatter.formatEditable(policy.sumInsured);
} }
if (policy.annualPremium != null) { if (policy.annualPremium != null) {
_annualPremiumController.text = policy.annualPremium.toString(); _annualPremiumController.text =
CurrencyFormatter.formatEditable(policy.annualPremium);
} }
_startDate = policy.policyStartDate; _startDate = policy.policyStartDate;
_endDate = policy.policyEndDate; _endDate = policy.policyEndDate;
@ -929,12 +1041,20 @@ class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
Future<void> _pickDate({ Future<void> _pickDate({
required DateTime? current, required DateTime? current,
required void Function(DateTime date) onPicked, required void Function(DateTime date) onPicked,
DateTime? firstDate,
DateTime? lastDate,
}) async { }) 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( final picked = await showAppDatePopup(
context: context, context: context,
initialDate: current ?? DateTime.now(), initialDate: initial,
firstDate: DateTime(2000), firstDate: first,
lastDate: DateTime(2100), lastDate: last,
helpText: 'Select date', helpText: 'Select date',
); );
if (picked != null) { 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() { Map<String, dynamic> _buildPayload() {
final sumInsured = double.tryParse(_sumInsuredController.text.trim()); final sumInsured =
final annualPremium = double.tryParse(_annualPremiumController.text.trim()); CurrencyFormatter.tryParse(_sumInsuredController.text);
final annualPremium =
CurrencyFormatter.tryParse(_annualPremiumController.text);
return { return {
'policy_no': _policyNoController.text.trim(), 'policy_no': _policyNoController.text.trim(),
'insurer_name': _insurerNameController.text.trim(), 'insurer_name': _insurerNameController.text.trim(),
@ -978,6 +1117,15 @@ class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
showSidePanelSnackBar(context, 'Please select start and end dates'); showSidePanelSnackBar(context, 'Please select start and end dates');
return; 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); setState(() => _isSubmitting = true);
try { try {
@ -991,7 +1139,7 @@ class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
if (mounted) Navigator.of(context, rootNavigator: true).pop(true); if (mounted) Navigator.of(context, rootNavigator: true).pop(true);
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
showSidePanelSnackBar(context, e.toString()); showSidePanelApiError(context, e);
} }
} finally { } finally {
if (mounted) setState(() => _isSubmitting = false); if (mounted) setState(() => _isSubmitting = false);
@ -1073,6 +1221,7 @@ class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
controller: _sumInsuredController, controller: _sumInsuredController,
label: 'Sum Insured', label: 'Sum Insured',
keyboardType: const TextInputType.numberWithOptions(decimal: true), keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: CurrencyFormatter.amountInput,
validator: (v) => Validators.optionalNonNegativeDouble( validator: (v) => Validators.optionalNonNegativeDouble(
v, v,
fieldName: 'Sum Insured', fieldName: 'Sum Insured',
@ -1084,6 +1233,7 @@ class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
controller: _annualPremiumController, controller: _annualPremiumController,
label: 'Annual Premium', label: 'Annual Premium',
keyboardType: const TextInputType.numberWithOptions(decimal: true), keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: CurrencyFormatter.amountInput,
validator: (v) => Validators.optionalNonNegativeDouble( validator: (v) => Validators.optionalNonNegativeDouble(
v, v,
fieldName: 'Annual Premium', fieldName: 'Annual Premium',
@ -1097,7 +1247,7 @@ class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
value: _startDate, value: _startDate,
onPick: () => _pickDate( onPick: () => _pickDate(
current: _startDate, current: _startDate,
onPicked: (date) => setState(() => _startDate = date), onPicked: _onStartDatePicked,
), ),
), ),
right: _SidePanelDateField( right: _SidePanelDateField(
@ -1106,7 +1256,8 @@ class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
value: _endDate, value: _endDate,
onPick: () => _pickDate( onPick: () => _pickDate(
current: _endDate, 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, value: _renewalDate,
onPick: () => _pickDate( onPick: () => _pickDate(
current: _renewalDate, current: _renewalDate,
onPicked: (date) => setState(() => _renewalDate = date), onPicked: (date) => _renewalDate = date,
), ),
), ),
right: _SidePanelDateField( right: _SidePanelDateField(
@ -1125,7 +1276,7 @@ class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
value: _premiumPaidDate, value: _premiumPaidDate,
onPick: () => _pickDate( onPick: () => _pickDate(
current: _premiumPaidDate, current: _premiumPaidDate,
onPicked: (date) => setState(() => _premiumPaidDate = date), onPicked: (date) => _premiumPaidDate = date,
), ),
), ),
), ),
@ -1171,7 +1322,7 @@ class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
footer: _panelFooter( footer: _panelFooter(
context, context,
isSubmitting: true, isSubmitting: true,
saveLabel: 'Edit Insurance Policy', saveLabel: 'Update Insurance Policy',
onSave: () {}, onSave: () {},
), ),
child: const Center(child: CircularProgressIndicator()), child: const Center(child: CircularProgressIndicator()),
@ -1187,7 +1338,7 @@ class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
footer: _panelFooter( footer: _panelFooter(
context, context,
isSubmitting: _isSubmitting, isSubmitting: _isSubmitting,
saveLabel: 'Edit Insurance Policy', saveLabel: 'Update Insurance Policy',
onSave: _save, onSave: _save,
), ),
child: _buildForm(), child: _buildForm(),
@ -1201,7 +1352,7 @@ class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
footer: _panelFooter( footer: _panelFooter(
context, context,
isSubmitting: _isSubmitting, isSubmitting: _isSubmitting,
saveLabel: 'Add Insurance Policy', saveLabel: 'Save Insurance Policy',
onSave: _save, onSave: _save,
), ),
child: _buildForm(), child: _buildForm(),
@ -1223,7 +1374,6 @@ class _TransferAssetPanelState extends ConsumerState<TransferAssetPanel> {
final _reasonController = TextEditingController(); final _reasonController = TextEditingController();
DateTime _transferDate = DateTime.now(); DateTime _transferDate = DateTime.now();
int? _toLocationId; int? _toLocationId;
int? _toDepartmentId;
int? _toUserId; int? _toUserId;
bool _isSubmitting = false; bool _isSubmitting = false;
@ -1249,10 +1399,7 @@ class _TransferAssetPanelState extends ConsumerState<TransferAssetPanel> {
Future<void> _save() async { Future<void> _save() async {
if (!_formKey.currentState!.validate()) return; if (!_formKey.currentState!.validate()) return;
final hasDestination = final hasDestination = _toLocationId != null || _toUserId != null;
_toLocationId != null ||
_toDepartmentId != null ||
_toUserId != null;
if (!hasDestination) { if (!hasDestination) {
showSidePanelSnackBar( showSidePanelSnackBar(
context, context,
@ -1266,7 +1413,6 @@ class _TransferAssetPanelState extends ConsumerState<TransferAssetPanel> {
await ref.read(assetDetailProvider(widget.assetId).notifier).transferAsset({ await ref.read(assetDetailProvider(widget.assetId).notifier).transferAsset({
'transfer_date': DateFormatter.toApiDate(_transferDate), 'transfer_date': DateFormatter.toApiDate(_transferDate),
if (_toLocationId != null) 'to_location_id': _toLocationId, if (_toLocationId != null) 'to_location_id': _toLocationId,
if (_toDepartmentId != null) 'to_department_id': _toDepartmentId,
if (_toUserId != null) 'to_user_id': _toUserId, if (_toUserId != null) 'to_user_id': _toUserId,
'reason': _reasonController.text.trim(), 'reason': _reasonController.text.trim(),
}); });
@ -1323,25 +1469,6 @@ class _TransferAssetPanelState extends ConsumerState<TransferAssetPanel> {
onChanged: (v) => setState(() => _toLocationId = v), onChanged: (v) => setState(() => _toLocationId = v),
), ),
const SizedBox(height: 12), 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>( AppSearchableDropdown<int>(
label: 'To User', label: 'To User',
value: _toUserId, value: _toUserId,
@ -1423,36 +1550,11 @@ class _TransferHistoryEntry extends StatelessWidget {
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
IntrinsicHeight( _TransferFromToGrid(
child: Row( fromLocation: item.fromLocationName,
crossAxisAlignment: CrossAxisAlignment.center, fromUser: item.fromUserName,
children: [ toLocation: item.toLocationName,
Expanded( toUser: item.toUserName,
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,
),
),
],
),
), ),
if (showReason) ...[ if (showReason) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
@ -1484,46 +1586,86 @@ class _TransferHistoryEntry extends StatelessWidget {
} }
} }
class _TransferLocationBlock extends StatelessWidget { class _TransferFromToGrid extends StatelessWidget {
const _TransferLocationBlock({ const _TransferFromToGrid({
required this.label, this.fromLocation,
this.location, this.fromUser,
this.department, this.toLocation,
this.user, this.toUser,
}); });
final String label; final String? fromLocation;
final String? location; final String? fromUser;
final String? department; final String? toLocation;
final String? user; final String? toUser;
static const _arrowWidth = 34.0;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final labelStyle = theme.textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.w700,
letterSpacing: 0.6,
color: theme.colorScheme.onSurfaceVariant,
);
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Text( Row(
label, children: [
style: theme.textTheme.labelSmall?.copyWith( Expanded(child: Text('From', style: labelStyle)),
fontWeight: FontWeight.w700, const SizedBox(width: _arrowWidth),
letterSpacing: 0.6, Expanded(child: Text('To', style: labelStyle)),
color: theme.colorScheme.onSurfaceVariant, ],
),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
_TransferLocationRow( Row(
icon: Icons.place_outlined, crossAxisAlignment: CrossAxisAlignment.start,
value: location, 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( Row(
icon: Icons.apartment_outlined, crossAxisAlignment: CrossAxisAlignment.start,
value: department, children: [
), Expanded(
_TransferLocationRow( child: _TransferLocationRow(
icon: Icons.person_outline, icon: Icons.person_outline,
value: user, value: fromUser,
),
),
const SizedBox(width: _arrowWidth),
Expanded(
child: _TransferLocationRow(
icon: Icons.person_outline,
value: toUser,
),
),
],
), ),
], ],
); );

View File

@ -1,6 +1,7 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import '../../../../core/constants/api_endpoints.dart'; import '../../../../core/constants/api_endpoints.dart';
import '../../../../core/utils/pagination_meta.dart';
import '../../../../shared/models/audit_log_model.dart'; import '../../../../shared/models/audit_log_model.dart';
import '../../../../shared/models/export_file_result.dart'; import '../../../../shared/models/export_file_result.dart';
@ -32,20 +33,23 @@ class AuditRemoteDataSource {
: <AuditLogEntryModel>[]; : <AuditLogEntryModel>[];
final meta = body['meta'] as Map<String, dynamic>? ?? {}; final meta = body['meta'] as Map<String, dynamic>? ?? {};
final page = (meta['page'] as num?)?.toInt() ?? query.page; final pagination = parsePagination(
final limit = (meta['limit'] as num?)?.toInt() ?? query.limit; body: body,
final total = (meta['total'] as num?)?.toInt() ?? items.length; fallbackPage: query.page,
final totalPages = limit > 0 fallbackLimit: query.limit,
? ((total + limit - 1) ~/ limit).clamp(1, 999999) itemCount: items.length,
: 1; );
final filtersRequired = meta['filters_required'] == true; final filtersRequired = meta['filters_required'] == true;
return AuditLogListResult( return AuditLogListResult(
items: items, items: items,
page: page, page: pagination.page,
limit: limit, limit: query.limit,
total: total, total: pagination.total,
totalPages: totalPages, totalPages: resolveTotalPages(
total: pagination.total,
limit: query.limit,
),
filtersRequired: filtersRequired, filtersRequired: filtersRequired,
); );
} }

View File

@ -1,5 +1,6 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/utils/column_search_paging.dart';
import '../../../../core/utils/table_search.dart'; import '../../../../core/utils/table_search.dart';
import '../../../../shared/models/audit_log_model.dart'; import '../../../../shared/models/audit_log_model.dart';
@ -82,6 +83,8 @@ final auditLogsListProvider =
); );
class AuditLogsListNotifier extends AsyncNotifier<AuditLogsListState> { class AuditLogsListNotifier extends AsyncNotifier<AuditLogsListState> {
final _columnSearch = ColumnSearchPaging();
@override @override
Future<AuditLogsListState> build() async { Future<AuditLogsListState> build() async {
ref.keepAlive(); ref.keepAlive();
@ -148,6 +151,27 @@ class AuditLogsListNotifier extends AsyncNotifier<AuditLogsListState> {
applyQuery(current.query.copyWith(search: TableSearch.normalize(search), page: 1)); 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) { void setPage(int page) {
final current = state.valueOrNull; final current = state.valueOrNull;
if (current == null) return; if (current == null) return;

View File

@ -23,7 +23,9 @@ import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/app_side_panel.dart'; import '../../../../shared/widgets/app_side_panel.dart';
import '../../../../shared/widgets/app_status_chip.dart'; import '../../../../shared/widgets/app_status_chip.dart';
import '../../../../shared/widgets/app_table_action_icon.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/app_table_shell.dart';
import '../../../../shared/providers/table_column_prefs_provider.dart';
import '../../../../shared/widgets/error_view.dart'; import '../../../../shared/widgets/error_view.dart';
import '../../../../shared/widgets/page_header.dart'; import '../../../../shared/widgets/page_header.dart';
import '../providers/audit_provider.dart'; import '../providers/audit_provider.dart';
@ -143,6 +145,11 @@ class _AuditLogsScreenState extends ConsumerState<AuditLogsScreen> {
() => _filtersExpanded = !_filtersExpanded, () => _filtersExpanded = !_filtersExpanded,
), ),
), ),
const SizedBox(width: 8),
AppTableColumnSelectorButton(
tableId: _AuditDataTable.tableId,
columns: _AuditDataTable.columnOptions,
),
if (canExport) ...[ if (canExport) ...[
const SizedBox(width: 8), const SizedBox(width: 8),
OutlinedButton.icon( OutlinedButton.icon(
@ -184,6 +191,7 @@ class _AuditLogsScreenState extends ConsumerState<AuditLogsScreen> {
totalPages: state.totalPages, totalPages: state.totalPages,
totalItems: state.total, totalItems: state.total,
pageSize: state.query.limit, pageSize: state.query.limit,
itemsOnPage: state.items.length,
itemLabel: 'audit logs', itemLabel: 'audit logs',
onPageChanged: notifier.setPage, onPageChanged: notifier.setPage,
onPageSizeChanged: notifier.setPageSize, onPageSizeChanged: notifier.setPageSize,
@ -205,30 +213,35 @@ class _AuditLogsScreenState extends ConsumerState<AuditLogsScreen> {
), ),
], ],
) )
: state.items.isEmpty : context.isMobile
? ListView( ? (state.items.isEmpty
physics: const AlwaysScrollableScrollPhysics(), ? ListView(
children: const [ physics:
SizedBox( const AlwaysScrollableScrollPhysics(),
height: 260, children: const [
child: AppEmptyState( SizedBox(
title: 'No audit logs found', height: 260,
description: child: AppEmptyState(
'Try adjusting filters or expanding the date range.', title: 'No audit logs found',
icon: Icons.history_outlined, description:
), 'Try adjusting filters or expanding the date range.',
), icon: Icons.history_outlined,
], ),
) ),
: context.isMobile ],
? _AuditCardList(
items: state.items,
onView: _viewLog,
) )
: _AuditDataTable( : _AuditCardList(
items: state.items, items: state.items,
onView: _viewLog, onView: _viewLog,
), ))
: _AuditDataTable(
items: state.items,
onView: _viewLog,
onEnsureFullDataset: () =>
notifier.ensureColumnSearchDataset(),
onColumnSearchCleared: () =>
notifier.clearColumnSearchDataset(),
),
), ),
), ),
), ),
@ -291,7 +304,10 @@ class _FiltersBar extends StatelessWidget {
options: [ options: [
const AppDropdownOption(value: null, label: 'All Tables'), const AppDropdownOption(value: null, label: 'All Tables'),
...filters.tableNames.map( ...filters.tableNames.map(
(name) => AppDropdownOption(value: name, label: name), (name) => AppDropdownOption(
value: name,
label: humanizeLabel(name),
),
), ),
], ],
onChanged: onTableChanged, onChanged: onTableChanged,
@ -339,7 +355,7 @@ class _FiltersBar extends StatelessWidget {
); );
final theme = Theme.of(context); final theme = Theme.of(context);
final iconColor = theme.colorScheme.primary; final iconColor = theme.colorScheme.secondary;
final resetButton = IconButton( final resetButton = IconButton(
tooltip: 'Reset', tooltip: 'Reset',
color: iconColor, color: iconColor,
@ -361,100 +377,143 @@ class _FiltersBar extends StatelessWidget {
} }
} }
class _AuditDataTable extends StatelessWidget { class _AuditDataTable extends ConsumerWidget {
const _AuditDataTable({ const _AuditDataTable({
required this.items, required this.items,
required this.onView, 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 List<AuditLogEntryModel> items;
final void Function(AuditLogEntryModel log) onView; 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 @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>( return AppDataTable<AuditLogEntryModel>(
wrapInCard: false, wrapInCard: false,
rows: items, rows: items,
emptyMessage: 'No audit logs found', emptyMessage: 'No audit logs found',
columns: [ onEnsureFullDataset: onEnsureFullDataset,
AppDataColumn( onColumnSearchCleared: onColumnSearchCleared,
label: 'When', columns: columns,
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),
),
],
),
),
],
); );
} }
} }
@ -485,7 +544,7 @@ class _AuditCardList extends StatelessWidget {
children: [ children: [
Expanded( Expanded(
child: Text( child: Text(
log.tableName, humanizeLabel(log.tableName),
style: Theme.of(context).textTheme.titleMedium, style: Theme.of(context).textTheme.titleMedium,
), ),
), ),

View File

@ -57,7 +57,7 @@ class _DetailBody extends StatelessWidget {
label: 'Action', label: 'Action',
child: AppStatusChip(status: detail.action, compact: true), 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: 'Record ID', value: detail.recordId ?? ''),
_DetailRow( _DetailRow(
label: 'Performed At', label: 'Performed At',
@ -96,6 +96,10 @@ class _DetailRow extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final labelStyle = theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
);
return Padding( return Padding(
padding: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.only(bottom: 12),
child: Row( child: Row(
@ -103,22 +107,20 @@ class _DetailRow extends StatelessWidget {
children: [ children: [
SizedBox( SizedBox(
width: 140, width: 140,
child: Text( child: Text(label, style: labelStyle),
label, ),
style: theme.textTheme.bodyMedium?.copyWith( if (child != null)
color: theme.colorScheme.onSurfaceVariant, // 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) { for (final entry in entries) {
final key = entry.key.toString(); final key = entry.key.toString();
final label = prefix == null final label = prefix == null
? _humanizeKey(key) ? humanizeLabel(key)
: '${_humanizeKey(prefix)} ${_humanizeKey(key)}'; : '${humanizeLabel(prefix)} ${humanizeLabel(key)}';
final value = entry.value; final value = entry.value;
if (value is Map) { if (value is Map) {
final nested = Map<String, dynamic>.from(value); final nested = Map<String, dynamic>.from(value);
final summary = _nestedSummary(nested); final summary = _nestedSummary(nested);
if (summary != null) { if (summary != null) {
rows.add(_FieldRow(label: _humanizeKey(key), value: summary)); rows.add(_FieldRow(label: humanizeLabel(key), value: summary));
} else { } else {
rows.addAll( rows.addAll(
_flattenFields( _flattenFields(
@ -330,7 +332,7 @@ String? _nestedSummary(Map<String, dynamic> nested) {
return nested.entries return nested.entries
.map( .map(
(e) => (e) =>
'${_humanizeKey(e.key.toString())}: ${_formatValue(e.key.toString(), e.value)}', '${humanizeLabel(e.key.toString())}: ${_formatValue(e.key.toString(), e.value)}',
) )
.join(', '); .join(', ');
} }
@ -393,30 +395,3 @@ String _formatValue(String key, Object? value) {
return text; 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(' ');
}

View File

@ -64,10 +64,59 @@ class AuthRemoteDataSource {
return _mapProfileToUser(profile, permissions: permissions, roleId: roleId); 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 response = await dio.get<Map<String, dynamic>>(ApiEndpoints.me);
final data = ApiEnvelope.data(response); 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 { Future<void> forgotPassword(ForgotPasswordRequest request) async {

View File

@ -33,7 +33,9 @@ class AuthRepositoryImpl implements AuthRepository {
return safeApiCall(() async { return safeApiCall(() async {
final loginResponse = await remote.login(request); final loginResponse = await remote.login(request);
await _persistSession(loginResponse); await _persistSession(loginResponse);
final user = await remote.getCurrentUser(); final user = await remote.getCurrentUser(
accessToken: loginResponse.tokens.accessToken,
);
return loginResponse.copyWith(user: user); return loginResponse.copyWith(user: user);
}); });
} }
@ -56,7 +58,10 @@ class AuthRepositoryImpl implements AuthRepository {
@override @override
Future<Result<UserModel>> getCurrentUser() async { Future<Result<UserModel>> getCurrentUser() async {
return safeApiCall(() => remote.getCurrentUser()); return safeApiCall(() async {
final accessToken = await tokenStorage.getAccessToken();
return remote.getCurrentUser(accessToken: accessToken);
});
} }
@override @override
@ -91,7 +96,8 @@ class AuthRepositoryImpl implements AuthRepository {
final updated = await remote.updateProfile(request); final updated = await remote.updateProfile(request);
// Prefer fresh /auth/me so avatar + role/department stay in sync. // Prefer fresh /auth/me so avatar + role/department stay in sync.
try { try {
return await remote.getCurrentUser(); final accessToken = await tokenStorage.getAccessToken();
return await remote.getCurrentUser(accessToken: accessToken);
} catch (_) { } catch (_) {
return updated; return updated;
} }

View File

@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.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/constants/route_constants.dart';
import '../../../../core/utils/validators.dart'; import '../../../../core/utils/validators.dart';
@ -78,7 +78,7 @@ class _ChangePasswordScreenState extends ConsumerState<ChangePasswordScreen> {
if (result.failure != null) { if (result.failure != null) {
setState( setState(
() => _errorMessage = () => _errorMessage =
result.failure?.message ?? result.failure.toString(), result.failure?.message ?? 'Unable to change password.',
); );
return; return;
} }
@ -108,13 +108,9 @@ class _ChangePasswordScreenState extends ConsumerState<ChangePasswordScreen> {
return InputDecoration( return InputDecoration(
labelText: label, labelText: label,
floatingLabelBehavior: FloatingLabelBehavior.auto, floatingLabelBehavior: FloatingLabelBehavior.auto,
labelStyle: GoogleFonts.inter( labelStyle: AppTypography.label2(color: colors.onSurfaceVariant),
fontSize: 14.5, floatingLabelStyle: AppTypography.label3(
color: colors.onSurfaceVariant, weight: AppTypography.semiBold,
),
floatingLabelStyle: GoogleFonts.inter(
fontSize: 12,
fontWeight: FontWeight.w600,
color: colors.primary, color: colors.primary,
), ),
prefixIcon: Icon(icon, color: colors.iconMuted, size: 18), prefixIcon: Icon(icon, color: colors.iconMuted, size: 18),
@ -212,9 +208,8 @@ class _ChangePasswordScreenState extends ConsumerState<ChangePasswordScreen> {
Expanded( Expanded(
child: Text( child: Text(
'Change Password', 'Change Password',
style: GoogleFonts.manrope( style: AppTypography.body1(
fontSize: 18, weight: AppTypography.bold,
fontWeight: FontWeight.w700,
color: colors.headingColor, color: colors.headingColor,
), ),
), ),
@ -285,21 +280,17 @@ class _ChangePasswordScreenState extends ConsumerState<ChangePasswordScreen> {
const SizedBox(height: 20), const SizedBox(height: 20),
Text( Text(
'Update your password', 'Update your password',
style: GoogleFonts.manrope( style: AppTypography.heading5(
fontSize: 24, weight: AppTypography.bold,
fontWeight: FontWeight.w800,
letterSpacing: -0.3,
color: colors.headingColor, color: colors.headingColor,
), ),
), ),
const SizedBox(height: 5), const SizedBox(height: 5),
Text( Text(
'Enter your current password and choose a new one. Youll need to sign in again afterward.', 'Enter your current password and choose a new one. Youll need to sign in again afterward.',
style: GoogleFonts.inter( style: AppTypography.body3(
fontSize: 13.5,
height: 1.45,
color: colors.subtitleColor, color: colors.subtitleColor,
), ).copyWith(height: 1.45),
), ),
const SizedBox(height: 26), const SizedBox(height: 26),
TextFormField( TextFormField(
@ -312,8 +303,8 @@ class _ChangePasswordScreenState extends ConsumerState<ChangePasswordScreen> {
v, v,
fieldName: 'Current password', fieldName: 'Current password',
), ),
style: GoogleFonts.inter( style: AppTypography.body3(
fontSize: 14.5, weight: AppTypography.medium,
color: colors.headingColor, color: colors.headingColor,
), ),
decoration: _fieldDecoration( decoration: _fieldDecoration(
@ -338,8 +329,8 @@ class _ChangePasswordScreenState extends ConsumerState<ChangePasswordScreen> {
AutofillHints.newPassword AutofillHints.newPassword
], ],
validator: Validators.password, validator: Validators.password,
style: GoogleFonts.inter( style: AppTypography.body3(
fontSize: 14.5, weight: AppTypography.medium,
color: colors.headingColor, color: colors.headingColor,
), ),
decoration: _fieldDecoration( decoration: _fieldDecoration(
@ -368,8 +359,8 @@ class _ChangePasswordScreenState extends ConsumerState<ChangePasswordScreen> {
} }
return Validators.password(v); return Validators.password(v);
}, },
style: GoogleFonts.inter( style: AppTypography.body3(
fontSize: 14.5, weight: AppTypography.medium,
color: colors.headingColor, color: colors.headingColor,
), ),
decoration: _fieldDecoration( decoration: _fieldDecoration(
@ -390,8 +381,7 @@ class _ChangePasswordScreenState extends ConsumerState<ChangePasswordScreen> {
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
_errorMessage!, _errorMessage!,
style: GoogleFonts.inter( style: AppTypography.body3(
fontSize: 13,
color: colors.colorScheme.error, color: colors.colorScheme.error,
), ),
), ),
@ -415,10 +405,8 @@ class _ChangePasswordScreenState extends ConsumerState<ChangePasswordScreen> {
borderRadius: borderRadius:
BorderRadius.circular(12), BorderRadius.circular(12),
), ),
textStyle: GoogleFonts.inter( textStyle: AppTypography.label1(
fontSize: 15, weight: AppTypography.bold,
fontWeight: FontWeight.w700,
letterSpacing: 0.2,
), ),
), ),
), ),
@ -446,11 +434,9 @@ class _ChangePasswordScreenState extends ConsumerState<ChangePasswordScreen> {
), ),
child: Text( child: Text(
'SECURE ACCESS', 'SECURE ACCESS',
style: GoogleFonts.inter( style: AppTypography.caption1(
fontSize: 11.5,
letterSpacing: 0.6,
color: colors.onSurfaceVariant, color: colors.onSurfaceVariant,
), ).copyWith(letterSpacing: 0.6),
), ),
), ),
Expanded( Expanded(
@ -473,8 +459,7 @@ class _ChangePasswordScreenState extends ConsumerState<ChangePasswordScreen> {
const SizedBox(width: 6), const SizedBox(width: 6),
Text( Text(
'Your data is protected and encrypted', 'Your data is protected and encrypted',
style: GoogleFonts.inter( style: AppTypography.body4(
fontSize: 12,
color: colors.onSurfaceVariant, color: colors.onSurfaceVariant,
), ),
), ),

View File

@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../../../core/constants/route_constants.dart'; import '../../../../core/constants/route_constants.dart';
import '../../../../core/theme/app_typography.dart';
import '../../../../core/utils/validators.dart'; import '../../../../core/utils/validators.dart';
import '../../../../shared/widgets/app_button.dart'; import '../../../../shared/widgets/app_button.dart';
import '../../../../shared/widgets/app_text_field.dart'; import '../../../../shared/widgets/app_text_field.dart';
@ -47,7 +48,10 @@ class _ForgotPasswordScreenState extends ConsumerState<ForgotPasswordScreen> {
setState(() => _isLoading = false); setState(() => _isLoading = false);
if (result.failure != null) { if (result.failure != null) {
setState(() => _errorMessage = result.failure.toString()); setState(
() => _errorMessage =
result.failure?.message ?? 'Unable to send reset email.',
);
return; return;
} }
@ -86,7 +90,9 @@ class _ForgotPasswordScreenState extends ConsumerState<ForgotPasswordScreen> {
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
_errorMessage!, _errorMessage!,
style: TextStyle(color: Theme.of(context).colorScheme.error), style: AppTypography.body3(
color: Theme.of(context).colorScheme.error,
),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
], ],
@ -94,7 +100,9 @@ class _ForgotPasswordScreenState extends ConsumerState<ForgotPasswordScreen> {
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
_successMessage!, _successMessage!,
style: TextStyle(color: Theme.of(context).colorScheme.primary), style: AppTypography.body3(
color: Theme.of(context).colorScheme.primary,
),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
], ],

View File

@ -3,10 +3,9 @@ import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.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/config/dev_config.dart';
import '../../../../core/constants/app_constants.dart';
import '../../../../core/constants/enums.dart'; import '../../../../core/constants/enums.dart';
import '../../../../core/constants/route_constants.dart'; import '../../../../core/constants/route_constants.dart';
import '../../../../core/constants/storage_keys.dart'; import '../../../../core/constants/storage_keys.dart';
@ -172,7 +171,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
setState(() => _isForgotLoading = false); setState(() => _isForgotLoading = false);
if (result.failure != null) { if (result.failure != null) {
setState(() => _forgotErrorMessage = result.failure.toString()); setState(
() => _forgotErrorMessage =
result.failure?.message ?? 'Unable to send reset email.',
);
return; return;
} }
@ -214,7 +216,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (result.failure != null) { if (result.failure != null) {
setState( setState(
() => _resetErrorMessage = () => _resetErrorMessage =
result.failure?.message ?? result.failure.toString(), result.failure?.message ?? 'Unable to reset password.',
); );
return; return;
} }
@ -296,13 +298,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
return InputDecoration( return InputDecoration(
labelText: label, labelText: label,
floatingLabelBehavior: FloatingLabelBehavior.auto, floatingLabelBehavior: FloatingLabelBehavior.auto,
labelStyle: GoogleFonts.inter( labelStyle: AppTypography.label2(color: colors.onSurfaceVariant),
fontSize: 14.5, floatingLabelStyle: AppTypography.label3(
color: colors.onSurfaceVariant, weight: AppTypography.semiBold,
),
floatingLabelStyle: GoogleFonts.inter(
fontSize: 12,
fontWeight: FontWeight.w600,
color: colors.primary, color: colors.primary,
), ),
prefixIcon: Icon(icon, color: colors.iconMuted, size: 18), 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( return Center(
child: SidebarLogo( child: DecoratedBox(
logoUrl: logoUrl, decoration: BoxDecoration(
height: 64, // Light: blend into the card. Dark: keep white so the logo stays readable.
width: 220, color: colors.isDark ? Colors.white : colors.cardBackground,
fit: BoxFit.contain, borderRadius: BorderRadius.circular(12),
showBackground: false, ),
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), padding: const EdgeInsets.symmetric(horizontal: 10),
child: Text( child: Text(
'SECURE ACCESS', 'SECURE ACCESS',
style: GoogleFonts.inter( style: AppTypography.caption1(color: colors.onSurfaceVariant)
fontSize: 11.5, .copyWith(letterSpacing: 0.6),
letterSpacing: 0.6,
color: colors.onSurfaceVariant,
),
), ),
), ),
Expanded(child: Divider(color: colors.outlineSoft, height: 1)), Expanded(child: Divider(color: colors.outlineSoft, height: 1)),
@ -400,10 +405,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
const SizedBox(width: 6), const SizedBox(width: 6),
Text( Text(
'Your data is protected and encrypted', 'Your data is protected and encrypted',
style: GoogleFonts.inter( style: AppTypography.body4(color: colors.onSurfaceVariant),
fontSize: 12,
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) { Widget _backToSignInButton(LoginColors colors) {
return Center( return Center(
child: TextButton( child: TextButton(
@ -433,10 +423,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
), ),
child: Text( child: Text(
'Back to Sign In', 'Back to Sign In',
style: GoogleFonts.inter( style: AppTypography.body3(weight: AppTypography.semiBold),
fontSize: 13.5,
fontWeight: FontWeight.w600,
),
), ),
), ),
); );
@ -459,11 +446,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
textStyle: GoogleFonts.inter( textStyle: AppTypography.label1(weight: AppTypography.bold),
fontSize: 15,
fontWeight: FontWeight.w700,
letterSpacing: 0.2,
),
), ),
), ),
), ),
@ -477,117 +460,109 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
}) { }) {
return Form( return Form(
key: _loginFormKey, key: _loginFormKey,
child: Column( child: SingleChildScrollView(
mainAxisSize: MainAxisSize.min, child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min,
children: [ crossAxisAlignment: CrossAxisAlignment.stretch,
_buildLogo(logoUrl), children: [
const SizedBox(height: 20), _buildLogo(logoUrl, colors),
Text( const SizedBox(height: 20),
'Welcome back', Text(
style: GoogleFonts.manrope( 'Welcome',
fontSize: 24, style: AppTypography.heading5(
fontWeight: FontWeight.w800, weight: AppTypography.bold,
letterSpacing: -0.3, color: colors.headingColor,
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),
), ),
), ),
), const SizedBox(height: 5),
const SizedBox(height: 10), Text(
Row( 'Sign in to access your BCPL workspace.',
children: [ style: AppTypography.body3(color: colors.subtitleColor),
SizedBox( ),
height: 34, const SizedBox(height: 26),
width: 34, TextFormField(
child: Checkbox( controller: _emailController,
value: _rememberMe, keyboardType: TextInputType.emailAddress,
activeColor: colors.primary, autofillHints: const [AutofillHints.email],
checkColor: colors.onPrimary, validator: Validators.email,
side: BorderSide(color: colors.outline, width: 1.5), style: AppTypography.body3(
shape: RoundedRectangleBorder( weight: AppTypography.medium,
borderRadius: BorderRadius.circular(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: 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', const SizedBox(height: 10),
style: GoogleFonts.inter( Row(
fontSize: 13, children: [
color: colors.labelColor, SizedBox(
), height: 34,
), width: 34,
const Spacer(), child: Checkbox(
TextButton( value: _rememberMe,
onPressed: _flipToForgot, activeColor: colors.primary,
style: TextButton.styleFrom( checkColor: colors.onPrimary,
foregroundColor: colors.linkColor, side: BorderSide(color: colors.outline, width: 1.5),
padding: const EdgeInsets.symmetric(horizontal: 4), shape: RoundedRectangleBorder(
minimumSize: Size.zero, borderRadius: BorderRadius.circular(5),
tapTargetSize: MaterialTapTargetSize.shrinkWrap, ),
), onChanged: (v) => setState(() => _rememberMe = v ?? false),
child: Text(
'Forgot password?',
style: GoogleFonts.inter(
fontSize: 13,
fontWeight: FontWeight.w600,
), ),
), ),
), Text(
], 'Remember me',
), style: AppTypography.body3(color: colors.labelColor),
const SizedBox(height: 14), ),
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( _primaryButtonTheme(
colors: colors, colors: colors,
child: AppButton( child: AppButton(
@ -598,45 +573,41 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
), ),
const SizedBox(height: 26), const SizedBox(height: 26),
_secureAccessFooter(colors), _secureAccessFooter(colors),
const SizedBox(height: 22),
_versionLabel(colors),
if (DevConfig.screenPreviewEnabled) ...[ if (DevConfig.screenPreviewEnabled) ...[
const SizedBox(height: 20), const SizedBox(height: 20),
Divider(color: colors.outlineSoft), Divider(color: colors.outlineSoft),
const SizedBox(height: 14), const SizedBox(height: 14),
Text( Text(
'Login API unavailable? Browse all screens without signing in:', 'Login API unavailable? Browse all screens without signing in:',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: GoogleFonts.inter( style: AppTypography.body4(color: colors.subtitleColor),
fontSize: 12,
color: colors.subtitleColor,
), ),
), const SizedBox(height: 12),
const SizedBox(height: 12), Theme(
Theme( data: Theme.of(context).copyWith(
data: Theme.of(context).copyWith( outlinedButtonTheme: OutlinedButtonThemeData(
outlinedButtonTheme: OutlinedButtonThemeData( style: OutlinedButton.styleFrom(
style: OutlinedButton.styleFrom( foregroundColor: colors.primary,
foregroundColor: colors.primary, side: BorderSide(color: colors.outlineSoft),
side: BorderSide(color: colors.outlineSoft), minimumSize: const Size(double.infinity, 46),
minimumSize: const Size(double.infinity, 46), shape: RoundedRectangleBorder(
shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12),
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, mainAxisSize: fillHeight ? MainAxisSize.max : MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
_buildLogo(logoUrl), _buildLogo(logoUrl, colors),
const SizedBox(height: 20), const SizedBox(height: 20),
Text( Text(
'Forgot Password', 'Forgot Password',
style: GoogleFonts.manrope( style: AppTypography.heading5(
fontSize: 24, weight: AppTypography.bold,
fontWeight: FontWeight.w800,
letterSpacing: -0.3,
color: colors.headingColor, color: colors.headingColor,
), ),
), ),
const SizedBox(height: 5), const SizedBox(height: 5),
Text( Text(
'Enter your registered email address to receive password reset instructions.', 'Enter your registered email address to receive password reset instructions.',
style: GoogleFonts.inter( style: AppTypography.body3(color: colors.subtitleColor)
fontSize: 13.5, .copyWith(height: 1.45),
height: 1.45,
color: colors.subtitleColor,
),
), ),
const SizedBox(height: 26), const SizedBox(height: 26),
TextFormField( TextFormField(
@ -678,8 +644,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
keyboardType: TextInputType.emailAddress, keyboardType: TextInputType.emailAddress,
autofillHints: const [AutofillHints.email], autofillHints: const [AutofillHints.email],
validator: Validators.email, validator: Validators.email,
style: GoogleFonts.inter( style: AppTypography.body3(
fontSize: 14.5, weight: AppTypography.medium,
color: colors.headingColor, color: colors.headingColor,
), ),
decoration: _fieldDecoration( decoration: _fieldDecoration(
@ -692,20 +658,14 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
_forgotErrorMessage!, _forgotErrorMessage!,
style: GoogleFonts.inter( style: AppTypography.body3(color: colors.colorScheme.error),
fontSize: 13,
color: colors.colorScheme.error,
),
), ),
], ],
if (_forgotSuccessMessage != null) ...[ if (_forgotSuccessMessage != null) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
_forgotSuccessMessage!, _forgotSuccessMessage!,
style: GoogleFonts.inter( style: AppTypography.body3(color: colors.primary),
fontSize: 13,
color: colors.primary,
),
), ),
], ],
const SizedBox(height: 18), const SizedBox(height: 18),
@ -724,8 +684,6 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
_backToSignInButton(colors), _backToSignInButton(colors),
const SizedBox(height: 18), const SizedBox(height: 18),
_secureAccessFooter(colors), _secureAccessFooter(colors),
const SizedBox(height: 22),
_versionLabel(colors),
], ],
), ),
); );
@ -742,25 +700,20 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
mainAxisSize: fillHeight ? MainAxisSize.max : MainAxisSize.min, mainAxisSize: fillHeight ? MainAxisSize.max : MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
_buildLogo(logoUrl), _buildLogo(logoUrl, colors),
const SizedBox(height: 20), const SizedBox(height: 20),
Text( Text(
'Reset Password', 'Reset Password',
style: GoogleFonts.manrope( style: AppTypography.heading5(
fontSize: 24, weight: AppTypography.bold,
fontWeight: FontWeight.w800,
letterSpacing: -0.3,
color: colors.headingColor, color: colors.headingColor,
), ),
), ),
const SizedBox(height: 5), const SizedBox(height: 5),
Text( Text(
'Choose a new password for your account.', 'Choose a new password for your account.',
style: GoogleFonts.inter( style: AppTypography.body3(color: colors.subtitleColor)
fontSize: 13.5, .copyWith(height: 1.45),
height: 1.45,
color: colors.subtitleColor,
),
), ),
const SizedBox(height: 26), const SizedBox(height: 26),
TextFormField( TextFormField(
@ -768,8 +721,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
obscureText: _obscureNewPassword, obscureText: _obscureNewPassword,
autofillHints: const [AutofillHints.newPassword], autofillHints: const [AutofillHints.newPassword],
validator: Validators.password, validator: Validators.password,
style: GoogleFonts.inter( style: AppTypography.body3(
fontSize: 14.5, weight: AppTypography.medium,
color: colors.headingColor, color: colors.headingColor,
), ),
decoration: _fieldDecoration( decoration: _fieldDecoration(
@ -801,10 +754,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (v != _newPasswordController.text) { if (v != _newPasswordController.text) {
return 'Passwords do not match'; return 'Passwords do not match';
} }
return Validators.required(v, fieldName: 'Confirm password'); return Validators.password(v);
}, },
style: GoogleFonts.inter( style: AppTypography.body3(
fontSize: 14.5, weight: AppTypography.medium,
color: colors.headingColor, color: colors.headingColor,
), ),
decoration: _fieldDecoration( decoration: _fieldDecoration(
@ -832,20 +785,14 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
_resetErrorMessage!, _resetErrorMessage!,
style: GoogleFonts.inter( style: AppTypography.body3(color: colors.colorScheme.error),
fontSize: 13,
color: colors.colorScheme.error,
),
), ),
], ],
if ((_resetToken ?? '').isEmpty) ...[ if ((_resetToken ?? '').isEmpty) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
'This reset link is invalid or has expired. Request a new one from Forgot Password.', 'This reset link is invalid or has expired. Request a new one from Forgot Password.',
style: GoogleFonts.inter( style: AppTypography.body3(color: colors.colorScheme.error),
fontSize: 13,
color: colors.colorScheme.error,
),
), ),
], ],
const SizedBox(height: 18), const SizedBox(height: 18),
@ -864,8 +811,6 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
_backToSignInButton(colors), _backToSignInButton(colors),
const SizedBox(height: 18), const SizedBox(height: 18),
_secureAccessFooter(colors), _secureAccessFooter(colors),
const SizedBox(height: 22),
_versionLabel(colors),
], ],
), ),
); );
@ -944,6 +889,14 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
); );
if (_cardHeight == null) return flipped; 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( return SizedBox(
height: _cardHeight, height: _cardHeight,
width: double.infinity, width: double.infinity,

View File

@ -52,7 +52,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
obscureText: true, obscureText: true,
validator: (v) { validator: (v) {
if (v != _passwordController.text) return 'Passwords do not match'; if (v != _passwordController.text) return 'Passwords do not match';
return Validators.required(v, fieldName: 'Confirm Password'); return Validators.password(v);
}, },
), ),
const SizedBox(height: 24), const SizedBox(height: 24),

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../../core/theme/app_typography.dart';
import 'login_colors.dart'; import 'login_colors.dart';
/// BCPL wordmark with blue and green swooshes, matching the login design. /// BCPL wordmark with blue and green swooshes, matching the login design.
@ -26,8 +27,9 @@ class BcplLogo extends StatelessWidget {
child: Text( child: Text(
'BCPL', 'BCPL',
style: TextStyle( style: TextStyle(
fontFamily: AppTypography.fontFamily,
fontSize: height * 0.58, fontSize: height * 0.58,
fontWeight: FontWeight.w800, fontWeight: AppTypography.bold,
fontStyle: FontStyle.italic, fontStyle: FontStyle.italic,
letterSpacing: -0.5, letterSpacing: -0.5,
color: colors.headingColor, color: colors.headingColor,

View File

@ -3,6 +3,7 @@ import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'login_colors.dart'; import 'login_colors.dart';
import '../../../../core/theme/app_typography.dart';
/// Orbiting ERP module illustration for the login brand panel. /// Orbiting ERP module illustration for the login brand panel.
class LoginHeroIllustration extends StatefulWidget { class LoginHeroIllustration extends StatefulWidget {
@ -200,12 +201,10 @@ class _Hub extends StatelessWidget {
), ),
child: Text( child: Text(
'ERP', 'ERP',
style: TextStyle( style: AppTypography.body2(
weight: AppTypography.bold,
color: colors.panelText, color: colors.panelText,
fontWeight: FontWeight.w800, ).copyWith(letterSpacing: 0.5),
fontSize: 17,
letterSpacing: 0.5,
),
), ),
); );
} }

View File

@ -1,5 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart'; import '../../../../core/theme/app_typography.dart';
import 'login_colors.dart'; import 'login_colors.dart';
import 'login_hero_illustration.dart'; import 'login_hero_illustration.dart';
@ -40,24 +40,26 @@ class LoginHeroPanel extends StatelessWidget {
SizedBox(height: compact ? 20 : 28), SizedBox(height: compact ? 20 : 28),
Text( Text(
'Smart. Integrated.\nEfficient.', 'Smart. Integrated.\nEfficient.',
style: GoogleFonts.manrope( style: (compact
fontSize: compact ? 28 : 40, ? AppTypography.heading4(
fontWeight: FontWeight.w800, weight: AppTypography.bold,
height: 1.12, color: colors.panelText,
letterSpacing: -0.5, )
color: colors.panelText, : AppTypography.heading3(
), weight: AppTypography.bold,
color: colors.panelText,
))
.copyWith(height: 1.12),
), ),
const SizedBox(height: 14), const SizedBox(height: 14),
ConstrainedBox( ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420), constraints: const BoxConstraints(maxWidth: 420),
child: Text( child: Text(
'One workspace for inventory, sales, purchases, accounts and reporting — built for teams who move fast.', 'One workspace for inventory, sales, purchases, accounts and reporting — built for teams who move fast.',
style: GoogleFonts.inter( style: (compact
fontSize: compact ? 13.5 : 15, ? AppTypography.body3(color: colors.panelTextDim)
height: 1.6, : AppTypography.body2(color: colors.panelTextDim))
color: colors.panelTextDim, .copyWith(height: 1.6),
),
), ),
), ),
SizedBox(height: compact ? 16 : 24), SizedBox(height: compact ? 16 : 24),
@ -84,50 +86,20 @@ class _BrandMark extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Row( return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Container( Text(
width: 40, 'BCPL',
height: 40, style: AppTypography.heading6(
alignment: Alignment.center, weight: AppTypography.bold,
decoration: BoxDecoration( color: colors.panelText,
color: colors.panelText.withValues(alpha: 0.12), ).copyWith(letterSpacing: 0.5),
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,
),
),
), ),
const SizedBox(width: 12), Text(
Column( 'BHARAT ERP',
crossAxisAlignment: CrossAxisAlignment.start, style: AppTypography.caption1(color: colors.panelTextDim)
children: [ .copyWith(letterSpacing: 1.5),
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,
),
),
],
), ),
], ],
); );
@ -142,74 +114,64 @@ class _FeatureRow extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final items = [ final items = [
( (Icons.layers_outlined, 'Unified data'),
Icons.layers_outlined, (Icons.bolt_outlined, 'Real-time sync'),
'Unified data', (Icons.bar_chart_rounded, 'Clear reporting'),
'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',
),
]; ];
return Wrap( return Row(
spacing: 28, mainAxisAlignment: MainAxisAlignment.center,
runSpacing: 16,
children: [ children: [
for (final item in items) for (var i = 0; i < items.length; i++) ...[
SizedBox( if (i > 0) const SizedBox(width: 28),
width: 160, _FeatureItem(
child: Row( colors: colors,
crossAxisAlignment: CrossAxisAlignment.start, icon: items[i].$1,
children: [ title: items[i].$2,
Container( ),
width: 30, ],
height: 30, ],
alignment: Alignment.center, );
decoration: BoxDecoration( }
color: colors.panelText.withValues(alpha: 0.10), }
borderRadius: BorderRadius.circular(9),
border: Border.all( class _FeatureItem extends StatelessWidget {
color: colors.panelText.withValues(alpha: 0.16), const _FeatureItem({
), required this.colors,
), required this.icon,
child: Icon(item.$1, size: 15, color: colors.panelText), required this.title,
), });
const SizedBox(width: 10),
Expanded( final LoginColors colors;
child: Column( final IconData icon;
crossAxisAlignment: CrossAxisAlignment.start, final String title;
children: [
Text( @override
item.$2, Widget build(BuildContext context) {
style: GoogleFonts.inter( return Row(
fontSize: 12.5, mainAxisSize: MainAxisSize.min,
fontWeight: FontWeight.w600, children: [
color: colors.panelText, Container(
), width: 30,
), height: 30,
const SizedBox(height: 2), alignment: Alignment.center,
Text( decoration: BoxDecoration(
item.$3, color: colors.panelText.withValues(alpha: 0.10),
style: GoogleFonts.inter( borderRadius: BorderRadius.circular(9),
fontSize: 11.5, border: Border.all(
height: 1.4, color: colors.panelText.withValues(alpha: 0.16),
color: colors.panelTextDim,
),
),
],
),
),
],
), ),
), ),
child: Icon(icon, size: 15, color: colors.panelText),
),
const SizedBox(width: 10),
Text(
title,
style: AppTypography.label3(
weight: AppTypography.semiBold,
color: colors.panelText,
),
),
], ],
); );
} }

View File

@ -61,7 +61,7 @@ class _BranchFormScreenState extends State<BranchFormScreen> {
AppTextField(controller: _managerController, label: 'Manager'), AppTextField(controller: _managerController, label: 'Manager'),
const SizedBox(height: 24), const SizedBox(height: 24),
AppButton( AppButton(
label: isEditing ? 'Update Branch' : 'Create Branch', label: isEditing ? 'Update Branch' : 'Save Branch',
onPressed: () { onPressed: () {
if (_formKey.currentState!.validate()) Navigator.of(context).pop(); if (_formKey.currentState!.validate()) Navigator.of(context).pop();
}, },

View File

@ -89,7 +89,7 @@ class _CompanyFormScreenState extends State<CompanyFormScreen> {
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
AppButton( AppButton(
label: isEditing ? 'Update Company' : 'Create Company', label: isEditing ? 'Update Company' : 'Save Company',
onPressed: () { onPressed: () {
if (_formKey.currentState!.validate()) { if (_formKey.currentState!.validate()) {
Navigator.of(context).pop(); Navigator.of(context).pop();

View File

@ -1,10 +1,7 @@
import '../../../../shared/widgets/app_card.dart';
import 'package:flutter/material.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 '../../../../core/utils/responsive_utils.dart';
import '../../../../shared/widgets/kpi_card.dart';
import '../../../../shared/widgets/page_header.dart'; import '../../../../shared/widgets/page_header.dart';
class DashboardScreen extends StatelessWidget { class DashboardScreen extends StatelessWidget {
@ -12,83 +9,52 @@ class DashboardScreen extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SingleChildScrollView( final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
child: Center( child: Center(
child: ConstrainedBox( child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: context.contentMaxWidth), constraints: BoxConstraints(maxWidth: context.contentMaxWidth),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
const PageHeader( const PageHeader(
title: 'Dashboard', title: 'Dashboard',
subtitle: 'Asset management overview', subtitle: 'Overview',
), ),
LayoutBuilder( Expanded(
builder: (context, constraints) { child: Center(
final crossAxisCount = constraints.maxWidth > 900 ? 3 : (constraints.maxWidth > 600 ? 2 : 1); child: Column(
return GridView.count( mainAxisSize: MainAxisSize.min,
crossAxisCount: crossAxisCount,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
mainAxisSpacing: 16,
crossAxisSpacing: 16,
childAspectRatio: 1.8,
children: [ children: [
KpiCard( Image.asset(
title: 'Total Assets', AppConstants.defaultLogoAsset,
value: '', height: 72,
icon: Icons.inventory_2_outlined, fit: BoxFit.contain,
onTap: () => context.go(RouteConstants.assets), errorBuilder: (_, __, ___) => Icon(
Icons.dashboard_outlined,
size: 72,
color: theme.colorScheme.primary,
),
), ),
KpiCard( const SizedBox(height: 24),
title: 'Allocated', Text(
value: '', 'Dashboard coming soon',
icon: Icons.assignment_ind_outlined, textAlign: TextAlign.center,
color: Colors.blue, style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
), ),
KpiCard( const SizedBox(height: 8),
title: 'Available', Text(
value: '', 'This space is empty for now. Insights and charts will appear here later.',
icon: Icons.check_circle_outline, textAlign: TextAlign.center,
color: Colors.green, style: theme.textTheme.bodyMedium?.copyWith(
), color: theme.colorScheme.onSurfaceVariant,
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: 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,
),
),
), ),
), ),
), ),

View File

@ -77,7 +77,7 @@ final _entries = [
), ),
// Assets // Assets
_GalleryEntry(title: 'Asset List', route: RouteConstants.assets, group: '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'), _GalleryEntry(title: 'Alerts', route: RouteConstants.assetAlerts, group: 'Assets'),
// Master data // Master data
_GalleryEntry(title: 'Master Data Hub', route: RouteConstants.masterData, group: 'Master Data'), _GalleryEntry(title: 'Master Data Hub', route: RouteConstants.masterData, group: 'Master Data'),

View File

@ -1,6 +1,7 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import '../../../../core/constants/api_endpoints.dart'; import '../../../../core/constants/api_endpoints.dart';
import '../../../../core/utils/pagination_meta.dart';
import '../../../../core/utils/export_file_name.dart'; import '../../../../core/utils/export_file_name.dart';
import '../../../../shared/models/api_response.dart'; import '../../../../shared/models/api_response.dart';
import '../../../../shared/models/export_file_result.dart'; import '../../../../shared/models/export_file_result.dart';
@ -16,7 +17,12 @@ class GrnRemoteDataSource {
ApiEndpoints.grn, ApiEndpoints.grn,
queryParameters: _queryToMap(query), 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`). /// Form-dropdown loader: all active GRNs (`dropdown_call=true`).
@ -158,58 +164,45 @@ class GrnRemoteDataSource {
PaginatedResponse<T> _parsePaginated<T>( PaginatedResponse<T> _parsePaginated<T>(
dynamic body, dynamic body,
T Function(Map<String, dynamic>) fromJson, T Function(Map<String, dynamic>) fromJson, {
) { int fallbackPage = 1,
if (body is! Map<String, dynamic>) { int fallbackLimit = 20,
return const PaginatedResponse( }) {
items: [], var items = <T>[];
page: 1, if (body is Map) {
limit: 20, final raw = body['data'];
total: 0, if (raw is List) {
totalPages: 1, items = raw
); .whereType<Map>()
} .map((e) => fromJson(Map<String, dynamic>.from(e)))
.toList();
final raw = body['data']; } else if (raw is Map) {
final meta = body['meta'] as Map<String, dynamic>? ?? {}; final list = raw['items'];
if (list is List) {
if (raw is List) { items = list
final items = raw.whereType<Map<String, dynamic>>().map(fromJson).toList(); .whereType<Map>()
final limit = (meta['limit'] as num?)?.toInt() ?? items.length; .map((e) => fromJson(Map<String, dynamic>.from(e)))
final total = (meta['total'] as num?)?.toInt() ?? items.length; .toList();
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,
);
} }
} }
return const PaginatedResponse( final pagination = parsePagination(
items: [], body: body,
page: 1, fallbackPage: fallbackPage,
limit: 20, fallbackLimit: fallbackLimit,
total: 0, itemCount: items.length,
totalPages: 1, );
return PaginatedResponse(
items: items,
page: pagination.page,
limit: fallbackLimit,
total: pagination.total,
totalPages: resolveTotalPages(
total: pagination.total,
limit: fallbackLimit,
),
); );
} }
} }

View File

@ -1,10 +1,15 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/utils/column_search_paging.dart';
import '../../../../core/utils/table_search.dart'; import '../../../../core/utils/table_search.dart';
import '../../../../shared/models/export_file_result.dart'; import '../../../../shared/models/export_file_result.dart';
import '../../../../shared/models/grn_model.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 '../../data/repositories/grn_repository_impl.dart';
import 'grn_lookups_provider.dart';
class GrnListState { class GrnListState {
const GrnListState({ const GrnListState({
@ -57,6 +62,8 @@ final grnListProvider =
); );
class GrnListNotifier extends AutoDisposeAsyncNotifier<GrnListState> { class GrnListNotifier extends AutoDisposeAsyncNotifier<GrnListState> {
final _columnSearch = ColumnSearchPaging();
@override @override
Future<GrnListState> build() async { Future<GrnListState> build() async {
return _load(const GrnListQuery(limit: 20)); return _load(const GrnListQuery(limit: 20));
@ -67,8 +74,73 @@ class GrnListNotifier extends AutoDisposeAsyncNotifier<GrnListState> {
final result = await repository.getGrns(query); final result = await repository.getGrns(query);
if (result.failure != null) throw result.failure!; if (result.failure != null) throw result.failure!;
final page = result.data!; 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( return GrnListState(
grns: page.items, grns: items,
query: query, query: query,
total: page.total, total: page.total,
totalPages: page.totalPages, totalPages: page.totalPages,
@ -103,6 +175,27 @@ class GrnListNotifier extends AutoDisposeAsyncNotifier<GrnListState> {
applyQuery(current.query.copyWith(search: TableSearch.normalize(search), page: 1)); 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) { void setStatusFilter(String? status) {
final current = state.valueOrNull; final current = state.valueOrNull;
if (current == null) return; if (current == null) return;

View File

@ -12,12 +12,14 @@ import '../../../../shared/providers/permissions_provider.dart';
import '../../../../shared/utils/file_download_helper.dart'; import '../../../../shared/utils/file_download_helper.dart';
import '../../../../shared/widgets/app_text_field.dart'; import '../../../../shared/widgets/app_text_field.dart';
import '../../../../shared/widgets/app_loading_view.dart'; import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/detail_overview_widgets.dart';
import '../../../../shared/widgets/error_view.dart'; import '../../../../shared/widgets/error_view.dart';
import '../providers/grn_lookups_provider.dart'; import '../providers/grn_lookups_provider.dart';
import '../providers/grn_provider.dart'; import '../providers/grn_provider.dart';
import '../widgets/grn_attachments_card.dart'; import '../widgets/grn_attachments_card.dart';
import '../widgets/grn_line_items_editor.dart'; import '../widgets/grn_line_items_editor.dart';
import '../widgets/grn_status_chip.dart'; import '../widgets/grn_status_chip.dart';
import '../../../../shared/widgets/api_feedback.dart';
import '../../../../shared/widgets/app_toast.dart'; import '../../../../shared/widgets/app_toast.dart';
class GrnDetailScreen extends ConsumerStatefulWidget { class GrnDetailScreen extends ConsumerStatefulWidget {
@ -32,6 +34,24 @@ class GrnDetailScreen extends ConsumerStatefulWidget {
class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> { class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> {
bool _isWorking = false; bool _isWorking = false;
bool _isDownloadingPdf = 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -44,7 +64,7 @@ class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> {
return Scaffold( return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface, backgroundColor: Theme.of(context).colorScheme.surface,
body: detailAsync.when( body: detailAsync.when(
loading: () => const AppLoadingView(message: 'Loading GRN...'), loading: () => const AppLoadingView(message: 'Loading Purchase Receipt...'),
error: (e, _) => ErrorView.fromFailure( error: (e, _) => ErrorView.fromFailure(
e is Failure ? e : Failure.unknown(message: e.toString()), e is Failure ? e : Failure.unknown(message: e.toString()),
onRetry: () => ref.invalidate(grnDetailProvider(widget.grnId)), onRetry: () => ref.invalidate(grnDetailProvider(widget.grnId)),
@ -52,13 +72,10 @@ class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> {
data: (grn) { data: (grn) {
final lookups = lookupsAsync.asData?.value; final lookups = lookupsAsync.asData?.value;
return SingleChildScrollView( return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 32), padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
child: Center( child: Column(
child: ConstrainedBox( crossAxisAlignment: CrossAxisAlignment.stretch,
constraints: const BoxConstraints(maxWidth: 1200), children: [
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_DetailHeader( _DetailHeader(
grn: grn, grn: grn,
isWorking: _isWorking, isWorking: _isWorking,
@ -100,8 +117,6 @@ class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> {
const SizedBox(height: 20), const SizedBox(height: 20),
_DetailFooter(grn: grn), _DetailFooter(grn: grn),
], ],
),
),
), ),
); );
}, },
@ -121,7 +136,10 @@ class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> {
} }
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
showAppToastFromSnackBar(context, SnackBar(content: Text(e.toString()))); showAppToastFromSnackBar(
context,
SnackBar(content: Text(errorDisplayMessage(e))),
);
} }
} finally { } finally {
if (mounted) setState(() => _isWorking = false); if (mounted) setState(() => _isWorking = false);
@ -133,7 +151,7 @@ class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> {
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: const Text('Cancel GRN'), title: const Text('Cancel Purchase Receipt'),
content: Column( content: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@ -158,7 +176,7 @@ class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> {
if (reasonController.text.trim().isEmpty) return; if (reasonController.text.trim().isEmpty) return;
Navigator.pop(context, true); 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( () => ref.read(grnDetailProvider(widget.grnId).notifier).cancel(
cancellationReason: reasonController.text.trim(), cancellationReason: reasonController.text.trim(),
), ),
'GRN cancelled', 'Purchase Receipt cancelled',
); );
reasonController.dispose(); reasonController.dispose();
} }
@ -182,14 +200,17 @@ class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> {
.downloadPdf(); .downloadPdf();
await downloadFile( await downloadFile(
bytes: bytes, bytes: bytes,
fileName: '${grn.grnNumber ?? 'GRN-${grn.id}'}.pdf', fileName: '${grn.grnNumber ?? 'PurchaseReceipt-${grn.id}'}.pdf',
); );
if (mounted) { if (mounted) {
showAppToastFromSnackBar(context, const SnackBar(content: Text('PDF downloaded'))); showAppToastFromSnackBar(context, const SnackBar(content: Text('PDF downloaded')));
} }
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
showAppToastFromSnackBar(context, SnackBar(content: Text(e.toString()))); showAppToastFromSnackBar(
context,
SnackBar(content: Text(errorDisplayMessage(e))),
);
} }
} finally { } finally {
if (mounted) setState(() => _isDownloadingPdf = false); if (mounted) setState(() => _isDownloadingPdf = false);
@ -280,7 +301,7 @@ class _DetailHeader extends StatelessWidget {
children: [ children: [
Flexible( Flexible(
child: Text( child: Text(
grn.grnNumber ?? 'GRN #${grn.id}', grn.grnNumber ?? 'Purchase Receipt #${grn.id}',
style: theme.textTheme.headlineSmall, style: theme.textTheme.headlineSmall,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
@ -508,124 +529,113 @@ class _ReceiptDetailsCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { 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( return _SectionCard(
title: 'RECEIPT DETAILS', title: 'RECEIPT DETAILS',
child: LayoutBuilder( child: Column(
builder: (context, constraints) { crossAxisAlignment: CrossAxisAlignment.stretch,
final cols = constraints.maxWidth < 600 children: [
? 1 DetailOverviewSection(
: constraints.maxWidth < 900 title: 'Summary',
? 2 child: DetailSummaryStrip(
: 4; metrics: [
const spacing = 20.0; DetailSummaryMetric(
final width = (constraints.maxWidth - spacing * (cols - 1)) / cols; icon: Icons.flag_outlined,
final items = [ label: 'Status',
_DetailField( accent: scheme.secondary,
label: 'GRN Date', child: GrnStatusChip(status: grn.status, compact: true),
value: DateFormatter.displayDate(grn.grnDate), ),
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', DetailOverviewSection(
value: _displayOrDash(grn.poNumber), 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', DetailOverviewSection(
value: _displayOrDash(grn.vendorName), 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 { class _LineItemsCard extends StatelessWidget {
const _LineItemsCard({required this.grn}); const _LineItemsCard({required this.grn});

View File

@ -5,26 +5,27 @@ import 'package:go_router/go_router.dart';
import '../../../../core/constants/route_constants.dart'; import '../../../../core/constants/route_constants.dart';
import '../../../../core/errors/failure.dart'; import '../../../../core/errors/failure.dart';
import '../../../../core/network/api_handler.dart';
import '../../../../core/utils/formatters.dart'; import '../../../../core/utils/formatters.dart';
import '../../../../core/utils/validators.dart'; import '../../../../core/utils/validators.dart';
import '../../../../shared/models/grn_model.dart'; import '../../../../shared/models/grn_model.dart';
import '../../../../shared/models/purchase_order_model.dart'; import '../../../../shared/models/purchase_order_model.dart';
import '../../../../shared/models/user_management_models.dart'; import '../../../../shared/models/user_management_models.dart';
import '../../../../shared/utils/navigation_utils.dart'; import '../../../../shared/utils/navigation_utils.dart';
import '../../../../shared/widgets/api_feedback.dart';
import '../../../../shared/widgets/app_button.dart'; import '../../../../shared/widgets/app_button.dart';
import '../../../../shared/widgets/app_date_popup.dart'; import '../../../../shared/widgets/app_date_popup.dart';
import '../../../../shared/widgets/app_dropdown.dart'; import '../../../../shared/widgets/app_dropdown.dart';
import '../../../../shared/widgets/app_loading_view.dart'; import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_searchable_dropdown.dart'; import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/app_side_panel.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/app_text_field.dart';
import '../../../../shared/widgets/error_view.dart'; import '../../../../shared/widgets/error_view.dart';
import '../../../../shared/widgets/app_toast.dart';
import '../providers/grn_lookups_provider.dart'; import '../providers/grn_lookups_provider.dart';
import '../providers/grn_provider.dart'; import '../providers/grn_provider.dart';
import '../widgets/grn_line_items_editor.dart'; import '../widgets/grn_line_items_editor.dart';
import '../widgets/grn_status_chip.dart'; import '../widgets/grn_status_chip.dart';
import '../../../../shared/widgets/app_toast.dart';
class GrnFormScreen extends ConsumerStatefulWidget { class GrnFormScreen extends ConsumerStatefulWidget {
const GrnFormScreen({super.key, this.grnId}); const GrnFormScreen({super.key, this.grnId});
@ -56,18 +57,29 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
final List<GrnLineItemDraft> _lines = []; final List<GrnLineItemDraft> _lines = [];
bool _isSubmitting = false; bool _isSubmitting = false;
String? _populatedSignature; String? _populatedSignature;
bool _requestedFreshLoad = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
if (!widget.isEditing) { if (!widget.isEditing) {
_grnDate = DateTime.now(); _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 @override
void dispose() { void dispose() {
_scrollController.dispose(); _scrollController.dispose();
@ -277,7 +289,7 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
if (!mounted) return; if (!mounted) return;
showAppToastFromSnackBar(context, showAppToastFromSnackBar(context,
SnackBar( SnackBar(
content: Text(widget.isEditing ? 'GRN updated' : 'GRN created'), content: Text(widget.isEditing ? 'Purchase Receipt updated' : 'Purchase Receipt created'),
), ),
); );
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
@ -289,9 +301,10 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
}); });
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
final message = showAppToastFromSnackBar(
e is Failure ? validationErrorMessage(e) : e.toString(); context,
showAppToastFromSnackBar(context, SnackBar(content: Text(message))); SnackBar(content: Text(errorDisplayMessage(e))),
);
} finally { } finally {
if (mounted) setState(() => _isSubmitting = false); if (mounted) setState(() => _isSubmitting = false);
} }
@ -350,7 +363,7 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
lookups: lookups, lookups: lookups,
existing: existingAsync.valueOrNull, existing: existingAsync.valueOrNull,
) )
: const AppLoadingView(message: 'Loading GRN...'), : const AppLoadingView(message: 'Loading Purchase Receipt...'),
error: (e, _) => ErrorView.fromFailure( error: (e, _) => ErrorView.fromFailure(
e is Failure ? e : Failure.unknown(message: e.toString()), e is Failure ? e : Failure.unknown(message: e.toString()),
onRetry: () => ref.invalidate(grnFormProvider(widget.grnId)), onRetry: () => ref.invalidate(grnFormProvider(widget.grnId)),
@ -378,7 +391,7 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
if (widget.isEditing && existing != null && !existing.canEdit) { if (widget.isEditing && existing != null && !existing.canEdit) {
return ErrorView.fromFailure( 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}'), onRetry: () => context.go('${RouteConstants.grn}/${existing.id}'),
); );
} }
@ -395,16 +408,16 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
.toList(); .toList();
final theme = Theme.of(context); final theme = Theme.of(context);
return SingleChildScrollView( return Form(
controller: _scrollController, key: _formKey,
padding: const EdgeInsets.fromLTRB(24, 16, 24, 32), child: AppStickyFormLayout(
child: Form( scrollController: _scrollController,
key: _formKey, headerPadding: const EdgeInsets.fromLTRB(24, 12, 24, 12),
child: Column( bodyPadding: const EdgeInsets.fromLTRB(24, 16, 24, 32),
header: _buildHeader(existing),
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
_buildHeader(existing),
const SizedBox(height: 16),
_SectionCard( _SectionCard(
title: 'RECEIPT DETAILS', title: 'RECEIPT DETAILS',
child: Column( child: Column(
@ -412,7 +425,7 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
FormRowFour( FormRowFour(
children: [ children: [
_DateField( _DateField(
label: 'GRN Date *', label: 'Purchase Receipt Date *',
value: _grnDate, value: _grnDate,
enabled: !widget.isEditing, enabled: !widget.isEditing,
onTap: widget.isEditing onTap: widget.isEditing
@ -451,8 +464,11 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
} }
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
showAppToastFromSnackBar(context, showAppToastFromSnackBar(
SnackBar(content: Text(e.toString())), context,
SnackBar(
content: Text(errorDisplayMessage(e)),
),
); );
} }
}, },
@ -625,8 +641,8 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
Widget _buildHeader(GrnModel? existing) { Widget _buildHeader(GrnModel? existing) {
final theme = Theme.of(context); final theme = Theme.of(context);
final title = widget.isEditing final title = widget.isEditing
? 'Edit ${existing?.grnNumber ?? 'GRN'}' ? 'Edit ${existing?.grnNumber ?? 'Purchase Receipt'}'
: 'Create Goods Received Note'; : 'Create Purchase Receipt';
final subtitle = widget.isEditing final subtitle = widget.isEditing
? null ? null
: 'Select an approved purchase order, enter receipt details, then confirm quantities.'; : 'Select an approved purchase order, enter receipt details, then confirm quantities.';
@ -640,7 +656,7 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
AppButton( AppButton(
label: widget.isEditing ? 'Update GRN' : 'Save GRN', label: widget.isEditing ? 'Update Purchase Receipt' : 'Save Purchase Receipt',
icon: Icons.check, icon: Icons.check,
expand: false, expand: false,
isLoading: _isSubmitting, isLoading: _isSubmitting,
@ -675,7 +691,7 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
); );
return Padding( return Padding(
padding: const EdgeInsets.only(bottom: 8), padding: EdgeInsets.zero,
child: LayoutBuilder( child: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final stack = constraints.maxWidth < 720; final stack = constraints.maxWidth < 720;

View File

@ -21,9 +21,11 @@ import '../../../../shared/widgets/app_search_filter_toggle.dart';
import '../../../../shared/widgets/app_searchable_dropdown.dart'; import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/can_permission.dart'; import '../../../../shared/widgets/can_permission.dart';
import '../../../../shared/widgets/app_table_action_icon.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/app_table_shell.dart';
import '../../../../shared/widgets/error_view.dart'; import '../../../../shared/widgets/error_view.dart';
import '../../../../shared/widgets/page_header.dart'; import '../../../../shared/widgets/page_header.dart';
import '../../../../shared/providers/table_column_prefs_provider.dart';
import '../../../../shared/utils/file_download_helper.dart'; import '../../../../shared/utils/file_download_helper.dart';
import '../providers/grn_lookups_provider.dart'; import '../providers/grn_lookups_provider.dart';
import '../providers/grn_provider.dart'; import '../providers/grn_provider.dart';
@ -64,7 +66,7 @@ class _GrnListScreenState extends ConsumerState<GrnListScreen> {
return Padding( return Padding(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
child: listAsync.when( child: listAsync.when(
loading: () => const AppLoadingView(message: 'Loading GRNs...'), loading: () => const AppLoadingView(message: 'Loading Purchase Receipts...'),
error: (error, _) => ErrorView.fromFailure( error: (error, _) => ErrorView.fromFailure(
error is Failure ? error : Failure.unknown(message: error.toString()), error is Failure ? error : Failure.unknown(message: error.toString()),
onRetry: () => ref.invalidate(grnListProvider), onRetry: () => ref.invalidate(grnListProvider),
@ -73,7 +75,7 @@ class _GrnListScreenState extends ConsumerState<GrnListScreen> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
PageHeader( PageHeader(
title: 'Goods Received Notes', title: 'Purchase Receipt',
subtitle: 'Record and track purchase order receipts', subtitle: 'Record and track purchase order receipts',
actions: [ actions: [
AppSearchFilterButton( AppSearchFilterButton(
@ -82,6 +84,11 @@ class _GrnListScreenState extends ConsumerState<GrnListScreen> {
() => _filtersExpanded = !_filtersExpanded, () => _filtersExpanded = !_filtersExpanded,
), ),
), ),
const SizedBox(width: 8),
AppTableColumnSelectorButton(
tableId: _GrnDataTable.tableId,
columns: _GrnDataTable.columnOptions,
),
if (canExport) ...[ if (canExport) ...[
const SizedBox(width: 8), const SizedBox(width: 8),
OutlinedButton.icon( OutlinedButton.icon(
@ -103,7 +110,7 @@ class _GrnListScreenState extends ConsumerState<GrnListScreen> {
child: ElevatedButton.icon( child: ElevatedButton.icon(
onPressed: () => context.go(RouteConstants.grnAdd), onPressed: () => context.go(RouteConstants.grnAdd),
icon: const Icon(Icons.add), 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, totalPages: state.totalPages,
totalItems: state.total, totalItems: state.total,
pageSize: state.query.limit, pageSize: state.query.limit,
itemLabel: 'GRNs', itemsOnPage: state.grns.length,
itemLabel: 'Purchase Receipts',
onPageChanged: ref.read(grnListProvider.notifier).setPage, onPageChanged: ref.read(grnListProvider.notifier).setPage,
onPageSizeChanged: ref.read(grnListProvider.notifier).setPageSize, onPageSizeChanged: ref.read(grnListProvider.notifier).setPageSize,
), ),
child: RefreshIndicator( child: RefreshIndicator(
onRefresh: () => ref.read(grnListProvider.notifier).refresh(), onRefresh: () => ref.read(grnListProvider.notifier).refresh(),
child: state.grns.isEmpty child: context.isMobile
? ListView( ? (state.grns.isEmpty
physics: const AlwaysScrollableScrollPhysics(), ? ListView(
children: const [ physics: const AlwaysScrollableScrollPhysics(),
SizedBox( children: const [
height: 240, SizedBox(
child: AppEmptyState( height: 240,
title: 'No GRNs found', child: AppEmptyState(
description: title: 'No Purchase Receipts found',
'Try adjusting filters or create a new goods received note.', description:
icon: Icons.inventory_2_outlined, 'Try adjusting filters or create a new purchase receipt.',
), icon: Icons.inventory_2_outlined,
), ),
], ),
) ],
: context.isMobile
? _GrnCardList(
grns: state.grns,
onView: _viewGrn,
onEdit: canEdit ? _editGrn : null,
) )
: _GrnDataTable( : _GrnCardList(
grns: state.grns, grns: state.grns,
onView: _viewGrn, onView: _viewGrn,
onEdit: canEdit ? _editGrn : null, 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) { void _editGrn(GrnModel grn) {
if (!grn.canEdit) { if (!grn.canEdit) {
showAppToastFromSnackBar(context, showAppToastFromSnackBar(context,
const SnackBar(content: Text('Only posted GRNs can be edited')), const SnackBar(content: Text('Only posted Purchase Receipts can be edited')),
); );
return; return;
} }
@ -236,7 +250,7 @@ class _FiltersBar extends StatelessWidget {
onChanged: onSearch, onChanged: onSearch,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Search', labelText: 'Search',
hintText: 'Search GRN number, PO, vendor...', hintText: 'Search receipt number, PO, vendor...',
prefixIcon: Icon(Icons.search, size: 20), prefixIcon: Icon(Icons.search, size: 20),
floatingLabelBehavior: FloatingLabelBehavior.always, floatingLabelBehavior: FloatingLabelBehavior.always,
isDense: true, isDense: true,
@ -282,83 +296,138 @@ class _FiltersBar extends StatelessWidget {
} }
} }
class _GrnDataTable extends StatelessWidget { class _GrnDataTable extends ConsumerWidget {
const _GrnDataTable({ const _GrnDataTable({
required this.grns, required this.grns,
required this.onView, required this.onView,
this.onEdit, 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 List<GrnModel> grns;
final ValueChanged<GrnModel> onView; final ValueChanged<GrnModel> onView;
final ValueChanged<GrnModel>? onEdit; 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 @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>( return AppDataTable<GrnModel>(
wrapInCard: false, wrapInCard: false,
columns: [ onEnsureFullDataset: onEnsureFullDataset,
AppDataColumn( onColumnSearchCleared: onColumnSearchCleared,
label: 'GRN Number', columns: columns,
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),
),
],
),
),
],
rows: grns, rows: grns,
); );
} }
@ -391,18 +460,26 @@ class _GrnCardList extends StatelessWidget {
Row( Row(
children: [ children: [
Expanded( Expanded(
child: Text( child: AppTableCell.link(
grn.grnNumber ?? 'GRN #${grn.id}', grn.grnNumber ?? 'Purchase Receipt #${grn.id}',
style: Theme.of(context).textTheme.titleMedium?.copyWith( onTap: () => onView(grn),
fontWeight: FontWeight.w600,
),
), ),
), ),
GrnStatusChip(status: grn.status, compact: true), GrnStatusChip(status: grn.status, compact: true),
], ],
), ),
const SizedBox(height: 8), 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('Vendor: ${grn.vendorName ?? ''}'),
Text('Location: ${grn.locationName ?? ''}'), Text('Location: ${grn.locationName ?? ''}'),
Text('Date: ${DateFormatter.displayDate(grn.grnDate)}'), Text('Date: ${DateFormatter.displayDate(grn.grnDate)}'),

View File

@ -2,11 +2,10 @@ import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/errors/failure.dart';
import '../../../../core/network/api_handler.dart';
import '../../../../core/utils/formatters.dart'; import '../../../../core/utils/formatters.dart';
import '../../../../shared/models/grn_model.dart'; import '../../../../shared/models/grn_model.dart';
import '../../../../shared/utils/file_download_helper.dart'; import '../../../../shared/utils/file_download_helper.dart';
import '../../../../shared/widgets/api_feedback.dart';
import '../../../../shared/widgets/app_confirmation_dialog.dart'; import '../../../../shared/widgets/app_confirmation_dialog.dart';
import '../providers/grn_provider.dart'; import '../providers/grn_provider.dart';
import '../../../../shared/widgets/app_toast.dart'; import '../../../../shared/widgets/app_toast.dart';
@ -95,10 +94,9 @@ class _GrnAttachmentsCardState extends ConsumerState<GrnAttachmentsCard> {
); );
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
final message = showAppToastFromSnackBar(
e is Failure ? validationErrorMessage(e) : e.toString(); context,
showAppToastFromSnackBar(context, SnackBar(content: Text(errorDisplayMessage(e))),
SnackBar(content: Text(message)),
); );
} finally { } finally {
if (mounted) setState(() => _isUploading = false); if (mounted) setState(() => _isUploading = false);
@ -118,8 +116,9 @@ class _GrnAttachmentsCardState extends ConsumerState<GrnAttachmentsCard> {
); );
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
showAppToastFromSnackBar(context, showAppToastFromSnackBar(
SnackBar(content: Text(e.toString())), context,
SnackBar(content: Text(errorDisplayMessage(e))),
); );
} finally { } finally {
if (mounted) setState(() => _busyAttachmentId = null); if (mounted) setState(() => _busyAttachmentId = null);
@ -150,10 +149,9 @@ class _GrnAttachmentsCardState extends ConsumerState<GrnAttachmentsCard> {
); );
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
final message = showAppToastFromSnackBar(
e is Failure ? validationErrorMessage(e) : e.toString(); context,
showAppToastFromSnackBar(context, SnackBar(content: Text(errorDisplayMessage(e))),
SnackBar(content: Text(message)),
); );
} finally { } finally {
if (mounted) setState(() => _busyAttachmentId = null); if (mounted) setState(() => _busyAttachmentId = null);
@ -208,8 +206,8 @@ class _GrnAttachmentsCardState extends ConsumerState<GrnAttachmentsCard> {
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
showUpload showUpload
? 'PDF, JPEG, PNG, or WebP · upload/delete only while GRN is Posted' ? 'PDF, JPEG, PNG, or WebP · upload/delete only while Purchase Receipt is Posted'
: 'Supporting documents for this GRN', : 'Supporting documents for this Purchase Receipt',
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
), ),
@ -258,7 +256,7 @@ class _GrnAttachmentsCardState extends ConsumerState<GrnAttachmentsCard> {
attachments.isNotEmpty) ...[ attachments.isNotEmpty) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
Text( 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( style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
), ),

View File

@ -332,9 +332,9 @@ class _GrnLineItemCardState extends State<_GrnLineItemCard> {
const SizedBox(height: 8), const SizedBox(height: 8),
FormRow( FormRow(
columnCount: 12, columnCount: 12,
spans: const [2, 2, 2, 2, 2, 2], spans: const [2, 2, 2, 3, 3],
spacing: 8, spacing: 8,
stackBelowWidth: 1100, stackBelowWidth: 0,
children: [ children: [
AppTextField( AppTextField(
key: ValueKey('$lineKey-accepted'), key: ValueKey('$lineKey-accepted'),
@ -398,14 +398,6 @@ class _GrnLineItemCardState extends State<_GrnLineItemCard> {
readOnly: true, readOnly: true,
fillColor: currentBg, fillColor: currentBg,
), ),
AppTextField(
key: ValueKey('$lineKey-rate'),
controller: item.rateController,
label: 'Rate',
hint: '0.00',
isDense: true,
readOnly: true,
),
AppTextField( AppTextField(
key: ValueKey('$lineKey-batch'), key: ValueKey('$lineKey-batch'),
controller: item.batchNoController, controller: item.batchNoController,
@ -439,7 +431,7 @@ class _GrnLineItemCardState extends State<_GrnLineItemCard> {
columnCount: 12, columnCount: 12,
spans: const [3, 3, 3, 3], spans: const [3, 3, 3, 3],
spacing: 8, spacing: 8,
stackBelowWidth: 1100, stackBelowWidth: 0,
children: [ children: [
_GrnLineDateField( _GrnLineDateField(
key: ValueKey('$lineKey-mfg'), key: ValueKey('$lineKey-mfg'),

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../../core/theme/app_typography.dart';
import '../../../../shared/models/grn_model.dart'; import '../../../../shared/models/grn_model.dart';
import '../../../../shared/widgets/app_status_chip.dart'; import '../../../../shared/widgets/app_status_chip.dart';
@ -17,7 +18,8 @@ class GrnStatusChip extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final (color, label) = _resolveStatus(status); final secondary = Theme.of(context).colorScheme.secondary;
final (color, label) = _resolveStatus(status, secondary);
if (forTable) { if (forTable) {
return TableStatusBadge( return TableStatusBadge(
label: label, label: label,
@ -30,11 +32,15 @@ class GrnStatusChip extends StatelessWidget {
return Chip( return Chip(
label: Text( label: Text(
label, label,
style: TextStyle( style: compact
color: color, ? AppTypography.caption1(
fontSize: compact ? 11 : 12, weight: AppTypography.semiBold,
fontWeight: FontWeight.w600, color: color,
), )
: AppTypography.label3(
weight: AppTypography.semiBold,
color: color,
),
), ),
backgroundColor: color.withValues(alpha: 0.12), backgroundColor: color.withValues(alpha: 0.12),
side: BorderSide(color: color.withValues(alpha: 0.3)), 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()) { switch (raw.toUpperCase()) {
case 'POSTED': case 'POSTED':
return (Colors.green.shade700, grnStatusLabel(raw)); return (secondary, grnStatusLabel(raw));
case 'CANCELLED': case 'CANCELLED':
return (Colors.grey.shade700, grnStatusLabel(raw)); return (Colors.grey.shade700, grnStatusLabel(raw));
default: default:
return (Colors.blueGrey, grnStatusLabel(raw)); return (secondary, grnStatusLabel(raw));
} }
} }
} }

View File

@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/network/dio_client.dart'; import '../../../../core/network/dio_client.dart';
import '../../../../core/utils/active_option.dart'; import '../../../../core/utils/active_option.dart';
import '../../../../core/utils/pagination_meta.dart';
import '../../../../shared/models/export_file_result.dart'; import '../../../../shared/models/export_file_result.dart';
import '../../domain/entities/master_definition.dart'; import '../../domain/entities/master_definition.dart';
@ -17,18 +18,14 @@ class MasterListResult {
required this.total, required this.total,
required this.page, required this.page,
required this.limit, required this.limit,
required this.totalPages,
}); });
final List<Map<String, dynamic>> items; final List<Map<String, dynamic>> items;
final int total; final int total;
final int page; final int page;
final int limit; final int limit;
final int totalPages;
int get totalPages {
if (limit <= 0) return 1;
final pages = (total / limit).ceil();
return pages < 1 ? 1 : pages;
}
} }
class MasterCrudRemoteDataSource { class MasterCrudRemoteDataSource {
@ -65,19 +62,26 @@ class MasterCrudRemoteDataSource {
final items = list final items = list
.whereType<Map<String, dynamic>>() .whereType<Map<String, dynamic>>()
.map((item) => Map<String, dynamic>.from(item)) .map((item) => _sanitizeLocationRow(
definition,
Map<String, dynamic>.from(item),
))
.toList(); .toList();
final meta = raw is Map<String, dynamic> ? raw : body; final pagination = parsePagination(
final total = _asInt(meta['total']) ?? items.length; body: body,
final currentPage = _asInt(meta['page']) ?? page; fallbackPage: page,
final currentLimit = _asInt(meta['limit']) ?? limit; fallbackLimit: limit,
itemCount: items.length,
);
return MasterListResult( return MasterListResult(
items: items, items: items,
total: total, total: pagination.total,
page: currentPage, page: pagination.page,
limit: currentLimit, // 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 return list
.whereType<Map>() .whereType<Map>()
.map((item) => Map<String, dynamic>.from(item)) .map(
(item) => _sanitizeLocationRow(
definition,
Map<String, dynamic>.from(item),
),
)
.where(isActiveOptionRow) .where(isActiveOptionRow)
.toList(); .toList();
} }
@ -113,7 +122,7 @@ class MasterCrudRemoteDataSource {
String id, String id,
) async { ) async {
final response = await dio.get('${definition.apiPath}/$id'); final response = await dio.get('${definition.apiPath}/$id');
return _extractData(response.data); return _sanitizeLocationRow(definition, _extractData(response.data));
} }
Future<Map<String, dynamic>> create( Future<Map<String, dynamic>> create(
@ -124,7 +133,7 @@ class MasterCrudRemoteDataSource {
definition.apiPath, definition.apiPath,
data: payload..removeWhere((_, value) => value == null), data: payload..removeWhere((_, value) => value == null),
); );
return _extractData(response.data); return _sanitizeLocationRow(definition, _extractData(response.data));
} }
Future<Map<String, dynamic>> update( Future<Map<String, dynamic>> update(
@ -136,7 +145,7 @@ class MasterCrudRemoteDataSource {
'${definition.apiPath}/$id', '${definition.apiPath}/$id',
data: payload..removeWhere((_, value) => value == null), data: payload..removeWhere((_, value) => value == null),
); );
return _extractData(response.data); return _sanitizeLocationRow(definition, _extractData(response.data));
} }
Future<void> delete(MasterDefinition definition, String id) async { Future<void> delete(MasterDefinition definition, String id) async {
@ -197,10 +206,15 @@ class MasterCrudRemoteDataSource {
return Map<String, dynamic>.from(body); return Map<String, dynamic>.from(body);
} }
int? _asInt(Object? value) { /// Locations API no longer nests warehouses under plants.
if (value is int) return value; Map<String, dynamic> _sanitizeLocationRow(
if (value is num) return value.toInt(); MasterDefinition definition,
if (value is String) return int.tryParse(value); Map<String, dynamic> row,
return null; ) {
if (definition.id != 'locations') return row;
row.remove('parent_id');
row.remove('plant_id');
row.remove('plant');
return row;
} }
} }

View File

@ -21,6 +21,7 @@ class MasterRepositoryImpl implements MasterRepository {
int page = 1, int page = 1,
int limit = 20, int limit = 20,
String? search, String? search,
Map<String, dynamic>? extraQueryParameters,
}) => }) =>
safeApiCall( safeApiCall(
() => remote.list( () => remote.list(
@ -28,6 +29,7 @@ class MasterRepositoryImpl implements MasterRepository {
page: page, page: page,
limit: limit, limit: limit,
search: search, search: search,
extraQueryParameters: extraQueryParameters,
), ),
); );

View File

@ -22,6 +22,7 @@ class MasterFieldDef {
this.filterByOptionKey, this.filterByOptionKey,
this.visibleWhenFieldKey, this.visibleWhenFieldKey,
this.visibleWhenValue, this.visibleWhenValue,
this.listNestedKey,
}); });
final String key; final String key;
@ -33,7 +34,7 @@ class MasterFieldDef {
final bool showInForm; final bool showInForm;
/// When true, shown in the form but not editable (value set by other fields). /// When true, shown in the form but not editable (value set by other fields).
final bool readOnly; 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; final String? optionsMasterKey;
/// Extra query parameters when loading [optionsMasterKey] options. /// Extra query parameters when loading [optionsMasterKey] options.
final Map<String, dynamic>? optionsQueryParams; final Map<String, dynamic>? optionsQueryParams;
@ -47,6 +48,8 @@ class MasterFieldDef {
/// Show this field only when [visibleWhenFieldKey] equals [visibleWhenValue]. /// Show this field only when [visibleWhenFieldKey] equals [visibleWhenValue].
final String? visibleWhenFieldKey; final String? visibleWhenFieldKey;
final String? visibleWhenValue; 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). /// Cache key for dropdown option rows (includes query params when set).
String get dropdownLookupKey { String get dropdownLookupKey {
@ -223,13 +226,14 @@ const masterDefinitions = <MasterDefinition>[
showInList: true, showInList: true,
showInForm: false, showInForm: false,
), ),
MasterFieldDef(key: 'item_name', label: 'Item Name', required: true, showInList: true),
MasterFieldDef( MasterFieldDef(
key: 'is_asset_item', key: 'is_asset_item',
label: 'Asset Item', label: 'Asset Item',
type: MasterFieldType.boolean, type: MasterFieldType.boolean,
required: true, required: true,
showInList: true,
), ),
MasterFieldDef(key: 'item_name', label: 'Item Name', required: true, showInList: true),
MasterFieldDef( MasterFieldDef(
key: 'item_category_id', key: 'item_category_id',
label: 'Category', label: 'Category',
@ -272,12 +276,17 @@ const masterDefinitions = <MasterDefinition>[
label: 'Min Order Qty', label: 'Min Order Qty',
type: MasterFieldType.number, type: MasterFieldType.number,
required: true, required: true,
// Stock items only hidden when Asset Item is checked.
visibleWhenFieldKey: 'is_asset_item',
visibleWhenValue: 'false',
), ),
MasterFieldDef( MasterFieldDef(
key: 'reorder_level', key: 'reorder_level',
label: 'Reorder Level', label: 'Reorder Level',
type: MasterFieldType.number, type: MasterFieldType.number,
required: true, required: true,
visibleWhenFieldKey: 'is_asset_item',
visibleWhenValue: 'false',
), ),
MasterFieldDef( MasterFieldDef(
key: 'tags', key: 'tags',
@ -368,31 +377,51 @@ const masterDefinitions = <MasterDefinition>[
MasterFieldDef(key: 'code', label: 'Code', required: true, showInList: true), MasterFieldDef(key: 'code', label: 'Code', required: true, showInList: true),
MasterFieldDef(key: 'name', label: 'Name', required: true, showInList: true), MasterFieldDef(key: 'name', label: 'Name', required: true, showInList: true),
MasterFieldDef( MasterFieldDef(
key: 'parent_id', key: 'gstin',
label: 'Parent Plant', label: 'GSTIN',
type: MasterFieldType.dropdown,
showInList: true, showInList: true,
optionsMasterKey: 'locations',
optionsQueryParams: const {'type': 'plant'},
visibleWhenFieldKey: 'type', visibleWhenFieldKey: 'type',
visibleWhenValue: 'warehouse', visibleWhenValue: 'plant',
required: true, ),
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( MasterFieldDef(
key: 'state', key: 'state',
label: 'State', label: 'State',
type: MasterFieldType.dropdown, type: MasterFieldType.dropdown,
optionsMasterKey: 'location_states', optionsMasterKey: 'location_states',
showInList: true, 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( MasterFieldDef(
key: 'location', key: 'location',
label: 'Location Detail', label: 'Location Detail',
showInList: true,
visibleWhenFieldKey: 'type', visibleWhenFieldKey: 'type',
visibleWhenValue: 'warehouse', visibleWhenValue: 'warehouse',
), ),
@ -563,6 +592,10 @@ String masterCellValue(Map<String, dynamic> row, MasterFieldDef field) {
final value = row[field.key]; final value = row[field.key];
if (value == null || value == '') return ''; if (value == null || value == '') return '';
if (field.key == 'is_asset_item') {
return masterIsAssetItem(value) ? 'Asset' : 'Stock';
}
if (field.key == 'tags') { if (field.key == 'tags') {
if (value is List) { if (value is List) {
final tags = value final tags = value
@ -606,18 +639,25 @@ String masterCellValue(Map<String, dynamic> row, MasterFieldDef field) {
if (explicitName != null && explicitName.toString().trim().isNotEmpty) { if (explicitName != null && explicitName.toString().trim().isNotEmpty) {
return explicitName.toString().trim(); return explicitName.toString().trim();
} }
final nested = row[baseKey];
if (nested is Map) { final nestedKeys = <String>[
for (final nestedKey in ['name', 'code', 'description']) { if (field.listNestedKey != null) field.listNestedKey!,
final nestedValue = nested[nestedKey]; baseKey,
if (nestedValue != null && ];
nestedValue.toString().trim().isNotEmpty) { for (final nestedKey in nestedKeys) {
return nestedValue.toString().trim(); 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'; 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. /// Category list filter for Items form: ASSET when Asset Item is checked.
String itemCategoryTypeForValues(Map<String, dynamic> values) => String itemCategoryTypeForValues(Map<String, dynamic> values) =>
values['is_asset_item'] == true ? 'ASSET' : 'STOCK'; values['is_asset_item'] == true ? 'ASSET' : 'STOCK';
@ -670,6 +717,28 @@ String masterFieldDropdownLookupKey({
return '$masterKey?$query'; 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`. /// Display label for GST filled from HSN nested `gst_rate.description`.
String? gstRateDisplayFromValues(Map<String, dynamic> values) { String? gstRateDisplayFromValues(Map<String, dynamic> values) {
final nested = values['gst_rate']; final nested = values['gst_rate'];

View File

@ -9,6 +9,7 @@ abstract class MasterRepository {
int page, int page,
int limit, int limit,
String? search, String? search,
Map<String, dynamic>? extraQueryParameters,
}); });
Future<Result<List<Map<String, dynamic>>>> listOptions( Future<Result<List<Map<String, dynamic>>>> listOptions(

View File

@ -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);
}
}

View File

@ -1,12 +1,15 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/constants/app_constants.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 '../../../../core/utils/table_search.dart';
import '../../../../shared/models/export_file_result.dart'; import '../../../../shared/models/export_file_result.dart';
import '../../../assets/data/repositories/asset_repository_impl.dart'; import '../../../assets/data/repositories/asset_repository_impl.dart';
import '../../../masters/data/datasources/master_remote_data_source.dart'; import '../../../masters/data/datasources/master_remote_data_source.dart';
import '../../data/repositories/master_repository_impl.dart'; import '../../data/repositories/master_repository_impl.dart';
import '../../domain/entities/master_definition.dart'; import '../../domain/entities/master_definition.dart';
import 'master_consumer_invalidation.dart';
class MasterListState { class MasterListState {
const MasterListState({ const MasterListState({
@ -94,6 +97,10 @@ final masterListProvider = AsyncNotifierProvider.family<
MasterListNotifier, MasterListState, String>(MasterListNotifier.new); MasterListNotifier, MasterListState, String>(MasterListNotifier.new);
class MasterListNotifier extends FamilyAsyncNotifier<MasterListState, String> { class MasterListNotifier extends FamilyAsyncNotifier<MasterListState, String> {
final _columnSearch = ColumnSearchPaging(
defaultLimit: AppConstants.defaultPageSize,
);
MasterDefinition get _definition { MasterDefinition get _definition {
final def = masterDefinitionById(arg); final def = masterDefinitionById(arg);
if (def == null) throw StateError('Unknown master: $arg'); if (def == null) throw StateError('Unknown master: $arg');
@ -114,45 +121,59 @@ class MasterListNotifier extends FamilyAsyncNotifier<MasterListState, String> {
final current = state.valueOrNull; final current = state.valueOrNull;
final nextPage = page ?? current?.page ?? 1; final nextPage = page ?? current?.page ?? 1;
final nextLimit = limit ?? current?.limit ?? AppConstants.defaultPageSize; 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( final result = await repository.list(
_definition, _definition,
page: nextPage, page: nextPage,
limit: nextLimit, limit: nextLimit,
search: nextSearch, search: nextSearch.isEmpty ? null : nextSearch,
); );
if (result.failure != null) throw result.failure!; 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( return MasterListState(
items: data.items, items: items,
search: nextSearch ?? '', search: nextSearch,
page: data.page, page: data.page,
// Keep the requested page size so the /page dropdown stays valid // Keep the requested page size so the /page dropdown stays valid
// even if API meta omits or mismatches `limit`. // even if API meta omits or mismatches `limit`.
limit: nextLimit, limit: nextLimit,
total: data.total, total: data.total,
totalPages: _resolveTotalPages( totalPages: resolveTotalPages(total: data.total, limit: nextLimit),
apiTotalPages: data.totalPages,
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 { Future<void> refresh() async {
final previous = state.valueOrNull; final previous = state.valueOrNull;
if (previous == null) { if (previous == null) {
@ -169,6 +190,32 @@ class MasterListNotifier extends FamilyAsyncNotifier<MasterListState, String> {
await _reload(page: 1, search: search); 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 { Future<void> setPage(int page) async {
await _reload(page: page); await _reload(page: page);
} }
@ -202,6 +249,7 @@ class MasterListNotifier extends FamilyAsyncNotifier<MasterListState, String> {
} }
await refresh(); await refresh();
invalidateMasterConsumerLookups(ref.invalidate, _definition.id);
return true; 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. // Load after values so Items category options use is_asset_item.
final dropdownOptions = await _loadDropdownOptions(values: values); 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) { void updateValue(String key, dynamic value) {
final current = state.valueOrNull; final current = state.valueOrNull;
if (current == null) return; if (current == null) return;
@ -447,10 +505,17 @@ class MasterFormNotifier extends FamilyAsyncNotifier<MasterFormState, MasterForm
_clearHiddenFieldValues(values); _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') { if (key == 'is_asset_item' && _definition.id == 'items') {
values['item_category_id'] = null; values['item_category_id'] = null;
values['item_subcategory_id'] = null; values['item_subcategory_id'] = null;
_clearHiddenFieldValues(values);
state = AsyncData(current.copyWith(values: values)); state = AsyncData(current.copyWith(values: values));
_reloadItemCategoryOptions(values); _reloadItemCategoryOptions(values);
return; return;
@ -496,7 +561,11 @@ class MasterFormNotifier extends FamilyAsyncNotifier<MasterFormState, MasterForm
final current = state.valueOrNull; final current = state.valueOrNull;
if (current == null) return; if (current == null) return;
final options = await _loadDropdownOptions(values: current.values); 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) { Map<String, dynamic> _buildPayload(MasterFormState current) {
@ -523,6 +592,14 @@ class MasterFormNotifier extends FamilyAsyncNotifier<MasterFormState, MasterForm
MasterFieldType.text => value.toString().trim(), 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; return payload;
} }
@ -569,8 +646,30 @@ class MasterFormNotifier extends FamilyAsyncNotifier<MasterFormState, MasterForm
} }
state = AsyncData(current.copyWith(isSubmitting: false, clearError: true)); 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; if (createdId != null && createdId.isNotEmpty) return createdId;
return arg.recordId ?? 'created'; 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;
}
} }

View File

@ -7,21 +7,23 @@ import '../../../../core/constants/route_constants.dart';
import '../../../../core/errors/failure.dart'; import '../../../../core/errors/failure.dart';
import '../../../../shared/utils/file_download_helper.dart'; import '../../../../shared/utils/file_download_helper.dart';
import '../../../../shared/providers/permissions_provider.dart'; import '../../../../shared/providers/permissions_provider.dart';
import '../../../../shared/providers/table_column_prefs_provider.dart';
import '../../../../shared/widgets/app_confirmation_dialog.dart'; import '../../../../shared/widgets/app_confirmation_dialog.dart';
import '../../../../shared/widgets/app_data_table.dart'; import '../../../../shared/widgets/app_data_table.dart';
import '../../../../shared/widgets/app_empty_state.dart';
import '../../../../shared/widgets/app_loading_view.dart'; import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_pagination.dart'; import '../../../../shared/widgets/app_pagination.dart';
import '../../../../shared/widgets/app_search_export_bar.dart'; import '../../../../shared/widgets/app_search_export_bar.dart';
import '../../../../shared/widgets/app_search_filter_toggle.dart'; import '../../../../shared/widgets/app_search_filter_toggle.dart';
import '../../../../shared/widgets/app_status_chip.dart'; import '../../../../shared/widgets/app_status_chip.dart';
import '../../../../shared/widgets/app_table_action_icon.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/app_table_shell.dart';
import '../../../../shared/widgets/error_view.dart'; import '../../../../shared/widgets/error_view.dart';
import '../../../../shared/widgets/app_side_panel.dart'; import '../../../../shared/widgets/app_side_panel.dart';
import '../../../../shared/widgets/page_header.dart'; import '../../../../shared/widgets/page_header.dart';
import '../../domain/entities/master_definition.dart'; import '../../domain/entities/master_definition.dart';
import '../providers/master_provider.dart'; import '../providers/master_provider.dart';
import '../providers/master_consumer_invalidation.dart';
import '../widgets/master_form_panel.dart'; import '../widgets/master_form_panel.dart';
import '../../../../shared/widgets/app_toast.dart'; import '../../../../shared/widgets/app_toast.dart';
@ -37,6 +39,7 @@ class MasterListScreen extends ConsumerStatefulWidget {
class _MasterListScreenState extends ConsumerState<MasterListScreen> { class _MasterListScreenState extends ConsumerState<MasterListScreen> {
final _searchController = TextEditingController(); final _searchController = TextEditingController();
bool _filtersExpanded = false; bool _filtersExpanded = false;
bool _didResetSearchOnOpen = false;
MasterDefinition get _definition { MasterDefinition get _definition {
final def = masterDefinitionById(widget.masterId); final def = masterDefinitionById(widget.masterId);
@ -44,6 +47,24 @@ class _MasterListScreenState extends ConsumerState<MasterListScreen> {
return def; 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 @override
void dispose() { void dispose() {
_searchController.dispose(); _searchController.dispose();
@ -107,7 +128,7 @@ class _MasterListScreenState extends ConsumerState<MasterListScreen> {
width: 560, width: 560,
); );
if (saved != null && mounted) { if (saved != null && mounted) {
ref.invalidate(masterListProvider(widget.masterId)); invalidateMasterConsumerLookups(ref.invalidate, widget.masterId);
showAppToastFromSnackBar(context, showAppToastFromSnackBar(context,
SnackBar( SnackBar(
content: Text( content: Text(
@ -137,6 +158,10 @@ class _MasterListScreenState extends ConsumerState<MasterListScreen> {
await ref.read(masterListProvider(widget.masterId).notifier).deleteRecord(id); await ref.read(masterListProvider(widget.masterId).notifier).deleteRecord(id);
if (!mounted) return; if (!mounted) return;
if (success) {
invalidateMasterConsumerLookups(ref.invalidate, widget.masterId);
}
showAppToastFromSnackBar(context, showAppToastFromSnackBar(context,
SnackBar( SnackBar(
content: Text( content: Text(
@ -181,6 +206,11 @@ class _MasterListScreenState extends ConsumerState<MasterListScreen> {
() => _filtersExpanded = !_filtersExpanded, () => _filtersExpanded = !_filtersExpanded,
), ),
), ),
const SizedBox(width: 8),
AppTableColumnSelectorButton(
tableId: _MasterListTable.tableIdFor(def),
columns: _MasterListTable.columnOptionsFor(def),
),
if (canExport) ...[ if (canExport) ...[
const SizedBox(width: 8), const SizedBox(width: 8),
OutlinedButton.icon( OutlinedButton.icon(
@ -197,7 +227,13 @@ class _MasterListScreenState extends ConsumerState<MasterListScreen> {
], ],
const SizedBox(width: 8), const SizedBox(width: 8),
OutlinedButton.icon( 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), icon: const Icon(Icons.grid_view_outlined),
label: const Text('All Masters'), label: const Text('All Masters'),
), ),
@ -225,36 +261,26 @@ class _MasterListScreenState extends ConsumerState<MasterListScreen> {
totalPages: state.totalPages, totalPages: state.totalPages,
totalItems: state.total, totalItems: state.total,
pageSize: state.limit, pageSize: state.limit,
itemsOnPage: state.items.length,
itemLabel: def.title.toLowerCase(), itemLabel: def.title.toLowerCase(),
onPageChanged: notifier.setPage, onPageChanged: notifier.setPage,
onPageSizeChanged: notifier.setPageSize, onPageSizeChanged: notifier.setPageSize,
), ),
child: RefreshIndicator( child: RefreshIndicator(
onRefresh: notifier.refresh, onRefresh: notifier.refresh,
child: state.items.isEmpty child: _MasterListTable(
? ListView( definition: def,
physics: const AlwaysScrollableScrollPhysics(), items: state.items,
children: [ isDeleting: state.isDeleting,
SizedBox( canEdit: canEdit,
height: 240, canDelete: canDelete,
child: AppEmptyState( onEdit: (id) => _openFormPanel(recordId: id),
title: 'No ${def.title.toLowerCase()} found', onDelete: _deleteRecord,
description: onEnsureFullDataset: () =>
'Add your first ${def.title.toLowerCase()} record to get started.', notifier.ensureColumnSearchDataset(),
icon: def.icon, onColumnSearchCleared: () =>
), notifier.clearColumnSearchDataset(),
), ),
],
)
: _MasterListTable(
definition: def,
items: state.items,
isDeleting: state.isDeleting,
canEdit: canEdit,
canDelete: canDelete,
onEdit: (id) => _openFormPanel(recordId: id),
onDelete: _deleteRecord,
),
), ),
), ),
), ),
@ -266,7 +292,7 @@ class _MasterListScreenState extends ConsumerState<MasterListScreen> {
} }
} }
class _MasterListTable extends StatelessWidget { class _MasterListTable extends ConsumerWidget {
const _MasterListTable({ const _MasterListTable({
required this.definition, required this.definition,
required this.items, required this.items,
@ -275,6 +301,8 @@ class _MasterListTable extends StatelessWidget {
required this.canDelete, required this.canDelete,
required this.onEdit, required this.onEdit,
required this.onDelete, required this.onDelete,
this.onEnsureFullDataset,
this.onColumnSearchCleared,
}); });
final MasterDefinition definition; final MasterDefinition definition;
@ -284,61 +312,114 @@ class _MasterListTable extends StatelessWidget {
final bool canDelete; final bool canDelete;
final ValueChanged<String> onEdit; final ValueChanged<String> onEdit;
final ValueChanged<Map<String, dynamic>> onDelete; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context); final theme = Theme.of(context);
final prefs = ref.watch(tableColumnPrefsProvider(tableIdFor(definition)));
final columns = resolveAppDataColumns(_allColumns(theme), prefs);
return AppDataTable<Map<String, dynamic>>( return AppDataTable<Map<String, dynamic>>(
wrapInCard: false, wrapInCard: false,
columns: [ onEnsureFullDataset: onEnsureFullDataset,
...definition.listFields.map( onColumnSearchCleared: onColumnSearchCleared,
(field) => AppDataColumn<Map<String, dynamic>>( emptyMessage: 'No ${definition.title.toLowerCase()} found',
label: field.label, columns: columns,
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),
),
],
);
},
),
],
rows: items, rows: items,
); );
} }
@ -346,6 +427,7 @@ class _MasterListTable extends StatelessWidget {
int _columnFlex(MasterFieldDef field) { int _columnFlex(MasterFieldDef field) {
return switch (field.key) { return switch (field.key) {
'code' || 'item_code' || 'series_code' || 'hsn_code_id' => 1, 'code' || 'item_code' || 'series_code' || 'hsn_code_id' => 1,
'is_asset_item' => 1,
'name' || 'item_name' || 'description' || 'term_name' => 3, 'name' || 'item_name' || 'description' || 'term_name' => 3,
_ => 2, _ => 2,
}; };

View File

@ -4,8 +4,10 @@ import 'package:go_router/go_router.dart';
import '../../../../core/constants/enums.dart'; import '../../../../core/constants/enums.dart';
import '../../../../core/constants/route_constants.dart'; import '../../../../core/constants/route_constants.dart';
import '../../../../shared/providers/auth_provider.dart';
import '../../../../shared/providers/permissions_provider.dart'; import '../../../../shared/providers/permissions_provider.dart';
import '../../../../shared/widgets/app_hover_effect.dart'; import '../../../../shared/widgets/app_hover_effect.dart';
import '../../../../shared/widgets/app_loading_view.dart';
import '../../domain/entities/master_definition.dart'; import '../../domain/entities/master_definition.dart';
class MastersHubScreen extends ConsumerWidget { class MastersHubScreen extends ConsumerWidget {
@ -16,8 +18,15 @@ class MastersHubScreen extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final authState = ref.watch(authStateProvider);
final categories = masterCategories; final categories = masterCategories;
final canViewMasters = ref.can('masters', PermissionAction.read); 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( return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
@ -28,64 +37,74 @@ class MastersHubScreen extends ConsumerWidget {
children: [ children: [
Text( Text(
'Master Data', 'Master Data',
style: Theme.of(context).textTheme.headlineSmall?.copyWith( style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'Browse and manage all master records by category.', 'Browse and manage all master records by category.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: theme.textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
), ),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
...categories.map((category) { if (!canViewMasters)
if (!canViewMasters) return const SizedBox.shrink(); Padding(
final items = masterDefinitions padding: const EdgeInsets.only(top: 48),
.where((def) => def.category == category) child: Center(
.toList(); child: Text(
return Column( 'You do not have permission to view master data.',
crossAxisAlignment: CrossAxisAlignment.start, style: theme.textTheme.bodyMedium?.copyWith(
children: [ color: theme.colorScheme.onSurfaceVariant,
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,
), ),
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( style: theme.textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
height: 1.2, height: 1.2,
fontSize: 12,
), ),
), ),
], ],

View File

@ -22,6 +22,7 @@ class MasterFormPanel extends ConsumerStatefulWidget {
this.recordId, this.recordId,
this.initialValues, this.initialValues,
this.formSessionId, this.formSessionId,
this.readOnlyFields,
}); });
final String masterId; final String masterId;
@ -33,6 +34,9 @@ class MasterFormPanel extends ConsumerStatefulWidget {
/// Unique per open so create forms always start empty. /// Unique per open so create forms always start empty.
final String? formSessionId; final String? formSessionId;
/// Field keys rendered as read-only (in addition to [MasterFieldDef.readOnly]).
final Set<String>? readOnlyFields;
bool get isEditing => recordId != null; bool get isEditing => recordId != null;
@override @override
@ -105,6 +109,10 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
); );
} }
bool _isFieldReadOnly(MasterFieldDef field) =>
field.readOnly ||
(widget.readOnlyFields?.contains(field.key) ?? false);
Widget _buildField( Widget _buildField(
BuildContext context, { BuildContext context, {
required MasterFieldDef field, required MasterFieldDef field,
@ -152,7 +160,7 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
); );
case MasterFieldType.dropdown: case MasterFieldType.dropdown:
if (field.readOnly) { if (_isFieldReadOnly(field)) {
final display = field.key == 'gst_rate_id' final display = field.key == 'gst_rate_id'
? (gstRateDisplayFromValues(formState.values) ?? '') ? (gstRateDisplayFromValues(formState.values) ?? '')
: (value?.toString() ?? ''); : (value?.toString() ?? '');
@ -226,7 +234,7 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
if (filterField != null) { if (filterField != null) {
for (final f in _definition.formFields) { for (final f in _definition.formFields) {
if (f.key == filterField) { if (f.key == filterField) {
parentLabel = f.label.toLowerCase(); parentLabel = masterFieldHintLabel(f.label);
break; break;
} }
} }
@ -247,8 +255,8 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
? 'Select $parentLabel first' ? 'Select $parentLabel first'
: dropdownOptions.isEmpty : dropdownOptions.isEmpty
? 'No options available' ? 'No options available'
: 'Select ${field.label.toLowerCase()}', : 'Select ${masterFieldHintLabel(field.label)}',
searchHint: 'Search ${field.label.toLowerCase()}...', searchHint: 'Search ${masterFieldHintLabel(field.label)}...',
enabled: parentSelected, enabled: parentSelected,
initialValues: () { initialValues: () {
final values = <String, dynamic>{}; final values = <String, dynamic>{};
@ -263,11 +271,9 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
} }
return values.isEmpty ? null : values; return values.isEmpty ? null : values;
}(), }(),
refreshLookups: () { refreshLookups: () => ref
ref .read(masterFormProvider(_args).notifier)
.read(masterFormProvider(_args).notifier) .reloadDropdownOptions(),
.reloadDropdownOptions();
},
parseCreatedId: (id) => id, parseCreatedId: (id) => id,
onChanged: (selected) => notifier.updateValue(field.key, selected), onChanged: (selected) => notifier.updateValue(field.key, selected),
validator: field.required validator: field.required
@ -287,8 +293,8 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
? 'Select $parentLabel first' ? 'Select $parentLabel first'
: dropdownOptions.isEmpty : dropdownOptions.isEmpty
? 'No options available' ? 'No options available'
: 'Select ${field.label.toLowerCase()}', : 'Select ${masterFieldHintLabel(field.label)}',
searchHint: 'Search ${field.label.toLowerCase()}...', searchHint: 'Search ${masterFieldHintLabel(field.label)}...',
enabled: field.staticOptions != null enabled: field.staticOptions != null
? true ? true
: parentSelected && dropdownOptions.isNotEmpty, : parentSelected && dropdownOptions.isNotEmpty,
@ -414,11 +420,13 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
final fullWidth = left.multiline || left.key == 'is_asset_item'; final fullWidth = left.multiline || left.key == 'is_asset_item';
if (fullWidth) { if (fullWidth) {
widgets.add( widgets.add(
Padding( QuickAddBlockable(
padding: const EdgeInsets.only(bottom: 12), child: Padding(
child: SizedBox( padding: const EdgeInsets.only(bottom: 12),
width: double.infinity, child: SizedBox(
child: _buildField(context, field: left, formState: formState), width: double.infinity,
child: _buildField(context, field: left, formState: formState),
),
), ),
), ),
); );
@ -440,11 +448,13 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
i += 2; i += 2;
} else { } else {
widgets.add( widgets.add(
Padding( QuickAddBlockable(
padding: const EdgeInsets.only(bottom: 12), child: Padding(
child: SizedBox( padding: const EdgeInsets.only(bottom: 12),
width: double.infinity, child: SizedBox(
child: _buildField(context, field: left, formState: formState), width: double.infinity,
child: _buildField(context, field: left, formState: formState),
),
), ),
), ),
); );
@ -454,11 +464,13 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
if (activeField != null) { if (activeField != null) {
widgets.add( widgets.add(
SidePanelSection( QuickAddBlockable(
title: 'STATUS', child: SidePanelSection(
children: [ title: 'STATUS',
_buildField(context, field: activeField, formState: formState), children: [
], _buildField(context, field: activeField, formState: formState),
],
),
), ),
); );
} }
@ -485,7 +497,8 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
AppButton( AppButton(
label: widget.isEditing ? 'Edit ${def.title}' : 'Add ${def.title}', label:
widget.isEditing ? 'Update ${def.title}' : 'Save ${def.title}',
expand: false, expand: false,
icon: Icons.check, icon: Icons.check,
isLoading: isSubmitting, isLoading: isSubmitting,

View File

@ -162,7 +162,7 @@ class _MasterInlineCreateFormState
if (filterField != null) { if (filterField != null) {
for (final f in _definition.formFields) { for (final f in _definition.formFields) {
if (f.key == filterField) { if (f.key == filterField) {
parentLabel = f.label.toLowerCase(); parentLabel = masterFieldHintLabel(f.label);
break; break;
} }
} }
@ -181,8 +181,8 @@ class _MasterInlineCreateFormState
? 'Select $parentLabel first' ? 'Select $parentLabel first'
: dropdownOptions.isEmpty : dropdownOptions.isEmpty
? 'No options available' ? 'No options available'
: 'Select ${field.label.toLowerCase()}', : 'Select ${masterFieldHintLabel(field.label)}',
searchHint: 'Search ${field.label.toLowerCase()}...', searchHint: 'Search ${masterFieldHintLabel(field.label)}...',
enabled: field.staticOptions != null enabled: field.staticOptions != null
? true ? true
: parentSelected && dropdownOptions.isNotEmpty, : parentSelected && dropdownOptions.isNotEmpty,
@ -497,7 +497,7 @@ class _MasterInlineCreateFormState
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
AppButton( AppButton(
label: 'Add ${def.title}', label: 'Save ${def.title}',
expand: false, expand: false,
icon: Icons.check, icon: Icons.check,
isLoading: isSubmitting, isLoading: isSubmitting,

View File

@ -9,15 +9,16 @@ import '../../../../shared/widgets/app_dropdown.dart';
import '../../../../shared/widgets/app_searchable_dropdown.dart'; import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/app_side_panel.dart'; import '../../../../shared/widgets/app_side_panel.dart';
import '../providers/master_provider.dart'; import '../providers/master_provider.dart';
import '../../domain/entities/master_definition.dart';
import 'master_form_panel.dart';
import 'master_inline_create_form.dart'; import 'master_inline_create_form.dart';
import '../../../../shared/widgets/app_toast.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 /// By default expands an inline create form below the field (or under a
/// create form is rendered at full row width under the row so every field and /// [QuickAddInlineHost]). Set [openInSidePanel] to open [MasterFormPanel]
/// button stays clickable. Without a host, the form expands directly below the /// in a popup side panel instead.
/// dropdown at the available column width.
class MasterQuickAddDropdown<T> extends ConsumerStatefulWidget { class MasterQuickAddDropdown<T> extends ConsumerStatefulWidget {
const MasterQuickAddDropdown({ const MasterQuickAddDropdown({
super.key, super.key,
@ -35,6 +36,8 @@ class MasterQuickAddDropdown<T> extends ConsumerStatefulWidget {
this.initialValues, this.initialValues,
this.refreshLookups, this.refreshLookups,
this.addNewLabel, this.addNewLabel,
this.openInSidePanel = false,
this.readOnlyFields,
}); });
final String masterId; final String masterId;
@ -54,6 +57,12 @@ class MasterQuickAddDropdown<T> extends ConsumerStatefulWidget {
final FutureOr<void> Function()? refreshLookups; final FutureOr<void> Function()? refreshLookups;
final String? addNewLabel; 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 @override
ConsumerState<MasterQuickAddDropdown<T>> createState() => ConsumerState<MasterQuickAddDropdown<T>> createState() =>
_MasterQuickAddDropdownState<T>(); _MasterQuickAddDropdownState<T>();
@ -75,9 +84,10 @@ class _MasterQuickAddDropdownState<T>
bool get _canQuickAdd => ref.can('masters', PermissionAction.create); bool get _canQuickAdd => ref.can('masters', PermissionAction.create);
void _openInlineForm() { Future<void> _onAddNew() async {
if (!_canQuickAdd) { if (!_canQuickAdd) {
showAppToastFromSnackBar(context, showAppToastFromSnackBar(
context,
const SnackBar( const SnackBar(
content: Text('You do not have permission to add master data'), content: Text('You do not have permission to add master data'),
), ),
@ -85,6 +95,42 @@ class _MasterQuickAddDropdownState<T>
return; 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 = final sessionId =
'inline-${widget.masterId}-${DateTime.now().microsecondsSinceEpoch}'; 'inline-${widget.masterId}-${DateTime.now().microsecondsSinceEpoch}';
ref.invalidate( ref.invalidate(
@ -107,8 +153,14 @@ class _MasterQuickAddDropdownState<T>
} }
void _collapse() { void _collapse() {
if (!_expanded) return; final host = _host ?? QuickAddInlineScope.maybeOf(context);
_host?.dismiss(this); // 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(() { setState(() {
_expanded = false; _expanded = false;
_sessionId = null; _sessionId = null;
@ -119,30 +171,40 @@ class _MasterQuickAddDropdownState<T>
} }
Future<void> _onSaved(String createdId) async { 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 { try {
final refresh = widget.refreshLookups; final refresh = widget.refreshLookups;
if (refresh != null) await refresh(); if (refresh != null) await refresh();
} catch (_) { } catch (_) {
// Still select the created row even if lookup refresh fails. // Still keep the created selection even if lookup refresh fails.
}
if (!mounted) return;
if (createdId != 'created') {
final parsed = widget.parseCreatedId(createdId);
if (parsed != null) {
widget.onChanged(parsed);
}
} }
_collapse(); if (!mounted) return;
// Re-apply after reload in case options refresh cleared dependent values.
if (parsed != null) {
widget.onChanged(parsed);
}
if (!mounted) return; if (!mounted) return;
showAppToastFromSnackBar( showAppToast(
context, context,
SnackBar( '${_titleCase(masterQuickAddNoun(widget.masterId))} added',
content: type: AppToastType.success,
Text('${_titleCase(masterQuickAddNoun(widget.masterId))} added'),
),
); );
} }
@ -162,13 +224,15 @@ class _MasterQuickAddDropdownState<T>
final host = _host ?? QuickAddInlineScope.maybeOf(context); final host = _host ?? QuickAddInlineScope.maybeOf(context);
if (host == null) return; if (host == null) return;
_host = host; _host = host;
host.present(owner: this, form: _buildCreateForm()); host.present(owner: this, formBuilder: _buildCreateForm);
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final addLabel = widget.addNewLabel ?? masterQuickAddLabel(widget.masterId); final addLabel = widget.addNewLabel ?? masterQuickAddLabel(widget.masterId);
_host ??= QuickAddInlineScope.maybeOf(context); if (!widget.openInSidePanel) {
_host ??= QuickAddInlineScope.maybeOf(context);
}
final dropdown = Focus( final dropdown = Focus(
focusNode: _dropdownFocus, focusNode: _dropdownFocus,
@ -179,19 +243,19 @@ class _MasterQuickAddDropdownState<T>
onChanged: widget.onChanged, onChanged: widget.onChanged,
validator: widget.validator, validator: widget.validator,
hint: widget.hint, hint: widget.hint,
searchHint: searchHint: widget.searchHint ??
widget.searchHint ?? 'Search ${widget.label.toLowerCase()}...', 'Search ${masterFieldHintLabel(widget.label.replaceAll('*', '').trim())}...',
enabled: widget.enabled, enabled: widget.enabled,
isDense: widget.isDense, isDense: widget.isDense,
addNewLabel: _canQuickAdd ? addLabel : null, addNewLabel: _canQuickAdd ? addLabel : null,
onAddNew: !_canQuickAdd || !widget.enabled onAddNew: !_canQuickAdd || !widget.enabled ? null : _onAddNew,
? null
: () async => _openInlineForm(),
), ),
); );
// Host renders the form at full row width. if (widget.openInSidePanel ||
if (_host != null || !_expanded || _sessionId == null) { _host != null ||
!_expanded ||
_sessionId == null) {
return dropdown; return dropdown;
} }
@ -199,7 +263,14 @@ class _MasterQuickAddDropdownState<T>
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
dropdown, IgnorePointer(
ignoring: true,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 180),
opacity: 0.45,
child: dropdown,
),
),
AnimatedSize( AnimatedSize(
duration: const Duration(milliseconds: 280), duration: const Duration(milliseconds: 280),
curve: Curves.easeOutCubic, curve: Curves.easeOutCubic,

View File

@ -5,6 +5,7 @@ import '../../../../core/constants/api_endpoints.dart';
import '../../../../core/constants/app_constants.dart'; import '../../../../core/constants/app_constants.dart';
import '../../../../core/network/dio_client.dart'; import '../../../../core/network/dio_client.dart';
import '../../../../core/utils/active_option.dart'; import '../../../../core/utils/active_option.dart';
import '../../../../core/utils/pagination_meta.dart';
import '../../../../shared/models/user_management_models.dart'; import '../../../../shared/models/user_management_models.dart';
final masterRemoteDataSourceProvider = Provider<MasterRemoteDataSource>((ref) { final masterRemoteDataSourceProvider = Provider<MasterRemoteDataSource>((ref) {
@ -71,6 +72,11 @@ class MasterRemoteDataSource {
for (final item in rows) { for (final item in rows) {
if (!isActiveOptionRow(item)) continue; 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 id = item['id']?.toString() ?? '';
final name = _optionLabel(item); final name = _optionLabel(item);
if (id.isEmpty || name.isEmpty) continue; if (id.isEmpty || name.isEmpty) continue;
@ -100,6 +106,39 @@ class MasterRemoteDataSource {
Future<List<FilterOptionModel>> listPaymentTerms() => Future<List<FilterOptionModel>> listPaymentTerms() =>
_listOptions(ApiEndpoints.paymentTerms); _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() => Future<List<FilterOptionModel>> listDeliveryTerms() =>
_listOptions(ApiEndpoints.deliveryTerms); _listOptions(ApiEndpoints.deliveryTerms);
@ -331,20 +370,12 @@ class MasterRemoteDataSource {
} }
final raw = body['data']; final raw = body['data'];
final meta = body['meta'] is Map
? Map<String, dynamic>.from(body['meta'] as Map)
: <String, dynamic>{};
List list; List list;
Map<String, dynamic> pageMeta = meta;
if (raw is List) { if (raw is List) {
list = raw; list = raw;
} else if (raw is Map) { } else if (raw is Map) {
final map = Map<String, dynamic>.from(raw); final items = raw['items'];
final items = map['items'];
list = items is List ? items : const []; list = items is List ? items : const [];
pageMeta = {...meta, ...map};
} else { } else {
list = const []; list = const [];
} }
@ -354,14 +385,20 @@ class MasterRemoteDataSource {
.map((item) => Map<String, dynamic>.from(item)) .map((item) => Map<String, dynamic>.from(item))
.toList(); .toList();
final total = _asInt(pageMeta['total']) ?? items.length; final pagination = parsePagination(
final limit = _asInt(pageMeta['limit']) ?? fallbackLimit; body: body,
final explicitTotalPages = _asInt(pageMeta['totalPages']) ?? fallbackPage: 1,
_asInt(pageMeta['total_pages']); fallbackLimit: fallbackLimit,
final totalPages = explicitTotalPages ?? itemCount: items.length,
(limit > 0 ? ((total + limit - 1) ~/ limit).clamp(1, 999999) : 1); );
return (items: items, totalPages: totalPages < 1 ? 1 : totalPages); return (
items: items,
totalPages: resolveTotalPages(
total: pagination.total,
limit: fallbackLimit,
),
);
} }
int? _asInt(dynamic value) { int? _asInt(dynamic value) {

View File

@ -1,6 +1,7 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import '../../../../core/constants/api_endpoints.dart'; import '../../../../core/constants/api_endpoints.dart';
import '../../../../core/utils/pagination_meta.dart';
import '../../../../core/utils/export_file_name.dart'; import '../../../../core/utils/export_file_name.dart';
import '../../../../shared/models/api_response.dart'; import '../../../../shared/models/api_response.dart';
import '../../../../shared/models/entity_attachment_model.dart'; import '../../../../shared/models/entity_attachment_model.dart';
@ -19,7 +20,12 @@ class PurchaseOrderRemoteDataSource {
ApiEndpoints.purchaseOrders, ApiEndpoints.purchaseOrders,
queryParameters: _queryToMap(query), 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. /// Approver-only list of POs with status PENDING_APPROVAL.
@ -30,7 +36,12 @@ class PurchaseOrderRemoteDataSource {
ApiEndpoints.purchaseOrdersPendingApproval, ApiEndpoints.purchaseOrdersPendingApproval,
queryParameters: _queryToMap(query), 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. /// Form-dropdown loader (`dropdown_call=true`). Optional status filter.
@ -123,11 +134,11 @@ class PurchaseOrderRemoteDataSource {
Future<PurchaseOrderModel> rejectPurchaseOrder( Future<PurchaseOrderModel> rejectPurchaseOrder(
String id, { String id, {
required String remarks, required String rejectReason,
}) async { }) async {
final response = await dio.post( final response = await dio.post(
ApiEndpoints.purchaseOrderReject(id), ApiEndpoints.purchaseOrderReject(id),
data: {'remarks': remarks}, data: {'reject_reason': rejectReason},
); );
return PurchaseOrderModel.fromJson( return PurchaseOrderModel.fromJson(
response.data['data'] as Map<String, dynamic>, 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 { Future<List<int>> downloadPurchaseOrderPdf(String id) async {
final response = await dio.get<List<int>>( final response = await dio.get<List<int>>(
ApiEndpoints.purchaseOrderPdf(id), ApiEndpoints.purchaseOrderPdf(id),
@ -252,58 +282,45 @@ class PurchaseOrderRemoteDataSource {
PaginatedResponse<T> _parsePaginated<T>( PaginatedResponse<T> _parsePaginated<T>(
dynamic body, dynamic body,
T Function(Map<String, dynamic>) fromJson, T Function(Map<String, dynamic>) fromJson, {
) { int fallbackPage = 1,
if (body is! Map<String, dynamic>) { int fallbackLimit = 20,
return const PaginatedResponse( }) {
items: [], var items = <T>[];
page: 1, if (body is Map) {
limit: 20, final raw = body['data'];
total: 0, if (raw is List) {
totalPages: 1, items = raw
); .whereType<Map>()
} .map((e) => fromJson(Map<String, dynamic>.from(e)))
.toList();
final raw = body['data']; } else if (raw is Map) {
final meta = body['meta'] as Map<String, dynamic>? ?? {}; final list = raw['items'];
if (list is List) {
if (raw is List) { items = list
final items = raw.whereType<Map<String, dynamic>>().map(fromJson).toList(); .whereType<Map>()
final limit = (meta['limit'] as num?)?.toInt() ?? items.length; .map((e) => fromJson(Map<String, dynamic>.from(e)))
final total = (meta['total'] as num?)?.toInt() ?? items.length; .toList();
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,
);
} }
} }
return const PaginatedResponse( final pagination = parsePagination(
items: [], body: body,
page: 1, fallbackPage: fallbackPage,
limit: 20, fallbackLimit: fallbackLimit,
total: 0, itemCount: items.length,
totalPages: 1, );
return PaginatedResponse(
items: items,
page: pagination.page,
limit: fallbackLimit,
total: pagination.total,
totalPages: resolveTotalPages(
total: pagination.total,
limit: fallbackLimit,
),
); );
} }
} }

View File

@ -98,10 +98,10 @@ class PurchaseOrderRepositoryImpl implements PurchaseOrderRepository {
@override @override
Future<Result<PurchaseOrderModel>> rejectPurchaseOrder( Future<Result<PurchaseOrderModel>> rejectPurchaseOrder(
String id, { String id, {
required String remarks, required String rejectReason,
}) { }) {
return safeApiCall( 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)); return safeApiCall(() => dataSource.cancelPurchaseOrder(id, remarks: remarks));
} }
@override
Future<Result<String>> triggerApprovalNotification(String poId) {
return safeApiCall(() => dataSource.triggerApprovalNotification(poId));
}
@override @override
Future<Result<List<int>>> downloadPurchaseOrderPdf(String id) { Future<Result<List<int>>> downloadPurchaseOrderPdf(String id) {
return safeApiCall(() => dataSource.downloadPurchaseOrderPdf(id)); return safeApiCall(() => dataSource.downloadPurchaseOrderPdf(id));

View File

@ -27,13 +27,18 @@ abstract class PurchaseOrderRepository {
Future<Result<PurchaseOrderModel>> approvePurchaseOrder(String id, {String? remarks}); Future<Result<PurchaseOrderModel>> approvePurchaseOrder(String id, {String? remarks});
Future<Result<PurchaseOrderModel>> rejectPurchaseOrder( Future<Result<PurchaseOrderModel>> rejectPurchaseOrder(
String id, { String id, {
required String remarks, required String rejectReason,
}); });
Future<Result<PurchaseOrderModel>> amendPurchaseOrder( Future<Result<PurchaseOrderModel>> amendPurchaseOrder(
String id, { String id, {
Map<String, dynamic>? data, Map<String, dynamic>? data,
}); });
Future<Result<PurchaseOrderModel>> cancelPurchaseOrder(String id, {String? remarks}); 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<int>>> downloadPurchaseOrderPdf(String id);
Future<Result<List<EntityAttachmentModel>>> listAttachments(String poId); Future<Result<List<EntityAttachmentModel>>> listAttachments(String poId);
Future<Result<EntityAttachmentModel>> uploadAttachment( Future<Result<EntityAttachmentModel>> uploadAttachment(

View File

@ -1,13 +1,26 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; 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 '../../../../core/utils/table_search.dart';
import '../../../../shared/models/api_response.dart';
import '../../../../shared/models/entity_attachment_model.dart'; import '../../../../shared/models/entity_attachment_model.dart';
import '../../../../shared/models/export_file_result.dart'; import '../../../../shared/models/export_file_result.dart';
import '../../../../shared/models/purchase_order_model.dart'; import '../../../../shared/models/purchase_order_model.dart';
import '../../../../shared/models/vendor_model.dart';
import '../../../grn/presentation/providers/grn_lookups_provider.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'; 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 { class PurchaseOrdersListState {
const PurchaseOrdersListState({ const PurchaseOrdersListState({
this.orders = const [], this.orders = const [],
@ -66,6 +79,8 @@ final pendingApprovalPurchaseOrdersListProvider =
class PurchaseOrdersListNotifier class PurchaseOrdersListNotifier
extends AutoDisposeAsyncNotifier<PurchaseOrdersListState> { extends AutoDisposeAsyncNotifier<PurchaseOrdersListState> {
final _columnSearch = ColumnSearchPaging();
@override @override
Future<PurchaseOrdersListState> build() async { Future<PurchaseOrdersListState> build() async {
return _load(const PurchaseOrderListQuery(limit: 20)); return _load(const PurchaseOrderListQuery(limit: 20));
@ -76,8 +91,14 @@ class PurchaseOrdersListNotifier
final result = await repository.getPurchaseOrders(query); final result = await repository.getPurchaseOrders(query);
if (result.failure != null) throw result.failure!; if (result.failure != null) throw result.failure!;
final page = result.data!; final page = result.data!;
final orders = await _enrichPurchaseOrderSearch(
ref: ref,
query: query,
items: page.items,
load: repository.getPurchaseOrders,
);
return PurchaseOrdersListState( return PurchaseOrdersListState(
orders: page.items, orders: orders,
query: query, query: query,
total: page.total, total: page.total,
totalPages: page.totalPages, totalPages: page.totalPages,
@ -112,6 +133,28 @@ class PurchaseOrdersListNotifier
applyQuery(current.query.copyWith(search: TableSearch.normalize(search), page: 1)); 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) { void setStatusFilter(String? status) {
final current = state.valueOrNull; final current = state.valueOrNull;
if (current == null) return; if (current == null) return;
@ -173,10 +216,34 @@ class PurchaseOrdersListNotifier
} }
return true; 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 class PendingApprovalPurchaseOrdersListNotifier
extends AutoDisposeAsyncNotifier<PurchaseOrdersListState> { extends AutoDisposeAsyncNotifier<PurchaseOrdersListState> {
final _columnSearch = ColumnSearchPaging();
@override @override
Future<PurchaseOrdersListState> build() async { Future<PurchaseOrdersListState> build() async {
return _load(const PurchaseOrderListQuery(limit: 20)); return _load(const PurchaseOrderListQuery(limit: 20));
@ -187,8 +254,14 @@ class PendingApprovalPurchaseOrdersListNotifier
final result = await repository.getPendingApprovalPurchaseOrders(query); final result = await repository.getPendingApprovalPurchaseOrders(query);
if (result.failure != null) throw result.failure!; if (result.failure != null) throw result.failure!;
final page = result.data!; final page = result.data!;
final orders = await _enrichPurchaseOrderSearch(
ref: ref,
query: query,
items: page.items,
load: repository.getPendingApprovalPurchaseOrders,
);
return PurchaseOrdersListState( return PurchaseOrdersListState(
orders: page.items, orders: orders,
query: query, query: query,
total: page.total, total: page.total,
totalPages: page.totalPages, totalPages: page.totalPages,
@ -223,6 +296,28 @@ class PendingApprovalPurchaseOrdersListNotifier
applyQuery(current.query.copyWith(search: TableSearch.normalize(search), page: 1)); 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) { void setPage(int page) {
final current = state.valueOrNull; final current = state.valueOrNull;
if (current == null) return; if (current == null) return;
@ -300,9 +395,12 @@ class PurchaseOrderDetailNotifier
return result.data!; return result.data!;
} }
Future<PurchaseOrderModel> reject({required String remarks}) async { Future<PurchaseOrderModel> reject({required String rejectReason}) async {
final repository = ref.read(purchaseOrderRepositoryProvider); 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!; if (result.failure != null) throw result.failure!;
state = AsyncData(result.data!); state = AsyncData(result.data!);
ref.invalidate(purchaseOrdersListProvider); ref.invalidate(purchaseOrdersListProvider);
@ -332,6 +430,14 @@ class PurchaseOrderDetailNotifier
return result.data!; 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 { Future<List<int>> downloadPdf() async {
final repository = ref.read(purchaseOrderRepositoryProvider); final repository = ref.read(purchaseOrderRepositoryProvider);
final result = await repository.downloadPurchaseOrderPdf(arg); final result = await repository.downloadPurchaseOrderPdf(arg);
@ -425,3 +531,50 @@ class PurchaseOrderFormNotifier extends FamilyAsyncNotifier<PurchaseOrderModel?,
return result.data!; 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;
}

View File

@ -15,6 +15,7 @@ import '../../../../shared/utils/file_download_helper.dart';
import '../../../../shared/widgets/app_confirmation_dialog.dart'; import '../../../../shared/widgets/app_confirmation_dialog.dart';
import '../../../../shared/widgets/app_loading_view.dart'; import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_text_field.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/entity_attachments_card.dart';
import '../../../../shared/widgets/error_view.dart'; import '../../../../shared/widgets/error_view.dart';
import '../providers/purchase_order_lookups_provider.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 '../../data/repositories/purchase_order_repository_impl.dart';
import '../widgets/po_status_chip.dart'; import '../widgets/po_status_chip.dart';
import '../widgets/purchase_order_line_items_editor.dart'; import '../widgets/purchase_order_line_items_editor.dart';
import '../../../../shared/widgets/api_feedback.dart';
import '../../../../shared/widgets/app_toast.dart'; import '../../../../shared/widgets/app_toast.dart';
class PurchaseOrderDetailScreen extends ConsumerStatefulWidget { class PurchaseOrderDetailScreen extends ConsumerStatefulWidget {
@ -38,6 +40,26 @@ class _PurchaseOrderDetailScreenState
extends ConsumerState<PurchaseOrderDetailScreen> { extends ConsumerState<PurchaseOrderDetailScreen> {
bool _isWorking = false; bool _isWorking = false;
bool _isDownloadingPdf = 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -82,12 +104,17 @@ class _PurchaseOrderDetailScreenState
'${RouteConstants.purchaseOrders}/${order.id}/edit', '${RouteConstants.purchaseOrders}/${order.id}/edit',
), ),
onSubmit: () => _submit(order), onSubmit: () => _submit(order),
onNotify: () => _notifyApprovers(order),
onApprove: () => _approve(order), onApprove: () => _approve(order),
onReject: () => _reject(order), onReject: () => _reject(order),
onAmend: () => _amend(order), onAmend: () => _amend(order),
onCancel: () => _cancel(order), onCancel: () => _cancel(order),
onDelete: _delete, onDelete: _delete,
), ),
if (order.rejectReasonForDisplay != null) ...[
const SizedBox(height: 12),
_RejectReasonBanner(reason: order.rejectReasonForDisplay!),
],
const SizedBox(height: 16), const SizedBox(height: 16),
_OrderDetailsCard(order: order, lookups: lookups), _OrderDetailsCard(order: order, lookups: lookups),
const SizedBox(height: 16), const SizedBox(height: 16),
@ -146,7 +173,10 @@ class _PurchaseOrderDetailScreenState
} }
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
showAppToastFromSnackBar(context, SnackBar(content: Text(e.toString()))); showAppToastFromSnackBar(
context,
SnackBar(content: Text(errorDisplayMessage(e))),
);
} }
} finally { } finally {
if (mounted) setState(() => _isWorking = false); if (mounted) setState(() => _isWorking = false);
@ -158,10 +188,42 @@ class _PurchaseOrderDetailScreenState
() => ref () => ref
.read(purchaseOrderDetailProvider(widget.purchaseOrderId).notifier) .read(purchaseOrderDetailProvider(widget.purchaseOrderId).notifier)
.submit(), .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 { Future<void> _approve(PurchaseOrderModel order) async {
await _runWorkflow( await _runWorkflow(
() => ref () => ref
@ -172,14 +234,15 @@ class _PurchaseOrderDetailScreenState
} }
Future<void> _reject(PurchaseOrderModel order) async { Future<void> _reject(PurchaseOrderModel order) async {
final remarksController = TextEditingController(); final reasonController = TextEditingController();
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: const Text('Reject Purchase Order'), title: const Text('Reject Purchase Order'),
content: AppTextField( content: AppTextField(
controller: remarksController, controller: reasonController,
label: 'Remarks *', label: 'Reject reason *',
hint: 'Why is this purchase order being rejected?',
maxLines: 3, maxLines: 3,
), ),
actions: [ actions: [
@ -195,18 +258,19 @@ class _PurchaseOrderDetailScreenState
), ),
); );
if (confirmed != true || !mounted) return; if (confirmed != true || !mounted) return;
final remarks = remarksController.text.trim(); final rejectReason = reasonController.text.trim();
remarksController.dispose(); reasonController.dispose();
if (remarks.isEmpty) { if (rejectReason.isEmpty) {
showAppToastFromSnackBar(context, showAppToastFromSnackBar(
const SnackBar(content: Text('Rejection remarks are required')), context,
const SnackBar(content: Text('Reject reason is required')),
); );
return; return;
} }
await _runWorkflow( await _runWorkflow(
() => ref () => ref
.read(purchaseOrderDetailProvider(widget.purchaseOrderId).notifier) .read(purchaseOrderDetailProvider(widget.purchaseOrderId).notifier)
.reject(remarks: remarks), .reject(rejectReason: rejectReason),
'Purchase order rejected', 'Purchase order rejected',
); );
} }
@ -233,7 +297,10 @@ class _PurchaseOrderDetailScreenState
} }
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
showAppToastFromSnackBar(context, SnackBar(content: Text(e.toString()))); showAppToastFromSnackBar(
context,
SnackBar(content: Text(errorDisplayMessage(e))),
);
} }
} finally { } finally {
if (mounted) setState(() => _isWorking = false); if (mounted) setState(() => _isWorking = false);
@ -282,7 +349,10 @@ class _PurchaseOrderDetailScreenState
} }
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
showAppToastFromSnackBar(context, SnackBar(content: Text(e.toString()))); showAppToastFromSnackBar(
context,
SnackBar(content: Text(errorDisplayMessage(e))),
);
} }
} finally { } finally {
if (mounted) setState(() => _isWorking = false); if (mounted) setState(() => _isWorking = false);
@ -302,7 +372,10 @@ class _PurchaseOrderDetailScreenState
); );
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
showAppToastFromSnackBar(context, SnackBar(content: Text(e.toString()))); showAppToastFromSnackBar(
context,
SnackBar(content: Text(errorDisplayMessage(e))),
);
} }
} finally { } finally {
if (mounted) setState(() => _isDownloadingPdf = false); if (mounted) setState(() => _isDownloadingPdf = false);
@ -373,6 +446,56 @@ String _hsnLabel(
return _lookupName(lookups?.hsnCodes, item.hsnCodeId); 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 { class _DetailHeader extends StatelessWidget {
const _DetailHeader({ const _DetailHeader({
required this.order, required this.order,
@ -386,6 +509,7 @@ class _DetailHeader extends StatelessWidget {
required this.onPdf, required this.onPdf,
required this.onEdit, required this.onEdit,
required this.onSubmit, required this.onSubmit,
required this.onNotify,
required this.onApprove, required this.onApprove,
required this.onReject, required this.onReject,
required this.onAmend, required this.onAmend,
@ -404,6 +528,7 @@ class _DetailHeader extends StatelessWidget {
final VoidCallback onPdf; final VoidCallback onPdf;
final VoidCallback onEdit; final VoidCallback onEdit;
final VoidCallback onSubmit; final VoidCallback onSubmit;
final VoidCallback onNotify;
final VoidCallback onApprove; final VoidCallback onApprove;
final VoidCallback onReject; final VoidCallback onReject;
final VoidCallback onAmend; final VoidCallback onAmend;
@ -441,11 +566,17 @@ class _DetailHeader extends StatelessWidget {
), ),
if (canEdit && order.canSubmit) if (canEdit && order.canSubmit)
_HeaderActionButton( _HeaderActionButton(
label: 'Submit', label: order.isRejected ? 'Resubmit' : 'Submit',
icon: Icons.send_outlined, icon: Icons.send_outlined,
filled: true, filled: true,
onPressed: isWorking ? null : onSubmit, onPressed: isWorking ? null : onSubmit,
), ),
if (canEdit && order.canNotifyApprovers)
_HeaderActionButton(
label: 'Notify',
icon: Icons.notifications_outlined,
onPressed: isWorking ? null : onNotify,
),
if (canApprove && order.canApprove) if (canApprove && order.canApprove)
_HeaderActionButton( _HeaderActionButton(
label: 'Approve', label: 'Approve',
@ -496,6 +627,41 @@ class _DetailHeader extends StatelessWidget {
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
PoStatusChip(status: order.status, compact: true), 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) ...[ if (order.revisionNo != null && order.revisionNo! > 0) ...[
const SizedBox(width: 8), const SizedBox(width: 8),
PoRevisionChip(revisionNo: order.revisionNo!, compact: true), PoRevisionChip(revisionNo: order.revisionNo!, compact: true),
@ -699,6 +865,8 @@ class _OrderDetailsCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final paymentTerm = final paymentTerm =
_lookupName(lookups?.paymentTerms, order.paymentTermId); _lookupName(lookups?.paymentTerms, order.paymentTermId);
final deliveryTerm = final deliveryTerm =
@ -706,91 +874,89 @@ class _OrderDetailsCard extends StatelessWidget {
return _SectionCard( return _SectionCard(
title: 'ORDER DETAILS', title: 'ORDER DETAILS',
child: LayoutBuilder( child: Column(
builder: (context, constraints) { crossAxisAlignment: CrossAxisAlignment.stretch,
final cols = constraints.maxWidth < 600 children: [
? 1 DetailOverviewSection(
: constraints.maxWidth < 900 title: 'Summary',
? 2 child: DetailSummaryStrip(
: 4; metrics: [
const spacing = 20.0; DetailSummaryMetric(
final width = (constraints.maxWidth - spacing * (cols - 1)) / cols; icon: Icons.flag_outlined,
final items = [ label: 'Status',
_DetailField( accent: scheme.secondary,
label: 'PO Date', child: PoStatusChip(status: order.status, compact: true),
value: DateFormatter.displayDate(order.poDate), ),
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', DetailOverviewSection(
value: DateFormatter.displayDate(order.expectedDeliveryDate), 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', DetailOverviewSection(
value: _displayOrDash(order.vendorName), 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 { class _PoAttachmentsSection extends ConsumerWidget {
const _PoAttachmentsSection({ const _PoAttachmentsSection({
required this.poId, required this.poId,
@ -1247,7 +1413,7 @@ class _AmountSummaryCard extends StatelessWidget {
Text( Text(
CurrencyFormatter.format(order.totalAmount), CurrencyFormatter.format(order.totalAmount),
style: theme.textTheme.titleMedium?.copyWith( style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800, fontWeight: FontWeight.w700,
color: theme.colorScheme.primary, color: theme.colorScheme.primary,
), ),
), ),

View File

@ -1,7 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../../../core/constants/enums.dart';
import '../../../../core/constants/route_constants.dart'; import '../../../../core/constants/route_constants.dart';
import '../../../../core/errors/failure.dart'; import '../../../../core/errors/failure.dart';
import '../../../../core/network/api_handler.dart'; import '../../../../core/network/api_handler.dart';
@ -9,6 +11,7 @@ import '../../../../core/theme/app_colors.dart';
import '../../../../core/utils/formatters.dart'; import '../../../../core/utils/formatters.dart';
import '../../../../shared/models/purchase_order_model.dart'; import '../../../../shared/models/purchase_order_model.dart';
import '../../../../shared/models/user_management_models.dart'; import '../../../../shared/models/user_management_models.dart';
import '../../../../shared/providers/permissions_provider.dart';
import '../../../../shared/utils/navigation_utils.dart'; import '../../../../shared/utils/navigation_utils.dart';
import '../../../../shared/widgets/app_button.dart'; import '../../../../shared/widgets/app_button.dart';
import '../../../../shared/widgets/app_date_popup.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/app_text_field.dart';
import '../../../../shared/widgets/error_view.dart'; import '../../../../shared/widgets/error_view.dart';
import '../../../../shared/widgets/app_side_panel.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 '../../data/repositories/purchase_order_repository_impl.dart';
import '../../../grn/presentation/providers/grn_lookups_provider.dart'; import '../../../grn/presentation/providers/grn_lookups_provider.dart';
import '../../../master_data/presentation/widgets/master_quick_add.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_order_lookups_provider.dart';
import '../providers/purchase_orders_provider.dart'; import '../providers/purchase_orders_provider.dart';
import '../widgets/po_status_chip.dart'; import '../widgets/po_status_chip.dart';
@ -59,6 +64,7 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
bool _isSubmitting = false; bool _isSubmitting = false;
String? _populatedSignature; String? _populatedSignature;
bool _defaultTermsApplied = false; bool _defaultTermsApplied = false;
bool _requestedFreshLoad = false;
@override @override
void initState() { void initState() {
@ -72,6 +78,15 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
_otherChargesController.addListener(_onChargesChanged); _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 @override
void dispose() { void dispose() {
_discountController.removeListener(_onChargesChanged); _discountController.removeListener(_onChargesChanged);
@ -203,6 +218,12 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
} }
Map<String, dynamic> _buildPayload() { 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 { return {
'po_date': DateFormatter.toApiDate(_poDate ?? DateTime.now()), 'po_date': DateFormatter.toApiDate(_poDate ?? DateTime.now()),
'vendor_id': _vendorId, 'vendor_id': _vendorId,
@ -213,12 +234,9 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
if (_expectedDeliveryDate != null) if (_expectedDeliveryDate != null)
'expected_delivery_date': 'expected_delivery_date':
DateFormatter.toApiDate(_expectedDeliveryDate!), DateFormatter.toApiDate(_expectedDeliveryDate!),
'discount_amount': 'discount_amount': discount,
double.tryParse(_discountController.text.trim()) ?? 0, 'freight_charges': freight,
'freight_charges': 'other_charges': other,
double.tryParse(_freightController.text.trim()) ?? 0,
'other_charges':
double.tryParse(_otherChargesController.text.trim()) ?? 0,
if (_termsController.text.trim().isNotEmpty) if (_termsController.text.trim().isNotEmpty)
'terms_and_conditions': _termsController.text.trim(), 'terms_and_conditions': _termsController.text.trim(),
if (_remarksController.text.trim().isNotEmpty) if (_remarksController.text.trim().isNotEmpty)
@ -239,6 +257,28 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
if (rate == null || rate < 0) { if (rate == null || rate < 0) {
return 'Enter a valid rate for line ${line.lineNo}'; 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; return null;
} }
@ -257,6 +297,20 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
return; 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) { if (_vendorId == null || _billingId == null || _shippingId == null) {
showAppToastFromSnackBar(context, showAppToastFromSnackBar(context,
const SnackBar(content: Text('Please complete all required fields')), const SnackBar(content: Text('Please complete all required fields')),
@ -279,6 +333,14 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
return; 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); setState(() => _isSubmitting = true);
try { try {
final payload = _buildPayload(); final payload = _buildPayload();
@ -327,12 +389,14 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
Future<void> _pickDate({ Future<void> _pickDate({
required DateTime? current, required DateTime? current,
required ValueChanged<DateTime?> onPicked, required ValueChanged<DateTime?> onPicked,
DateTime? firstDate,
DateTime? lastDate,
}) async { }) async {
final picked = await showAppDatePopup( final picked = await showAppDatePopup(
context: context, context: context,
initialDate: current ?? DateTime.now(), initialDate: current ?? DateTime.now(),
firstDate: DateTime(2020), firstDate: firstDate ?? DateTime(2020),
lastDate: DateTime(2100), lastDate: lastDate ?? DateTime(2100),
helpText: 'Select date', helpText: 'Select date',
); );
if (picked != null) onPicked(picked); if (picked != null) onPicked(picked);
@ -376,27 +440,35 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
final totals = _computeTotals(lookups.gstRatePctById); final totals = _computeTotals(lookups.gstRatePctById);
final theme = Theme.of(context); final theme = Theme.of(context);
return SingleChildScrollView( return Form(
controller: _scrollController, key: _formKey,
padding: const EdgeInsets.fromLTRB(24, 16, 24, 32), child: AppStickyFormLayout(
child: Form( scrollController: _scrollController,
key: _formKey, headerPadding: const EdgeInsets.fromLTRB(24, 12, 24, 12),
child: Column( 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, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
_buildHeader(existing),
if (_showReapprovalWarning(existing)) ...[
const SizedBox(height: 8),
_ReapprovalBanner(),
],
const SizedBox(height: 16),
_SectionCard( _SectionCard(
title: 'ORDER DETAILS', title: 'ORDER DETAILS',
child: QuickAddInlineHost( child: QuickAddInlineHost(
child: ResponsiveFormGrid( child: QuickAddBlockable(
child: ResponsiveFormGrid(
xsColumns: 1,
smallColumns: 1, smallColumns: 1,
mediumColumns: 2, mediumColumns: 2,
largeColumns: 4, largeColumns: 4,
smallBreakpoint: 640,
mediumBreakpoint: 640, mediumBreakpoint: 640,
largeBreakpoint: 1100, largeBreakpoint: 1100,
children: [ children: [
@ -417,24 +489,74 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
onChanged: (v) => setState(() => _vendorId = v), onChanged: (v) => setState(() => _vendorId = v),
validator: (v) => validator: (v) =>
v == null ? 'Vendor is required' : null, 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 *', label: 'Billing *',
value: _dropdownValue(_billingId, locationIds), value: _dropdownValue(_billingId, locationIds),
hint: 'Select billing location', hint: 'Select billing location',
searchHint: 'Search plant or warehouse...', searchHint: 'Search plant or warehouse...',
options: _intOptions(lookups.locations), options: _intOptions(lookups.locations),
openInSidePanel: true,
refreshLookups: () async {
ref.invalidate(purchaseOrderLookupsProvider);
await ref.read(
purchaseOrderLookupsProvider.future,
);
},
parseCreatedId: int.tryParse,
onChanged: (v) => onChanged: (v) =>
setState(() => _billingId = v), setState(() => _billingId = v),
validator: (v) => validator: (v) =>
v == null ? 'Billing is required' : null, v == null ? 'Billing is required' : null,
), ),
AppSearchableDropdown<int>( MasterQuickAddDropdown<int>(
masterId: 'locations',
label: 'Shipping *', label: 'Shipping *',
value: _dropdownValue(_shippingId, locationIds), value: _dropdownValue(_shippingId, locationIds),
hint: 'Select shipping location', hint: 'Select shipping location',
searchHint: 'Search plant or warehouse...', searchHint: 'Search plant or warehouse...',
options: _intOptions(lookups.locations), options: _intOptions(lookups.locations),
openInSidePanel: true,
refreshLookups: () async {
ref.invalidate(purchaseOrderLookupsProvider);
await ref.read(
purchaseOrderLookupsProvider.future,
);
},
parseCreatedId: int.tryParse,
onChanged: (v) => onChanged: (v) =>
setState(() => _shippingId = v), setState(() => _shippingId = v),
validator: (v) => validator: (v) =>
@ -448,6 +570,7 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
searchHint: 'Search payment term...', searchHint: 'Search payment term...',
options: options:
_nullableIntOptions(lookups.paymentTerms), _nullableIntOptions(lookups.paymentTerms),
openInSidePanel: true,
refreshLookups: () => refreshLookups: () =>
ref.invalidate(purchaseOrderLookupsProvider), ref.invalidate(purchaseOrderLookupsProvider),
parseCreatedId: int.tryParse, parseCreatedId: int.tryParse,
@ -462,6 +585,7 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
searchHint: 'Search delivery term...', searchHint: 'Search delivery term...',
options: options:
_nullableIntOptions(lookups.deliveryTerms), _nullableIntOptions(lookups.deliveryTerms),
openInSidePanel: true,
refreshLookups: () => refreshLookups: () =>
ref.invalidate(purchaseOrderLookupsProvider), ref.invalidate(purchaseOrderLookupsProvider),
parseCreatedId: int.tryParse, parseCreatedId: int.tryParse,
@ -473,6 +597,7 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
value: _expectedDeliveryDate, value: _expectedDeliveryDate,
onTap: () => _pickDate( onTap: () => _pickDate(
current: _expectedDeliveryDate, current: _expectedDeliveryDate,
firstDate: _poDate ?? DateTime(2020),
onPicked: (d) => setState( onPicked: (d) => setState(
() => _expectedDeliveryDate = d, () => _expectedDeliveryDate = d,
), ),
@ -480,6 +605,7 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
), ),
], ],
), ),
),
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@ -563,7 +689,7 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
const Spacer(), const Spacer(),
Text( Text(
widget.isEditing 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', : '${_lines.length} line item${_lines.length == 1 ? '' : 's'} · draft not yet saved',
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
@ -638,7 +764,7 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
); );
return Padding( return Padding(
padding: const EdgeInsets.only(bottom: 8), padding: EdgeInsets.zero,
child: LayoutBuilder( child: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final stack = constraints.maxWidth < 720; final stack = constraints.maxWidth < 720;
@ -795,6 +921,15 @@ class _AmountSummaryCard extends StatelessWidget {
final bool isEditing; final bool isEditing;
final bool isInterState; 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) { String? _validateDiscount(String? value) {
final text = value?.trim() ?? ''; final text = value?.trim() ?? '';
if (text.isEmpty) return null; if (text.isEmpty) return null;
@ -858,10 +993,14 @@ class _AmountSummaryCard extends StatelessWidget {
_SummaryInputRow( _SummaryInputRow(
label: 'Freight Charges', label: 'Freight Charges',
controller: freightController, controller: freightController,
validator: (v) => _validateNonNegativeAmount(v, 'Freight'),
autovalidateMode: AutovalidateMode.onUserInteraction,
), ),
_SummaryInputRow( _SummaryInputRow(
label: 'Other Charges', label: 'Other Charges',
controller: otherChargesController, controller: otherChargesController,
validator: (v) => _validateNonNegativeAmount(v, 'Other charges'),
autovalidateMode: AutovalidateMode.onUserInteraction,
), ),
_SummaryInputRow( _SummaryInputRow(
label: 'Discount Amount', label: 'Discount Amount',
@ -902,7 +1041,7 @@ class _AmountSummaryCard extends StatelessWidget {
Text( Text(
CurrencyFormatter.format(totals.grandTotal), CurrencyFormatter.format(totals.grandTotal),
style: theme.textTheme.titleMedium?.copyWith( style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800, fontWeight: FontWeight.w700,
color: theme.colorScheme.primary, color: theme.colorScheme.primary,
), ),
), ),
@ -1000,6 +1139,9 @@ class _SummaryInputRow extends StatelessWidget {
controller: controller, controller: controller,
keyboardType: keyboardType:
const TextInputType.numberWithOptions(decimal: true), const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*')),
],
textAlign: TextAlign.right, textAlign: TextAlign.right,
autovalidateMode: autovalidateMode, autovalidateMode: autovalidateMode,
validator: validator, validator: validator,
@ -1017,9 +1159,8 @@ class _SummaryInputRow extends StatelessWidget {
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
errorMaxLines: 2, errorMaxLines: 2,
errorStyle: theme.textTheme.bodySmall?.copyWith( errorStyle: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.error, color: theme.colorScheme.error,
fontSize: 11,
), ),
), ),
), ),

View File

@ -23,10 +23,13 @@ import '../../../../shared/widgets/app_search_filter_toggle.dart';
import '../../../../shared/widgets/app_searchable_dropdown.dart'; import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/can_permission.dart'; import '../../../../shared/widgets/can_permission.dart';
import '../../../../shared/widgets/app_table_action_icon.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/app_table_shell.dart';
import '../../../../shared/widgets/error_view.dart'; import '../../../../shared/widgets/error_view.dart';
import '../../../../shared/widgets/page_header.dart'; import '../../../../shared/widgets/page_header.dart';
import '../../../../shared/providers/table_column_prefs_provider.dart';
import '../../../../shared/utils/file_download_helper.dart'; import '../../../../shared/utils/file_download_helper.dart';
import '../../data/repositories/purchase_order_repository_impl.dart';
import '../providers/purchase_orders_provider.dart'; import '../providers/purchase_orders_provider.dart';
import '../widgets/po_status_chip.dart'; import '../widgets/po_status_chip.dart';
import '../../../../shared/widgets/app_toast.dart'; import '../../../../shared/widgets/app_toast.dart';
@ -76,8 +79,10 @@ class _PurchaseOrderListScreenState extends ConsumerState<PurchaseOrderListScree
!pendingOnly && ref.can('purchase_orders', PermissionAction.delete); !pendingOnly && ref.can('purchase_orders', PermissionAction.delete);
final canExport = final canExport =
!pendingOnly && ref.can('purchase_orders', PermissionAction.export); !pendingOnly && ref.can('purchase_orders', PermissionAction.export);
final canApprove = final canApprove = ref.can('purchase_orders', PermissionAction.approve);
pendingOnly && ref.can('purchase_orders', PermissionAction.approve); // API: notifications/trigger requires PURCHASE_ORDER edit.
final canNotify =
ref.can('purchase_orders', PermissionAction.update);
void listenListMessages( void listenListMessages(
AsyncValue<PurchaseOrdersListState>? prev, AsyncValue<PurchaseOrdersListState>? prev,
@ -130,6 +135,11 @@ class _PurchaseOrderListScreenState extends ConsumerState<PurchaseOrderListScree
() => _filtersExpanded = !_filtersExpanded, () => _filtersExpanded = !_filtersExpanded,
), ),
), ),
const SizedBox(width: 8),
AppTableColumnSelectorButton(
tableId: _PoDataTable.tableId,
columns: _PoDataTable.columnOptions,
),
if (canExport) ...[ if (canExport) ...[
const SizedBox(width: 8), const SizedBox(width: 8),
OutlinedButton.icon( OutlinedButton.icon(
@ -185,6 +195,7 @@ class _PurchaseOrderListScreenState extends ConsumerState<PurchaseOrderListScree
totalPages: state.totalPages, totalPages: state.totalPages,
totalItems: state.total, totalItems: state.total,
pageSize: state.query.limit, pageSize: state.query.limit,
itemsOnPage: state.orders.length,
itemLabel: pendingOnly itemLabel: pendingOnly
? 'pending approvals' ? 'pending approvals'
: 'purchase orders', : 'purchase orders',
@ -213,41 +224,71 @@ class _PurchaseOrderListScreenState extends ConsumerState<PurchaseOrderListScree
) )
.refresh() .refresh()
: ref.read(purchaseOrdersListProvider.notifier).refresh(), : ref.read(purchaseOrdersListProvider.notifier).refresh(),
child: state.orders.isEmpty child: context.isMobile
? ListView( ? (state.orders.isEmpty
physics: const AlwaysScrollableScrollPhysics(), ? ListView(
children: [ physics: const AlwaysScrollableScrollPhysics(),
SizedBox( children: [
height: 240, SizedBox(
child: AppEmptyState( height: 240,
title: pendingOnly child: AppEmptyState(
? 'No pending approvals' title: pendingOnly
: 'No purchase orders found', ? 'No pending approvals'
description: pendingOnly : 'No purchase orders found',
? 'There are no purchase orders waiting for approval.' description: pendingOnly
: 'Try adjusting filters or create a new purchase order.', ? 'There are no purchase orders waiting for approval.'
icon: pendingOnly : 'Try adjusting filters or create a new purchase order.',
? Icons.pending_actions_outlined icon: pendingOnly
: Icons.receipt_long_outlined, ? 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,
) )
: _PoDataTable( : _PoCardList(
orders: state.orders, orders: state.orders,
onView: _viewOrder, onView: _viewOrder,
onEdit: canEdit ? _editOrder : null, onEdit: canEdit ? _editOrder : null,
onDelete: canDelete ? _deleteOrder : null, onDelete: canDelete ? _deleteOrder : null,
onApprove: canApprove ? _approveOrder : 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) { 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}'); context.push('${RouteConstants.purchaseOrders}/${order.id}');
} }
@ -321,9 +360,40 @@ class _PurchaseOrderListScreenState extends ConsumerState<PurchaseOrderListScree
confirmLabel: 'Approve', confirmLabel: 'Approve',
); );
if (confirmed != true || !mounted) return; if (confirmed != true || !mounted) return;
await ref if (widget.pendingApprovalOnly) {
.read(pendingApprovalPurchaseOrdersListProvider.notifier) await ref
.approvePurchaseOrder(order.id); .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 { Future<void> _deleteOrder(PurchaseOrderModel order) async {
@ -419,102 +489,164 @@ class _FiltersBar extends StatelessWidget {
} }
} }
class _PoDataTable extends StatelessWidget { class _PoDataTable extends ConsumerWidget {
const _PoDataTable({ const _PoDataTable({
required this.orders, required this.orders,
required this.onView, required this.onView,
this.onEdit, this.onEdit,
this.onDelete, this.onDelete,
this.onApprove, 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 List<PurchaseOrderModel> orders;
final ValueChanged<PurchaseOrderModel> onView; final ValueChanged<PurchaseOrderModel> onView;
final ValueChanged<PurchaseOrderModel>? onEdit; final ValueChanged<PurchaseOrderModel>? onEdit;
final ValueChanged<PurchaseOrderModel>? onDelete; final ValueChanged<PurchaseOrderModel>? onDelete;
final ValueChanged<PurchaseOrderModel>? onApprove; final ValueChanged<PurchaseOrderModel>? onApprove;
final ValueChanged<PurchaseOrderModel>? onNotify;
final Future<void> Function()? onEnsureFullDataset;
final VoidCallback? onColumnSearchCleared;
@override List<AppDataColumn<PurchaseOrderModel>> _allColumns(ThemeData theme) {
Widget build(BuildContext context) { return [
final theme = Theme.of(context); AppDataColumn(
return AppDataTable<PurchaseOrderModel>( id: 'po_number',
wrapInCard: false, label: 'PO Number',
columns: [ sortKey: 'po_number',
AppDataColumn( locked: true,
label: 'PO Number', flex: 2,
flex: 2, searchText: (order) => order.poNo ?? '',
searchText: (order) => order.poNo ?? '', cellBuilder: (_, order) => AppTableCell.link(
cellBuilder: (_, order) => Text(order.poNo ?? ''), order.poNo,
onTap: () => onView(order),
), ),
AppDataColumn( ),
label: 'Date', AppDataColumn(
flex: 1, id: 'date',
searchText: (order) => DateFormatter.displayDate(order.poDate), label: 'Date',
cellBuilder: (_, order) => Text(DateFormatter.displayDate(order.poDate)), sortKey: 'date',
), flex: 1,
AppDataColumn( searchText: (order) => DateFormatter.searchableDate(order.poDate),
label: 'Vendor', sortValue: (order) => order.poDate,
flex: 2, cellBuilder: (_, order) => Text(DateFormatter.displayDate(order.poDate)),
searchText: (order) => order.vendorName ?? '', ),
cellBuilder: (_, order) => Text(order.vendorName ?? ''), AppDataColumn(
), id: 'vendor',
AppDataColumn( label: 'Vendor',
label: 'Total', sortKey: 'vendor',
flex: 1, flex: 2,
searchText: (order) => CurrencyFormatter.format(order.totalAmount), searchText: (order) => order.vendorName ?? '',
cellBuilder: (_, order) => SizedBox( cellBuilder: (_, order) => Text(order.vendorName ?? ''),
width: double.infinity, ),
child: AppTableCell.text( AppDataColumn(
CurrencyFormatter.format(order.totalAmount), id: 'total',
textAlign: TextAlign.right, 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', AppDataColumn(
flex: 1, id: 'status',
searchText: (order) => order.status, label: 'Status',
cellBuilder: (_, order) => PoStatusChip( sortKey: 'status',
flex: 1,
searchText: (order) => order.status,
cellBuilder: (_, order) {
final chip = PoStatusChip(
status: order.status, status: order.status,
compact: true, compact: true,
forTable: true, forTable: true,
), );
), final reason = order.rejectReasonForDisplay;
AppDataColumn( if (reason == null) return chip;
label: 'Actions', return Tooltip(
flex: onApprove != null ? 2 : 1, message: 'Reject reason: $reason',
alignment: Alignment.centerRight, child: chip,
enableSearch: false, );
cellBuilder: (_, order) => AppTableActions( },
children: [ ),
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( AppTableActionIcon(
tooltip: 'View', tooltip: 'Notify approvers',
icon: Icons.visibility_outlined, icon: Icons.notifications_outlined,
onPressed: () => onView(order), onPressed: () => onNotify!(order),
), ),
if (onApprove != null && order.canApprove) if (onApprove != null && order.canApprove)
AppTableActionIcon( AppTableActionIcon(
tooltip: 'Approve', tooltip: 'Approve',
icon: Icons.check_circle_outline, icon: Icons.check_circle_outline,
color: theme.colorScheme.primary, color: theme.colorScheme.primary,
onPressed: () => onApprove!(order), onPressed: () => onApprove!(order),
), ),
if (onEdit != null && order.canEdit) if (onEdit != null && order.canEdit)
AppTableActionIcon( AppTableActionIcon(
tooltip: 'Edit', tooltip: 'Edit',
icon: Icons.edit_outlined, icon: Icons.edit_outlined,
onPressed: () => onEdit!(order), onPressed: () => onEdit!(order),
), ),
if (onDelete != null && order.canDelete) if (onDelete != null && order.canDelete)
AppTableActionIcon( AppTableActionIcon(
tooltip: 'Delete', tooltip: 'Delete',
icon: Icons.delete_outline, icon: Icons.delete_outline,
onPressed: () => onDelete!(order), 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, rows: orders,
); );
} }
@ -527,6 +659,7 @@ class _PoCardList extends StatelessWidget {
this.onEdit, this.onEdit,
this.onDelete, this.onDelete,
this.onApprove, this.onApprove,
this.onNotify,
}); });
final List<PurchaseOrderModel> orders; final List<PurchaseOrderModel> orders;
@ -534,6 +667,7 @@ class _PoCardList extends StatelessWidget {
final ValueChanged<PurchaseOrderModel>? onEdit; final ValueChanged<PurchaseOrderModel>? onEdit;
final ValueChanged<PurchaseOrderModel>? onDelete; final ValueChanged<PurchaseOrderModel>? onDelete;
final ValueChanged<PurchaseOrderModel>? onApprove; final ValueChanged<PurchaseOrderModel>? onApprove;
final ValueChanged<PurchaseOrderModel>? onNotify;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -544,14 +678,26 @@ class _PoCardList extends StatelessWidget {
final order = orders[index]; final order = orders[index];
return AppCard( return AppCard(
child: ListTile( child: ListTile(
title: Text(order.poNo ?? 'PO #${order.id}'), title: AppTableCell.link(
order.poNo ?? 'PO #${order.id}',
onTap: () => onView(order),
),
subtitle: Text( subtitle: Text(
'${order.vendorName ?? ''} · ${vendorTypeLabel(order.vendorType)}', '${order.vendorName ?? ''} · ${vendorTypeLabel(order.vendorType)}',
), ),
onTap: () => onView(order),
trailing: Row( trailing: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
PoStatusChip(status: order.status, compact: true), 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) ...[ if (onApprove != null && order.canApprove) ...[
const SizedBox(width: 4), const SizedBox(width: 4),
IconButton( IconButton(
@ -565,7 +711,6 @@ class _PoCardList extends StatelessWidget {
], ],
], ],
), ),
onTap: () => onView(order),
), ),
); );
}, },

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../../core/theme/app_typography.dart';
import '../../../../shared/models/purchase_order_model.dart'; import '../../../../shared/models/purchase_order_model.dart';
import '../../../../shared/widgets/app_status_chip.dart'; import '../../../../shared/widgets/app_status_chip.dart';
@ -17,7 +18,8 @@ class PoStatusChip extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final (color, label) = _resolveStatus(status); final secondary = Theme.of(context).colorScheme.secondary;
final (color, label) = _resolveStatus(status, secondary);
if (forTable) { if (forTable) {
return TableStatusBadge( return TableStatusBadge(
label: label, label: label,
@ -29,7 +31,7 @@ class PoStatusChip extends StatelessWidget {
return _PoBadge(label: label, color: color, compact: compact); return _PoBadge(label: label, color: color, compact: compact);
} }
(Color, String) _resolveStatus(String raw) { (Color, String) _resolveStatus(String raw, Color secondary) {
switch (raw.toUpperCase()) { switch (raw.toUpperCase()) {
case 'DRAFT': case 'DRAFT':
return (const Color(0xFF546E7A), poStatusLabel(raw)); return (const Color(0xFF546E7A), poStatusLabel(raw));
@ -38,17 +40,17 @@ class PoStatusChip extends StatelessWidget {
case 'PENDING': case 'PENDING':
return (const Color(0xFFE65100), poStatusLabel(raw)); return (const Color(0xFFE65100), poStatusLabel(raw));
case 'APPROVED': case 'APPROVED':
return (const Color(0xFF2E7D32), poStatusLabel(raw)); return (secondary, poStatusLabel(raw));
case 'REJECTED': case 'REJECTED':
return (const Color(0xFFC62828), poStatusLabel(raw)); return (const Color(0xFFC62828), poStatusLabel(raw));
case 'CANCELLED': case 'CANCELLED':
return (const Color(0xFF616161), poStatusLabel(raw)); return (const Color(0xFF616161), poStatusLabel(raw));
case 'PARTIALLY_RECEIVED': case 'PARTIALLY_RECEIVED':
return (const Color(0xFF00695C), poStatusLabel(raw)); return (secondary, poStatusLabel(raw));
case 'FULLY_RECEIVED': case 'FULLY_RECEIVED':
return (const Color(0xFF283593), poStatusLabel(raw)); return (secondary, poStatusLabel(raw));
default: default:
return (const Color(0xFF546E7A), poStatusLabel(raw)); return (secondary, poStatusLabel(raw));
} }
} }
} }
@ -67,7 +69,7 @@ class PoRevisionChip extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return _PoBadge( return _PoBadge(
label: 'Revision $revisionNo', label: 'Revision $revisionNo',
color: const Color(0xFF607D8B), color: Theme.of(context).colorScheme.secondary,
compact: compact, compact: compact,
); );
} }
@ -100,13 +102,16 @@ class _PoBadge extends StatelessWidget {
widthFactor: 1, widthFactor: 1,
child: Text( child: Text(
label, label,
style: TextStyle( style: (compact
color: color, ? AppTypography.caption1(
fontSize: compact ? 11 : 12, weight: AppTypography.semiBold,
fontWeight: FontWeight.w600, color: color,
height: 1, )
letterSpacing: 0.1, : AppTypography.label3(
), weight: AppTypography.semiBold,
color: color,
))
.copyWith(height: 1, letterSpacing: 0.1),
), ),
), ),
); );

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/utils/formatters.dart'; import '../../../../core/utils/formatters.dart';
@ -41,15 +42,19 @@ class PoLineCalculation {
required double discPct, required double discPct,
required double gstPct, required double gstPct,
}) { }) {
final baseAmount = qty * rate; final qtySafe = qty < 0 ? 0.0 : qty;
final discountAmount = baseAmount * discPct / 100; 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 lineAmount = baseAmount - discountAmount;
final gstAmount = lineAmount * gstPct / 100; final gstAmount = lineAmount * gstSafe / 100;
return PoLineCalculation( return PoLineCalculation(
baseAmount: baseAmount, baseAmount: baseAmount,
discountAmount: discountAmount, discountAmount: discountAmount,
lineAmount: lineAmount, lineAmount: lineAmount < 0 ? 0 : lineAmount,
gstAmount: gstAmount, gstAmount: gstAmount < 0 ? 0 : gstAmount,
); );
} }
} }
@ -72,7 +77,7 @@ class PoOrderTotals {
final double taxAmount; final double taxAmount;
final double grandTotal; final double grandTotal;
/// Sub Total + Tax + Freight + Other (discount cannot exceed this). /// Sub Total + Freight + Other (discount cannot exceed this).
final double maxDiscountAmount; final double maxDiscountAmount;
static const zero = PoOrderTotals( static const zero = PoOrderTotals(
@ -85,7 +90,7 @@ class PoOrderTotals {
/// Sub Total = sum of line amounts /// Sub Total = sum of line amounts
/// Taxable = Sub Total + Freight + Other Discount /// 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 /// Grand Total = Taxable + Tax
factory PoOrderTotals.compute({ factory PoOrderTotals.compute({
required Iterable<PoLineCalculation> lines, required Iterable<PoLineCalculation> lines,
@ -94,22 +99,29 @@ class PoOrderTotals {
required double discountAmount, required double discountAmount,
}) { }) {
var subTotal = 0.0; var subTotal = 0.0;
var tax = 0.0; var lineTax = 0.0;
for (final line in lines) { for (final line in lines) {
subTotal += line.lineAmount; 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 freightSafe = freight < 0 ? 0.0 : freight;
final otherSafe = otherCharges < 0 ? 0.0 : otherCharges; final otherSafe = otherCharges < 0 ? 0.0 : otherCharges;
final clampedDiscount = discountAmount < 0 ? 0.0 : discountAmount; final maxDiscount = subSafe + freightSafe + otherSafe;
final taxableRaw = subTotal + freightSafe + otherSafe - clampedDiscount; final clampedDiscount = discountAmount < 0
final taxable = taxableRaw < 0 ? 0.0 : taxableRaw; ? 0.0
final maxDiscount = subTotal + tax + freightSafe + otherSafe; : (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; final grandTotal = taxable + tax;
return PoOrderTotals( return PoOrderTotals(
subTotal: subTotal, subTotal: subSafe,
taxableAmount: taxable, taxableAmount: taxable < 0 ? 0 : taxable,
taxAmount: tax, taxAmount: tax < 0 ? 0 : tax,
grandTotal: grandTotal < 0 ? 0 : grandTotal, grandTotal: grandTotal < 0 ? 0 : grandTotal,
maxDiscountAmount: maxDiscount < 0 ? 0 : maxDiscount, maxDiscountAmount: maxDiscount < 0 ? 0 : maxDiscount,
); );
@ -119,9 +131,12 @@ class PoOrderTotals {
class PoLineItemDraft { class PoLineItemDraft {
PoLineItemDraft({ PoLineItemDraft({
this.itemId, this.itemId,
this.itemName,
this.itemCode,
required this.lineNo, required this.lineNo,
TextEditingController? qtyController, TextEditingController? qtyController,
this.uomId, this.uomId,
this.uomName,
TextEditingController? rateController, TextEditingController? rateController,
TextEditingController? discountController, TextEditingController? discountController,
this.gstRateId, this.gstRateId,
@ -132,9 +147,14 @@ class PoLineItemDraft {
discountController ?? TextEditingController(text: '0'); discountController ?? TextEditingController(text: '0');
int? itemId; 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; int lineNo;
final TextEditingController qtyController; final TextEditingController qtyController;
int? uomId; int? uomId;
String? uomName;
final TextEditingController rateController; final TextEditingController rateController;
final TextEditingController discountController; final TextEditingController discountController;
int? gstRateId; int? gstRateId;
@ -143,10 +163,13 @@ class PoLineItemDraft {
factory PoLineItemDraft.fromModel(PurchaseOrderItemModel item) { factory PoLineItemDraft.fromModel(PurchaseOrderItemModel item) {
return PoLineItemDraft( return PoLineItemDraft(
itemId: item.itemId, itemId: item.itemId,
itemName: item.itemName,
itemCode: item.itemCode,
lineNo: item.lineNo ?? 1, lineNo: item.lineNo ?? 1,
qtyController: qtyController:
TextEditingController(text: item.orderedQty?.toString() ?? ''), TextEditingController(text: item.orderedQty?.toString() ?? ''),
uomId: item.uomId, uomId: item.uomId,
uomName: item.uomName,
rateController: TextEditingController(text: item.rate?.toString() ?? ''), rateController: TextEditingController(text: item.rate?.toString() ?? ''),
discountController: discountController:
TextEditingController(text: item.discountPct?.toString() ?? '0'), TextEditingController(text: item.discountPct?.toString() ?? '0'),
@ -448,7 +471,22 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
void _onItemChanged(int? itemId) { void _onItemChanged(int? itemId) {
_updateLine(() { _updateLine(() {
widget.line.itemId = itemId; 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 key = itemId.toString();
final defaultUom = _itemUomById[key]; final defaultUom = _itemUomById[key];
if (defaultUom != null) { 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 @override
void didUpdateWidget(covariant _LineItemCard oldWidget) { void didUpdateWidget(covariant _LineItemCard oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
@ -497,29 +590,10 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
alpha: isDark ? 0.18 : 0.08, alpha: isDark ? 0.18 : 0.08,
); );
final itemOptions = widget.items final itemOptions = _itemOptionsWithSelected();
.map((e) { final uomOptions = _uomOptionsWithSelected();
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 gstOptions = [ final gstOptions = [
const AppDropdownOption<int?>(value: null, label: 'Select GST Rate'), const AppDropdownOption<int?>(value: null, label: 'Select'),
...widget.gstRates.map((e) { ...widget.gstRates.map((e) {
final id = _parseId(e.id); final id = _parseId(e.id);
if (id == null) return null; if (id == null) return null;
@ -527,9 +601,24 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
final label = pct != null final label = pct != null
? (pct % 1 == 0 ? '${pct.toInt()}%' : '$pct%') ? (pct % 1 == 0 ? '${pct.toInt()}%' : '$pct%')
: e.name.split('').first.trim(); : e.name.split('').first.trim();
if (label.isEmpty) return null;
return AppDropdownOption<int?>(value: id, label: label); return AppDropdownOption<int?>(value: id, label: label);
}), }),
].whereType<AppDropdownOption<int?>>().toList(); ].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; const spacing = 8.0;
@ -541,6 +630,7 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
hint: 'Select item', hint: 'Select item',
searchHint: 'Search item name or code...', searchHint: 'Search item name or code...',
options: itemOptions, options: itemOptions,
openInSidePanel: true,
refreshLookups: () async { refreshLookups: () async {
ref.invalidate(purchaseOrderLookupsProvider); ref.invalidate(purchaseOrderLookupsProvider);
await ref.read(purchaseOrderLookupsProvider.future); await ref.read(purchaseOrderLookupsProvider.future);
@ -554,7 +644,11 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
controller: line.qtyController, controller: line.qtyController,
label: 'Qty *', label: 'Qty *',
hint: '0', hint: '0',
isDense: true,
keyboardType: const TextInputType.numberWithOptions(decimal: true), keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*')),
],
validator: (v) { validator: (v) {
if (v == null || v.trim().isEmpty) return 'Required'; if (v == null || v.trim().isEmpty) return 'Required';
final qty = double.tryParse(v); final qty = double.tryParse(v);
@ -570,6 +664,7 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
hint: 'Select UOM', hint: 'Select UOM',
searchHint: 'Search UOM...', searchHint: 'Search UOM...',
options: uomOptions, options: uomOptions,
openInSidePanel: true,
refreshLookups: () async { refreshLookups: () async {
ref.invalidate(purchaseOrderLookupsProvider); ref.invalidate(purchaseOrderLookupsProvider);
await ref.read(purchaseOrderLookupsProvider.future); await ref.read(purchaseOrderLookupsProvider.future);
@ -584,6 +679,9 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
label: 'Rate *', label: 'Rate *',
hint: '0.00', hint: '0.00',
keyboardType: const TextInputType.numberWithOptions(decimal: true), keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*')),
],
validator: (v) { validator: (v) {
if (v == null || v.trim().isEmpty) return 'Required'; if (v == null || v.trim().isEmpty) return 'Required';
final rate = double.tryParse(v); final rate = double.tryParse(v);
@ -596,7 +694,19 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
controller: line.discountController, controller: line.discountController,
label: 'Disc %', label: 'Disc %',
hint: '0', hint: '0',
isDense: true,
keyboardType: const TextInputType.numberWithOptions(decimal: 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?>( final gstField = MasterQuickAddDropdown<int?>(
key: ValueKey('$lineKey-gst'), key: ValueKey('$lineKey-gst'),
@ -606,6 +716,8 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
hint: 'Select', hint: 'Select',
searchHint: 'Search GST %...', searchHint: 'Search GST %...',
options: gstOptions, options: gstOptions,
isDense: true,
openInSidePanel: true,
refreshLookups: () async { refreshLookups: () async {
ref.invalidate(purchaseOrderLookupsProvider); ref.invalidate(purchaseOrderLookupsProvider);
await ref.read(purchaseOrderLookupsProvider.future); await ref.read(purchaseOrderLookupsProvider.future);
@ -613,110 +725,64 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
parseCreatedId: int.tryParse, parseCreatedId: int.tryParse,
onChanged: (v) => _updateLine(() => line.gstRateId = v), onChanged: (v) => _updateLine(() => line.gstRateId = v),
); );
final amountField = _AmountWithRemove( final amountField = _AmountDisplay(
amount: CurrencyFormatter.format(calc.lineAmount), label: 'Amount',
value: CurrencyFormatter.format(calc.lineAmount),
backgroundColor: amountBg, backgroundColor: amountBg,
onRemove: widget.onRemove,
); );
return Container( return Container(
padding: const EdgeInsets.all(16), width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border.all(color: borderColor), border: Border.all(color: borderColor),
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
), ),
child: QuickAddInlineHost( child: QuickAddInlineHost(
child: LayoutBuilder( child: QuickAddBlockable(
builder: (context, constraints) { child: SizedBox(
final width = constraints.maxWidth; width: double.infinity,
child: Row(
// Wide: single flex row crossAxisAlignment: CrossAxisAlignment.start,
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 (23 columns)
return ResponsiveFormGrid(
spacing: spacing,
smallColumns: 1,
mediumColumns: 2,
largeColumns: 3,
mediumBreakpoint: 520,
largeBreakpoint: 800,
children: [ children: [
itemField, Expanded(flex: 3, child: itemField),
qtyField, const SizedBox(width: spacing),
uomField, // Wider than flex 1 so floating labels ("Qty *", "Disc %") aren't clipped.
rateField, Expanded(flex: 2, child: qtyField),
discField, const SizedBox(width: spacing),
gstField, Expanded(flex: 2, child: uomField),
amountField, 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), borderRadius: BorderRadius.circular(8),
), ),
), ),
child: Text( child: FittedBox(
value, fit: BoxFit.scaleDown,
style: theme.textTheme.titleSmall?.copyWith( alignment: Alignment.centerLeft,
fontWeight: FontWeight.w700, child: Text(
color: theme.colorScheme.onSurface, value,
maxLines: 1,
softWrap: false,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
color: theme.colorScheme.onSurface,
),
), ),
), ),
), ),

View File

@ -62,7 +62,7 @@ const rbacModules = [
), ),
RbacModule( RbacModule(
key: 'grn', key: 'grn',
label: 'GRN / PO Receipt', label: 'Purchase Receipt',
icon: Icons.inventory_outlined, icon: Icons.inventory_outlined,
color: Color(0xFF0891B2), color: Color(0xFF0891B2),
), ),
@ -288,7 +288,7 @@ List<ManagedRole> defaultRoles = [
ManagedRole( ManagedRole(
id: 'store_manager', id: 'store_manager',
name: 'Store Manager', name: 'Store Manager',
description: 'GRN, warehouse receipts, stock view', description: 'Purchase Receipt, warehouse receipts, stock view',
icon: Icons.warehouse_outlined, icon: Icons.warehouse_outlined,
color: const Color(0xFF0891B2), color: const Color(0xFF0891B2),
userCount: 12, userCount: 12,
@ -297,7 +297,7 @@ List<ManagedRole> defaultRoles = [
ManagedRole( ManagedRole(
id: 'accounts', id: 'accounts',
name: 'Accounts', name: 'Accounts',
description: 'View PO, GRN, vendor financial details', description: 'View PO, Purchase Receipt, vendor financial details',
icon: Icons.account_balance_wallet_outlined, icon: Icons.account_balance_wallet_outlined,
color: const Color(0xFF7C3AED), color: const Color(0xFF7C3AED),
userCount: 8, userCount: 8,

View File

@ -6,6 +6,7 @@ import '../../../../core/constants/route_constants.dart';
import '../../../../core/errors/failure.dart'; import '../../../../core/errors/failure.dart';
import '../../../../core/theme/theme_provider.dart'; import '../../../../core/theme/theme_provider.dart';
import '../../../../core/utils/responsive_utils.dart'; import '../../../../core/utils/responsive_utils.dart';
import '../../../../core/utils/validators.dart';
import '../../../../shared/models/permission_matrix_models.dart'; import '../../../../shared/models/permission_matrix_models.dart';
import '../../../../shared/models/user_management_models.dart'; import '../../../../shared/models/user_management_models.dart';
import '../../../../shared/utils/file_download_helper.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/role_form_provider.dart';
import '../providers/rbac_provider.dart'; import '../providers/rbac_provider.dart';
import '../../../../shared/widgets/app_hover_effect.dart'; import '../../../../shared/widgets/app_hover_effect.dart';
import '../../../../shared/widgets/app_segmented_tab_bar.dart';
import '../../../../shared/widgets/app_side_panel.dart'; import '../../../../shared/widgets/app_side_panel.dart';
import '../../../roles/presentation/providers/roles_provider.dart'; import '../../../roles/presentation/providers/roles_provider.dart';
import '../widgets/add_user_panel.dart'; import '../widgets/add_user_panel.dart';
@ -395,37 +397,45 @@ class _TabBar extends ConsumerWidget {
final canExport = ref.can('users', PermissionAction.export); final canExport = ref.can('users', PermissionAction.export);
final isExporting = usersState?.isExporting ?? false; 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( return Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Expanded( Expanded(
child: SingleChildScrollView( child: AppSegmentedTabBar(
scrollDirection: Axis.horizontal, selectedIndex: selectedIndex,
child: Row( onChanged: (index) => onSelectTab(tabEntries[index].$1),
children: [ tabs: [for (final entry in tabEntries) entry.$2],
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),
),
],
),
), ),
), ),
if (isUsersTab) ...[ 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 { class _UsersTab extends ConsumerStatefulWidget {
const _UsersTab({ const _UsersTab({
required this.filtersExpanded, required this.filtersExpanded,
@ -639,17 +596,22 @@ class _UsersTabState extends ConsumerState<_UsersTab> {
Future<void> _resetPassword(ManagedUserModel user) async { Future<void> _resetPassword(ManagedUserModel user) async {
final controller = TextEditingController(); final controller = TextEditingController();
final formKey = GlobalKey<FormState>();
final password = await showDialog<String>( final password = await showDialog<String>(
context: context, context: context,
builder: (dialogContext) => AlertDialog( builder: (dialogContext) => AlertDialog(
title: const Text('Reset Password'), title: const Text('Reset Password'),
content: TextField( content: Form(
controller: controller, key: formKey,
obscureText: true, child: TextFormField(
autofocus: true, controller: controller,
decoration: const InputDecoration( obscureText: true,
labelText: 'New Temporary Password', autofocus: true,
hintText: 'Min. 8 characters', validator: Validators.password,
decoration: const InputDecoration(
labelText: 'New Temporary Password',
hintText: '8+ chars, upper, lower, digit, special',
),
), ),
), ),
actions: [ actions: [
@ -659,9 +621,8 @@ class _UsersTabState extends ConsumerState<_UsersTab> {
), ),
TextButton( TextButton(
onPressed: () { onPressed: () {
final value = controller.text.trim(); if (!(formKey.currentState?.validate() ?? false)) return;
if (value.length < 8) return; Navigator.of(dialogContext).pop(controller.text.trim());
Navigator.of(dialogContext).pop(value);
}, },
child: const Text('Reset'), child: const Text('Reset'),
), ),
@ -833,39 +794,34 @@ class _UsersTabState extends ConsumerState<_UsersTab> {
}, },
), ),
), ),
if (usersState.users.isEmpty) Expanded(
Expanded( child: UserRichDataTable(
child: Center( wrapInCard: false,
child: Text( users: usersState.users,
'No users found', onEnsureFullDataset: () => ref
style: Theme.of(context).textTheme.bodyLarge?.copyWith( .read(usersListProvider.notifier)
color: Theme.of(context).colorScheme.onSurfaceVariant, .ensureColumnSearchDataset(),
), onColumnSearchCleared: () => ref
), .read(usersListProvider.notifier)
), .clearColumnSearchDataset(),
) actionsBuilder: (_, user) => UserTableActions(
else user: user,
Expanded( canEdit: canEditUser,
child: UserRichDataTable( canResetPassword: canEditUser,
wrapInCard: false, canDeactivate: canDeleteUser,
users: usersState.users, onEdit: () => _editUser(user),
actionsBuilder: (_, user) => UserTableActions( onResetPassword: () => _resetPassword(user),
user: user, onDeactivate: () => _deactivateUser(user),
canEdit: canEditUser,
canResetPassword: canEditUser,
canDeactivate: canDeleteUser,
onEdit: () => _editUser(user),
onResetPassword: () => _resetPassword(user),
onDeactivate: () => _deactivateUser(user),
),
), ),
), ),
),
const Divider(height: 1), const Divider(height: 1),
AppPagination( AppPagination(
currentPage: page, currentPage: page,
totalPages: usersState.totalPages, totalPages: usersState.totalPages,
totalItems: total, totalItems: total,
pageSize: pageSize, pageSize: pageSize,
itemsOnPage: usersState.users.length,
itemLabel: 'users', itemLabel: 'users',
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
onPageChanged: onPageChanged:
@ -1061,7 +1017,7 @@ class _RolesTab extends ConsumerWidget {
key: ValueKey('$themeMode-$brightness-${rolesState.page}'), key: ValueKey('$themeMode-$brightness-${rolesState.page}'),
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 320, maxCrossAxisExtent: 320,
mainAxisExtent: 168, mainAxisExtent: 176,
crossAxisSpacing: 16, crossAxisSpacing: 16,
mainAxisSpacing: 16, mainAxisSpacing: 16,
), ),
@ -1126,31 +1082,51 @@ class _RolesTab extends ConsumerWidget {
children: [ children: [
Row( Row(
children: [ children: [
Container( Tooltip(
width: 36, message: role.name,
height: 36, child: Container(
decoration: BoxDecoration( width: 36,
color: appearance.color.withValues(alpha: 0.12), height: 36,
borderRadius: BorderRadius.circular(8), decoration: BoxDecoration(
), color:
child: Icon( appearance.color.withValues(alpha: 0.12),
appearance.icon, borderRadius: BorderRadius.circular(8),
color: appearance.color, ),
size: 20, child: Icon(
appearance.icon,
color: appearance.color,
size: 20,
),
), ),
), ),
const Spacer(), const Spacer(),
if (canEditRole) if (canEditRole)
IconButton( IconButton(
tooltip: 'Edit role',
icon: const Icon(Icons.edit_outlined, size: 18), icon: const Icon(Icons.edit_outlined, size: 18),
onPressed: () => onEditRole(role), onPressed: () => onEditRole(role),
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 32,
minHeight: 32,
),
), ),
if (canDeleteRole && !isProtectedRole(role)) if (canDeleteRole && !isProtectedRole(role))
IconButton( 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), onPressed: () => onDeleteRole(role),
visualDensity: VisualDensity.compact, 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), const SizedBox(height: 4),
Tooltip( Expanded(
message: displayDescription == '' ? '' : displayDescription, child: Tooltip(
waitDuration: const Duration(milliseconds: 300), message: displayDescription == ''
child: Text( ? ''
displayDescription, : displayDescription,
maxLines: 2, waitDuration: const Duration(milliseconds: 300),
overflow: TextOverflow.ellipsis, child: Align(
style: Theme.of(context).textTheme.bodySmall?.copyWith( alignment: Alignment.topLeft,
color: Theme.of(context) child: Text(
.colorScheme displayDescription,
.onSurfaceVariant, maxLines: 2,
height: 1.35, 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( Row(
children: [ children: [
Icon( Tooltip(
Icons.people_outline, message: 'Assigned users',
size: 14, child: Row(
color: Theme.of(context) mainAxisSize: MainAxisSize.min,
.colorScheme children: [
.onSurfaceVariant, Icon(
), Icons.people_outline,
const SizedBox(width: 4), size: 14,
Text(
'${role.userCount} users',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context) color: Theme.of(context)
.colorScheme .colorScheme
.onSurfaceVariant, .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), const SizedBox(width: 16),
Icon( Tooltip(
Icons.vpn_key_outlined, message: 'Granted permissions',
size: 14, child: Row(
color: Theme.of(context) mainAxisSize: MainAxisSize.min,
.colorScheme children: [
.onSurfaceVariant, Icon(
), Icons.vpn_key_outlined,
const SizedBox(width: 4), size: 14,
Text(
'${role.permissionCount} permissions',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context) color: Theme.of(context)
.colorScheme .colorScheme
.onSurfaceVariant, .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, totalPages: rolesState.totalPages,
totalItems: rolesState.total, totalItems: rolesState.total,
pageSize: rolesState.limit, pageSize: rolesState.limit,
itemsOnPage: rolesState.pagedRoles.length,
itemLabel: 'roles', itemLabel: 'roles',
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
onPageChanged: notifier.setPage, onPageChanged: notifier.setPage,
@ -1468,9 +1477,12 @@ class _PermissionMatrixTable extends ConsumerWidget {
wrapInCard: false, wrapInCard: false,
columns: [ columns: [
AppDataColumn( AppDataColumn(
id: 'module',
label: 'Module', label: 'Module',
sortKey: 'module',
flex: 3, flex: 3,
enableSearch: false, enableSearch: false,
searchText: (module) => module.name,
cellBuilder: (context, module) { cellBuilder: (context, module) {
final index = matrix.modules.indexOf(module); final index = matrix.modules.indexOf(module);
final appearance = final appearance =
@ -1511,18 +1523,34 @@ class _PermissionMatrixTable extends ConsumerWidget {
alignment: Alignment.center, alignment: Alignment.center,
enableSearch: false, enableSearch: false,
cellBuilder: (context, module) { 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; final checked = module.granted[action] ?? false;
return Checkbox( return Checkbox(
value: checked, value: applicable ? checked : false,
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
onChanged: (value) => ref onChanged: applicable
.read(permissionMatrixProvider(roleId).notifier) ? (value) => ref
.toggleAction( .read(permissionMatrixProvider(roleId).notifier)
module.moduleId, .toggleAction(
action, module.moduleId,
value ?? false, action,
), value ?? false,
)
: null,
); );
}, },
), ),

View File

@ -41,6 +41,7 @@ class _AddUserPanelState extends ConsumerState<AddUserPanel> {
String? _selectedDesignationId; String? _selectedDesignationId;
String? _selectedReportingToId; String? _selectedReportingToId;
bool _prefilled = false; bool _prefilled = false;
bool _obscurePassword = true;
static const _statusOptions = [ static const _statusOptions = [
AppDropdownOption(value: 'Active', label: 'Active'), AppDropdownOption(value: 'Active', label: 'Active'),
@ -243,7 +244,7 @@ class _AddUserPanelState extends ConsumerState<AddUserPanel> {
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
AppButton( AppButton(
label: widget.isEditing ? 'Update user' : 'Save user', label: widget.isEditing ? 'Update User' : 'Save User',
expand: false, expand: false,
icon: Icons.check, icon: Icons.check,
isLoading: isSubmitting, isLoading: isSubmitting,
@ -384,11 +385,25 @@ class _AddUserPanelState extends ConsumerState<AddUserPanel> {
label: widget.isEditing label: widget.isEditing
? 'New Password' ? 'New Password'
: 'Temporary Password *', : 'Temporary Password *',
hint: 'Min. 8 characters', hint: '8+ chars, upper, lower, digit, special',
obscureText: true, 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 validator: widget.isEditing
? null ? Validators.optionalPassword
: (v) => Validators.required(v, fieldName: 'Password'), : Validators.password,
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(

View File

@ -113,7 +113,7 @@ class _RoleFormPanelState extends ConsumerState<RoleFormPanel> {
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: AppButton( child: AppButton(
label: widget.isEditing ? 'Update role' : 'Create role', label: widget.isEditing ? 'Update Role' : 'Save Role',
expand: true, expand: true,
isLoading: isSubmitting, isLoading: isSubmitting,
onPressed: isSubmitting ? null : _save, onPressed: isSubmitting ? null : _save,

View File

@ -1,6 +1,7 @@
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../../core/theme/app_typography.dart';
import '../../../../shared/widgets/app_data_table.dart'; import '../../../../shared/widgets/app_data_table.dart';
import '../../../../shared/widgets/app_table_action_icon.dart'; import '../../../../shared/widgets/app_table_action_icon.dart';
@ -126,11 +127,11 @@ class RoleBadge extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final primary = Theme.of(context).colorScheme.primary; final secondary = Theme.of(context).colorScheme.secondary;
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration( decoration: BoxDecoration(
color: primary.withValues(alpha: 0.08), color: secondary.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
), ),
child: Row( child: Row(
@ -139,7 +140,7 @@ class RoleBadge extends StatelessWidget {
Container( Container(
width: 6, width: 6,
height: 6, height: 6,
decoration: BoxDecoration(color: primary, shape: BoxShape.circle), decoration: BoxDecoration(color: secondary, shape: BoxShape.circle),
), ),
const SizedBox(width: 6), const SizedBox(width: 6),
Flexible( Flexible(
@ -148,7 +149,7 @@ class RoleBadge extends StatelessWidget {
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelSmall?.copyWith( style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: primary, color: secondary,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
), ),
@ -278,7 +279,7 @@ class UserTableUserCell extends StatelessWidget {
children: [ children: [
AppTableCell.text( AppTableCell.text(
user.fullName, user.fullName,
style: const TextStyle(fontWeight: FontWeight.w600), style: AppTypography.label2(weight: AppTypography.semiBold),
), ),
AppTableCell.text( AppTableCell.text(
user.email, user.email,
@ -325,8 +326,9 @@ class UserAvatarChip extends StatelessWidget {
child: Text( child: Text(
display.length > 2 ? display.substring(0, 2) : display, display.length > 2 ? display.substring(0, 2) : display,
style: TextStyle( style: TextStyle(
fontFamily: AppTypography.fontFamily,
color: color, color: color,
fontWeight: FontWeight.w700, fontWeight: AppTypography.bold,
fontSize: radius * 0.6, fontSize: radius * 0.6,
), ),
), ),
@ -605,6 +607,7 @@ class _ScrollArrowButton extends StatelessWidget {
child: IconButton( child: IconButton(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
tooltip: icon == Icons.chevron_left ? 'Scroll left' : 'Scroll right',
onPressed: enabled ? onPressed : null, onPressed: enabled ? onPressed : null,
icon: Icon( icon: Icon(
icon, icon,

View File

@ -1,6 +1,7 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import '../../../../core/constants/api_endpoints.dart'; import '../../../../core/constants/api_endpoints.dart';
import '../../../../core/utils/pagination_meta.dart';
import '../../../../core/utils/formatters.dart'; import '../../../../core/utils/formatters.dart';
import '../../../../shared/models/export_file_result.dart'; import '../../../../shared/models/export_file_result.dart';
import '../../domain/entities/depreciation_report.dart'; import '../../domain/entities/depreciation_report.dart';
@ -47,12 +48,16 @@ class ReportsRemoteDataSource {
) )
.toList(); .toList();
final page = (meta['page'] as num?)?.toInt() ?? query.page; final pagination = parsePagination(
final limit = (meta['limit'] as num?)?.toInt() ?? query.limit; body: body,
final total = (meta['total'] as num?)?.toInt() ?? items.length; fallbackPage: query.page,
final totalPages = limit > 0 fallbackLimit: query.limit,
? ((total + limit - 1) ~/ limit).clamp(1, 999999) itemCount: items.length,
: 1; );
final page = pagination.page;
final limit = query.limit;
final total = pagination.total;
final totalPages = resolveTotalPages(total: total, limit: limit);
final summaryRaw = meta['summary']; final summaryRaw = meta['summary'];
final summary = summaryRaw is Map final summary = summaryRaw is Map

View File

@ -170,6 +170,7 @@ class DepreciationReportRow {
this.usefulLifeYears, this.usefulLifeYears,
this.yearsElapsed, this.yearsElapsed,
this.salvageValue, this.salvageValue,
this.salvagePercentage,
this.annualDepreciation, this.annualDepreciation,
this.accumulatedDepreciation, this.accumulatedDepreciation,
this.bookValue, this.bookValue,
@ -190,6 +191,7 @@ class DepreciationReportRow {
final int? usefulLifeYears; final int? usefulLifeYears;
final double? yearsElapsed; final double? yearsElapsed;
final double? salvageValue; final double? salvageValue;
final double? salvagePercentage;
final double? annualDepreciation; final double? annualDepreciation;
final double? accumulatedDepreciation; final double? accumulatedDepreciation;
final double? bookValue; final double? bookValue;
@ -300,6 +302,10 @@ class DepreciationReportRow {
json['salvage_value'] ?? json['salvageValue'], json['salvage_value'] ?? json['salvageValue'],
) ?? ) ??
toDouble(depMap['salvage_value']), toDouble(depMap['salvage_value']),
salvagePercentage: toDouble(
json['salvage_percentage'] ?? json['salvagePercentage'],
) ??
toDouble(depMap['salvage_percentage']),
annualDepreciation: toDouble( annualDepreciation: toDouble(
json['annual_depreciation'] ?? json['annualDepreciation'], json['annual_depreciation'] ?? json['annualDepreciation'],
) ?? ) ??

View File

@ -1,7 +1,7 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/constants/app_constants.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 '../../../../shared/models/export_file_result.dart';
import '../../data/repositories/reports_repository_impl.dart'; import '../../data/repositories/reports_repository_impl.dart';
import '../../domain/entities/depreciation_report.dart'; import '../../domain/entities/depreciation_report.dart';
@ -73,6 +73,10 @@ final depreciationReportProvider = AsyncNotifierProvider<
class DepreciationReportNotifier class DepreciationReportNotifier
extends AsyncNotifier<DepreciationReportState> { extends AsyncNotifier<DepreciationReportState> {
final _columnSearch = ColumnSearchPaging(
defaultLimit: AppConstants.defaultPageSize,
);
@override @override
Future<DepreciationReportState> build() async { Future<DepreciationReportState> build() async {
ref.keepAlive(); 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 { Future<void> setLocationId(String? value) async {
final current = state.valueOrNull?.query ?? final current = state.valueOrNull?.query ??
const DepreciationReportQuery(limit: AppConstants.defaultPageSize); const DepreciationReportQuery(limit: AppConstants.defaultPageSize);

View File

@ -10,6 +10,7 @@ import '../../../../core/utils/responsive_utils.dart';
import '../../../../shared/providers/permissions_provider.dart'; import '../../../../shared/providers/permissions_provider.dart';
import '../../../../shared/utils/file_download_helper.dart'; import '../../../../shared/utils/file_download_helper.dart';
import '../../../rbac/presentation/widgets/rbac_widgets.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_data_table.dart';
import '../../../../shared/widgets/app_date_popup.dart'; import '../../../../shared/widgets/app_date_popup.dart';
import '../../../../shared/widgets/app_date_range_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_responsive_filter_bar.dart';
import '../../../../shared/widgets/app_searchable_dropdown.dart'; import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/app_search_filter_toggle.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_shell.dart';
import '../../../../shared/widgets/error_view.dart'; import '../../../../shared/widgets/error_view.dart';
import '../../../../shared/widgets/page_header.dart'; import '../../../../shared/widgets/page_header.dart';
@ -157,6 +159,11 @@ class _DepreciationReportScreenState
() => _filtersExpanded = !_filtersExpanded, () => _filtersExpanded = !_filtersExpanded,
), ),
), ),
const SizedBox(width: 8),
AppTableColumnSelectorButton(
tableId: _ReportTable.tableId,
columns: _ReportTable.columnOptions,
),
if (canExport) ...[ if (canExport) ...[
const SizedBox(width: 8), const SizedBox(width: 8),
OutlinedButton.icon( OutlinedButton.icon(
@ -208,30 +215,37 @@ class _DepreciationReportScreenState
totalPages: state.totalPages, totalPages: state.totalPages,
totalItems: state.total, totalItems: state.total,
pageSize: state.query.limit, pageSize: state.query.limit,
itemsOnPage: state.items.length,
itemLabel: 'assets', itemLabel: 'assets',
onPageChanged: notifier.setPage, onPageChanged: notifier.setPage,
onPageSizeChanged: notifier.setPageSize, onPageSizeChanged: notifier.setPageSize,
), ),
child: RefreshIndicator( child: RefreshIndicator(
onRefresh: notifier.refresh, onRefresh: notifier.refresh,
child: state.items.isEmpty child: context.isMobile
? ListView( ? (state.items.isEmpty
physics: const AlwaysScrollableScrollPhysics(), ? ListView(
children: const [ physics: const AlwaysScrollableScrollPhysics(),
SizedBox( children: const [
height: 260, SizedBox(
child: AppEmptyState( height: 260,
title: 'No depreciation data', child: AppEmptyState(
description: title: 'No depreciation data',
'Try adjusting filters or the as-of date.', description:
icon: Icons.trending_down_outlined, 'Try adjusting filters or the as-of date.',
), icon: Icons.trending_down_outlined,
), ),
], ),
) ],
: context.isMobile )
? _MobileList(items: state.items) : _MobileList(items: state.items))
: _ReportTable(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 ($moreCount)' : 'More filters ($moreCount)')
: (_moreOpen ? 'Less filters' : 'More filters'); : (_moreOpen ? 'Less filters' : 'More filters');
final iconColor = theme.colorScheme.primary; final iconColor = theme.colorScheme.secondary;
final moreButton = IconButton( final moreButton = IconButton(
tooltip: moreTooltip, tooltip: moreTooltip,
@ -579,91 +593,153 @@ class _FiltersBarState extends State<_FiltersBar> {
} }
} }
class _ReportTable extends StatelessWidget { class _ReportTable extends ConsumerWidget {
const _ReportTable({required this.items}); 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 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 @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>( return AppDataTable<DepreciationReportRow>(
wrapInCard: false, wrapInCard: false,
rows: items, rows: items,
columns: [ onEnsureFullDataset: onEnsureFullDataset,
AppDataColumn( onColumnSearchCleared: onColumnSearchCleared,
label: 'Asset Code', columns: columns,
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,
),
),
],
); );
} }
} }

View File

@ -1,5 +1,6 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/utils/column_search_paging.dart';
import '../../../../core/utils/table_search.dart'; import '../../../../core/utils/table_search.dart';
import '../../../../shared/models/permission_matrix_models.dart'; import '../../../../shared/models/permission_matrix_models.dart';
@ -21,15 +22,8 @@ class RolesListState {
final int limit; final int limit;
List<RoleCardModel> get filteredRoles { List<RoleCardModel> get filteredRoles {
if (search.isEmpty) return roles; // Roles are already filtered by the API when [search] is set.
final q = search.toLowerCase(); return roles;
return roles
.where(
(role) =>
role.name.toLowerCase().contains(q) ||
(role.description?.toLowerCase().contains(q) ?? false),
)
.toList();
} }
int get total => filteredRoles.length; int get total => filteredRoles.length;
@ -93,6 +87,8 @@ final rolesListProvider =
AsyncNotifierProvider<RolesListNotifier, RolesListState>(RolesListNotifier.new); AsyncNotifierProvider<RolesListNotifier, RolesListState>(RolesListNotifier.new);
class RolesListNotifier extends AsyncNotifier<RolesListState> { class RolesListNotifier extends AsyncNotifier<RolesListState> {
final _columnSearch = ColumnSearchPaging(defaultLimit: 10);
@override @override
Future<RolesListState> build() async { Future<RolesListState> build() async {
ref.keepAlive(); ref.keepAlive();
@ -124,10 +120,47 @@ class RolesListNotifier extends AsyncNotifier<RolesListState> {
return true; return true;
} }
void setSearch(String search) { Future<void> setSearch(String search) async {
final current = state.valueOrNull; final current = state.valueOrNull;
if (current == null) return; 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) { void setPage(int page) {
@ -162,10 +195,37 @@ class PermissionMatrixNotifier
final current = state.valueOrNull; final current = state.valueOrNull;
if (current == null) return; if (current == null) return;
final normalizedAction = action.toLowerCase();
final updated = current.modules.map((row) { final updated = current.modules.map((row) {
if (row.moduleId != moduleId) return 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); 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); return row.copyWith(granted: granted);
}).toList(); }).toList();

View File

@ -89,9 +89,12 @@ class _MatrixGrid extends ConsumerWidget {
wrapInCard: true, wrapInCard: true,
columns: [ columns: [
AppDataColumn( AppDataColumn(
id: 'module',
label: 'Module', label: 'Module',
sortKey: 'module',
flex: 3, flex: 3,
enableSearch: false, enableSearch: false,
searchText: (row) => row.name,
cellBuilder: (_, row) => AppTableCell.text(row.name), cellBuilder: (_, row) => AppTableCell.text(row.name),
), ),
...matrix.actionColumns.map( ...matrix.actionColumns.map(
@ -100,14 +103,23 @@ class _MatrixGrid extends ConsumerWidget {
flex: 1, flex: 1,
alignment: Alignment.center, alignment: Alignment.center,
enableSearch: false, enableSearch: false,
cellBuilder: (_, row) => Checkbox( cellBuilder: (_, row) {
value: row.granted[action] ?? false, final applicable = isPermissionActionApplicable(
visualDensity: VisualDensity.compact, row.code,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, action,
onChanged: (checked) => ref columnActions: matrix.actionColumns,
.read(permissionMatrixProvider(roleId).notifier) );
.toggleAction(row.moduleId, action, checked ?? false), 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), title: Text(row.name),
children: matrix.actionColumns children: matrix.actionColumns
.map( .map(
(action) => _PermissionSwitch( (action) {
label: permissionActionLabel(action), final applicable = isPermissionActionApplicable(
value: row.granted[action] ?? false, row.code,
onChanged: (v) => ref action,
.read(permissionMatrixProvider(roleId).notifier) columnActions: matrix.actionColumns,
.toggleAction(row.moduleId, action, v), );
), 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(), .toList(),
), ),
@ -159,7 +180,7 @@ class _PermissionSwitch extends StatelessWidget {
final String label; final String label;
final bool value; final bool value;
final ValueChanged<bool> onChanged; final ValueChanged<bool>? onChanged;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {

View File

@ -6,12 +6,14 @@ import 'package:go_router/go_router.dart';
import '../../../../core/errors/failure.dart'; import '../../../../core/errors/failure.dart';
import '../../../../core/utils/responsive_utils.dart'; import '../../../../core/utils/responsive_utils.dart';
import '../../../../shared/models/user_management_models.dart'; import '../../../../shared/models/user_management_models.dart';
import '../../../../shared/providers/table_column_prefs_provider.dart';
import '../../../../shared/widgets/app_data_table.dart'; import '../../../../shared/widgets/app_data_table.dart';
import '../../../../shared/widgets/app_empty_state.dart'; import '../../../../shared/widgets/app_empty_state.dart';
import '../../../../shared/widgets/app_loading_view.dart'; import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_pagination.dart'; import '../../../../shared/widgets/app_pagination.dart';
import '../../../../shared/widgets/app_search_field.dart'; import '../../../../shared/widgets/app_search_field.dart';
import '../../../../shared/widgets/app_search_filter_toggle.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_shell.dart';
import '../../../../shared/widgets/app_table_action_icon.dart'; import '../../../../shared/widgets/app_table_action_icon.dart';
import '../../../../shared/widgets/error_view.dart'; import '../../../../shared/widgets/error_view.dart';
@ -63,6 +65,11 @@ class _RoleListScreenState extends ConsumerState<RoleListScreen> {
() => _filtersExpanded = !_filtersExpanded, () => _filtersExpanded = !_filtersExpanded,
), ),
), ),
const SizedBox(width: 8),
AppTableColumnSelectorButton(
tableId: _RoleDataTable.tableId,
columns: _RoleDataTable.columnOptions,
),
], ],
), ),
Expanded( Expanded(
@ -81,30 +88,38 @@ class _RoleListScreenState extends ConsumerState<RoleListScreen> {
totalPages: state.totalPages, totalPages: state.totalPages,
totalItems: state.total, totalItems: state.total,
pageSize: state.limit, pageSize: state.limit,
itemsOnPage: state.roles.length,
itemLabel: 'roles', itemLabel: 'roles',
onPageChanged: notifier.setPage, onPageChanged: notifier.setPage,
onPageSizeChanged: notifier.setPageSize, onPageSizeChanged: notifier.setPageSize,
), ),
child: RefreshIndicator( child: RefreshIndicator(
onRefresh: notifier.refresh, onRefresh: notifier.refresh,
child: roles.isEmpty child: context.isMobile
? ListView( ? (roles.isEmpty
physics: const AlwaysScrollableScrollPhysics(), ? ListView(
children: const [ physics: const AlwaysScrollableScrollPhysics(),
SizedBox( children: const [
height: 240, SizedBox(
child: AppEmptyState( height: 240,
title: 'No roles found', child: AppEmptyState(
description: title: 'No roles found',
'Roles from the API will appear here.', description:
icon: Icons.security_outlined, 'Roles from the API will appear here.',
), icon: Icons.security_outlined,
), ),
], ),
) ],
: context.isMobile )
? _RoleCardList(roles: roles, onOpen: _openRole) : _RoleCardList(roles: roles, onOpen: _openRole))
: _RoleDataTable(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 { class _RoleDataTable extends ConsumerWidget {
const _RoleDataTable({required this.roles, required this.onOpen}); 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 List<RoleCardModel> roles;
final void Function(RoleCardModel role) onOpen; 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 @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>( return AppDataTable<RoleCardModel>(
wrapInCard: false, wrapInCard: false,
columns: [ onEnsureFullDataset: onEnsureFullDataset,
AppDataColumn(label: 'Role Name', flex: 2, searchText: (r) => r.name, cellBuilder: (_, r) => Text(r.name)), onColumnSearchCleared: onColumnSearchCleared,
AppDataColumn( columns: columns,
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),
),
],
),
),
],
rows: roles, rows: roles,
); );
} }

View File

@ -140,6 +140,15 @@ class AppSettingsNotifier extends StateNotifier<AppSettings> {
return result.failure; 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 { Future<void> _persist(AppSettings settings) async {
state = settings; state = settings;
final result = await _saveSettings(settings); final result = await _saveSettings(settings);

View File

@ -9,6 +9,75 @@ import '../providers/settings_provider.dart';
import '../widgets/settings_widgets.dart'; import '../widgets/settings_widgets.dart';
import '../../../../shared/widgets/app_toast.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 { class AppearanceSettingsScreen extends ConsumerWidget {
const AppearanceSettingsScreen({super.key}); const AppearanceSettingsScreen({super.key});
@ -17,6 +86,9 @@ class AppearanceSettingsScreen extends ConsumerWidget {
final themeMode = ref.watch(themeModeProvider); final themeMode = ref.watch(themeModeProvider);
final branding = ref.watch(brandingProvider); final branding = ref.watch(brandingProvider);
final uiPrefs = ref.watch(appSettingsProvider).uiPreferences; final uiPrefs = ref.watch(appSettingsProvider).uiPreferences;
final selectedPreset = _brandThemePresets
.where((preset) => preset.matches(branding))
.firstOrNull;
return SettingsPageLayout( return SettingsPageLayout(
title: 'Appearance', title: 'Appearance',
@ -43,58 +115,66 @@ class AppearanceSettingsScreen extends ConsumerWidget {
const SizedBox(height: 16), const SizedBox(height: 16),
SettingsFormCard( SettingsFormCard(
title: 'Branding', title: 'Branding',
subtitle: 'Primary and secondary colors for the application', subtitle:
'Professionally curated primary and secondary color pairs',
children: [ children: [
ListTile( _BrandingPreviewCard(
contentPadding: EdgeInsets.zero, primary: branding.primaryColor,
leading: CircleAvatar(backgroundColor: branding.primaryColor), secondary: branding.secondaryColor,
title: const Text('Primary Color'), themeName: selectedPreset?.name ?? 'Custom',
subtitle: Text( primaryHex: _hex(branding.primaryColorValue),
'#${branding.primaryColorValue.toRadixString(16).padLeft(8, '0').substring(2)}', secondaryHex: _hex(branding.secondaryColorValue),
),
), ),
ListTile( const SizedBox(height: 20),
contentPadding: EdgeInsets.zero, Text(
leading: CircleAvatar(backgroundColor: branding.secondaryColor), 'Color themes',
title: const Text('Secondary Color'), style: Theme.of(context).textTheme.titleSmall?.copyWith(
subtitle: Text( fontWeight: FontWeight.w600,
'#${branding.secondaryColorValue.toRadixString(16).padLeft(8, '0').substring(2)}', ),
),
), ),
const SizedBox(height: 8), const SizedBox(height: 4),
Wrap( Text(
spacing: 8, 'Choose a cohesive pair optimized for light and dark modes.',
runSpacing: 8, style: Theme.of(context).textTheme.bodySmall?.copyWith(
children: [ color: Theme.of(context).colorScheme.onSurfaceVariant,
_ColorPreset( ),
label: 'Blue', ),
primary: 0xFF1565C0, const SizedBox(height: 12),
secondary: 0xFF00897B, LayoutBuilder(
branding: branding, builder: (context, constraints) {
ref: ref, final width = constraints.maxWidth;
), final crossAxisCount = width >= 720
_ColorPreset( ? 3
label: 'Purple', : width >= 480
primary: 0xFF6A1B9A, ? 2
secondary: 0xFF00838F, : 1;
branding: branding, final spacing = 12.0;
ref: ref, final itemWidth =
), (width - spacing * (crossAxisCount - 1)) / crossAxisCount;
_ColorPreset(
label: 'Green', return Wrap(
primary: 0xFF2E7D32, spacing: spacing,
secondary: 0xFF558B2F, runSpacing: spacing,
branding: branding, children: [
ref: ref, for (final preset in _brandThemePresets)
), SizedBox(
_ColorPreset( width: itemWidth,
label: 'Orange', child: _ThemePresetCard(
primary: 0xFFE65100, preset: preset,
secondary: 0xFFF57C00, selected: preset.matches(branding),
branding: branding, onTap: () {
ref: ref, 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', : 'Horizontal menu bar at the top',
), ),
value: layout, value: layout,
groupValue: NavigationLayout.fromValue(uiPrefs.navigationLayout), groupValue:
NavigationLayout.fromValue(uiPrefs.navigationLayout),
onChanged: (v) { onChanged: (v) {
if (v != null) { if (v != null) {
ref.read(appSettingsProvider.notifier).updateUiPreferences( ref
.read(appSettingsProvider.notifier)
.updateUiPreferences(
uiPrefs.copyWith(navigationLayout: v.value), uiPrefs.copyWith(navigationLayout: v.value),
); );
} }
@ -183,8 +266,11 @@ class AppearanceSettingsScreen extends ConsumerWidget {
AppButton( AppButton(
label: 'Settings Auto-Saved', label: 'Settings Auto-Saved',
onPressed: () { onPressed: () {
showAppToastFromSnackBar(context, showAppToastFromSnackBar(
const SnackBar(content: Text('Appearance settings are saved automatically')), context,
const SnackBar(
content: Text('Appearance settings are saved automatically'),
),
); );
}, },
), ),
@ -198,42 +284,348 @@ class AppearanceSettingsScreen extends ConsumerWidget {
ThemeModeOption.dark => 'Dark Theme', ThemeModeOption.dark => 'Dark Theme',
ThemeModeOption.system => 'System Theme', ThemeModeOption.system => 'System Theme',
}; };
static String _hex(int value) =>
'#${value.toRadixString(16).padLeft(8, '0').substring(2).toUpperCase()}';
} }
class _ColorPreset extends StatelessWidget { class _BrandingPreviewCard extends StatelessWidget {
const _ColorPreset({ const _BrandingPreviewCard({
required this.label,
required this.primary, required this.primary,
required this.secondary, required this.secondary,
required this.branding, required this.themeName,
required this.ref, required this.primaryHex,
required this.secondaryHex,
}); });
final String label; final Color primary;
final int primary; final Color secondary;
final int secondary; final String themeName;
final BrandingConfig branding; final String primaryHex;
final WidgetRef ref; final String secondaryHex;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return OutlinedButton( final theme = Theme.of(context);
onPressed: () { final onPrimary =
ref.read(brandingProvider.notifier).updateBranding( ThemeData.estimateBrightnessForColor(primary) == Brightness.dark
branding.copyWith( ? Colors.white
primaryColorValue: primary, : Colors.black87;
secondaryColorValue: secondary,
), return Container(
); decoration: BoxDecoration(
}, borderRadius: BorderRadius.circular(12),
child: Row( border: Border.all(
mainAxisSize: MainAxisSize.min, 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: [ children: [
CircleAvatar(radius: 8, backgroundColor: Color(primary)), Container(
const SizedBox(width: 6), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
CircleAvatar(radius: 8, backgroundColor: Color(secondary)), color: primary,
const SizedBox(width: 8), child: Row(
Text(label), 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),
),
),
),
], ],
), ),
); );

View File

@ -1,6 +1,7 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import '../../../../core/constants/api_endpoints.dart'; import '../../../../core/constants/api_endpoints.dart';
import '../../../../core/utils/pagination_meta.dart';
import '../../../../core/utils/active_option.dart'; import '../../../../core/utils/active_option.dart';
import '../../../../shared/models/api_response.dart'; import '../../../../shared/models/api_response.dart';
import '../../../../shared/models/export_file_result.dart'; import '../../../../shared/models/export_file_result.dart';
@ -80,19 +81,22 @@ class UserRemoteDataSource {
(json) => ManagedUserModel.fromJson(json! as Map<String, dynamic>), (json) => ManagedUserModel.fromJson(json! as Map<String, dynamic>),
).items; ).items;
final meta = body['meta'] as Map<String, dynamic>? ?? {}; final pagination = parsePagination(
final page = (meta['page'] as num?)?.toInt() ?? query.page; body: body,
final limit = (meta['limit'] as num?)?.toInt() ?? query.limit; fallbackPage: query.page,
final total = (meta['total'] as num?)?.toInt() ?? items.length; fallbackLimit: query.limit,
final totalPages = itemCount: items.length,
limit > 0 ? ((total + limit - 1) ~/ limit).clamp(1, 999999) : 1; );
return PaginatedResponse<ManagedUserModel>( return PaginatedResponse<ManagedUserModel>(
items: items, items: items,
page: page, page: pagination.page,
limit: limit, limit: query.limit,
total: total, total: pagination.total,
totalPages: totalPages, totalPages: resolveTotalPages(
total: pagination.total,
limit: query.limit,
),
); );
} }

View File

@ -1,5 +1,6 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/utils/column_search_paging.dart';
import '../../../../core/utils/table_search.dart'; import '../../../../core/utils/table_search.dart';
import '../../../../shared/models/export_file_result.dart'; import '../../../../shared/models/export_file_result.dart';
@ -100,6 +101,8 @@ final usersListProvider =
AsyncNotifierProvider<UsersListNotifier, UsersListState>(UsersListNotifier.new); AsyncNotifierProvider<UsersListNotifier, UsersListState>(UsersListNotifier.new);
class UsersListNotifier extends AsyncNotifier<UsersListState> { class UsersListNotifier extends AsyncNotifier<UsersListState> {
final _columnSearch = ColumnSearchPaging(defaultLimit: 10);
@override @override
Future<UsersListState> build() async { Future<UsersListState> build() async {
ref.keepAlive(); ref.keepAlive();
@ -152,6 +155,27 @@ class UsersListNotifier extends AsyncNotifier<UsersListState> {
applyQuery(current.query.copyWith(search: TableSearch.normalize(search), page: 1)); 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) { void setPage(int page) {
final current = state.valueOrNull; final current = state.valueOrNull;
if (current == null) return; if (current == null) return;
@ -236,7 +260,9 @@ class UsersListNotifier extends AsyncNotifier<UsersListState> {
if (result.failure != null) { if (result.failure != null) {
final current = state.valueOrNull; final current = state.valueOrNull;
if (current != null) { if (current != null) {
state = AsyncData(current.copyWith(actionError: result.failure.toString())); state = AsyncData(
current.copyWith(actionError: result.failure!.message),
);
} }
return false; return false;
} }
@ -257,7 +283,9 @@ class UsersListNotifier extends AsyncNotifier<UsersListState> {
if (result.failure != null) { if (result.failure != null) {
final current = state.valueOrNull; final current = state.valueOrNull;
if (current != null) { if (current != null) {
state = AsyncData(current.copyWith(actionError: result.failure.toString())); state = AsyncData(
current.copyWith(actionError: result.failure!.message),
);
} }
return false; return false;
} }

View File

@ -5,23 +5,49 @@ import 'package:go_router/go_router.dart';
import '../../../../core/utils/formatters.dart'; import '../../../../core/utils/formatters.dart';
import '../../../../core/errors/failure.dart'; import '../../../../core/errors/failure.dart';
import '../../../../shared/models/user_management_models.dart';
import '../../../../shared/widgets/app_button.dart'; import '../../../../shared/widgets/app_button.dart';
import '../../../../shared/widgets/app_confirmation_dialog.dart'; import '../../../../shared/widgets/app_confirmation_dialog.dart';
import '../../../../shared/widgets/app_loading_view.dart'; import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_status_chip.dart'; import '../../../../shared/widgets/app_status_chip.dart';
import '../../../../shared/widgets/detail_overview_widgets.dart';
import '../../../../shared/widgets/error_view.dart'; import '../../../../shared/widgets/error_view.dart';
import '../../../../shared/widgets/page_header.dart'; import '../../../../shared/widgets/page_header.dart';
import '../../../../shared/models/user_management_models.dart';
import '../providers/users_provider.dart'; import '../providers/users_provider.dart';
import '../../../../shared/widgets/app_toast.dart'; import '../../../../shared/widgets/app_toast.dart';
class UserDetailScreen extends ConsumerWidget { class UserDetailScreen extends ConsumerStatefulWidget {
const UserDetailScreen({super.key, required this.userId}); const UserDetailScreen({super.key, required this.userId});
final String userId; final String userId;
@override @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)); final userAsync = ref.watch(userDetailProvider(userId));
return Padding( return Padding(
@ -48,7 +74,7 @@ class UserDetailScreen extends ConsumerWidget {
AppButton( AppButton(
label: 'Deactivate', label: 'Deactivate',
expand: false, expand: false,
onPressed: () => _deactivate(context, ref), onPressed: () => _deactivate(context),
), ),
], ],
), ),
@ -56,27 +82,87 @@ class UserDetailScreen extends ConsumerWidget {
AppCard( AppCard(
child: Padding( child: Padding(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
child: Column( child: DetailOverviewCard(
crossAxisAlignment: CrossAxisAlignment.start, title: 'User Details',
children: [ children: [
_DetailRow(label: 'Email', value: user.email), DetailOverviewSection(
_DetailRow(label: 'Mobile', value: user.mobile), title: 'Summary',
_DetailRow(label: 'Role', value: user.roleLabel), child: DetailSummaryStrip(
_DetailRow(label: 'Department', value: user.departmentLabel), metrics: [
_DetailRow( DetailSummaryMetric(
label: 'Status', icon: Icons.flag_outlined,
valueWidget: AppStatusChip(status: user.status), 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) DetailOverviewSection(
_DetailRow( title: 'Contact',
label: 'Created', child: DetailInfoGrid(
value: DateFormatter.displayDateTime(user.createdAt), items: [
DetailInfoItem('Email', user.email),
DetailInfoItem('Mobile', user.mobile),
],
), ),
if (user.updatedAt != null) ),
_DetailRow( DetailOverviewSection(
label: 'Updated', title: 'Account',
value: DateFormatter.displayDateTime(user.updatedAt), 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( final confirmed = await showAppConfirmationDialog(
context: context, context: context,
title: 'Deactivate user', title: 'Deactivate user',
@ -97,48 +183,16 @@ class UserDetailScreen extends ConsumerWidget {
); );
if (confirmed != true || !context.mounted) return; 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; if (!context.mounted) return;
showAppToastFromSnackBar(context, showAppToastFromSnackBar(
SnackBar(content: Text(success ? 'User deactivated' : 'Failed to deactivate')), context,
SnackBar(
content: Text(success ? 'User deactivated' : 'Failed to deactivate'),
),
); );
if (success) context.pop(); 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 ?? ''),
),
],
),
);
}
}

View File

@ -13,6 +13,7 @@ import '../../../../shared/widgets/app_text_field.dart';
import '../../../../shared/widgets/error_view.dart'; import '../../../../shared/widgets/error_view.dart';
import '../../../master_data/presentation/widgets/master_quick_add.dart'; import '../../../master_data/presentation/widgets/master_quick_add.dart';
import '../providers/users_provider.dart'; import '../providers/users_provider.dart';
import '../../../../shared/widgets/api_feedback.dart';
import '../../../../shared/widgets/app_toast.dart'; import '../../../../shared/widgets/app_toast.dart';
class UserFormScreen extends ConsumerStatefulWidget { class UserFormScreen extends ConsumerStatefulWidget {
@ -37,9 +38,19 @@ class _UserFormScreenState extends ConsumerState<UserFormScreen> {
String _selectedStatus = 'active'; String _selectedStatus = 'active';
bool _isSubmitting = false; bool _isSubmitting = false;
bool _prefilled = false; bool _prefilled = false;
bool _requestedFreshLoad = false;
bool get isEditing => widget.userId != null; 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 @override
void dispose() { void dispose() {
_employeeIdController.dispose(); _employeeIdController.dispose();
@ -134,8 +145,9 @@ class _UserFormScreenState extends ConsumerState<UserFormScreen> {
context.pop(); context.pop();
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
showAppToastFromSnackBar(context, showAppToastFromSnackBar(
SnackBar(content: Text(e.toString())), context,
SnackBar(content: Text(errorDisplayMessage(e))),
); );
} finally { } finally {
if (mounted) setState(() => _isSubmitting = false); if (mounted) setState(() => _isSubmitting = false);
@ -262,7 +274,7 @@ class _UserFormScreenState extends ConsumerState<UserFormScreen> {
children: [ children: [
Expanded( Expanded(
child: AppButton( child: AppButton(
label: isEditing ? 'Update User' : 'Create User', label: isEditing ? 'Update User' : 'Save User',
isLoading: _isSubmitting, isLoading: _isSubmitting,
onPressed: _submit, onPressed: _submit,
), ),

View File

@ -21,6 +21,7 @@ import '../../../../shared/widgets/error_view.dart';
import '../../../../shared/widgets/kpi_card.dart'; import '../../../../shared/widgets/kpi_card.dart';
import '../../../../shared/widgets/app_table_shell.dart'; import '../../../../shared/widgets/app_table_shell.dart';
import '../../../../shared/widgets/app_table_action_icon.dart'; import '../../../../shared/widgets/app_table_action_icon.dart';
import '../../../../shared/widgets/app_table_column_selector.dart';
import '../../../../shared/widgets/page_header.dart'; import '../../../../shared/widgets/page_header.dart';
import '../widgets/user_rich_data_table.dart'; import '../widgets/user_rich_data_table.dart';
import '../providers/users_provider.dart'; import '../providers/users_provider.dart';
@ -69,6 +70,11 @@ class _UserListScreenState extends ConsumerState<UserListScreen> {
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
AppTableColumnSelectorButton(
tableId: UserRichDataTable.tableId,
columns: UserRichDataTable.columnOptions,
),
const SizedBox(width: 8),
ElevatedButton.icon( ElevatedButton.icon(
onPressed: () => context.push(RouteConstants.userAdd), onPressed: () => context.push(RouteConstants.userAdd),
icon: const Icon(Icons.person_add), icon: const Icon(Icons.person_add),
@ -101,6 +107,7 @@ class _UserListScreenState extends ConsumerState<UserListScreen> {
totalPages: state.totalPages, totalPages: state.totalPages,
totalItems: state.total, totalItems: state.total,
pageSize: state.query.limit, pageSize: state.query.limit,
itemsOnPage: state.users.length,
itemLabel: 'users', itemLabel: 'users',
onPageChanged: ref.read(usersListProvider.notifier).setPage, onPageChanged: ref.read(usersListProvider.notifier).setPage,
onPageSizeChanged: onPageSizeChanged:
@ -108,41 +115,47 @@ class _UserListScreenState extends ConsumerState<UserListScreen> {
), ),
child: RefreshIndicator( child: RefreshIndicator(
onRefresh: () => ref.read(usersListProvider.notifier).refresh(), onRefresh: () => ref.read(usersListProvider.notifier).refresh(),
child: state.users.isEmpty child: context.isMobile
? ListView( ? (state.users.isEmpty
physics: const AlwaysScrollableScrollPhysics(), ? ListView(
children: const [ physics: const AlwaysScrollableScrollPhysics(),
SizedBox( children: const [
height: 240, SizedBox(
child: AppEmptyState( height: 240,
title: 'No users found', child: AppEmptyState(
description: title: 'No users found',
'Try adjusting filters or add a new user.', description:
icon: Icons.people_outline, 'Try adjusting filters or add a new user.',
), icon: Icons.people_outline,
), ),
], ),
) ],
: context.isMobile
? _UserCardList(
users: state.users,
onView: _viewUser,
onEdit: _editUser,
onToggleStatus: _toggleStatus,
onDeactivate: _deactivateUser,
) )
: _UserDataTable( : _UserCardList(
users: state.users, 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, onView: _viewUser,
onEdit: _editUser, onEdit: _editUser,
onToggleStatus: _toggleStatus, onToggleStatus: _toggleStatus,
onDeactivate: _deactivateUser, 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.onEdit,
required this.onToggleStatus, required this.onToggleStatus,
required this.onDeactivate, required this.onDeactivate,
this.onEnsureFullDataset,
this.onColumnSearchCleared,
}); });
final List<ManagedUserModel> users; final List<ManagedUserModel> users;
@ -310,6 +325,8 @@ class _UserDataTable extends StatelessWidget {
final void Function(ManagedUserModel user) onEdit; final void Function(ManagedUserModel user) onEdit;
final Future<void> Function(ManagedUserModel user) onToggleStatus; final Future<void> Function(ManagedUserModel user) onToggleStatus;
final Future<void> Function(ManagedUserModel user) onDeactivate; final Future<void> Function(ManagedUserModel user) onDeactivate;
final Future<void> Function()? onEnsureFullDataset;
final VoidCallback? onColumnSearchCleared;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -319,6 +336,8 @@ class _UserDataTable extends StatelessWidget {
sortAscending: sortOrder == 'asc', sortAscending: sortOrder == 'asc',
onSort: onSort, onSort: onSort,
wrapInCard: false, wrapInCard: false,
onEnsureFullDataset: onEnsureFullDataset,
onColumnSearchCleared: onColumnSearchCleared,
actionsBuilder: (_, user) => _UserActions( actionsBuilder: (_, user) => _UserActions(
user: user, user: user,
onView: onView, onView: onView,

View File

@ -86,7 +86,7 @@ class _UserProfileScreenState extends ConsumerState<UserProfileScreen> {
context, context,
SnackBar( SnackBar(
content: Text( 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( showAppToastFromSnackBar(
context, context,
SnackBar( SnackBar(
content: Text(result.failure?.message ?? result.failure.toString()), content: Text(
result.failure?.message ?? 'Unable to update profile.',
),
), ),
); );
return; return;

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../../core/constants/route_constants.dart'; import '../../../../core/constants/route_constants.dart';
import '../../../../shared/widgets/app_segmented_tab_bar.dart';
import '../../../roles/presentation/screens/role_list_screen.dart'; import '../../../roles/presentation/screens/role_list_screen.dart';
import 'user_list_screen.dart'; import 'user_list_screen.dart';
@ -33,13 +34,19 @@ class _UsersRolesHubScreenState extends State<UsersRolesHubScreen>
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return Column(
children: [ children: [
Material( Padding(
color: Theme.of(context).colorScheme.surface, padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: TabBar( child: AppSegmentedTabBar(
controller: _tabController, controller: _tabController,
tabs: const [ tabs: const [
Tab(text: 'Users', icon: Icon(Icons.people_outline)), AppSegmentedTab(
Tab(text: 'Roles', icon: Icon(Icons.security_outlined)), label: 'Users',
icon: Icons.people_outline,
),
AppSegmentedTab(
label: 'Roles',
icon: Icons.security_outlined,
),
], ],
), ),
), ),

View File

@ -1,9 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/utils/formatters.dart'; import '../../../../core/utils/formatters.dart';
import '../../../../shared/models/user_management_models.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_data_table.dart';
import '../../../../shared/widgets/app_status_chip.dart'; import '../../../../shared/widgets/app_status_chip.dart';
import '../../../../shared/widgets/app_table_column_selector.dart';
import '../../../rbac/presentation/widgets/rbac_widgets.dart'; import '../../../rbac/presentation/widgets/rbac_widgets.dart';
typedef UserTableActionsBuilder = Widget Function( typedef UserTableActionsBuilder = Widget Function(
@ -11,7 +14,7 @@ typedef UserTableActionsBuilder = Widget Function(
ManagedUserModel user, ManagedUserModel user,
); );
class UserRichDataTable extends StatelessWidget { class UserRichDataTable extends ConsumerWidget {
const UserRichDataTable({ const UserRichDataTable({
super.key, super.key,
required this.users, required this.users,
@ -20,84 +23,119 @@ class UserRichDataTable extends StatelessWidget {
this.sortAscending = true, this.sortAscending = true,
this.onSort, this.onSort,
this.wrapInCard = false, this.wrapInCard = false,
this.onServerSearchChanged,
this.onEnsureFullDataset,
this.onColumnSearchCleared,
}); });
static const tableId = 'users_list';
final List<ManagedUserModel> users; final List<ManagedUserModel> users;
final UserTableActionsBuilder actionsBuilder; final UserTableActionsBuilder actionsBuilder;
final String? sortColumn; final String? sortColumn;
final bool sortAscending; final bool sortAscending;
final void Function(String column, bool ascending)? onSort; final void Function(String column, bool ascending)? onSort;
final bool wrapInCard; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context); final theme = Theme.of(context);
final prefs = ref.watch(tableColumnPrefsProvider(tableId));
final columns = resolveAppDataColumns(_allColumns(theme), prefs);
return AppDataTable<ManagedUserModel>( return AppDataTable<ManagedUserModel>(
wrapInCard: wrapInCard, wrapInCard: wrapInCard,
sortColumn: sortColumn, sortColumn: sortColumn,
sortAscending: sortAscending, sortAscending: sortAscending,
onSort: onSort, onSort: onSort,
columns: [ onServerSearchChanged: onServerSearchChanged,
AppDataColumn( onEnsureFullDataset: onEnsureFullDataset,
label: 'User', onColumnSearchCleared: onColumnSearchCleared,
sortKey: 'full_name', columns: columns,
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),
),
],
rows: users, rows: users,
); );
} }

Some files were not shown because too many files have changed in this diff Show More