Compare commits

..

No commits in common. "f2d5b49dac240aa8c72565967daeccb30a4b09c2" and "4360931be49642c2e27fc4a2e5b5529df20c53fd" have entirely different histories.

154 changed files with 5859 additions and 14309 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`.
API URL and `.env` file are picked automatically (see `lib/core/config/environment.dart`).
| Flavor | Entry point | API URL (auto) | Web base-href |
|--------|-------------|----------------|---------------|
| **dev** | `lib/config/main_dev.dart` | `https://demo.venbait.in/api/v1` | `/` |
| **uat** | `lib/config/main_uat.dart` | `https://uat-api.bharaterp.com/api/v1` | `/` |
| **prod** | `lib/config/main_prod.dart` | `https://api.bharaterp.com/api/v1` | `/` |
| Flavor | Entry point | API URL (auto) | Web URL | Web base-href |
|--------|-------------|----------------|---------|---------------|
| **dev** | `lib/config/main_dev.dart` | `https://demo.venbait.in/api/v1` | `https://bharatconsumerproducts.com/erp/login` | `/erp/` |
| **uat** | `lib/config/main_uat.dart` | `https://uat-api.bharaterp.com/api/v1` | — | `/` |
| **prod** | `lib/config/main_prod.dart` | `https://api.bharaterp.com/api/v1` | — | `/app/` |
Dev web uses **path URLs** (no `index.html#`). Build with `--base-href /` and deploy `web/.htaccess` for Apache/LiteSpeed SPA fallback.
Dev web uses **path URLs** (no `index.html#`). Build with `--base-href /erp/` and deploy `web/.htaccess` for Apache SPA fallback.
### Web builds
```bash
# Dev
flutter build web -t lib/config/main_dev.dart --release --base-href /
flutter build web -t lib/config/main_dev.dart --release --base-href /erp/
# UAT
flutter build web -t lib/config/main_uat.dart --release --base-href /
# Prod
flutter build web -t lib/config/main_prod.dart --release --base-href /
flutter build web -t lib/config/main_prod.dart --release --base-href /app/
```
### Android APK / AAB

View File

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

View File

@ -17,6 +17,20 @@ class Environment {
Flavor.prod => 'production',
};
/// Web deploy path (`--base-href` should match this value).
static String get baseHref => switch (flavor) {
Flavor.dev => '/erp/',
Flavor.uat => '/',
Flavor.prod => '/app/',
};
/// Public web app URL (path-based routing — no `index.html#`).
static String get webAppUrl => switch (flavor) {
Flavor.dev => 'https://bharatconsumerproducts.com/erp',
Flavor.uat => 'https://uat.bharaterp.com',
Flavor.prod => 'https://app.bharaterp.com',
};
static String get envFileName => switch (flavor) {
Flavor.dev => '.env.development',
Flavor.uat => '.env.uat',

View File

@ -213,5 +213,4 @@ class ApiEndpoints {
// 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 grnDetail = '/grn/:id';
// Assets (Asset Master)
static const String assets = '/assetsmaster';
static const String assetAdd = '/assetsmaster/add';
static const String assetEdit = '/assetsmaster/:id/edit';
static const String assetDetail = '/assetsmaster/:id';
static const String assetAlerts = '/assetsmaster/alerts';
static const String assetMaintenance = '/assetsmaster/maintenance';
// Assets
static const String assets = '/assets';
static const String assetAdd = '/assets/add';
static const String assetEdit = '/assets/:id/edit';
static const String assetDetail = '/assets/:id';
static const String assetAlerts = '/assets/alerts';
static const String assetMaintenance = '/assets/maintenance';
// Master Data
static const String masterData = '/master-data';

View File

@ -13,9 +13,4 @@ class StorageKeys {
static const String appSettings = 'app_settings';
static const String rememberMe = 'remember_me';
static const String rememberedEmail = 'remembered_email';
/// Prefix for per-table column visibility/order prefs (`table_columns_<tableId>`).
static const String tableColumnsPrefix = 'table_columns_';
static String tableColumns(String tableId) => '$tableColumnsPrefix$tableId';
}

View File

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

View File

@ -12,9 +12,8 @@ class AppTheme {
final primary = branding?.primaryColor ?? AppColors.primary;
final secondary = branding?.secondaryColor ?? AppColors.secondary;
final colorScheme = _brandedColorScheme(
seed: primary,
primary: primary,
final colorScheme = ColorScheme.fromSeed(
seedColor: primary,
secondary: secondary,
brightness: Brightness.light,
surface: AppColors.lightSurface,
@ -27,9 +26,8 @@ class AppTheme {
final primary = branding?.primaryColor ?? AppColors.primary;
final secondary = branding?.secondaryColor ?? AppColors.secondary;
final colorScheme = _brandedColorScheme(
seed: primary,
primary: primary,
final colorScheme = ColorScheme.fromSeed(
seedColor: primary,
secondary: secondary,
brightness: Brightness.dark,
surface: AppColors.darkSurface,
@ -38,56 +36,12 @@ class AppTheme {
return _buildTheme(colorScheme, Brightness.dark);
}
/// Keeps Material tonal containers from [seed], but locks brand [primary] /
/// [secondary] to the exact chosen hex values (fromSeed remaps primary).
static ColorScheme _brandedColorScheme({
required Color seed,
required Color primary,
required Color secondary,
required Brightness brightness,
required Color surface,
}) {
final base = ColorScheme.fromSeed(
seedColor: seed,
secondary: secondary,
brightness: brightness,
surface: surface,
);
final onPrimary = _onColor(primary);
final onSecondary = _onColor(secondary);
return base.copyWith(
primary: primary,
onPrimary: onPrimary,
primaryContainer: Color.alphaBlend(
primary.withValues(alpha: 0.18),
surface,
),
onPrimaryContainer: primary,
secondary: secondary,
onSecondary: onSecondary,
secondaryContainer: Color.alphaBlend(
secondary.withValues(alpha: 0.18),
surface,
),
onSecondaryContainer: secondary,
);
}
static Color _onColor(Color color) {
return ThemeData.estimateBrightnessForColor(color) == Brightness.dark
? Colors.white
: const Color(0xFF212121);
}
/// Theme for white cards, form fields, and picker sheets (unchanged in dark mode).
static ThemeData cardContentTheme(ThemeData theme) {
final scheme = theme.brightness == Brightness.light
? theme.colorScheme
: _brandedColorScheme(
seed: theme.colorScheme.primary,
primary: theme.colorScheme.primary,
: ColorScheme.fromSeed(
seedColor: theme.colorScheme.primary,
secondary: theme.colorScheme.secondary,
brightness: Brightness.light,
surface: AppColors.card,
@ -201,27 +155,13 @@ class AppTheme {
borderSide: BorderSide(color: colorScheme.primary, width: 2),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
labelStyle: textTheme.labelLarge,
labelStyle: textTheme.bodyMedium,
hintStyle: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
helperStyle: textTheme.bodySmall,
errorStyle: textTheme.labelSmall?.copyWith(color: colorScheme.error),
errorStyle: textTheme.bodySmall?.copyWith(color: colorScheme.error),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
disabledBackgroundColor: colorScheme.onSurface.withValues(alpha: 0.12),
disabledForegroundColor: colorScheme.onSurface.withValues(alpha: 0.38),
minimumSize: const Size(0, 48),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
textStyle: textTheme.labelLarge,
),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
minimumSize: const Size(0, 48),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
@ -230,30 +170,18 @@ class AppTheme {
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: colorScheme.secondary,
minimumSize: const Size(0, 48),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
side: BorderSide(color: colorScheme.secondary.withValues(alpha: 0.55)),
textStyle: textTheme.labelLarge,
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: colorScheme.secondary,
minimumSize: const Size(0, 48),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
textStyle: textTheme.labelLarge,
),
),
iconButtonTheme: IconButtonThemeData(
style: IconButton.styleFrom(
foregroundColor: colorScheme.secondary,
),
),
floatingActionButtonTheme: FloatingActionButtonThemeData(
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
),
navigationRailTheme: NavigationRailThemeData(
backgroundColor: isDark ? AppColors.darkSurface : AppColors.lightSurface,
selectedIconTheme: IconThemeData(color: colorScheme.primary),
@ -287,17 +215,7 @@ class AppTheme {
dataTextStyle: textTheme.bodyMedium,
),
chipTheme: ChipThemeData(
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),
labelStyle: textTheme.labelSmall,
),
dialogTheme: DialogThemeData(
titleTextStyle: textTheme.titleLarge,

View File

@ -1,189 +1,57 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
/// ERP typography — Figtree design tokens.
///
/// | Token | Sizes | Weights |
/// |--------------|-------|----------------------------------|
/// | Heading 1–6 | 56→20 | Medium, SemiBold, Bold |
/// | Body 1–4 | 18→12 | Regular, Medium |
/// | Label 1–3 | 16→12 | Regular, Medium, SemiBold, Bold |
/// | Caption 1–2 | 10→9 | Medium, SemiBold |
/// ERP typography — Figtree (Google Font).
/// Weights: Regular 400, Medium 500, SemiBold 600.
class AppTypography {
AppTypography._();
static const FontWeight regular = FontWeight.w400;
static const FontWeight medium = FontWeight.w500;
static const FontWeight semiBold = FontWeight.w600;
static const FontWeight bold = FontWeight.w700;
static String get fontFamily => GoogleFonts.figtree().fontFamily ?? 'Figtree';
// ─── Design tokens ─────────────────────────────────────────────────────────
static TextStyle heading1({
FontWeight weight = semiBold,
Color? color,
double? height,
}) =>
_token(56, weight, color, height);
static TextStyle heading2({
FontWeight weight = semiBold,
Color? color,
double? height,
}) =>
_token(48, weight, color, height);
static TextStyle heading3({
FontWeight weight = semiBold,
Color? color,
double? height,
}) =>
_token(40, weight, color, height);
static TextStyle heading4({
FontWeight weight = semiBold,
Color? color,
double? height,
}) =>
_token(32, weight, color, height);
static TextStyle heading5({
FontWeight weight = semiBold,
Color? color,
double? height,
}) =>
_token(24, weight, color, height);
static TextStyle heading6({
FontWeight weight = semiBold,
Color? color,
double? height,
}) =>
_token(20, weight, color, height);
static TextStyle body1({
FontWeight weight = regular,
Color? color,
double? height,
}) =>
_token(18, weight, color, height);
static TextStyle body2({
FontWeight weight = regular,
Color? color,
double? height,
}) =>
_token(16, weight, color, height);
static TextStyle body3({
FontWeight weight = regular,
Color? color,
double? height,
}) =>
_token(14, weight, color, height);
static TextStyle body4({
FontWeight weight = regular,
Color? color,
double? height,
}) =>
_token(12, weight, color, height);
static TextStyle label1({
FontWeight weight = medium,
Color? color,
double? height,
}) =>
_token(16, weight, color, height);
static TextStyle label2({
FontWeight weight = medium,
Color? color,
double? height,
}) =>
_token(14, weight, color, height);
static TextStyle label3({
FontWeight weight = medium,
Color? color,
double? height,
}) =>
_token(12, weight, color, height);
static TextStyle caption1({
FontWeight weight = medium,
Color? color,
double? height,
}) =>
_token(10, weight, color, height);
static TextStyle caption2({
FontWeight weight = medium,
Color? color,
double? height,
}) =>
_token(9, weight, color, height);
// ─── Material TextTheme mapping ────────────────────────────────────────────
/// Maps design tokens onto Material slots used across the app.
///
/// | Material slot | Token | Default weight |
/// |-----------------|-----------|----------------|
/// | displayLarge | Heading-1 | SemiBold |
/// | displayMedium | Heading-2 | SemiBold |
/// | displaySmall | Heading-3 | SemiBold |
/// | headlineLarge | Heading-4 | SemiBold |
/// | headlineMedium | Heading-5 | SemiBold |
/// | headlineSmall | Heading-6 | SemiBold |
/// | titleLarge | Heading-5 | SemiBold |
/// | titleMedium | Label-1 | Medium |
/// | titleSmall | Label-2 | Medium |
/// | bodyLarge | Body-1 | Regular |
/// | bodyMedium | Body-2 | Regular |
/// | bodySmall | Body-3 | Regular |
/// | labelLarge | Label-2 | Medium |
/// | labelMedium | Label-3 | Medium |
/// | labelSmall | Caption-1 | Medium |
///
/// Use [body4], [caption2], or weight variants via the named helpers above.
static TextTheme textTheme(ColorScheme colorScheme) {
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,
);
}
final onSurface = colorScheme.onSurface;
final onSurfaceVariant = colorScheme.onSurfaceVariant;
return TextTheme(
displayLarge: heading1(color: onSurface),
displayMedium: heading2(color: onSurface),
displaySmall: heading3(color: onSurface),
headlineLarge: heading4(color: onSurface),
headlineMedium: heading5(color: onSurface),
headlineSmall: heading6(color: onSurface),
titleLarge: heading5(color: onSurface),
titleMedium: label1(color: onSurface),
titleSmall: label2(color: onSurface),
bodyLarge: body1(color: onSurface),
bodyMedium: body2(color: onSurface),
bodySmall: body3(color: onSurfaceVariant),
labelLarge: label2(color: onSurface),
labelMedium: label3(color: onSurfaceVariant),
labelSmall: caption1(color: onSurfaceVariant),
);
}
static TextStyle _token(
double size,
FontWeight weight,
Color? color,
double? height,
) {
return GoogleFonts.figtree(
fontSize: size,
fontWeight: weight,
letterSpacing: 0,
height: height,
color: color,
// Display
displayLarge: token(size: 57, weight: semiBold, color: onSurface),
displayMedium: token(size: 45, weight: semiBold, color: onSurface),
displaySmall: token(size: 36, weight: semiBold, color: onSurface),
// Headline
headlineLarge: token(size: 20, weight: semiBold, color: onSurface),
headlineMedium: token(size: 16, weight: regular, color: onSurface),
headlineSmall: token(size: 18, weight: semiBold, color: onSurface),
// Title
titleLarge: token(size: 22, weight: semiBold, color: onSurface),
titleMedium: token(size: 16, weight: medium, color: onSurface),
titleSmall: token(size: 14, weight: medium, color: onSurface),
// Body
bodyLarge: token(size: 16, weight: regular, color: onSurface),
bodyMedium: token(size: 16, weight: regular, color: onSurface),
bodySmall: token(size: 12, weight: regular, color: onSurfaceVariant),
// Label
labelLarge: token(size: 14, weight: medium, color: onSurface),
labelMedium: token(size: 12, weight: medium, color: onSurfaceVariant),
labelSmall: token(size: 10, weight: regular, color: onSurfaceVariant),
);
}
}

View File

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

View File

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

View File

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

View File

@ -1,54 +0,0 @@
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,4 +1,3 @@
import 'package:flutter/services.dart';
import 'package:intl/intl.dart';
class DateFormatter {
@ -43,31 +42,13 @@ class DateFormatter {
if (dayDiff < 7) return '$dayDiff days ago';
return displayDateTime(local);
}
/// Multiple date formats for client-side column search.
static String searchableDate(DateTime? date) {
if (date == null) return '';
final local = date.toLocal();
return [
displayDate(local),
displayDateTime(local),
formatUserLastLogin(local),
DateFormat('yyyy-MM-dd').format(local),
DateFormat('dd-MM-yyyy').format(local),
DateFormat('dd/MM/yy').format(local),
DateFormat('d/M/yyyy').format(local),
DateFormat('d/MM/yyyy').format(local),
].join(' ');
}
}
class CurrencyFormatter {
CurrencyFormatter._();
static const locale = 'en_IN';
static final _formatter = NumberFormat.currency(
locale: locale,
locale: 'en_IN',
symbol: '₹',
decimalDigits: 2,
);
@ -76,150 +57,4 @@ class CurrencyFormatter {
if (amount == null) return '-';
return _formatter.format(amount);
}
/// Grouped amount for text fields (no currency symbol), e.g. `1,23,456.5`.
static String formatEditable(num? amount, {int maxDecimals = 2}) {
if (amount == null) return '';
final value = amount.toDouble();
if (value % 1 == 0) {
return NumberFormat('#,##,##0', locale).format(value);
}
return NumberFormat(
'#,##,##0.${'#' * maxDecimals}',
locale,
).format(value);
}
/// Removes grouping commas so values can be parsed / sent to the API.
static String stripGrouping(String value) => value.replaceAll(',', '').trim();
static double? tryParse(String? value) {
if (value == null) return null;
final cleaned = stripGrouping(value);
if (cleaned.isEmpty || cleaned == '.' || cleaned == '-') return null;
return double.tryParse(cleaned);
}
/// Formatted + raw numeric text for client-side column search.
static String searchable(double? amount) {
if (amount == null) return '';
return '${format(amount)} $amount';
}
/// Input formatters for cost / amount fields (Indian comma grouping).
static List<TextInputFormatter> get amountInput => const [
AmountThousandsSeparatorFormatter(),
];
}
/// Formats numeric input with Indian-style thousand separators while typing.
///
/// Example: `1234567.5` → `12,34,567.5`
class AmountThousandsSeparatorFormatter extends TextInputFormatter {
const AmountThousandsSeparatorFormatter({this.maxDecimals = 2});
final int maxDecimals;
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
final raw = newValue.text;
if (raw.isEmpty) {
return newValue;
}
// Keep only digits and a single decimal point.
final buffer = StringBuffer();
var seenDot = false;
var decimals = 0;
for (final rune in raw.runes) {
final ch = String.fromCharCode(rune);
if (ch == ',') continue;
if (ch == '.') {
if (seenDot) continue;
seenDot = true;
buffer.write(ch);
continue;
}
if (ch.compareTo('0') >= 0 && ch.compareTo('9') <= 0) {
if (seenDot) {
if (decimals >= maxDecimals) continue;
decimals++;
}
buffer.write(ch);
}
}
final cleaned = buffer.toString();
if (cleaned.isEmpty) {
return const TextEditingValue(
text: '',
selection: TextSelection.collapsed(offset: 0),
);
}
final parts = cleaned.split('.');
final intPart = parts[0];
final hasDot = cleaned.contains('.');
final fracPart = parts.length > 1 ? parts[1] : '';
final formattedInt = intPart.isEmpty
? ''
: NumberFormat('#,##,##0', CurrencyFormatter.locale)
.format(int.parse(intPart));
final formatted = hasDot ? '$formattedInt.$fracPart' : formattedInt;
// Place caret at end; stable enough for amount entry.
return TextEditingValue(
text: formatted,
selection: TextSelection.collapsed(offset: formatted.length),
);
}
}
/// Converts `snake_case` / `kebab-case` keys into readable Title Case labels.
String humanizeLabel(String key) {
final cleaned = key.trim().replaceAll(RegExp(r'[_\-.]+'), ' ');
if (cleaned.isEmpty) return key;
const acronyms = {
'id': 'ID',
'amc': 'AMC',
'gst': 'GST',
'hsn': 'HSN',
'uom': 'UOM',
'po': 'PO',
'grn': 'Purchase Receipt',
'url': 'URL',
'api': 'API',
};
return cleaned
.split(RegExp(r'\s+'))
.where((part) => part.isNotEmpty)
.map((part) {
final lower = part.toLowerCase();
if (acronyms.containsKey(lower)) return acronyms[lower]!;
return '${lower[0].toUpperCase()}${lower.substring(1)}';
})
.join(' ');
}
/// Subject used in dropdown hints (`Select …` / `Search …`).
/// Strips required asterisks and title-cases each word.
String dropdownHintLabel(String label) {
final cleaned =
label.replaceAll('*', '').trim().replaceAll(RegExp(r'\s+'), ' ');
if (cleaned.isEmpty) return label.trim();
return cleaned.split(' ').map((word) {
if (word.isEmpty) return word;
// Keep short all-caps tokens (GST, UOM, PO…).
if (word.length <= 4 && word == word.toUpperCase()) return word;
final lower = word.toLowerCase();
return '${lower[0].toUpperCase()}${lower.substring(1)}';
}).join(' ');
}

View File

@ -1,102 +0,0 @@
/// 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,8 +19,6 @@ const Map<String, String> permissionModuleAliases = {
'purchase_orders': 'PURCHASE_ORDER',
'purchase_order': 'PURCHASE_ORDER',
'grn': 'GRN',
'purchase_receipt': 'GRN',
'purchase_receipts': 'GRN',
'reports': 'REPORTS',
'audit_logs': 'AUDIT_LOGS',
'audit': 'AUDIT_LOGS',

View File

@ -8,15 +8,6 @@ class AppBreakpoints {
static const double tablet = 600;
static const double desktop = 1024;
static const double wide = 1440;
/// Form grid: 2 cols from this width up.
static const double formSmall = 600;
/// Form grid: 3 cols from this width up.
static const double formMedium = 900;
/// Form grid: 4 cols from this width up.
static const double formLarge = 1200;
}
extension ResponsiveContext on BuildContext {
@ -31,27 +22,23 @@ extension ResponsiveContext on BuildContext {
return double.infinity;
}
/// Form grid columns: 4 / 3 / 2 / 1 by viewport width.
/// Form grid columns: 4 on medium+, 2 on small screens.
int get formGridColumns {
final width = MediaQuery.sizeOf(this).width;
return formGridColumnsForWidth(width);
}
}
/// Responsive form field columns:
/// large ≥1200 → 4, medium ≥900 → 3, small ≥600 → 2, else → 1.
/// Responsive form field columns based on viewport width.
int formGridColumnsForWidth(
double width, {
int xsColumns = 1,
int smallColumns = 2,
int mediumColumns = 3,
int mediumColumns = 4,
int largeColumns = 4,
double smallBreakpoint = AppBreakpoints.formSmall,
double mediumBreakpoint = AppBreakpoints.formMedium,
double largeBreakpoint = AppBreakpoints.formLarge,
double mediumBreakpoint = AppBreakpoints.tablet,
double largeBreakpoint = AppBreakpoints.desktop,
}) {
if (width >= largeBreakpoint) return largeColumns;
if (width >= mediumBreakpoint) return mediumColumns;
if (width >= smallBreakpoint) return smallColumns;
return xsColumns;
return smallColumns;
}

View File

@ -36,26 +36,6 @@ class TableSearch {
}
return items.where((item) => matches(q, valuesOf(item))).toList();
}
/// Merges two lists by [idOf], preferring items from [primary] on conflict.
static List<T> mergeById<T>(
Iterable<T> primary,
Iterable<T> secondary,
String Function(T item) idOf,
) {
final map = <String, T>{};
for (final item in secondary) {
final id = idOf(item);
if (id.isEmpty) continue;
map[id] = item;
}
for (final item in primary) {
final id = idOf(item);
if (id.isEmpty) continue;
map[id] = item;
}
return map.values.toList();
}
}
/// Debounces search input so API-backed lists are not hit on every keystroke.

View File

@ -1,7 +1,5 @@
import 'package:flutter/services.dart';
import 'formatters.dart';
class Validators {
Validators._();
@ -139,35 +137,15 @@ class Validators {
return null;
}
/// Strong password policy used for create / change / reset flows.
///
/// Requires: 8+ chars, uppercase, lowercase, digit, and a special character.
static String? password(String? value) {
if (value == null || value.isEmpty) return 'Password is required';
if (value.length < 8) {
return 'Password must be at least 8 characters';
}
if (!RegExp(r'[A-Z]').hasMatch(value)) {
return 'Password must contain at least one uppercase letter (A–Z)';
}
if (!RegExp(r'[a-z]').hasMatch(value)) {
return 'Password must contain at least one lowercase letter (a–z)';
}
if (!RegExp(r'[0-9]').hasMatch(value)) {
return 'Password must contain at least one numeric digit (0–9)';
}
if (!RegExp(r'''[!@#$%^&*(),.?":{}|<>_\-+=\[\]\\;/`'~]''').hasMatch(value)) {
return 'Password must contain at least one special character (e.g. @, #, \$, %, &, !)';
}
if (value.length < 8) return 'Password must be at least 8 characters';
if (!RegExp(r'[A-Z]').hasMatch(value)) return 'Must contain an uppercase letter';
if (!RegExp(r'[a-z]').hasMatch(value)) return 'Must contain a lowercase letter';
if (!RegExp(r'[0-9]').hasMatch(value)) return 'Must contain a number';
return null;
}
/// Same as [password], but allows empty (e.g. edit user keep current password).
static String? optionalPassword(String? value) {
if (value == null || value.isEmpty) return null;
return password(value);
}
/// Required 15-character GSTIN pattern.
static String? gstin(String? value) {
if (value == null || value.trim().isEmpty) {
@ -234,7 +212,7 @@ class Validators {
String fieldName = 'Value',
}) {
if (value == null || value.trim().isEmpty) return null;
final parsed = CurrencyFormatter.tryParse(value);
final parsed = double.tryParse(value.trim());
if (parsed == null) return 'Enter a valid number';
if (parsed <= 0) return '$fieldName must be greater than 0';
return null;
@ -246,7 +224,7 @@ class Validators {
String fieldName = 'Value',
}) {
if (value == null || value.trim().isEmpty) return null;
final parsed = CurrencyFormatter.tryParse(value);
final parsed = double.tryParse(value.trim());
if (parsed == null) return 'Enter a valid number';
if (parsed < 0) return '$fieldName cannot be negative';
return null;
@ -270,7 +248,7 @@ class Validators {
String fieldName = 'Percentage',
}) {
if (value == null || value.trim().isEmpty) return null;
final parsed = CurrencyFormatter.tryParse(value);
final parsed = double.tryParse(value.trim());
if (parsed == null) return 'Enter a valid percentage';
if (parsed < 0 || parsed > 100) {
return '$fieldName must be between 0 and 100';

View File

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

View File

@ -2,12 +2,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../data/repositories/asset_repository_impl.dart';
import '../../../../shared/models/asset_model.dart';
import '../../../../shared/models/user_management_models.dart';
import '../../../masters/data/datasources/master_remote_data_source.dart';
/// Categories for Assets list filter (paginated list API — no dropdown_call).
final itemCategoriesProvider =
FutureProvider.autoDispose<List<AssetCategoryModel>>((ref) async {
final itemCategoriesProvider = FutureProvider<List<AssetCategoryModel>>((ref) async {
final repository = ref.watch(assetRepositoryProvider);
final result = await repository.getCategories();
if (result.failure != null) throw result.failure!;
@ -16,20 +13,12 @@ final itemCategoriesProvider =
/// Categories for Asset form dropdowns (`dropdown_call=true`).
final itemCategoriesFormProvider =
FutureProvider.autoDispose<List<AssetCategoryModel>>((ref) async {
FutureProvider<List<AssetCategoryModel>>((ref) async {
final repository = ref.watch(assetRepositoryProvider);
final result = await repository.getCategories(dropdownCall: true);
if (result.failure != null) throw result.failure!;
return result.data ?? [];
});
/// Subcategories for a selected item category (Asset form).
final itemSubcategoriesProvider = FutureProvider.autoDispose
.family<List<FilterOptionModel>, int?>((ref, categoryId) async {
if (categoryId == null) return [];
final dataSource = ref.watch(masterRemoteDataSourceProvider);
return dataSource.listItemSubcategories(itemCategoryId: categoryId);
});
@Deprecated('Use itemCategoriesProvider')
final assetCategoriesProvider = itemCategoriesProvider;

View File

@ -67,27 +67,6 @@ final assetFormLookupsProvider =
);
});
/// Lightweight lookups for Asset Master list filters only.
/// Avoids [assetFormLookupsProvider] (PO/GRN/vendors/users/options) on the list.
final assetListFilterLookupsProvider = FutureProvider.autoDispose<
({
List<FilterOptionModel> locations,
List<AssetDropdownOption> statuses,
})>((ref) async {
final master = ref.watch(masterRemoteDataSourceProvider);
final locations = await _safeOptions(master.listLocations);
List<AssetDropdownOption> statuses = const [];
try {
final result = await ref.read(assetRepositoryProvider).getStatuses();
if (result.failure == null && result.data != null) {
statuses = result.data!;
}
} catch (_) {}
return (locations: locations, statuses: statuses);
});
final assetDropdownOptionsProvider =
FutureProvider.autoDispose<AssetDropdownOptionsModel>((ref) async {
return _safeAssetOptions(ref);
@ -203,7 +182,7 @@ Future<List<FilterOptionModel>> _safeGrnOptions(Ref ref) async {
.map(
(grn) => FilterOptionModel(
id: grn.id,
name: grn.grnNumber ?? 'Purchase Receipt #${grn.id}',
name: grn.grnNumber ?? 'GRN #${grn.id}',
),
)
.toList();

View File

@ -1,13 +1,11 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/utils/column_search_paging.dart';
import '../../../../core/utils/table_search.dart';
import '../../../../shared/models/asset_model.dart';
import '../../../../shared/models/entity_attachment_model.dart';
import '../../../../shared/models/export_file_result.dart';
import '../../data/repositories/asset_repository_impl.dart';
import 'asset_categories_provider.dart';
class AssetsListState {
const AssetsListState({
@ -60,8 +58,6 @@ final assetsListProvider =
);
class AssetsListNotifier extends AutoDisposeAsyncNotifier<AssetsListState> {
final _columnSearch = ColumnSearchPaging();
@override
Future<AssetsListState> build() async {
return _load(const AssetListQuery(limit: 20));
@ -72,35 +68,8 @@ class AssetsListNotifier extends AutoDisposeAsyncNotifier<AssetsListState> {
final result = await repository.getAssets(query);
if (result.failure != null) throw result.failure!;
final page = result.data!;
var items = List<AssetModel>.from(page.items);
final search = TableSearch.normalize(query.search);
// API search often ignores category name — pull matching categories too.
if (search.isNotEmpty && query.itemCategoryId == null) {
final categories = await ref.read(itemCategoriesProvider.future);
var categoryMatches = 0;
for (final category in categories) {
if (!TableSearch.matches(search, [category.name])) continue;
if (++categoryMatches > 5) break;
final categoryId = int.tryParse(category.id);
if (categoryId == null) continue;
final byCategory = await repository.getAssets(
query.copyWith(search: null, itemCategoryId: categoryId),
);
if (byCategory.failure == null && byCategory.data != null) {
items = TableSearch.mergeById(
items,
byCategory.data!.items,
(asset) => asset.id,
);
}
}
// Keep API page results + enriched matches; pagination stays server-driven.
}
return AssetsListState(
assets: items,
assets: page.items,
query: query,
total: page.total,
totalPages: page.totalPages,
@ -137,27 +106,6 @@ class AssetsListNotifier extends AutoDisposeAsyncNotifier<AssetsListState> {
);
}
Future<void> ensureColumnSearchDataset() async {
final current = state.valueOrNull;
if (current == null) return;
final limit = _columnSearch.beginFullDataset(
currentLimit: current.query.limit,
total: current.total,
);
if (limit == null) return;
await applyQuery(
current.query.copyWith(search: null, page: 1, limit: limit),
);
}
void clearColumnSearchDataset() {
final current = state.valueOrNull;
if (current == null) return;
final limit = _columnSearch.endFullDataset();
if (limit == null) return;
applyQuery(current.query.copyWith(search: null, page: 1, limit: limit));
}
void setStatusFilter(String? status) {
final current = state.valueOrNull;
if (current == null) return;
@ -223,7 +171,6 @@ class AssetsListNotifier extends AutoDisposeAsyncNotifier<AssetsListState> {
return false;
}
await refresh();
ref.invalidate(myMaintenanceProvider);
final current = state.valueOrNull;
if (current != null) {
state = AsyncData(current.copyWith(actionSuccess: 'Asset deleted'));
@ -356,7 +303,6 @@ class AssetDetailNotifier extends FamilyAsyncNotifier<AssetDetailState, String>
if (result.failure != null) throw result.failure!;
await reload();
ref.invalidate(assetsListProvider);
ref.invalidate(myMaintenanceProvider);
return result.data;
}
@ -365,7 +311,6 @@ class AssetDetailNotifier extends FamilyAsyncNotifier<AssetDetailState, String>
final result = await repository.deleteAsset(arg);
if (result.failure != null) return false;
ref.invalidate(assetsListProvider);
ref.invalidate(myMaintenanceProvider);
return true;
}
@ -503,7 +448,6 @@ class AssetFormNotifier extends AutoDisposeFamilyAsyncNotifier<AssetModel?, Stri
final result = await repository.createAsset(data);
if (result.failure != null) throw result.failure!;
ref.invalidate(assetsListProvider);
ref.invalidate(myMaintenanceProvider);
return result.data;
}
@ -513,7 +457,6 @@ class AssetFormNotifier extends AutoDisposeFamilyAsyncNotifier<AssetModel?, Stri
if (result.failure != null) throw result.failure!;
ref.invalidate(assetsListProvider);
ref.invalidate(assetDetailProvider(id));
ref.invalidate(myMaintenanceProvider);
final fresh = await repository.getAssetById(id);
final asset = fresh.data ?? result.data;
@ -693,11 +636,11 @@ class MyMaintenanceState {
}
final myMaintenanceProvider =
AsyncNotifierProvider.autoDispose<MyMaintenanceNotifier, MyMaintenanceState>(
AsyncNotifierProvider<MyMaintenanceNotifier, MyMaintenanceState>(
MyMaintenanceNotifier.new,
);
class MyMaintenanceNotifier extends AutoDisposeAsyncNotifier<MyMaintenanceState> {
class MyMaintenanceNotifier extends AsyncNotifier<MyMaintenanceState> {
@override
Future<MyMaintenanceState> build() async {
return _load(const MyMaintenanceState());

View File

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

View File

@ -6,7 +6,6 @@ import 'package:intl/intl.dart';
import '../../../../core/constants/enums.dart';
import '../../../../core/constants/route_constants.dart';
import '../../../../core/errors/failure.dart';
import '../../../../core/utils/formatters.dart';
import '../../../../shared/models/asset_model.dart';
import '../../../../shared/models/entity_attachment_model.dart';
import '../../../../shared/models/user_management_models.dart';
@ -17,17 +16,13 @@ import '../../../../shared/widgets/app_empty_state.dart';
import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_status_chip.dart';
import '../../../../shared/widgets/can_permission.dart';
import '../../../../shared/widgets/detail_overview_widgets.dart';
import '../../../../shared/widgets/document_preview_dialog.dart';
import '../../../../shared/widgets/entity_attachments_card.dart';
import '../../../../shared/widgets/error_view.dart';
import '../../../../shared/widgets/page_header.dart';
import '../../../../shared/widgets/app_side_panel.dart';
import '../../../../shared/widgets/app_segmented_tab_bar.dart';
import '../providers/assets_provider.dart';
import '../providers/asset_form_lookups_provider.dart';
import '../utils/maintenance_due_display.dart';
import 'asset_form_screen.dart';
import '../widgets/asset_form_panel.dart';
import '../widgets/asset_maintenance_panel.dart';
import '../widgets/asset_side_panels.dart';
import '../../../../shared/widgets/app_toast.dart';
@ -44,7 +39,6 @@ class AssetDetailScreen extends ConsumerStatefulWidget {
class _AssetDetailScreenState extends ConsumerState<AssetDetailScreen>
with SingleTickerProviderStateMixin {
late final TabController _tabController;
bool _requestedFreshLoad = false;
@override
void initState() {
@ -52,23 +46,6 @@ class _AssetDetailScreenState extends ConsumerState<AssetDetailScreen>
_tabController = TabController(length: 4, vsync: this);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_requestedFreshLoad) return;
_requestedFreshLoad = true;
// Always hit GET /assets/{id} (+ related) when opening view.
ref.invalidate(assetDetailProvider(widget.assetId));
}
@override
void didUpdateWidget(covariant AssetDetailScreen oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.assetId != widget.assetId) {
ref.invalidate(assetDetailProvider(widget.assetId));
}
}
@override
void dispose() {
_tabController.dispose();
@ -99,16 +76,12 @@ class _AssetDetailScreenState extends ConsumerState<AssetDetailScreen>
onPressed: () => context.go(RouteConstants.assets),
),
title: state.asset.assetName,
titleTrailing: AppStatusChip(
status: state.asset.status ?? 'IN_USE',
compact: true,
),
subtitle: state.asset.assetCode ?? 'Asset ID: ${state.asset.id}',
actions: [
if (canEdit)
OutlinedButton.icon(
onPressed: () =>
openAssetForm(context, ref, assetId: widget.assetId),
openAssetFormPanel(context, ref, assetId: widget.assetId),
icon: const Icon(Icons.edit_outlined),
label: const Text('Edit'),
),
@ -131,25 +104,13 @@ class _AssetDetailScreenState extends ConsumerState<AssetDetailScreen>
],
),
const SizedBox(height: 16),
AppSegmentedTabBar(
TabBar(
controller: _tabController,
tabs: const [
AppSegmentedTab(
label: 'Overview',
icon: Icons.dashboard_outlined,
),
AppSegmentedTab(
label: 'AMC',
icon: Icons.handshake_outlined,
),
AppSegmentedTab(
label: 'Service Visits',
icon: Icons.build_outlined,
),
AppSegmentedTab(
label: 'Insurance',
icon: Icons.health_and_safety_outlined,
),
Tab(text: 'Overview'),
Tab(text: 'AMC'),
Tab(text: 'Service Visits'),
Tab(text: 'Insurance'),
],
),
const SizedBox(height: 16),
@ -244,16 +205,6 @@ class _OverviewTab extends ConsumerWidget {
asset.maintenanceInchargeUserId,
users,
);
final daysUntilDueDisplay = MaintenanceDueDisplay.fromDaysUntilDue(
asset.maintenance?.daysUntilDue,
);
final retainedPct = _retainedPercentage(
currentValue: asset.resolvedCurrentValue,
purchaseCost: asset.purchaseCost,
);
final scheme = theme.colorScheme;
final activeColor =
asset.isActive ? const Color(0xFF16A34A) : scheme.onSurfaceVariant;
return SingleChildScrollView(
child: Center(
@ -270,183 +221,110 @@ class _OverviewTab extends ConsumerWidget {
children: [
Row(
children: [
Expanded(
child: Text(
'Asset Details',
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
),
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: activeColor,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: activeColor.withValues(alpha: 0.35),
blurRadius: 6,
),
],
),
),
const SizedBox(width: 8),
Text(
asset.isActive ? 'Active' : 'Inactive',
style: theme.textTheme.labelMedium?.copyWith(
color: activeColor,
fontWeight: FontWeight.w700,
'Asset Details',
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
TextButton.icon(
onPressed: onOpenTransferHistory,
icon: const Icon(Icons.history, size: 18),
label: const Text('Transfer History'),
),
],
),
const SizedBox(height: 16),
// 1) Identity
DetailOverviewSection(
title: 'Identity',
child: DetailInfoGrid(
items: [
DetailInfoItem('Asset Name', asset.assetName),
DetailInfoItem('Asset Code', asset.assetCode ?? '—'),
DetailInfoItem('Location', asset.locationName ?? '—'),
DetailInfoItem(
'Asset Category',
asset.assetCategoryName ?? '—',
const SizedBox(height: 12),
_AssetInfoGrid(
items: [
_AssetInfo('Asset Name', asset.assetName),
_AssetInfo('Asset Code', asset.assetCode ?? '—'),
_AssetInfo('Category', asset.assetCategoryName ?? '—'),
_AssetInfo(
'Subcategory',
asset.assetSubcategoryName ?? '—',
),
_AssetInfo('Location', asset.locationName ?? '—'),
_AssetInfo(
'Commencement Date',
asset.commencementDate != null
? dateFormat.format(asset.commencementDate!)
: '—',
),
_AssetInfo(
'Maintenance Incharge',
maintenanceInchargeLabel,
),
_AssetInfo(
'Maintenance Frequency',
asset.maintenanceFrequencyInDays != null
? '${asset.maintenanceFrequencyInDays} days'
: '—',
),
if (asset.maintenance != null) ...[
_AssetInfo(
'Maintenance Due',
asset.maintenance!.isDue ? 'Yes' : 'No',
),
DetailInfoItem(
'Subcategory',
asset.assetSubcategoryName ?? '—',
),
DetailInfoItem(
'Manufacturer',
asset.manufacturer ?? '—',
),
DetailInfoItem(
'Brand / Model',
asset.brandModel ?? '—',
),
DetailInfoItem(
'Serial Number',
asset.serialNumber ?? '—',
),
],
),
),
// 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(
_AssetInfo(
'Next Due Date',
_nextDueDateValue(
theme: theme,
nextDueDate: asset.maintenance?.nextDueDate,
dateFormat: dateFormat,
daysUntilDueDisplay: daysUntilDueDisplay,
),
asset.maintenance!.nextDueDate != null
? dateFormat
.format(asset.maintenance!.nextDueDate!)
: '—',
),
DetailInfoItem(
'Condition',
assetConditionLabel(asset.condition),
_AssetInfo(
'Days Until Due',
asset.maintenance!.daysUntilDue?.toString() ?? '—',
),
],
),
_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'),
],
),
if (asset.maintenanceFrequencyInDays != null ||
asset.maintenanceChecklistJson?.isNotEmpty == true ||
asset.maintenance != null) ...[
@ -454,48 +332,26 @@ class _OverviewTab extends ConsumerWidget {
padding: EdgeInsets.symmetric(vertical: 16),
child: Divider(height: 1),
),
AssetRecentMaintenanceLogsSection(
assetId: assetId,
leadingActions: [
OutlinedButton.icon(
onPressed: () async {
final saved = await openSubmitMaintenancePanel(
context,
ref,
asset: asset,
);
if (saved == true && context.mounted) {
ref.invalidate(myMaintenanceProvider);
ref.invalidate(assetDetailProvider(assetId));
showAppToastFromSnackBar(
context,
const SnackBar(
content:
Text('Maintenance log submitted'),
),
);
}
},
icon: const Icon(Icons.checklist_outlined),
label: const Text('Log Maintenance'),
),
],
trailingActions: [
OutlinedButton.icon(
onPressed: onOpenTransferHistory,
icon: const Icon(Icons.history, size: 18),
label: const Text('Transfer History'),
),
],
),
] else ...[
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: OutlinedButton.icon(
onPressed: onOpenTransferHistory,
icon: const Icon(Icons.history, size: 18),
label: const Text('Transfer History'),
onPressed: () async {
final saved = await openSubmitMaintenancePanel(
context,
ref,
asset: asset,
);
if (saved == true && context.mounted) {
showAppToastFromSnackBar(
context,
const SnackBar(
content: Text('Maintenance log submitted'),
),
);
}
},
icon: const Icon(Icons.checklist_outlined),
label: const Text('Log Maintenance'),
),
),
],
@ -504,8 +360,8 @@ class _OverviewTab extends ConsumerWidget {
padding: EdgeInsets.symmetric(vertical: 20),
child: Divider(height: 1),
),
DetailInfoGrid(
items: [DetailInfoItem('Remarks', asset.remarks!)],
_AssetInfoGrid(
items: [_AssetInfo('Remarks', asset.remarks!)],
),
],
],
@ -542,354 +398,88 @@ class _OverviewTab extends ConsumerWidget {
}
}
String _purchaseWarrantyRange({
required DateTime? purchaseDate,
required DateTime? warrantyExpiryDate,
required DateFormat dateFormat,
}) {
final purchase =
purchaseDate != null ? dateFormat.format(purchaseDate) : '—';
final warranty =
warrantyExpiryDate != null ? dateFormat.format(warrantyExpiryDate) : '—';
return '$purchase - $warranty';
class _AssetInfoGrid extends StatelessWidget {
const _AssetInfoGrid({required this.items});
final List<_AssetInfo> items;
static const int _columns = 3;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final maxWidth = constraints.maxWidth;
final cols = maxWidth < 600
? 1
: maxWidth < 900
? 2
: _columns;
const spacing = 16.0;
final colWidth = (maxWidth - spacing * (cols - 1)) / cols;
return Wrap(
spacing: spacing,
runSpacing: 16,
children: items
.map(
(item) => SizedBox(
width: colWidth,
child: _AssetDetailTile(
label: item.label,
value: item.value,
valueWidget: item.valueWidget,
),
),
)
.toList(),
);
},
);
}
}
class _DocumentNumberLink extends StatelessWidget {
const _DocumentNumberLink({
class _AssetDetailTile extends StatelessWidget {
const _AssetDetailTile({
required this.label,
required this.enabled,
required this.onTap,
this.value,
this.valueWidget,
});
final String? label;
final bool enabled;
final VoidCallback onTap;
final String label;
final String? value;
final Widget? valueWidget;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final text = label?.trim().isNotEmpty == true ? label!.trim() : '—';
if (!enabled) {
return Text(
text,
style: theme.textTheme.titleSmall?.copyWith(
color: theme.colorScheme.onSurface,
fontWeight: FontWeight.w600,
),
);
}
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),
),
),
);
}
}
Widget _nextDueDateValue({
required ThemeData theme,
required DateTime? nextDueDate,
required DateFormat dateFormat,
required MaintenanceDueDisplay? daysUntilDueDisplay,
}) {
final dateLabel =
nextDueDate != null ? dateFormat.format(nextDueDate) : '—';
final valueStyle = theme.textTheme.titleSmall?.copyWith(
color: theme.colorScheme.onSurface,
fontWeight: FontWeight.w600,
);
if (daysUntilDueDisplay == null) {
return Text(dateLabel, style: valueStyle);
}
return Text.rich(
TextSpan(
style: valueStyle,
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextSpan(text: dateLabel),
TextSpan(
text: ' (${daysUntilDueDisplay.label})',
style: valueStyle?.copyWith(
color: daysUntilDueDisplay.color,
fontWeight: FontWeight.w600,
Text(
label,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
valueWidget ?? Text(value ?? '—', style: theme.textTheme.bodyLarge),
],
),
);
}
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,
),
),
],
),
),
],
);
},
),
),
],
),
);
}
}
class _AssetInfo {
const _AssetInfo(this.label, this.value) : valueWidget = null;
const _AssetInfo.widget(this.label, this.valueWidget) : value = null;
final String label;
final String? value;
final Widget? valueWidget;
}
String _userLabel(int? userId, List<FilterOptionModel> users) {
if (userId == null || userId <= 0) return '—';
final id = userId.toString();

File diff suppressed because it is too large Load Diff

View File

@ -22,16 +22,14 @@ import '../../../../shared/widgets/app_responsive_filter_bar.dart';
import '../../../../shared/widgets/app_search_filter_toggle.dart';
import '../../../../shared/widgets/app_status_chip.dart';
import '../../../../shared/widgets/app_table_action_icon.dart';
import '../../../../shared/widgets/app_table_column_selector.dart';
import '../../../../shared/widgets/can_permission.dart';
import '../../../../shared/providers/table_column_prefs_provider.dart';
import '../../../../shared/widgets/error_view.dart';
import '../../../../shared/widgets/page_header.dart';
import '../../../../shared/utils/file_download_helper.dart';
import '../providers/asset_categories_provider.dart';
import '../providers/asset_form_lookups_provider.dart';
import '../providers/assets_provider.dart';
import 'asset_form_screen.dart';
import '../widgets/asset_form_panel.dart';
import '../../../../shared/widgets/app_toast.dart';
class AssetListScreen extends ConsumerStatefulWidget {
@ -47,10 +45,6 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
@override
Widget build(BuildContext context) {
final assetsAsync = ref.watch(assetsListProvider);
final filterLookups =
ref.watch(assetListFilterLookupsProvider).valueOrNull;
final allCategories =
ref.watch(itemCategoriesProvider).valueOrNull ?? [];
final canEdit = ref.can('assets', PermissionAction.update);
final canDelete = ref.can('assets', PermissionAction.delete);
final canExport = ref.can('assets', PermissionAction.export);
@ -75,8 +69,10 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
onRetry: () => ref.invalidate(assetsListProvider),
),
data: (state) {
final allLocations = filterLookups?.locations ?? const [];
final statuses = filterLookups?.statuses ?? const [];
final allCategories =
ref.watch(itemCategoriesProvider).valueOrNull ?? [];
final lookups = ref.watch(assetFormLookupsProvider).valueOrNull;
final allLocations = lookups?.locations ?? const [];
final notifier = ref.read(assetsListProvider.notifier);
return Column(
@ -93,11 +89,6 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
),
),
const SizedBox(width: 8),
AppTableColumnSelectorButton(
tableId: _AssetDataTable.tableId,
columns: _AssetDataTable.columnOptions,
),
const SizedBox(width: 8),
if (canExport)
OutlinedButton.icon(
onPressed: state.isExporting ? null : _exportAssets,
@ -121,7 +112,7 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
module: 'assets',
action: PermissionAction.create,
child: ElevatedButton.icon(
onPressed: () => openAssetForm(context, ref),
onPressed: () => openAssetFormPanel(context, ref),
icon: const Icon(Icons.add),
label: const Text('Add Asset'),
),
@ -160,27 +151,40 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
query: state.query,
categories: allCategories,
locations: allLocations,
statuses: statuses,
statuses: lookups?.statuses ?? const [],
onSearch: notifier.setSearch,
onCategoryChanged: notifier.setCategoryFilter,
onLocationChanged: notifier.setLocationFilter,
onStatusChanged: notifier.setStatusFilter,
),
),
Expanded(
child: _AssetDataTable(
assets: state.assets,
canEdit: canEdit,
canDelete: canDelete,
onView: _viewAsset,
onEdit: _editAsset,
onDelete: _deleteAsset,
onEnsureFullDataset: () =>
notifier.ensureColumnSearchDataset(),
onColumnSearchCleared: () =>
notifier.clearColumnSearchDataset(),
if (state.assets.isEmpty)
Expanded(
child: Center(
child: Text(
'No assets found',
style: Theme.of(context)
.textTheme
.bodyLarge
?.copyWith(
color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
),
),
),
)
else
Expanded(
child: _AssetDataTable(
assets: state.assets,
canEdit: canEdit,
canDelete: canDelete,
onView: _viewAsset,
onEdit: _editAsset,
onDelete: _deleteAsset,
),
),
),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.all(16),
@ -189,7 +193,6 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
totalPages: state.totalPages,
totalItems: state.total,
pageSize: state.query.limit,
itemsOnPage: state.assets.length,
itemLabel: 'assets',
onPageChanged: notifier.setPage,
onPageSizeChanged: notifier.setPageSize,
@ -212,7 +215,7 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
}
void _editAsset(AssetModel asset) {
openAssetForm(context, ref, assetId: asset.id);
openAssetFormPanel(context, ref, assetId: asset.id);
}
Future<void> _exportAssets() async {
@ -378,7 +381,7 @@ class _AssetsFilterBar extends StatelessWidget {
}
}
class _AssetDataTable extends ConsumerWidget {
class _AssetDataTable extends StatelessWidget {
const _AssetDataTable({
required this.assets,
required this.canEdit,
@ -386,177 +389,96 @@ class _AssetDataTable extends ConsumerWidget {
required this.onView,
required this.onEdit,
required this.onDelete,
this.onEnsureFullDataset,
this.onColumnSearchCleared,
});
static const tableId = 'assets_list';
static List<AppTableColumnOption> get columnOptions => const [
AppTableColumnOption(
id: 'asset_code',
label: 'Asset Code',
required: true,
),
AppTableColumnOption(id: 'asset_name', label: 'Asset Name'),
AppTableColumnOption(id: 'category', label: 'Category'),
AppTableColumnOption(id: 'location', label: 'Location'),
AppTableColumnOption(id: 'current_value', label: 'Current Value'),
AppTableColumnOption(
id: 'depreciated_amount',
label: 'Depreciated Amount',
),
AppTableColumnOption(
id: 'warranty_validity',
label: 'Warranty Validity',
),
AppTableColumnOption(id: 'status', label: 'Status'),
];
final List<AssetModel> assets;
final bool canEdit;
final bool canDelete;
final void Function(AssetModel asset) onView;
final void Function(AssetModel asset) onEdit;
final Future<void> Function(AssetModel asset) onDelete;
final Future<void> Function()? onEnsureFullDataset;
final VoidCallback? onColumnSearchCleared;
List<AppDataColumn<AssetModel>> _allColumns() {
return [
AppDataColumn(
id: 'asset_code',
label: 'Asset Code',
sortKey: 'asset_code',
locked: true,
flex: 1,
searchText: (asset) => asset.assetCode ?? '',
cellBuilder: (_, asset) {
final code = asset.assetCode;
if (code == null || code.isEmpty) return const Text('—');
return AppTableCell.link(code, onTap: () => onView(asset));
},
),
AppDataColumn(
id: 'asset_name',
label: 'Asset Name',
sortKey: 'asset_name',
flex: 2,
searchText: (asset) => asset.assetName,
cellBuilder: (_, asset) => Text(asset.assetName),
),
AppDataColumn(
id: 'category',
label: 'Category',
sortKey: 'category',
flex: 2,
searchText: (asset) => asset.assetCategoryName ?? '',
cellBuilder: (_, asset) => Text(asset.assetCategoryName ?? '—'),
),
AppDataColumn(
id: 'location',
label: 'Location',
sortKey: 'location',
flex: 1,
searchText: (asset) => asset.locationName ?? '',
cellBuilder: (_, asset) => Text(asset.locationName ?? '—'),
),
AppDataColumn(
id: 'current_value',
label: 'Current Value',
sortKey: 'current_value',
flex: 1,
searchText: (asset) =>
CurrencyFormatter.searchable(asset.resolvedCurrentValue),
sortValue: (asset) => asset.resolvedCurrentValue,
cellBuilder: (_, asset) => Text(
asset.resolvedCurrentValue != null
? CurrencyFormatter.format(asset.resolvedCurrentValue)
: '—',
),
),
AppDataColumn(
id: 'depreciated_amount',
label: 'Depreciated Amount',
sortKey: 'depreciated_amount',
flex: 1,
searchText: (asset) =>
CurrencyFormatter.searchable(asset.depreciatedAmount),
sortValue: (asset) => asset.depreciatedAmount,
cellBuilder: (_, asset) => Text(
asset.depreciatedAmount != null
? CurrencyFormatter.format(asset.depreciatedAmount)
: '—',
),
),
AppDataColumn(
id: 'warranty_validity',
label: 'Warranty Validity',
sortKey: 'warranty_validity',
flex: 1,
searchText: (asset) =>
DateFormatter.searchableDate(asset.warrantyExpiryDate),
sortValue: (asset) => asset.warrantyExpiryDate,
cellBuilder: (_, asset) => Text(
DateFormatter.displayDate(asset.warrantyExpiryDate),
),
),
AppDataColumn(
id: 'status',
label: 'Status',
sortKey: 'status',
flex: 1,
searchText: (asset) =>
asset.status?.toLowerCase().replaceAll(' ', '_') ?? 'active',
cellBuilder: (_, asset) => AppStatusChip(
status: asset.status?.toLowerCase().replaceAll(' ', '_') ?? 'active',
compact: true,
forTable: true,
),
),
AppDataColumn(
id: 'actions',
label: 'Actions',
flex: 1,
alignment: Alignment.centerRight,
enableSearch: false,
locked: true,
includeInColumnSelector: false,
cellBuilder: (_, asset) => AppTableActions(
children: [
AppTableActionIcon(
tooltip: 'View',
icon: Icons.visibility_outlined,
onPressed: () => onView(asset),
),
if (canEdit)
AppTableActionIcon(
tooltip: 'Edit',
icon: Icons.edit_outlined,
onPressed: () => onEdit(asset),
),
if (canDelete)
AppTableActionIcon(
tooltip: 'Delete',
icon: Icons.delete_outline,
onPressed: () => onDelete(asset),
),
],
),
),
];
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final prefs = ref.watch(tableColumnPrefsProvider(tableId));
final columns = resolveAppDataColumns(_allColumns(), prefs);
Widget build(BuildContext context) {
return AppDataTable<AssetModel>(
wrapInCard: false,
onEnsureFullDataset: onEnsureFullDataset,
onColumnSearchCleared: onColumnSearchCleared,
columns: columns,
columns: [
AppDataColumn(
label: 'Asset Code',
flex: 1,
searchText: (asset) => asset.assetCode ?? '',
cellBuilder: (_, asset) {
final code = asset.assetCode;
if (code == null || code.isEmpty) return const Text('—');
return _AssetCodeBadge(code: code);
},
),
AppDataColumn(
label: 'Asset Name',
flex: 2,
searchText: (asset) => asset.assetName,
cellBuilder: (_, asset) => Text(asset.assetName),
),
AppDataColumn(
label: 'Category',
flex: 2,
searchText: (asset) => asset.assetCategoryName ?? '',
cellBuilder: (_, asset) => Text(asset.assetCategoryName ?? '—'),
),
AppDataColumn(
label: 'Location',
flex: 1,
searchText: (asset) => asset.locationName ?? '',
cellBuilder: (_, asset) => Text(asset.locationName ?? '—'),
),
AppDataColumn(
label: 'Warranty',
flex: 1,
searchText: (asset) =>
DateFormatter.displayDate(asset.warrantyExpiryDate),
cellBuilder: (_, asset) => Text(
DateFormatter.displayDate(asset.warrantyExpiryDate),
),
),
AppDataColumn(
label: 'Status',
flex: 1,
searchText: (asset) =>
asset.status?.toLowerCase().replaceAll(' ', '_') ?? 'active',
cellBuilder: (_, asset) => AppStatusChip(
status: asset.status?.toLowerCase().replaceAll(' ', '_') ?? 'active',
compact: true,
forTable: true,
),
),
AppDataColumn(
label: 'Actions',
flex: 1,
alignment: Alignment.centerRight,
enableSearch: false,
cellBuilder: (_, asset) => AppTableActions(
children: [
AppTableActionIcon(
tooltip: 'View',
icon: Icons.visibility_outlined,
onPressed: () => onView(asset),
),
if (canEdit)
AppTableActionIcon(
tooltip: 'Edit',
icon: Icons.edit_outlined,
onPressed: () => onEdit(asset),
),
if (canDelete)
AppTableActionIcon(
tooltip: 'Delete',
icon: Icons.delete_outline,
onPressed: () => onDelete(asset),
),
],
),
),
],
rows: assets,
);
}
@ -618,10 +540,7 @@ class _AssetMobileList extends StatelessWidget {
),
const SizedBox(height: 4),
if (asset.assetCode != null && asset.assetCode!.isNotEmpty)
AppTableCell.link(
asset.assetCode!,
onTap: () => onView(asset),
)
_AssetCodeBadge(code: asset.assetCode!)
else
const Text('—'),
Text('${asset.assetCategoryName ?? '—'} · ${asset.locationName ?? '—'}'),
@ -656,3 +575,28 @@ 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,44 +8,23 @@ import '../../../../core/errors/failure.dart';
import '../../../../core/utils/formatters.dart';
import '../../../../core/utils/responsive_utils.dart';
import '../../../../shared/models/asset_model.dart';
import '../../../../shared/providers/table_column_prefs_provider.dart';
import '../../../../shared/widgets/app_data_table.dart';
import '../../../../shared/widgets/app_empty_state.dart';
import '../../../../shared/widgets/app_form_toggle_field.dart';
import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_pagination.dart';
import '../../../../shared/widgets/app_table_action_icon.dart';
import '../../../../shared/widgets/app_table_column_selector.dart';
import '../../../../shared/widgets/app_toast.dart';
import '../../../../shared/widgets/error_view.dart';
import '../../../../shared/widgets/page_header.dart';
import '../providers/assets_provider.dart';
import '../utils/maintenance_due_display.dart';
import '../widgets/asset_maintenance_panel.dart';
class AssetMaintenanceScreen extends ConsumerStatefulWidget {
class AssetMaintenanceScreen extends ConsumerWidget {
const AssetMaintenanceScreen({super.key});
@override
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) {
Widget build(BuildContext context, WidgetRef ref) {
final maintenanceAsync = ref.watch(myMaintenanceProvider);
return Padding(
@ -74,10 +53,7 @@ class _MyMaintenanceBody extends ConsumerWidget {
AssetModel asset,
) async {
final saved = await openSubmitMaintenancePanel(context, ref, asset: asset);
if (!context.mounted) return;
if (saved == true) {
await ref.read(myMaintenanceProvider.notifier).refresh();
if (!context.mounted) return;
if (saved == true && context.mounted) {
showAppToastFromSnackBar(
context,
const SnackBar(content: Text('Maintenance log submitted')),
@ -97,11 +73,6 @@ class _MyMaintenanceBody extends ConsumerWidget {
title: 'My Maintenance',
subtitle: 'Assets assigned to you for checklist-based maintenance',
actions: [
AppTableColumnSelectorButton(
tableId: _MaintenanceTable.tableId,
columns: _MaintenanceTable.columnOptions,
),
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: () => context.go(RouteConstants.assets),
icon: const Icon(Icons.inventory_2_outlined),
@ -171,7 +142,6 @@ class _MyMaintenanceBody extends ConsumerWidget {
totalPages: state.totalPages,
totalItems: state.total,
pageSize: state.limit,
itemsOnPage: state.assets.length,
onPageChanged: notifier.setPage,
onPageSizeChanged: notifier.setPageSize,
itemLabel: 'assets',
@ -181,116 +151,80 @@ class _MyMaintenanceBody extends ConsumerWidget {
}
}
class _MaintenanceTable extends ConsumerWidget {
class _MaintenanceTable extends StatelessWidget {
const _MaintenanceTable({
required this.assets,
required this.onSubmit,
required this.onView,
});
static const tableId = 'my_maintenance';
static List<AppTableColumnOption> get columnOptions => const [
AppTableColumnOption(
id: 'asset_code',
label: 'Asset Code',
required: true,
),
AppTableColumnOption(id: 'asset_name', label: 'Asset Name'),
AppTableColumnOption(id: 'next_due', label: 'Next Due'),
AppTableColumnOption(id: 'status', label: 'Status'),
];
final List<AssetModel> assets;
final void Function(AssetModel asset) onSubmit;
final void Function(AssetModel asset) onView;
List<AppDataColumn<AssetModel>> _allColumns() {
final dateFormat = DateFormat('dd MMM yyyy');
return [
AppDataColumn(
id: 'asset_code',
label: 'Asset Code',
sortKey: 'asset_code',
locked: true,
flex: 1,
searchText: (asset) => asset.assetCode ?? '',
cellBuilder: (_, asset) => AppTableCell.link(
asset.assetCode,
onTap: () => onView(asset),
),
),
AppDataColumn(
id: 'asset_name',
label: 'Asset Name',
sortKey: 'asset_name',
flex: 2,
searchText: (asset) => asset.assetName,
cellBuilder: (_, asset) => Text(asset.assetName),
),
AppDataColumn(
id: 'next_due',
label: 'Next Due',
sortKey: 'next_due',
flex: 1,
searchText: (asset) => DateFormatter.displayDate(
asset.maintenance?.nextDueDate,
),
sortValue: (asset) => asset.maintenance?.nextDueDate,
cellBuilder: (_, asset) {
final nextDue = asset.maintenance?.nextDueDate;
return Text(
nextDue != null ? dateFormat.format(nextDue) : '—',
);
},
),
AppDataColumn(
id: 'status',
label: 'Status',
sortKey: 'status',
flex: 1,
searchText: (asset) =>
asset.maintenance?.isDue == true ? 'due' : 'ok',
cellBuilder: (_, asset) => _DueBadge(
isDue: asset.maintenance?.isDue == true,
daysUntilDue: asset.maintenance?.daysUntilDue,
),
),
AppDataColumn(
id: 'actions',
label: 'Actions',
flex: 1,
alignment: Alignment.centerRight,
enableSearch: false,
locked: true,
includeInColumnSelector: false,
cellBuilder: (_, asset) => AppTableActions(
children: [
AppTableActionIcon(
tooltip: 'Submit checklist',
icon: Icons.checklist_outlined,
onPressed: () => onSubmit(asset),
),
AppTableActionIcon(
tooltip: 'View asset',
icon: Icons.visibility_outlined,
onPressed: () => onView(asset),
),
],
),
),
];
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final prefs = ref.watch(tableColumnPrefsProvider(tableId));
final columns = resolveAppDataColumns(_allColumns(), prefs);
Widget build(BuildContext context) {
final dateFormat = DateFormat('dd MMM yyyy');
return AppDataTable<AssetModel>(
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,
);
}
@ -392,10 +326,16 @@ class _DueBadge extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final due = MaintenanceDueDisplay.fromDaysUntilDue(daysUntilDue);
final color = due?.color ??
(isDue ? const Color(0xFFDC2626) : const Color(0xFF16A34A));
final label = due?.label ?? (isDue ? 'Due' : 'On track');
final color = isDue
? const Color(0xFFDC2626)
: const Color(0xFF16A34A);
final label = isDue
? (daysUntilDue != null && daysUntilDue! < 0
? 'Overdue'
: 'Due')
: (daysUntilDue != null
? 'In $daysUntilDue day${daysUntilDue == 1 ? '' : 's'}'
: 'On track');
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),

View File

@ -1,38 +0,0 @@
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,7 +4,6 @@ import 'package:intl/intl.dart';
import '../../../../core/utils/formatters.dart';
import '../../../../shared/models/asset_model.dart';
import '../../../../shared/widgets/api_feedback.dart';
import '../../../../shared/widgets/app_button.dart';
import '../../../../shared/widgets/app_date_popup.dart';
import '../../../../shared/widgets/app_dropdown.dart';
@ -46,14 +45,13 @@ class SubmitMaintenanceLogPanel extends ConsumerStatefulWidget {
class _ChecklistRowState {
_ChecklistRowState({
required this.keyName,
required this.label,
required this.required,
}) : status = required ? null : 'OK';
});
final String keyName;
final String label;
final bool required;
/// Null until the user picks a status (required items must choose explicitly).
String? status;
String status = 'OK';
final TextEditingController remarksController = TextEditingController();
void dispose() => remarksController.dispose();
@ -66,17 +64,20 @@ class _SubmitMaintenanceLogPanelState
DateTime _performedDate = DateTime.now();
late final List<_ChecklistRowState> _rows;
bool _isSubmitting = false;
bool _showLogs = false;
List<AssetMaintenanceLogModel>? _logs;
bool _logsLoading = false;
String? _logsError;
@override
void initState() {
super.initState();
final checklist = checklistForAsset(widget.asset);
_rows = checklist
.where((item) => item.label.trim().isNotEmpty)
.map(
(item) => _ChecklistRowState(
label: item.label.trim(),
required: item.required,
keyName: item.key.isNotEmpty ? item.key : item.label,
label: item.label.isNotEmpty ? item.label : item.key,
),
)
.toList();
@ -104,6 +105,27 @@ class _SubmitMaintenanceLogPanelState
}
}
Future<void> _loadLogs() async {
setState(() {
_showLogs = true;
_logsLoading = true;
_logsError = null;
});
final result = await ref
.read(assetRepositoryProvider)
.getMaintenanceLogs(widget.asset.id);
if (!mounted) return;
setState(() {
_logsLoading = false;
if (result.failure != null) {
_logsError = result.failure!.message;
_logs = null;
} else {
_logs = result.data ?? [];
}
});
}
Future<void> _save() async {
if (!_formKey.currentState!.validate()) return;
if (_rows.isEmpty) {
@ -111,34 +133,17 @@ class _SubmitMaintenanceLogPanelState
return;
}
final missingRequired = _rows.where((row) {
if (!row.required) return false;
final statusMissing = row.status == null || row.status!.trim().isEmpty;
final remarksMissing = row.remarksController.text.trim().isEmpty;
return statusMissing || remarksMissing;
}).toList();
if (missingRequired.isNotEmpty) {
showSidePanelSnackBar(
context,
'Complete required checklist items: ${missingRequired.map((r) => r.label).join(', ')}',
);
return;
}
setState(() => _isSubmitting = true);
try {
final payload = <String, dynamic>{
'performed_date': DateFormatter.toApiDate(_performedDate),
'checklist_json': _rows
.map(
(row) {
final remarks = row.remarksController.text.trim();
return <String, dynamic>{
'label': row.label,
'status': row.status,
'remarks': remarks.isEmpty ? null : remarks,
'required': row.required,
};
(row) => {
'key': row.keyName,
'status': row.status,
if (row.remarksController.text.trim().isNotEmpty)
'remarks': row.remarksController.text.trim(),
},
)
.toList(),
@ -157,7 +162,7 @@ class _SubmitMaintenanceLogPanelState
if (mounted) Navigator.of(context, rootNavigator: true).pop(true);
} catch (e) {
if (mounted) {
showSidePanelApiError(context, e);
showSidePanelSnackBar(context, e.toString());
}
} finally {
if (mounted) setState(() => _isSubmitting = false);
@ -258,7 +263,61 @@ class _SubmitMaintenanceLogPanelState
],
),
const SizedBox(height: 16),
AssetRecentMaintenanceLogsSection(assetId: widget.asset.id),
Row(
children: [
Text(
'Recent Logs',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const Spacer(),
TextButton.icon(
onPressed: _logsLoading
? null
: () {
if (_showLogs && _logs != null) {
setState(() => _showLogs = !_showLogs);
} else {
_loadLogs();
}
},
icon: Icon(
_showLogs ? Icons.expand_less : Icons.history,
size: 18,
),
label: Text(_showLogs ? 'Hide' : 'View'),
),
],
),
if (_showLogs) ...[
const SizedBox(height: 8),
if (_logsLoading)
const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Center(child: CircularProgressIndicator()),
)
else if (_logsError != null)
Text(
_logsError!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
),
)
else if (_logs == null || _logs!.isEmpty)
const AppEmptyState(
title: 'No logs yet',
description: 'Submitted maintenance visits will appear here.',
icon: Icons.history_toggle_off_outlined,
)
else
..._logs!.take(5).map(
(log) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: _MaintenanceLogTile(log: log),
),
),
],
],
),
),
@ -266,133 +325,6 @@ class _SubmitMaintenanceLogPanelState
}
}
/// Expandable recent maintenance logs (asset detail + submit panel).
class AssetRecentMaintenanceLogsSection extends ConsumerStatefulWidget {
const AssetRecentMaintenanceLogsSection({
super.key,
required this.assetId,
this.limit = 5,
this.leadingActions,
this.trailingActions,
});
final String assetId;
final int limit;
/// Optional actions shown before Recent Logs (e.g. Log Maintenance).
final List<Widget>? leadingActions;
/// Optional actions shown after Recent Logs (e.g. Transfer History).
final List<Widget>? trailingActions;
@override
ConsumerState<AssetRecentMaintenanceLogsSection> createState() =>
_AssetRecentMaintenanceLogsSectionState();
}
class _AssetRecentMaintenanceLogsSectionState
extends ConsumerState<AssetRecentMaintenanceLogsSection> {
bool _showLogs = false;
List<AssetMaintenanceLogModel>? _logs;
bool _logsLoading = false;
String? _logsError;
Future<void> _loadLogs() async {
setState(() {
_showLogs = true;
_logsLoading = true;
_logsError = null;
});
final result = await ref
.read(assetRepositoryProvider)
.getMaintenanceLogs(widget.assetId);
if (!mounted) return;
setState(() {
_logsLoading = false;
if (result.failure != null) {
_logsError = result.failure!.message;
_logs = null;
} else {
_logs = result.data ?? [];
}
});
}
void _toggle() {
if (_showLogs && _logs != null) {
setState(() => _showLogs = !_showLogs);
return;
}
_loadLogs();
}
@override
Widget build(BuildContext context) {
// Refresh open logs after a new maintenance submit updates the asset.
ref.listen(assetDetailProvider(widget.assetId), (previous, next) {
if (_showLogs && previous != next) {
_loadLogs();
}
});
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Align(
alignment: Alignment.centerLeft,
child: Wrap(
spacing: 12,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
...?widget.leadingActions,
OutlinedButton.icon(
onPressed: _logsLoading ? null : _toggle,
icon: Icon(
_showLogs ? Icons.expand_less : Icons.history,
size: 18,
),
label: const Text('Recent Logs'),
),
...?widget.trailingActions,
],
),
),
if (_showLogs) ...[
const SizedBox(height: 12),
if (_logsLoading)
const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Center(child: CircularProgressIndicator()),
)
else if (_logsError != null)
Text(
_logsError!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
),
)
else if (_logs == null || _logs!.isEmpty)
const AppEmptyState(
title: 'No logs yet',
description: 'Submitted maintenance visits will appear here.',
icon: Icons.history_toggle_off_outlined,
)
else
..._logs!.take(widget.limit).map(
(log) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: _MaintenanceLogTile(log: log),
),
),
],
],
);
}
}
class _DateField extends StatelessWidget {
const _DateField({
required this.label,
@ -444,37 +376,27 @@ class _ChecklistItemCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
row.required ? '${row.label} *' : row.label,
row.label,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 10),
AppDropdown<String>(
label: row.required ? 'Status *' : 'Status',
label: 'Status',
isDense: true,
value: row.status,
hint: 'Select status',
options: const [
AppDropdownOption(value: 'OK', label: 'OK'),
AppDropdownOption(value: 'NOT_OK', label: 'Not OK'),
AppDropdownOption(value: 'NA', label: 'N/A'),
],
onChanged: onStatusChanged,
validator: row.required
? (v) =>
(v == null || v.trim().isEmpty) ? 'Status is required' : null
: null,
),
const SizedBox(height: 10),
AppTextField(
controller: row.remarksController,
label: row.required ? 'Item remarks *' : 'Item remarks',
validator: row.required
? (v) => (v == null || v.trim().isEmpty)
? 'Remarks are required for this checklist item'
: null
: null,
label: 'Item remarks',
),
],
),
@ -532,19 +454,12 @@ class _MaintenanceLogTile extends StatelessWidget {
spacing: 6,
runSpacing: 4,
children: log.checklistJson.map((item) {
final label = item['label']?.toString().trim();
final rawStatus = item['status']?.toString().trim() ?? '—';
final status = rawStatus.replaceAll('_', ' ');
final remarks = item['remarks']?.toString().trim();
final title =
(label != null && label.isNotEmpty) ? label : 'Item';
final chipText = (remarks != null && remarks.isNotEmpty)
? '$title: $status — $remarks'
: '$title: $status';
final key = item['key']?.toString() ?? 'Item';
final status = item['status']?.toString() ?? '—';
return Chip(
visualDensity: VisualDensity.compact,
label: Text(
chipText,
'$key: $status',
style: theme.textTheme.labelSmall,
),
padding: EdgeInsets.zero,

View File

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

View File

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

View File

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

View File

@ -23,9 +23,7 @@ import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/app_side_panel.dart';
import '../../../../shared/widgets/app_status_chip.dart';
import '../../../../shared/widgets/app_table_action_icon.dart';
import '../../../../shared/widgets/app_table_column_selector.dart';
import '../../../../shared/widgets/app_table_shell.dart';
import '../../../../shared/providers/table_column_prefs_provider.dart';
import '../../../../shared/widgets/error_view.dart';
import '../../../../shared/widgets/page_header.dart';
import '../providers/audit_provider.dart';
@ -145,11 +143,6 @@ class _AuditLogsScreenState extends ConsumerState<AuditLogsScreen> {
() => _filtersExpanded = !_filtersExpanded,
),
),
const SizedBox(width: 8),
AppTableColumnSelectorButton(
tableId: _AuditDataTable.tableId,
columns: _AuditDataTable.columnOptions,
),
if (canExport) ...[
const SizedBox(width: 8),
OutlinedButton.icon(
@ -191,7 +184,6 @@ class _AuditLogsScreenState extends ConsumerState<AuditLogsScreen> {
totalPages: state.totalPages,
totalItems: state.total,
pageSize: state.query.limit,
itemsOnPage: state.items.length,
itemLabel: 'audit logs',
onPageChanged: notifier.setPage,
onPageSizeChanged: notifier.setPageSize,
@ -213,35 +205,30 @@ class _AuditLogsScreenState extends ConsumerState<AuditLogsScreen> {
),
],
)
: context.isMobile
? (state.items.isEmpty
? ListView(
physics:
const AlwaysScrollableScrollPhysics(),
children: const [
SizedBox(
height: 260,
child: AppEmptyState(
title: 'No audit logs found',
description:
'Try adjusting filters or expanding the date range.',
icon: Icons.history_outlined,
),
),
],
)
: _AuditCardList(
: state.items.isEmpty
? ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: const [
SizedBox(
height: 260,
child: AppEmptyState(
title: 'No audit logs found',
description:
'Try adjusting filters or expanding the date range.',
icon: Icons.history_outlined,
),
),
],
)
: context.isMobile
? _AuditCardList(
items: state.items,
onView: _viewLog,
))
: _AuditDataTable(
items: state.items,
onView: _viewLog,
onEnsureFullDataset: () =>
notifier.ensureColumnSearchDataset(),
onColumnSearchCleared: () =>
notifier.clearColumnSearchDataset(),
),
)
: _AuditDataTable(
items: state.items,
onView: _viewLog,
),
),
),
),
@ -304,10 +291,7 @@ class _FiltersBar extends StatelessWidget {
options: [
const AppDropdownOption(value: null, label: 'All Tables'),
...filters.tableNames.map(
(name) => AppDropdownOption(
value: name,
label: humanizeLabel(name),
),
(name) => AppDropdownOption(value: name, label: name),
),
],
onChanged: onTableChanged,
@ -355,7 +339,7 @@ class _FiltersBar extends StatelessWidget {
);
final theme = Theme.of(context);
final iconColor = theme.colorScheme.secondary;
final iconColor = theme.colorScheme.primary;
final resetButton = IconButton(
tooltip: 'Reset',
color: iconColor,
@ -377,143 +361,100 @@ class _FiltersBar extends StatelessWidget {
}
}
class _AuditDataTable extends ConsumerWidget {
class _AuditDataTable extends StatelessWidget {
const _AuditDataTable({
required this.items,
required this.onView,
this.onEnsureFullDataset,
this.onColumnSearchCleared,
});
static const tableId = 'audit_logs';
static List<AppTableColumnOption> get columnOptions => const [
AppTableColumnOption(id: 'when', label: 'When', required: true),
AppTableColumnOption(id: 'action', label: 'Action'),
AppTableColumnOption(id: 'table', label: 'Table'),
AppTableColumnOption(id: 'record', label: 'Record'),
AppTableColumnOption(id: 'performed_by', label: 'Performed By'),
AppTableColumnOption(id: 'request_id', label: 'Request ID'),
AppTableColumnOption(id: 'changes', label: 'Changes'),
];
final List<AuditLogEntryModel> items;
final void Function(AuditLogEntryModel log) onView;
final Future<void> Function()? onEnsureFullDataset;
final VoidCallback? onColumnSearchCleared;
static String _changesText(AuditLogEntryModel row) {
final parts = <String>[];
if (row.hasOldValue) parts.add('old');
if (row.hasNewValue) parts.add('new');
return parts.isEmpty ? '' : parts.join(' / ');
}
List<AppDataColumn<AuditLogEntryModel>> _allColumns() {
return [
AppDataColumn(
id: 'when',
label: 'When',
sortKey: 'when',
locked: true,
flex: 2,
searchText: (row) => DateFormatter.searchableDate(row.performedAt),
sortValue: (row) => row.performedAt,
cellBuilder: (_, row) => AppTableCell.text(
DateFormatter.displayDateTime(row.performedAt),
),
),
AppDataColumn(
id: 'action',
label: 'Action',
sortKey: 'action',
flex: 1,
searchText: (row) => row.action,
cellBuilder: (_, row) => AppTableCell.child(
AppStatusChip(
status: row.action,
compact: true,
forTable: true,
),
),
),
AppDataColumn(
id: 'table',
label: 'Table',
sortKey: 'table',
flex: 2,
searchText: (row) => humanizeLabel(row.tableName),
cellBuilder: (_, row) =>
AppTableCell.text(humanizeLabel(row.tableName)),
),
AppDataColumn(
id: 'record',
label: 'Record',
sortKey: 'record',
flex: 1,
searchText: (row) => row.recordId ?? '',
cellBuilder: (_, row) => AppTableCell.text(row.recordId),
),
AppDataColumn(
id: 'performed_by',
label: 'Performed By',
sortKey: 'performed_by',
flex: 2,
searchText: (row) => row.performerLabel,
cellBuilder: (_, row) => AppTableCell.text(row.performerLabel),
),
AppDataColumn(
id: 'request_id',
label: 'Request ID',
sortKey: 'request_id',
flex: 2,
searchText: (row) => row.requestId ?? '',
cellBuilder: (_, row) => AppTableCell.text(row.requestId),
),
AppDataColumn(
id: 'changes',
label: 'Changes',
sortKey: 'changes',
flex: 1,
searchText: _changesText,
cellBuilder: (_, row) {
final text = _changesText(row);
return AppTableCell.text(text.isEmpty ? '—' : text);
},
),
AppDataColumn(
id: 'actions',
label: 'Actions',
flex: 1,
alignment: Alignment.centerRight,
enableSearch: false,
locked: true,
includeInColumnSelector: false,
cellBuilder: (_, row) => AppTableActions(
children: [
AppTableActionIcon(
tooltip: 'View',
icon: Icons.visibility_outlined,
onPressed: () => onView(row),
),
],
),
),
];
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final prefs = ref.watch(tableColumnPrefsProvider(tableId));
final columns = resolveAppDataColumns(_allColumns(), prefs);
Widget build(BuildContext context) {
return AppDataTable<AuditLogEntryModel>(
wrapInCard: false,
rows: items,
emptyMessage: 'No audit logs found',
onEnsureFullDataset: onEnsureFullDataset,
onColumnSearchCleared: onColumnSearchCleared,
columns: columns,
columns: [
AppDataColumn(
label: 'When',
flex: 2,
searchText: (row) => DateFormatter.displayDateTime(row.performedAt),
cellBuilder: (_, row) => AppTableCell.text(
DateFormatter.displayDateTime(row.performedAt),
),
),
AppDataColumn(
label: 'Action',
flex: 1,
searchText: (row) => row.action,
cellBuilder: (_, row) => AppTableCell.child(
AppStatusChip(
status: row.action,
compact: true,
forTable: true,
),
),
),
AppDataColumn(
label: 'Table',
flex: 2,
searchText: (row) => row.tableName,
cellBuilder: (_, row) => AppTableCell.text(row.tableName),
),
AppDataColumn(
label: 'Record',
flex: 1,
searchText: (row) => row.recordId ?? '',
cellBuilder: (_, row) => AppTableCell.text(row.recordId),
),
AppDataColumn(
label: 'Performed By',
flex: 2,
searchText: (row) => row.performerLabel,
cellBuilder: (_, row) => AppTableCell.text(row.performerLabel),
),
AppDataColumn(
label: 'Request ID',
flex: 2,
searchText: (row) => row.requestId ?? '',
cellBuilder: (_, row) => AppTableCell.text(row.requestId),
),
AppDataColumn(
label: 'Changes',
flex: 1,
searchText: (row) {
final parts = <String>[];
if (row.hasOldValue) parts.add('old');
if (row.hasNewValue) parts.add('new');
return parts.isEmpty ? '' : parts.join(' / ');
},
cellBuilder: (_, row) {
final parts = <String>[];
if (row.hasOldValue) parts.add('old');
if (row.hasNewValue) parts.add('new');
return AppTableCell.text(
parts.isEmpty ? '—' : parts.join(' / '),
);
},
),
AppDataColumn(
label: 'Actions',
flex: 1,
alignment: Alignment.centerRight,
enableSearch: false,
cellBuilder: (_, row) => AppTableActions(
children: [
AppTableActionIcon(
tooltip: 'View',
icon: Icons.visibility_outlined,
onPressed: () => onView(row),
),
],
),
),
],
);
}
}
@ -544,7 +485,7 @@ class _AuditCardList extends StatelessWidget {
children: [
Expanded(
child: Text(
humanizeLabel(log.tableName),
log.tableName,
style: Theme.of(context).textTheme.titleMedium,
),
),

View File

@ -57,7 +57,7 @@ class _DetailBody extends StatelessWidget {
label: 'Action',
child: AppStatusChip(status: detail.action, compact: true),
),
_DetailRow(label: 'Table', value: humanizeLabel(detail.tableName)),
_DetailRow(label: 'Table', value: _humanizeKey(detail.tableName)),
_DetailRow(label: 'Record ID', value: detail.recordId ?? '—'),
_DetailRow(
label: 'Performed At',
@ -96,10 +96,6 @@ class _DetailRow extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final labelStyle = theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
);
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
@ -107,20 +103,22 @@ class _DetailRow extends StatelessWidget {
children: [
SizedBox(
width: 140,
child: Text(label, style: labelStyle),
),
if (child != null)
// Keep badge left-aligned with text values (do not expand Chip).
child!
else
Expanded(
child: SelectableText(
value ?? '—',
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
child: Text(
label,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
Expanded(
child: child ??
SelectableText(
value ?? '—',
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
),
],
),
);
@ -271,15 +269,15 @@ List<_FieldRow> _flattenFields(
for (final entry in entries) {
final key = entry.key.toString();
final label = prefix == null
? humanizeLabel(key)
: '${humanizeLabel(prefix)} › ${humanizeLabel(key)}';
? _humanizeKey(key)
: '${_humanizeKey(prefix)} › ${_humanizeKey(key)}';
final value = entry.value;
if (value is Map) {
final nested = Map<String, dynamic>.from(value);
final summary = _nestedSummary(nested);
if (summary != null) {
rows.add(_FieldRow(label: humanizeLabel(key), value: summary));
rows.add(_FieldRow(label: _humanizeKey(key), value: summary));
} else {
rows.addAll(
_flattenFields(
@ -332,7 +330,7 @@ String? _nestedSummary(Map<String, dynamic> nested) {
return nested.entries
.map(
(e) =>
'${humanizeLabel(e.key.toString())}: ${_formatValue(e.key.toString(), e.value)}',
'${_humanizeKey(e.key.toString())}: ${_formatValue(e.key.toString(), e.value)}',
)
.join(', ');
}
@ -395,3 +393,30 @@ String _formatValue(String key, Object? value) {
return text;
}
String _humanizeKey(String key) {
final cleaned = key.trim().replaceAll(RegExp(r'[_\-.]+'), ' ');
if (cleaned.isEmpty) return key;
const acronyms = {
'id': 'ID',
'amc': 'AMC',
'gst': 'GST',
'hsn': 'HSN',
'uom': 'UOM',
'po': 'PO',
'grn': 'GRN',
'url': 'URL',
'api': 'API',
};
return cleaned
.split(RegExp(r'\s+'))
.where((part) => part.isNotEmpty)
.map((part) {
final lower = part.toLowerCase();
if (acronyms.containsKey(lower)) return acronyms[lower]!;
return '${lower[0].toUpperCase()}${lower.substring(1)}';
})
.join(' ');
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,15 +1,10 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/utils/column_search_paging.dart';
import '../../../../core/utils/table_search.dart';
import '../../../../shared/models/export_file_result.dart';
import '../../../../shared/models/grn_model.dart';
import '../../../../shared/models/purchase_order_model.dart';
import '../../../purchase_orders/data/repositories/purchase_order_repository_impl.dart';
import '../../../purchase_orders/presentation/providers/purchase_order_lookups_provider.dart';
import '../../data/repositories/grn_repository_impl.dart';
import 'grn_lookups_provider.dart';
class GrnListState {
const GrnListState({
@ -62,8 +57,6 @@ final grnListProvider =
);
class GrnListNotifier extends AutoDisposeAsyncNotifier<GrnListState> {
final _columnSearch = ColumnSearchPaging();
@override
Future<GrnListState> build() async {
return _load(const GrnListQuery(limit: 20));
@ -74,73 +67,8 @@ class GrnListNotifier extends AutoDisposeAsyncNotifier<GrnListState> {
final result = await repository.getGrns(query);
if (result.failure != null) throw result.failure!;
final page = result.data!;
var items = List<GrnModel>.from(page.items);
final search = TableSearch.normalize(query.search);
// API search often ignores PO number / vendor name — enrich via filters.
if (search.isNotEmpty &&
query.poId == null &&
query.vendorId == null) {
try {
final grnLookups = await ref.read(grnLookupsProvider.future);
final poCandidates = <PurchaseOrderModel>[
...grnLookups.receivablePurchaseOrders,
];
// Include broader PO dropdown options so search works beyond receivable.
final allPoOptions = await ref
.read(purchaseOrderRepositoryProvider)
.listPurchaseOrderOptions();
if (allPoOptions.failure == null && allPoOptions.data != null) {
poCandidates.addAll(allPoOptions.data!);
}
final seenPoIds = <String>{};
var poMatches = 0;
for (final po in poCandidates) {
if (!seenPoIds.add(po.id)) continue;
if (!TableSearch.matches(search, [po.poNo, po.vendorName])) {
continue;
}
if (++poMatches > 5) break;
final poId = int.tryParse(po.id);
if (poId == null) continue;
final byPo = await repository.getGrns(
query.copyWith(search: null, poId: poId),
);
if (byPo.failure == null && byPo.data != null) {
items = TableSearch.mergeById(
items,
byPo.data!.items,
(grn) => grn.id,
);
}
}
final poLookups = await ref.read(purchaseOrderLookupsProvider.future);
var vendorMatches = 0;
for (final vendor in poLookups.vendors) {
if (!TableSearch.matches(search, [vendor.name])) continue;
if (++vendorMatches > 5) break;
final vendorId = int.tryParse(vendor.id);
if (vendorId == null) continue;
final byVendor = await repository.getGrns(
query.copyWith(search: null, vendorId: vendorId),
);
if (byVendor.failure == null && byVendor.data != null) {
items = TableSearch.mergeById(
items,
byVendor.data!.items,
(grn) => grn.id,
);
}
}
} catch (_) {
// Lookups enrichment is best-effort.
}
}
return GrnListState(
grns: items,
grns: page.items,
query: query,
total: page.total,
totalPages: page.totalPages,
@ -175,27 +103,6 @@ class GrnListNotifier extends AutoDisposeAsyncNotifier<GrnListState> {
applyQuery(current.query.copyWith(search: TableSearch.normalize(search), page: 1));
}
Future<void> ensureColumnSearchDataset() async {
final current = state.valueOrNull;
if (current == null) return;
final limit = _columnSearch.beginFullDataset(
currentLimit: current.query.limit,
total: current.total,
);
if (limit == null) return;
await applyQuery(
current.query.copyWith(search: null, page: 1, limit: limit),
);
}
void clearColumnSearchDataset() {
final current = state.valueOrNull;
if (current == null) return;
final limit = _columnSearch.endFullDataset();
if (limit == null) return;
applyQuery(current.query.copyWith(search: null, page: 1, limit: limit));
}
void setStatusFilter(String? status) {
final current = state.valueOrNull;
if (current == null) return;

View File

@ -12,14 +12,12 @@ import '../../../../shared/providers/permissions_provider.dart';
import '../../../../shared/utils/file_download_helper.dart';
import '../../../../shared/widgets/app_text_field.dart';
import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/detail_overview_widgets.dart';
import '../../../../shared/widgets/error_view.dart';
import '../providers/grn_lookups_provider.dart';
import '../providers/grn_provider.dart';
import '../widgets/grn_attachments_card.dart';
import '../widgets/grn_line_items_editor.dart';
import '../widgets/grn_status_chip.dart';
import '../../../../shared/widgets/api_feedback.dart';
import '../../../../shared/widgets/app_toast.dart';
class GrnDetailScreen extends ConsumerStatefulWidget {
@ -34,24 +32,6 @@ class GrnDetailScreen extends ConsumerStatefulWidget {
class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> {
bool _isWorking = false;
bool _isDownloadingPdf = false;
bool _requestedFreshLoad = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_requestedFreshLoad) return;
_requestedFreshLoad = true;
// Always hit GET /grn/{id} when opening view.
ref.invalidate(grnDetailProvider(widget.grnId));
}
@override
void didUpdateWidget(covariant GrnDetailScreen oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.grnId != widget.grnId) {
ref.invalidate(grnDetailProvider(widget.grnId));
}
}
@override
Widget build(BuildContext context) {
@ -64,7 +44,7 @@ class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> {
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface,
body: detailAsync.when(
loading: () => const AppLoadingView(message: 'Loading Purchase Receipt...'),
loading: () => const AppLoadingView(message: 'Loading GRN...'),
error: (e, _) => ErrorView.fromFailure(
e is Failure ? e : Failure.unknown(message: e.toString()),
onRetry: () => ref.invalidate(grnDetailProvider(widget.grnId)),
@ -72,10 +52,13 @@ class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> {
data: (grn) {
final lookups = lookupsAsync.asData?.value;
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
padding: const EdgeInsets.fromLTRB(24, 16, 24, 32),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1200),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_DetailHeader(
grn: grn,
isWorking: _isWorking,
@ -117,6 +100,8 @@ class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> {
const SizedBox(height: 20),
_DetailFooter(grn: grn),
],
),
),
),
);
},
@ -136,10 +121,7 @@ class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> {
}
} catch (e) {
if (mounted) {
showAppToastFromSnackBar(
context,
SnackBar(content: Text(errorDisplayMessage(e))),
);
showAppToastFromSnackBar(context, SnackBar(content: Text(e.toString())));
}
} finally {
if (mounted) setState(() => _isWorking = false);
@ -151,7 +133,7 @@ class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Cancel Purchase Receipt'),
title: const Text('Cancel GRN'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
@ -176,7 +158,7 @@ class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> {
if (reasonController.text.trim().isEmpty) return;
Navigator.pop(context, true);
},
child: const Text('Cancel Purchase Receipt'),
child: const Text('Cancel GRN'),
),
],
),
@ -187,7 +169,7 @@ class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> {
() => ref.read(grnDetailProvider(widget.grnId).notifier).cancel(
cancellationReason: reasonController.text.trim(),
),
'Purchase Receipt cancelled',
'GRN cancelled',
);
reasonController.dispose();
}
@ -200,17 +182,14 @@ class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> {
.downloadPdf();
await downloadFile(
bytes: bytes,
fileName: '${grn.grnNumber ?? 'PurchaseReceipt-${grn.id}'}.pdf',
fileName: '${grn.grnNumber ?? 'GRN-${grn.id}'}.pdf',
);
if (mounted) {
showAppToastFromSnackBar(context, const SnackBar(content: Text('PDF downloaded')));
}
} catch (e) {
if (mounted) {
showAppToastFromSnackBar(
context,
SnackBar(content: Text(errorDisplayMessage(e))),
);
showAppToastFromSnackBar(context, SnackBar(content: Text(e.toString())));
}
} finally {
if (mounted) setState(() => _isDownloadingPdf = false);
@ -301,7 +280,7 @@ class _DetailHeader extends StatelessWidget {
children: [
Flexible(
child: Text(
grn.grnNumber ?? 'Purchase Receipt #${grn.id}',
grn.grnNumber ?? 'GRN #${grn.id}',
style: theme.textTheme.headlineSmall,
overflow: TextOverflow.ellipsis,
),
@ -529,113 +508,124 @@ class _ReceiptDetailsCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final locationDisplay = _displayOrDash(
[
if (grn.locationName?.trim().isNotEmpty == true)
grn.locationName!.trim(),
if (grn.locationType?.trim().isNotEmpty == true)
'(${grn.locationType!.trim()})',
].join(' ').trim(),
);
return _SectionCard(
title: 'RECEIPT DETAILS',
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
DetailOverviewSection(
title: 'Summary',
child: DetailSummaryStrip(
metrics: [
DetailSummaryMetric(
icon: Icons.flag_outlined,
label: 'Status',
accent: scheme.secondary,
child: GrnStatusChip(status: grn.status, compact: true),
),
DetailSummaryMetric(
icon: Icons.calendar_today_outlined,
label: 'Purchase Receipt Date',
child: Text(
DateFormatter.displayDate(grn.grnDate),
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
DetailSummaryMetric(
icon: Icons.receipt_long_outlined,
label: 'PO Number',
child: Text(
_displayOrDash(grn.poNumber),
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
DetailSummaryMetric(
icon: Icons.payments_outlined,
label: 'Invoice Amount',
accent: scheme.primary,
child: Text(
grn.vendorInvoiceAmount != null
? CurrencyFormatter.format(grn.vendorInvoiceAmount!)
: '—',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800,
),
),
),
],
child: LayoutBuilder(
builder: (context, constraints) {
final cols = constraints.maxWidth < 600
? 1
: constraints.maxWidth < 900
? 2
: 4;
const spacing = 20.0;
final width = (constraints.maxWidth - spacing * (cols - 1)) / cols;
final items = [
_DetailField(
label: 'GRN Date',
value: DateFormatter.displayDate(grn.grnDate),
),
),
DetailOverviewSection(
title: 'Receipt',
child: DetailInfoGrid(
items: [
DetailInfoItem('Vendor', _displayOrDash(grn.vendorName)),
DetailInfoItem('Location', locationDisplay),
DetailInfoItem(
'Vendor Invoice No',
_displayOrDash(grn.vendorInvoiceNo),
),
DetailInfoItem(
'Vendor Invoice Date',
DateFormatter.displayDate(grn.vendorInvoiceDate),
),
DetailInfoItem(
'Received By',
_userLabel(lookups?.users, grn.receivedBy),
),
DetailInfoItem(
'Quality Checked By',
_userLabel(lookups?.users, grn.qualityCheckedBy),
),
],
_DetailField(
label: 'PO Number',
value: _displayOrDash(grn.poNumber),
),
),
DetailOverviewSection(
title: 'Transport',
showDivider: false,
child: DetailInfoGrid(
items: [
DetailInfoItem('Vehicle No', _displayOrDash(grn.vehicleNo)),
DetailInfoItem('LR No', _displayOrDash(grn.lrNo)),
DetailInfoItem(
'LR Date',
DateFormatter.displayDate(grn.lrDate),
),
],
_DetailField(
label: 'Vendor',
value: _displayOrDash(grn.vendorName),
),
),
],
_DetailField(
label: 'Location',
value: _displayOrDash(
[
if (grn.locationName?.trim().isNotEmpty == true)
grn.locationName!.trim(),
if (grn.locationType?.trim().isNotEmpty == true)
'(${grn.locationType!.trim()})',
].join(' ').trim(),
),
),
_DetailField(
label: 'Vendor Invoice No',
value: _displayOrDash(grn.vendorInvoiceNo),
),
_DetailField(
label: 'Vendor Invoice Date',
value: DateFormatter.displayDate(grn.vendorInvoiceDate),
),
_DetailField(
label: 'Vendor Invoice Amount',
value: grn.vendorInvoiceAmount != null
? CurrencyFormatter.format(grn.vendorInvoiceAmount!)
: '—',
),
_DetailField(
label: 'Vehicle No',
value: _displayOrDash(grn.vehicleNo),
),
_DetailField(
label: 'LR No',
value: _displayOrDash(grn.lrNo),
),
_DetailField(
label: 'LR Date',
value: DateFormatter.displayDate(grn.lrDate),
),
_DetailField(
label: 'Received By',
value: _userLabel(lookups?.users, grn.receivedBy),
),
_DetailField(
label: 'Quality Checked By',
value: _userLabel(lookups?.users, grn.qualityCheckedBy),
),
];
return Wrap(
spacing: spacing,
runSpacing: 16,
children: items
.map((item) => SizedBox(width: width, child: item))
.toList(),
);
},
),
);
}
}
class _DetailField extends StatelessWidget {
const _DetailField({
required this.label,
required this.value,
});
final String label;
final String value;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
Text(
value,
style: theme.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
],
);
}
}
class _LineItemsCard extends StatelessWidget {
const _LineItemsCard({required this.grn});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -22,7 +22,6 @@ class MasterFieldDef {
this.filterByOptionKey,
this.visibleWhenFieldKey,
this.visibleWhenValue,
this.listNestedKey,
});
final String key;
@ -34,7 +33,7 @@ class MasterFieldDef {
final bool showInForm;
/// When true, shown in the form but not editable (value set by other fields).
final bool readOnly;
/// Master key used to populate dropdown options (e.g. `locations` for FKs).
/// Master key used to populate dropdown options (e.g. `plants` for plant_id).
final String? optionsMasterKey;
/// Extra query parameters when loading [optionsMasterKey] options.
final Map<String, dynamic>? optionsQueryParams;
@ -48,8 +47,6 @@ class MasterFieldDef {
/// Show this field only when [visibleWhenFieldKey] equals [visibleWhenValue].
final String? visibleWhenFieldKey;
final String? visibleWhenValue;
/// Nested object key on list rows for display (e.g. `gst_rate` for `gst_rate_id`).
final String? listNestedKey;
/// Cache key for dropdown option rows (includes query params when set).
String get dropdownLookupKey {
@ -226,14 +223,13 @@ const masterDefinitions = <MasterDefinition>[
showInList: true,
showInForm: false,
),
MasterFieldDef(key: 'item_name', label: 'Item Name', required: true, showInList: true),
MasterFieldDef(
key: 'is_asset_item',
label: 'Asset Item',
type: MasterFieldType.boolean,
required: true,
showInList: true,
),
MasterFieldDef(key: 'item_name', label: 'Item Name', required: true, showInList: true),
MasterFieldDef(
key: 'item_category_id',
label: 'Category',
@ -276,17 +272,12 @@ const masterDefinitions = <MasterDefinition>[
label: 'Min Order Qty',
type: MasterFieldType.number,
required: true,
// Stock items only — hidden when Asset Item is checked.
visibleWhenFieldKey: 'is_asset_item',
visibleWhenValue: 'false',
),
MasterFieldDef(
key: 'reorder_level',
label: 'Reorder Level',
type: MasterFieldType.number,
required: true,
visibleWhenFieldKey: 'is_asset_item',
visibleWhenValue: 'false',
),
MasterFieldDef(
key: 'tags',
@ -377,51 +368,31 @@ const masterDefinitions = <MasterDefinition>[
MasterFieldDef(key: 'code', label: 'Code', required: true, showInList: true),
MasterFieldDef(key: 'name', label: 'Name', required: true, showInList: true),
MasterFieldDef(
key: 'gstin',
label: 'GSTIN',
key: 'parent_id',
label: 'Parent Plant',
type: MasterFieldType.dropdown,
showInList: true,
optionsMasterKey: 'locations',
optionsQueryParams: const {'type': 'plant'},
visibleWhenFieldKey: 'type',
visibleWhenValue: 'plant',
),
MasterFieldDef(
key: 'city',
label: 'City',
showInList: true,
visibleWhenFieldKey: 'type',
visibleWhenValue: 'plant',
visibleWhenValue: 'warehouse',
required: true,
),
MasterFieldDef(key: 'gstin', label: 'GSTIN', showInList: true),
MasterFieldDef(key: 'city', label: 'City', showInList: true),
MasterFieldDef(
key: 'state',
label: 'State',
type: MasterFieldType.dropdown,
optionsMasterKey: 'location_states',
showInList: true,
visibleWhenFieldKey: 'type',
visibleWhenValue: 'plant',
),
MasterFieldDef(
key: 'address',
label: 'Address',
multiline: true,
visibleWhenFieldKey: 'type',
visibleWhenValue: 'plant',
),
MasterFieldDef(
key: 'pincode',
label: 'Pincode',
visibleWhenFieldKey: 'type',
visibleWhenValue: 'plant',
),
MasterFieldDef(
key: 'phone',
label: 'Phone',
visibleWhenFieldKey: 'type',
visibleWhenValue: 'plant',
),
MasterFieldDef(key: 'address', label: 'Address', multiline: true),
MasterFieldDef(key: 'pincode', label: 'Pincode'),
MasterFieldDef(key: 'phone', label: 'Phone'),
MasterFieldDef(
key: 'location',
label: 'Location Detail',
showInList: true,
visibleWhenFieldKey: 'type',
visibleWhenValue: 'warehouse',
),
@ -592,10 +563,6 @@ String masterCellValue(Map<String, dynamic> row, MasterFieldDef field) {
final value = row[field.key];
if (value == null || value == '') return '—';
if (field.key == 'is_asset_item') {
return masterIsAssetItem(value) ? 'Asset' : 'Stock';
}
if (field.key == 'tags') {
if (value is List) {
final tags = value
@ -639,25 +606,18 @@ String masterCellValue(Map<String, dynamic> row, MasterFieldDef field) {
if (explicitName != null && explicitName.toString().trim().isNotEmpty) {
return explicitName.toString().trim();
}
final nestedKeys = <String>[
if (field.listNestedKey != null) field.listNestedKey!,
baseKey,
];
for (final nestedKey in nestedKeys) {
final nested = row[nestedKey];
if (nested is Map) {
for (final nameKey in ['name', 'code', 'description']) {
final nestedValue = nested[nameKey];
if (nestedValue != null &&
nestedValue.toString().trim().isNotEmpty) {
return nestedValue.toString().trim();
}
final nested = row[baseKey];
if (nested is Map) {
for (final nestedKey in ['name', 'code', 'description']) {
final nestedValue = nested[nestedKey];
if (nestedValue != null &&
nestedValue.toString().trim().isNotEmpty) {
return nestedValue.toString().trim();
}
} 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();
}
}
@ -675,13 +635,6 @@ String masterStatusValue(Map<String, dynamic> row) {
return 'active';
}
bool masterIsAssetItem(Object? value) {
if (value == true || value == 1) return true;
if (value == false || value == 0) return false;
final text = value?.toString().trim().toLowerCase() ?? '';
return text == 'true' || text == '1' || text == 'yes';
}
/// Category list filter for Items form: ASSET when Asset Item is checked.
String itemCategoryTypeForValues(Map<String, dynamic> values) =>
values['is_asset_item'] == true ? 'ASSET' : 'STOCK';
@ -717,28 +670,6 @@ String masterFieldDropdownLookupKey({
return '$masterKey?$query';
}
/// Placeholder / hint casing for master field labels (keeps UOM, HSN, GST, etc.).
String masterFieldHintLabel(String label) {
const acronyms = {
'uom': 'UOM',
'hsn': 'HSN',
'gst': 'GST',
'sac': 'SAC',
'po': 'PO',
'grn': 'Purchase Receipt',
'amc': 'AMC',
};
return label
.trim()
.split(RegExp(r'\s+'))
.where((part) => part.isNotEmpty)
.map((part) {
final lower = part.toLowerCase();
return acronyms[lower] ?? lower;
})
.join(' ');
}
/// Display label for GST filled from HSN nested `gst_rate.description`.
String? gstRateDisplayFromValues(Map<String, dynamic> values) {
final nested = values['gst_rate'];

View File

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

View File

@ -1,48 +0,0 @@
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,15 +1,12 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/constants/app_constants.dart';
import '../../../../core/utils/column_search_paging.dart';
import '../../../../core/utils/pagination_meta.dart';
import '../../../../core/utils/table_search.dart';
import '../../../../shared/models/export_file_result.dart';
import '../../../assets/data/repositories/asset_repository_impl.dart';
import '../../../masters/data/datasources/master_remote_data_source.dart';
import '../../data/repositories/master_repository_impl.dart';
import '../../domain/entities/master_definition.dart';
import 'master_consumer_invalidation.dart';
class MasterListState {
const MasterListState({
@ -97,10 +94,6 @@ final masterListProvider = AsyncNotifierProvider.family<
MasterListNotifier, MasterListState, String>(MasterListNotifier.new);
class MasterListNotifier extends FamilyAsyncNotifier<MasterListState, String> {
final _columnSearch = ColumnSearchPaging(
defaultLimit: AppConstants.defaultPageSize,
);
MasterDefinition get _definition {
final def = masterDefinitionById(arg);
if (def == null) throw StateError('Unknown master: $arg');
@ -121,59 +114,45 @@ class MasterListNotifier extends FamilyAsyncNotifier<MasterListState, String> {
final current = state.valueOrNull;
final nextPage = page ?? current?.page ?? 1;
final nextLimit = limit ?? current?.limit ?? AppConstants.defaultPageSize;
final nextSearch = TableSearch.normalize(search ?? current?.search);
final repository = ref.read(masterRepositoryProvider);
final nextSearch = search ?? current?.search;
final result = await repository.list(
_definition,
page: nextPage,
limit: nextLimit,
search: nextSearch.isEmpty ? null : nextSearch,
);
final result = await ref.read(masterRepositoryProvider).list(
_definition,
page: nextPage,
limit: nextLimit,
search: nextSearch,
);
if (result.failure != null) throw result.failure!;
var items = List<Map<String, dynamic>>.from(result.data!.items);
// API `search` often ignores related/enum fields (e.g. category_type).
// Supplement with dedicated filters and client-side multi-field matching.
if (nextSearch.isNotEmpty) {
if (_definition.id == 'item_categories') {
var typeMatches = 0;
for (final type in categoryTypeOptions) {
if (!TableSearch.matches(nextSearch, [type])) continue;
if (++typeMatches > 5) break;
final typed = await repository.list(
_definition,
page: nextPage,
limit: nextLimit,
extraQueryParameters: {'category_type': type},
);
if (typed.failure == null && typed.data != null) {
items = TableSearch.mergeById(
items,
typed.data!.items,
(row) => row['id']?.toString() ?? '',
);
}
}
}
// Keep API page results + enriched matches; pagination stays server-driven.
}
final data = result.data!;
return MasterListState(
items: items,
search: nextSearch,
items: data.items,
search: nextSearch ?? '',
page: data.page,
// Keep the requested page size so the /page dropdown stays valid
// even if API meta omits or mismatches `limit`.
limit: nextLimit,
total: data.total,
totalPages: resolveTotalPages(total: data.total, limit: nextLimit),
totalPages: _resolveTotalPages(
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 {
final previous = state.valueOrNull;
if (previous == null) {
@ -190,32 +169,6 @@ class MasterListNotifier extends FamilyAsyncNotifier<MasterListState, String> {
await _reload(page: 1, search: search);
}
Future<void> ensureColumnSearchDataset() async {
final current = state.valueOrNull;
if (current == null) return;
final limit = _columnSearch.beginFullDataset(
currentLimit: current.limit,
total: current.total,
);
if (limit == null) return;
await _reload(page: 1, limit: limit, search: '');
}
void clearColumnSearchDataset() {
final current = state.valueOrNull;
if (current == null) return;
final limit = _columnSearch.endFullDataset();
if (limit == null) return;
_reload(page: 1, limit: limit, search: '');
}
/// Clears generic search and reloads the full list.
Future<void> clearSearch() async {
final current = state.valueOrNull;
if (current != null && current.search.isEmpty) return;
await _reload(page: 1, search: '');
}
Future<void> setPage(int page) async {
await _reload(page: page);
}
@ -249,7 +202,6 @@ class MasterListNotifier extends FamilyAsyncNotifier<MasterListState, String> {
}
await refresh();
invalidateMasterConsumerLookups(ref.invalidate, _definition.id);
return true;
}
@ -327,8 +279,6 @@ class MasterFormNotifier extends FamilyAsyncNotifier<MasterFormState, MasterForm
}
}
_sanitizeLocationValues(values);
// Load after values so Items category options use is_asset_item.
final dropdownOptions = await _loadDropdownOptions(values: values);
@ -483,14 +433,6 @@ class MasterFormNotifier extends FamilyAsyncNotifier<MasterFormState, MasterForm
}
}
/// Warehouses are no longer nested under plants — drop obsolete keys.
void _sanitizeLocationValues(Map<String, dynamic> values) {
if (_definition.id != 'locations') return;
values.remove('parent_id');
values.remove('plant_id');
values.remove('plant');
}
void updateValue(String key, dynamic value) {
final current = state.valueOrNull;
if (current == null) return;
@ -505,17 +447,10 @@ class MasterFormNotifier extends FamilyAsyncNotifier<MasterFormState, MasterForm
_clearHiddenFieldValues(values);
}
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).
// Asset Item toggles Items category list between STOCK / ASSET.
if (key == 'is_asset_item' && _definition.id == 'items') {
values['item_category_id'] = null;
values['item_subcategory_id'] = null;
_clearHiddenFieldValues(values);
state = AsyncData(current.copyWith(values: values));
_reloadItemCategoryOptions(values);
return;
@ -561,11 +496,7 @@ class MasterFormNotifier extends FamilyAsyncNotifier<MasterFormState, MasterForm
final current = state.valueOrNull;
if (current == null) return;
final options = await _loadDropdownOptions(values: current.values);
// 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));
state = AsyncData(current.copyWith(dropdownOptions: options));
}
Map<String, dynamic> _buildPayload(MasterFormState current) {
@ -592,14 +523,6 @@ class MasterFormNotifier extends FamilyAsyncNotifier<MasterFormState, MasterForm
MasterFieldType.text => value.toString().trim(),
};
}
// Never send plant nesting fields for locations (API breaking change).
if (_definition.id == 'locations') {
payload.remove('parent_id');
payload.remove('plant_id');
payload.remove('plant');
}
return payload;
}
@ -646,30 +569,8 @@ class MasterFormNotifier extends FamilyAsyncNotifier<MasterFormState, MasterForm
}
state = AsyncData(current.copyWith(isSubmitting: false, clearError: true));
// 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);
final createdId = result.data?['id']?.toString();
if (createdId != null && createdId.isNotEmpty) return createdId;
return arg.recordId ?? 'created';
}
String? _readCreatedId(Map<String, dynamic>? data) {
if (data == null) return null;
for (final key in const ['id', 'ID', 'gst_rate_id']) {
final value = data[key];
if (value != null && value.toString().trim().isNotEmpty) {
return value.toString().trim();
}
}
final nested = data['data'];
if (nested is Map) {
final value = nested['id'];
if (value != null && value.toString().trim().isNotEmpty) {
return value.toString().trim();
}
}
return null;
}
}

View File

@ -7,23 +7,21 @@ import '../../../../core/constants/route_constants.dart';
import '../../../../core/errors/failure.dart';
import '../../../../shared/utils/file_download_helper.dart';
import '../../../../shared/providers/permissions_provider.dart';
import '../../../../shared/providers/table_column_prefs_provider.dart';
import '../../../../shared/widgets/app_confirmation_dialog.dart';
import '../../../../shared/widgets/app_data_table.dart';
import '../../../../shared/widgets/app_empty_state.dart';
import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_pagination.dart';
import '../../../../shared/widgets/app_search_export_bar.dart';
import '../../../../shared/widgets/app_search_filter_toggle.dart';
import '../../../../shared/widgets/app_status_chip.dart';
import '../../../../shared/widgets/app_table_action_icon.dart';
import '../../../../shared/widgets/app_table_column_selector.dart';
import '../../../../shared/widgets/app_table_shell.dart';
import '../../../../shared/widgets/error_view.dart';
import '../../../../shared/widgets/app_side_panel.dart';
import '../../../../shared/widgets/page_header.dart';
import '../../domain/entities/master_definition.dart';
import '../providers/master_provider.dart';
import '../providers/master_consumer_invalidation.dart';
import '../widgets/master_form_panel.dart';
import '../../../../shared/widgets/app_toast.dart';
@ -39,7 +37,6 @@ class MasterListScreen extends ConsumerStatefulWidget {
class _MasterListScreenState extends ConsumerState<MasterListScreen> {
final _searchController = TextEditingController();
bool _filtersExpanded = false;
bool _didResetSearchOnOpen = false;
MasterDefinition get _definition {
final def = masterDefinitionById(widget.masterId);
@ -47,24 +44,6 @@ class _MasterListScreenState extends ConsumerState<MasterListScreen> {
return def;
}
@override
void initState() {
super.initState();
// keepAlive can restore a previous search after All Masters → another master.
// Always open list screens with a clean generic search.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _didResetSearchOnOpen) return;
_didResetSearchOnOpen = true;
_searchController.clear();
final notifier = ref.read(masterListProvider(widget.masterId).notifier);
final existing =
ref.read(masterListProvider(widget.masterId)).valueOrNull;
if (existing != null && existing.search.isNotEmpty) {
notifier.clearSearch();
}
});
}
@override
void dispose() {
_searchController.dispose();
@ -128,7 +107,7 @@ class _MasterListScreenState extends ConsumerState<MasterListScreen> {
width: 560,
);
if (saved != null && mounted) {
invalidateMasterConsumerLookups(ref.invalidate, widget.masterId);
ref.invalidate(masterListProvider(widget.masterId));
showAppToastFromSnackBar(context,
SnackBar(
content: Text(
@ -158,10 +137,6 @@ class _MasterListScreenState extends ConsumerState<MasterListScreen> {
await ref.read(masterListProvider(widget.masterId).notifier).deleteRecord(id);
if (!mounted) return;
if (success) {
invalidateMasterConsumerLookups(ref.invalidate, widget.masterId);
}
showAppToastFromSnackBar(context,
SnackBar(
content: Text(
@ -206,11 +181,6 @@ class _MasterListScreenState extends ConsumerState<MasterListScreen> {
() => _filtersExpanded = !_filtersExpanded,
),
),
const SizedBox(width: 8),
AppTableColumnSelectorButton(
tableId: _MasterListTable.tableIdFor(def),
columns: _MasterListTable.columnOptionsFor(def),
),
if (canExport) ...[
const SizedBox(width: 8),
OutlinedButton.icon(
@ -227,13 +197,7 @@ class _MasterListScreenState extends ConsumerState<MasterListScreen> {
],
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: () {
_searchController.clear();
ref
.read(masterListProvider(widget.masterId).notifier)
.clearSearch();
context.go(RouteConstants.masterData);
},
onPressed: () => context.push(RouteConstants.masterData),
icon: const Icon(Icons.grid_view_outlined),
label: const Text('All Masters'),
),
@ -261,26 +225,36 @@ class _MasterListScreenState extends ConsumerState<MasterListScreen> {
totalPages: state.totalPages,
totalItems: state.total,
pageSize: state.limit,
itemsOnPage: state.items.length,
itemLabel: def.title.toLowerCase(),
onPageChanged: notifier.setPage,
onPageSizeChanged: notifier.setPageSize,
),
child: RefreshIndicator(
onRefresh: notifier.refresh,
child: _MasterListTable(
definition: def,
items: state.items,
isDeleting: state.isDeleting,
canEdit: canEdit,
canDelete: canDelete,
onEdit: (id) => _openFormPanel(recordId: id),
onDelete: _deleteRecord,
onEnsureFullDataset: () =>
notifier.ensureColumnSearchDataset(),
onColumnSearchCleared: () =>
notifier.clearColumnSearchDataset(),
),
child: state.items.isEmpty
? ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: [
SizedBox(
height: 240,
child: AppEmptyState(
title: 'No ${def.title.toLowerCase()} found',
description:
'Add your first ${def.title.toLowerCase()} record to get started.',
icon: def.icon,
),
),
],
)
: _MasterListTable(
definition: def,
items: state.items,
isDeleting: state.isDeleting,
canEdit: canEdit,
canDelete: canDelete,
onEdit: (id) => _openFormPanel(recordId: id),
onDelete: _deleteRecord,
),
),
),
),
@ -292,7 +266,7 @@ class _MasterListScreenState extends ConsumerState<MasterListScreen> {
}
}
class _MasterListTable extends ConsumerWidget {
class _MasterListTable extends StatelessWidget {
const _MasterListTable({
required this.definition,
required this.items,
@ -301,8 +275,6 @@ class _MasterListTable extends ConsumerWidget {
required this.canDelete,
required this.onEdit,
required this.onDelete,
this.onEnsureFullDataset,
this.onColumnSearchCleared,
});
final MasterDefinition definition;
@ -312,114 +284,61 @@ class _MasterListTable extends ConsumerWidget {
final bool canDelete;
final ValueChanged<String> onEdit;
final ValueChanged<Map<String, dynamic>> onDelete;
final Future<void> Function()? onEnsureFullDataset;
final VoidCallback? onColumnSearchCleared;
static String tableIdFor(MasterDefinition definition) =>
'master_${definition.id}';
static String _fieldLabel(MasterFieldDef field) =>
field.key == 'is_asset_item' ? 'Type' : field.label;
static List<AppTableColumnOption> columnOptionsFor(
MasterDefinition definition,
) {
final fields = definition.listFields;
return [
for (var i = 0; i < fields.length; i++)
AppTableColumnOption(
id: fields[i].key,
label: _fieldLabel(fields[i]),
required: i == 0,
),
const AppTableColumnOption(id: 'status', label: 'Status'),
];
}
List<AppDataColumn<Map<String, dynamic>>> _allColumns(ThemeData theme) {
final fields = definition.listFields;
return [
for (var i = 0; i < fields.length; i++)
AppDataColumn<Map<String, dynamic>>(
id: fields[i].key,
label: _fieldLabel(fields[i]),
sortKey: fields[i].key,
locked: i == 0,
flex: _columnFlex(fields[i]),
searchText: (row) => masterCellValue(row, fields[i]),
cellBuilder: (_, row) {
final field = fields[i];
if (field.key == 'is_asset_item') {
final isAsset = masterIsAssetItem(row['is_asset_item']);
return TableStatusBadge(
label: isAsset ? 'Asset' : 'Stock',
color: isAsset
? const Color(0xFF2563EB)
: const Color(0xFF64748B),
compact: true,
);
}
return Text(masterCellValue(row, field));
},
),
AppDataColumn(
id: 'status',
label: 'Status',
sortKey: 'status',
flex: 1,
searchText: (row) => masterStatusValue(row),
cellBuilder: (_, row) => AppStatusChip(
status: masterStatusValue(row),
compact: true,
forTable: true,
),
),
AppDataColumn(
id: 'actions',
label: 'Actions',
flex: 1,
alignment: Alignment.centerRight,
enableSearch: false,
locked: true,
includeInColumnSelector: false,
cellBuilder: (_, row) {
final id = row['id']?.toString();
return AppTableActions(
children: [
if (canEdit)
AppTableActionIcon(
tooltip: 'Edit',
icon: Icons.edit_outlined,
enabled: id != null,
onPressed: () => onEdit(id!),
),
if (canDelete)
AppTableActionIcon(
tooltip: 'Delete',
icon: Icons.delete_outline,
color: theme.colorScheme.error,
enabled: !isDeleting,
onPressed: () => onDelete(row),
),
],
);
},
),
];
}
@override
Widget build(BuildContext context, WidgetRef ref) {
Widget build(BuildContext context) {
final theme = Theme.of(context);
final prefs = ref.watch(tableColumnPrefsProvider(tableIdFor(definition)));
final columns = resolveAppDataColumns(_allColumns(theme), prefs);
return AppDataTable<Map<String, dynamic>>(
wrapInCard: false,
onEnsureFullDataset: onEnsureFullDataset,
onColumnSearchCleared: onColumnSearchCleared,
emptyMessage: 'No ${definition.title.toLowerCase()} found',
columns: columns,
columns: [
...definition.listFields.map(
(field) => AppDataColumn<Map<String, dynamic>>(
label: field.label,
flex: _columnFlex(field),
searchText: (row) => masterCellValue(row, field),
cellBuilder: (_, row) => Text(masterCellValue(row, field)),
),
),
AppDataColumn(
label: 'Status',
flex: 1,
searchText: (row) => masterStatusValue(row),
cellBuilder: (_, row) => AppStatusChip(
status: masterStatusValue(row),
compact: true,
forTable: true,
),
),
AppDataColumn(
label: 'Actions',
flex: 1,
alignment: Alignment.centerRight,
enableSearch: false,
cellBuilder: (_, row) {
final id = row['id']?.toString();
return AppTableActions(
children: [
if (canEdit)
AppTableActionIcon(
tooltip: 'Edit',
icon: Icons.edit_outlined,
enabled: id != null,
onPressed: () => onEdit(id!),
),
if (canDelete)
AppTableActionIcon(
tooltip: 'Delete',
icon: Icons.delete_outline,
color: theme.colorScheme.error,
enabled: !isDeleting,
onPressed: () => onDelete(row),
),
],
);
},
),
],
rows: items,
);
}
@ -427,7 +346,6 @@ class _MasterListTable extends ConsumerWidget {
int _columnFlex(MasterFieldDef field) {
return switch (field.key) {
'code' || 'item_code' || 'series_code' || 'hsn_code_id' => 1,
'is_asset_item' => 1,
'name' || 'item_name' || 'description' || 'term_name' => 3,
_ => 2,
};

View File

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

View File

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

View File

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

View File

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

View File

@ -5,7 +5,6 @@ import '../../../../core/constants/api_endpoints.dart';
import '../../../../core/constants/app_constants.dart';
import '../../../../core/network/dio_client.dart';
import '../../../../core/utils/active_option.dart';
import '../../../../core/utils/pagination_meta.dart';
import '../../../../shared/models/user_management_models.dart';
final masterRemoteDataSourceProvider = Provider<MasterRemoteDataSource>((ref) {
@ -72,11 +71,6 @@ class MasterRemoteDataSource {
for (final item in rows) {
if (!isActiveOptionRow(item)) continue;
// Ignore obsolete plant nesting fields if the API still returns them.
item.remove('parent_id');
item.remove('plant_id');
item.remove('plant');
final id = item['id']?.toString() ?? '';
final name = _optionLabel(item);
if (id.isEmpty || name.isEmpty) continue;
@ -106,39 +100,6 @@ class MasterRemoteDataSource {
Future<List<FilterOptionModel>> listPaymentTerms() =>
_listOptions(ApiEndpoints.paymentTerms);
/// Payment terms with `credit_days` for vendor credit-period autofill.
Future<
({
List<FilterOptionModel> options,
Map<int, int> creditDaysById,
})> listPaymentTermsWithCreditDays() async {
final rows = await _listAllMaps(ApiEndpoints.paymentTerms);
final options = <FilterOptionModel>[];
final creditDaysById = <int, int>{};
for (final item in rows) {
if (!isActiveOptionRow(item)) continue;
final id = item['id']?.toString() ?? '';
if (id.isEmpty) continue;
final name = _optionLabel(item);
if (name.isEmpty) continue;
final idInt = int.tryParse(id);
final creditDays = _asInt(
item['credit_days'] ??
item['credit_period_days'] ??
item['days'],
);
if (idInt != null && creditDays != null) {
creditDaysById[idInt] = creditDays;
}
options.add(FilterOptionModel(id: id, name: name));
}
return (options: options, creditDaysById: creditDaysById);
}
Future<List<FilterOptionModel>> listDeliveryTerms() =>
_listOptions(ApiEndpoints.deliveryTerms);
@ -370,12 +331,20 @@ class MasterRemoteDataSource {
}
final raw = body['data'];
final meta = body['meta'] is Map
? Map<String, dynamic>.from(body['meta'] as Map)
: <String, dynamic>{};
List list;
Map<String, dynamic> pageMeta = meta;
if (raw is List) {
list = raw;
} else if (raw is Map) {
final items = raw['items'];
final map = Map<String, dynamic>.from(raw);
final items = map['items'];
list = items is List ? items : const [];
pageMeta = {...meta, ...map};
} else {
list = const [];
}
@ -385,20 +354,14 @@ class MasterRemoteDataSource {
.map((item) => Map<String, dynamic>.from(item))
.toList();
final pagination = parsePagination(
body: body,
fallbackPage: 1,
fallbackLimit: fallbackLimit,
itemCount: items.length,
);
final total = _asInt(pageMeta['total']) ?? items.length;
final limit = _asInt(pageMeta['limit']) ?? fallbackLimit;
final explicitTotalPages = _asInt(pageMeta['totalPages']) ??
_asInt(pageMeta['total_pages']);
final totalPages = explicitTotalPages ??
(limit > 0 ? ((total + limit - 1) ~/ limit).clamp(1, 999999) : 1);
return (
items: items,
totalPages: resolveTotalPages(
total: pagination.total,
limit: fallbackLimit,
),
);
return (items: items, totalPages: totalPages < 1 ? 1 : totalPages);
}
int? _asInt(dynamic value) {

View File

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

View File

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

View File

@ -27,18 +27,13 @@ abstract class PurchaseOrderRepository {
Future<Result<PurchaseOrderModel>> approvePurchaseOrder(String id, {String? remarks});
Future<Result<PurchaseOrderModel>> rejectPurchaseOrder(
String id, {
required String rejectReason,
required String remarks,
});
Future<Result<PurchaseOrderModel>> amendPurchaseOrder(
String id, {
Map<String, dynamic>? data,
});
Future<Result<PurchaseOrderModel>> cancelPurchaseOrder(String id, {String? remarks});
/// Triggers `PO_SUBMIT_APPROVAL` email for a pending-approval PO.
/// Returns the API success message when available.
Future<Result<String>> triggerApprovalNotification(String poId);
Future<Result<List<int>>> downloadPurchaseOrderPdf(String id);
Future<Result<List<EntityAttachmentModel>>> listAttachments(String poId);
Future<Result<EntityAttachmentModel>> uploadAttachment(

View File

@ -1,26 +1,13 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/network/api_handler.dart';
import '../../../../core/utils/active_option.dart';
import '../../../../core/utils/column_search_paging.dart';
import '../../../../core/utils/table_search.dart';
import '../../../../shared/models/api_response.dart';
import '../../../../shared/models/entity_attachment_model.dart';
import '../../../../shared/models/export_file_result.dart';
import '../../../../shared/models/purchase_order_model.dart';
import '../../../../shared/models/vendor_model.dart';
import '../../../grn/presentation/providers/grn_lookups_provider.dart';
import '../../../vendors/data/repositories/vendor_repository_impl.dart';
import '../../data/repositories/purchase_order_repository_impl.dart';
/// Cached vendor options for list search enrichment only (not full PO lookups).
final _poListVendorOptionsProvider =
FutureProvider<List<VendorModel>>((ref) async {
final result = await ref.watch(vendorRepositoryProvider).listVendorOptions();
if (result.failure != null) return const [];
return result.data ?? const [];
});
class PurchaseOrdersListState {
const PurchaseOrdersListState({
this.orders = const [],
@ -79,8 +66,6 @@ final pendingApprovalPurchaseOrdersListProvider =
class PurchaseOrdersListNotifier
extends AutoDisposeAsyncNotifier<PurchaseOrdersListState> {
final _columnSearch = ColumnSearchPaging();
@override
Future<PurchaseOrdersListState> build() async {
return _load(const PurchaseOrderListQuery(limit: 20));
@ -91,14 +76,8 @@ class PurchaseOrdersListNotifier
final result = await repository.getPurchaseOrders(query);
if (result.failure != null) throw result.failure!;
final page = result.data!;
final orders = await _enrichPurchaseOrderSearch(
ref: ref,
query: query,
items: page.items,
load: repository.getPurchaseOrders,
);
return PurchaseOrdersListState(
orders: orders,
orders: page.items,
query: query,
total: page.total,
totalPages: page.totalPages,
@ -133,28 +112,6 @@ class PurchaseOrdersListNotifier
applyQuery(current.query.copyWith(search: TableSearch.normalize(search), page: 1));
}
/// Column search: load all rows once (`limit = total`), then filter client-side.
Future<void> ensureColumnSearchDataset() async {
final current = state.valueOrNull;
if (current == null) return;
final limit = _columnSearch.beginFullDataset(
currentLimit: current.query.limit,
total: current.total,
);
if (limit == null) return;
await applyQuery(
current.query.copyWith(search: null, page: 1, limit: limit),
);
}
void clearColumnSearchDataset() {
final current = state.valueOrNull;
if (current == null) return;
final limit = _columnSearch.endFullDataset();
if (limit == null) return;
applyQuery(current.query.copyWith(search: null, page: 1, limit: limit));
}
void setStatusFilter(String? status) {
final current = state.valueOrNull;
if (current == null) return;
@ -216,34 +173,10 @@ class PurchaseOrdersListNotifier
}
return true;
}
Future<bool> approvePurchaseOrder(String id, {String? remarks}) async {
final repository = ref.read(purchaseOrderRepositoryProvider);
final result = await repository.approvePurchaseOrder(id, remarks: remarks);
if (result.failure != null) {
final current = state.valueOrNull;
if (current != null) {
state = AsyncData(current.copyWith(actionError: result.failure!.message));
}
return false;
}
ref.invalidate(pendingApprovalPurchaseOrdersListProvider);
ref.invalidate(grnLookupsProvider);
await refresh();
final current = state.valueOrNull;
if (current != null) {
state = AsyncData(
current.copyWith(actionSuccess: 'Purchase order approved'),
);
}
return true;
}
}
class PendingApprovalPurchaseOrdersListNotifier
extends AutoDisposeAsyncNotifier<PurchaseOrdersListState> {
final _columnSearch = ColumnSearchPaging();
@override
Future<PurchaseOrdersListState> build() async {
return _load(const PurchaseOrderListQuery(limit: 20));
@ -254,14 +187,8 @@ class PendingApprovalPurchaseOrdersListNotifier
final result = await repository.getPendingApprovalPurchaseOrders(query);
if (result.failure != null) throw result.failure!;
final page = result.data!;
final orders = await _enrichPurchaseOrderSearch(
ref: ref,
query: query,
items: page.items,
load: repository.getPendingApprovalPurchaseOrders,
);
return PurchaseOrdersListState(
orders: orders,
orders: page.items,
query: query,
total: page.total,
totalPages: page.totalPages,
@ -296,28 +223,6 @@ class PendingApprovalPurchaseOrdersListNotifier
applyQuery(current.query.copyWith(search: TableSearch.normalize(search), page: 1));
}
/// Column search: load all rows once (`limit = total`), then filter client-side.
Future<void> ensureColumnSearchDataset() async {
final current = state.valueOrNull;
if (current == null) return;
final limit = _columnSearch.beginFullDataset(
currentLimit: current.query.limit,
total: current.total,
);
if (limit == null) return;
await applyQuery(
current.query.copyWith(search: null, page: 1, limit: limit),
);
}
void clearColumnSearchDataset() {
final current = state.valueOrNull;
if (current == null) return;
final limit = _columnSearch.endFullDataset();
if (limit == null) return;
applyQuery(current.query.copyWith(search: null, page: 1, limit: limit));
}
void setPage(int page) {
final current = state.valueOrNull;
if (current == null) return;
@ -395,12 +300,9 @@ class PurchaseOrderDetailNotifier
return result.data!;
}
Future<PurchaseOrderModel> reject({required String rejectReason}) async {
Future<PurchaseOrderModel> reject({required String remarks}) async {
final repository = ref.read(purchaseOrderRepositoryProvider);
final result = await repository.rejectPurchaseOrder(
arg,
rejectReason: rejectReason,
);
final result = await repository.rejectPurchaseOrder(arg, remarks: remarks);
if (result.failure != null) throw result.failure!;
state = AsyncData(result.data!);
ref.invalidate(purchaseOrdersListProvider);
@ -430,14 +332,6 @@ class PurchaseOrderDetailNotifier
return result.data!;
}
/// Sends PO_SUBMIT_APPROVAL notification emails to approvers.
Future<String> triggerApprovalNotification() async {
final repository = ref.read(purchaseOrderRepositoryProvider);
final result = await repository.triggerApprovalNotification(arg);
if (result.failure != null) throw result.failure!;
return result.data!;
}
Future<List<int>> downloadPdf() async {
final repository = ref.read(purchaseOrderRepositoryProvider);
final result = await repository.downloadPurchaseOrderPdf(arg);
@ -531,50 +425,3 @@ class PurchaseOrderFormNotifier extends FamilyAsyncNotifier<PurchaseOrderModel?,
return result.data!;
}
}
Future<List<PurchaseOrderModel>> _enrichPurchaseOrderSearch({
required Ref ref,
required PurchaseOrderListQuery query,
required List<PurchaseOrderModel> items,
required Future<Result<PaginatedResponse<PurchaseOrderModel>>> Function(
PurchaseOrderListQuery query,
) load,
}) async {
final search = TableSearch.normalize(query.search);
if (search.isEmpty || query.vendorId != null) {
return items;
}
var merged = List<PurchaseOrderModel>.from(items);
try {
// Vendor-only cache — do not pull full PO master lookups on every search.
final vendors = await ref.read(_poListVendorOptionsProvider.future);
var vendorMatches = 0;
for (final vendor in vendors) {
if (!isActiveVendorOption(
isActive: vendor.isActive,
status: vendor.status,
)) {
continue;
}
if (!TableSearch.matches(search, [vendor.vendorName])) continue;
if (++vendorMatches > 5) break;
final vendorId = int.tryParse(vendor.id);
if (vendorId == null) continue;
final byVendor = await load(
query.copyWith(search: null, vendorId: vendorId),
);
if (byVendor.failure == null && byVendor.data != null) {
merged = TableSearch.mergeById(
merged,
byVendor.data!.items,
(order) => order.id,
);
}
}
} catch (_) {
// Vendor enrichment is best-effort.
}
return merged;
}

View File

@ -15,7 +15,6 @@ import '../../../../shared/utils/file_download_helper.dart';
import '../../../../shared/widgets/app_confirmation_dialog.dart';
import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_text_field.dart';
import '../../../../shared/widgets/detail_overview_widgets.dart';
import '../../../../shared/widgets/entity_attachments_card.dart';
import '../../../../shared/widgets/error_view.dart';
import '../providers/purchase_order_lookups_provider.dart';
@ -23,7 +22,6 @@ import '../providers/purchase_orders_provider.dart';
import '../../data/repositories/purchase_order_repository_impl.dart';
import '../widgets/po_status_chip.dart';
import '../widgets/purchase_order_line_items_editor.dart';
import '../../../../shared/widgets/api_feedback.dart';
import '../../../../shared/widgets/app_toast.dart';
class PurchaseOrderDetailScreen extends ConsumerStatefulWidget {
@ -40,26 +38,6 @@ class _PurchaseOrderDetailScreenState
extends ConsumerState<PurchaseOrderDetailScreen> {
bool _isWorking = false;
bool _isDownloadingPdf = false;
bool _requestedFreshLoad = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_requestedFreshLoad) return;
_requestedFreshLoad = true;
// Always hit GET /purchase-orders/{id} when opening view.
ref.invalidate(purchaseOrderDetailProvider(widget.purchaseOrderId));
ref.invalidate(purchaseOrderAttachmentsProvider(widget.purchaseOrderId));
}
@override
void didUpdateWidget(covariant PurchaseOrderDetailScreen oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.purchaseOrderId != widget.purchaseOrderId) {
ref.invalidate(purchaseOrderDetailProvider(widget.purchaseOrderId));
ref.invalidate(purchaseOrderAttachmentsProvider(widget.purchaseOrderId));
}
}
@override
Widget build(BuildContext context) {
@ -104,17 +82,12 @@ class _PurchaseOrderDetailScreenState
'${RouteConstants.purchaseOrders}/${order.id}/edit',
),
onSubmit: () => _submit(order),
onNotify: () => _notifyApprovers(order),
onApprove: () => _approve(order),
onReject: () => _reject(order),
onAmend: () => _amend(order),
onCancel: () => _cancel(order),
onDelete: _delete,
),
if (order.rejectReasonForDisplay != null) ...[
const SizedBox(height: 12),
_RejectReasonBanner(reason: order.rejectReasonForDisplay!),
],
const SizedBox(height: 16),
_OrderDetailsCard(order: order, lookups: lookups),
const SizedBox(height: 16),
@ -173,10 +146,7 @@ class _PurchaseOrderDetailScreenState
}
} catch (e) {
if (mounted) {
showAppToastFromSnackBar(
context,
SnackBar(content: Text(errorDisplayMessage(e))),
);
showAppToastFromSnackBar(context, SnackBar(content: Text(e.toString())));
}
} finally {
if (mounted) setState(() => _isWorking = false);
@ -188,42 +158,10 @@ class _PurchaseOrderDetailScreenState
() => ref
.read(purchaseOrderDetailProvider(widget.purchaseOrderId).notifier)
.submit(),
order.isRejected
? 'Purchase order resubmitted for approval'
: 'Purchase order submitted for approval',
'Purchase order submitted for approval',
);
}
Future<void> _notifyApprovers(PurchaseOrderModel order) async {
if (!order.canNotifyApprovers) {
showAppToastFromSnackBar(
context,
const SnackBar(
content: Text('Notifications can only be sent for pending approval POs'),
),
);
return;
}
setState(() => _isWorking = true);
try {
final message = await ref
.read(purchaseOrderDetailProvider(widget.purchaseOrderId).notifier)
.triggerApprovalNotification();
if (mounted) {
showAppToastFromSnackBar(context, SnackBar(content: Text(message)));
}
} catch (e) {
if (mounted) {
showAppToastFromSnackBar(
context,
SnackBar(content: Text(errorDisplayMessage(e))),
);
}
} finally {
if (mounted) setState(() => _isWorking = false);
}
}
Future<void> _approve(PurchaseOrderModel order) async {
await _runWorkflow(
() => ref
@ -234,15 +172,14 @@ class _PurchaseOrderDetailScreenState
}
Future<void> _reject(PurchaseOrderModel order) async {
final reasonController = TextEditingController();
final remarksController = TextEditingController();
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Reject Purchase Order'),
content: AppTextField(
controller: reasonController,
label: 'Reject reason *',
hint: 'Why is this purchase order being rejected?',
controller: remarksController,
label: 'Remarks *',
maxLines: 3,
),
actions: [
@ -258,19 +195,18 @@ class _PurchaseOrderDetailScreenState
),
);
if (confirmed != true || !mounted) return;
final rejectReason = reasonController.text.trim();
reasonController.dispose();
if (rejectReason.isEmpty) {
showAppToastFromSnackBar(
context,
const SnackBar(content: Text('Reject reason is required')),
final remarks = remarksController.text.trim();
remarksController.dispose();
if (remarks.isEmpty) {
showAppToastFromSnackBar(context,
const SnackBar(content: Text('Rejection remarks are required')),
);
return;
}
await _runWorkflow(
() => ref
.read(purchaseOrderDetailProvider(widget.purchaseOrderId).notifier)
.reject(rejectReason: rejectReason),
.reject(remarks: remarks),
'Purchase order rejected',
);
}
@ -297,10 +233,7 @@ class _PurchaseOrderDetailScreenState
}
} catch (e) {
if (mounted) {
showAppToastFromSnackBar(
context,
SnackBar(content: Text(errorDisplayMessage(e))),
);
showAppToastFromSnackBar(context, SnackBar(content: Text(e.toString())));
}
} finally {
if (mounted) setState(() => _isWorking = false);
@ -349,10 +282,7 @@ class _PurchaseOrderDetailScreenState
}
} catch (e) {
if (mounted) {
showAppToastFromSnackBar(
context,
SnackBar(content: Text(errorDisplayMessage(e))),
);
showAppToastFromSnackBar(context, SnackBar(content: Text(e.toString())));
}
} finally {
if (mounted) setState(() => _isWorking = false);
@ -372,10 +302,7 @@ class _PurchaseOrderDetailScreenState
);
} catch (e) {
if (mounted) {
showAppToastFromSnackBar(
context,
SnackBar(content: Text(errorDisplayMessage(e))),
);
showAppToastFromSnackBar(context, SnackBar(content: Text(e.toString())));
}
} finally {
if (mounted) setState(() => _isDownloadingPdf = false);
@ -446,56 +373,6 @@ String _hsnLabel(
return _lookupName(lookups?.hsnCodes, item.hsnCodeId);
}
class _RejectReasonBanner extends StatelessWidget {
const _RejectReasonBanner({required this.reason});
final String reason;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: theme.colorScheme.errorContainer.withValues(alpha: 0.35),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: theme.colorScheme.error.withValues(alpha: 0.35),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.info_outline, color: theme.colorScheme.error, size: 20),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Reject reason',
style: theme.textTheme.labelMedium?.copyWith(
color: theme.colorScheme.error,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 2),
Text(
reason,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.error,
),
),
],
),
),
],
),
);
}
}
class _DetailHeader extends StatelessWidget {
const _DetailHeader({
required this.order,
@ -509,7 +386,6 @@ class _DetailHeader extends StatelessWidget {
required this.onPdf,
required this.onEdit,
required this.onSubmit,
required this.onNotify,
required this.onApprove,
required this.onReject,
required this.onAmend,
@ -528,7 +404,6 @@ class _DetailHeader extends StatelessWidget {
final VoidCallback onPdf;
final VoidCallback onEdit;
final VoidCallback onSubmit;
final VoidCallback onNotify;
final VoidCallback onApprove;
final VoidCallback onReject;
final VoidCallback onAmend;
@ -566,17 +441,11 @@ class _DetailHeader extends StatelessWidget {
),
if (canEdit && order.canSubmit)
_HeaderActionButton(
label: order.isRejected ? 'Resubmit' : 'Submit',
label: 'Submit',
icon: Icons.send_outlined,
filled: true,
onPressed: isWorking ? null : onSubmit,
),
if (canEdit && order.canNotifyApprovers)
_HeaderActionButton(
label: 'Notify',
icon: Icons.notifications_outlined,
onPressed: isWorking ? null : onNotify,
),
if (canApprove && order.canApprove)
_HeaderActionButton(
label: 'Approve',
@ -627,41 +496,6 @@ class _DetailHeader extends StatelessWidget {
),
const SizedBox(width: 10),
PoStatusChip(status: order.status, compact: true),
if (order.isRejected) ...[
const SizedBox(width: 4),
Tooltip(
richMessage: TextSpan(
children: [
TextSpan(
text: 'Reject reason\n',
style: theme.textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w700,
color: theme.colorScheme.onInverseSurface,
),
),
TextSpan(
text: order.rejectReasonForDisplay ??
'No reject reason provided',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onInverseSurface,
),
),
],
),
waitDuration: const Duration(milliseconds: 150),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
margin: const EdgeInsets.only(top: 6),
decoration: BoxDecoration(
color: theme.colorScheme.inverseSurface,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
Icons.info_outline,
size: 18,
color: theme.colorScheme.error,
),
),
],
if (order.revisionNo != null && order.revisionNo! > 0) ...[
const SizedBox(width: 8),
PoRevisionChip(revisionNo: order.revisionNo!, compact: true),
@ -865,8 +699,6 @@ class _OrderDetailsCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final paymentTerm =
_lookupName(lookups?.paymentTerms, order.paymentTermId);
final deliveryTerm =
@ -874,89 +706,91 @@ class _OrderDetailsCard extends StatelessWidget {
return _SectionCard(
title: 'ORDER DETAILS',
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
DetailOverviewSection(
title: 'Summary',
child: DetailSummaryStrip(
metrics: [
DetailSummaryMetric(
icon: Icons.flag_outlined,
label: 'Status',
accent: scheme.secondary,
child: PoStatusChip(status: order.status, compact: true),
),
DetailSummaryMetric(
icon: Icons.payments_outlined,
label: 'Total Amount',
accent: scheme.primary,
child: Text(
order.totalAmount != null
? CurrencyFormatter.format(order.totalAmount)
: '—',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800,
),
),
),
DetailSummaryMetric(
icon: Icons.calendar_today_outlined,
label: 'PO Date',
child: Text(
DateFormatter.displayDate(order.poDate),
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
DetailSummaryMetric(
icon: Icons.inventory_2_outlined,
label: 'Line Items',
child: Text(
'${order.items.length}',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
],
child: LayoutBuilder(
builder: (context, constraints) {
final cols = constraints.maxWidth < 600
? 1
: constraints.maxWidth < 900
? 2
: 4;
const spacing = 20.0;
final width = (constraints.maxWidth - spacing * (cols - 1)) / cols;
final items = [
_DetailField(
label: 'PO Date',
value: DateFormatter.displayDate(order.poDate),
),
),
DetailOverviewSection(
title: 'Vendor & Delivery',
child: DetailInfoGrid(
items: [
DetailInfoItem('Vendor', _displayOrDash(order.vendorName)),
DetailInfoItem(
'Vendor Type',
vendorTypeLabel(order.vendorType),
),
DetailInfoItem(
'Expected Delivery',
DateFormatter.displayDate(order.expectedDeliveryDate),
),
DetailInfoItem('Billing', _displayOrDash(order.billingName)),
DetailInfoItem('Shipping', _displayOrDash(order.shippingName)),
],
_DetailField(
label: 'Expected Delivery',
value: DateFormatter.displayDate(order.expectedDeliveryDate),
),
),
DetailOverviewSection(
title: 'Terms',
showDivider: false,
child: DetailInfoGrid(
items: [
DetailInfoItem('Payment Term', paymentTerm),
DetailInfoItem('Delivery Term', deliveryTerm),
],
_DetailField(
label: 'Vendor',
value: _displayOrDash(order.vendorName),
),
),
],
_DetailField(
label: 'Vendor Type',
value: vendorTypeLabel(order.vendorType),
),
_DetailField(
label: 'Billing',
value: _displayOrDash(order.billingName),
),
_DetailField(
label: 'Shipping',
value: _displayOrDash(order.shippingName),
),
_DetailField(label: 'Payment Term', value: paymentTerm),
_DetailField(label: 'Delivery Term', value: deliveryTerm),
];
return Wrap(
spacing: spacing,
runSpacing: 16,
children: items
.map((item) => SizedBox(width: width, child: item))
.toList(),
);
},
),
);
}
}
class _DetailField extends StatelessWidget {
const _DetailField({
required this.label,
required this.value,
});
final String label;
final String value;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
Text(
value,
style: theme.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
],
);
}
}
class _PoAttachmentsSection extends ConsumerWidget {
const _PoAttachmentsSection({
required this.poId,
@ -1413,7 +1247,7 @@ class _AmountSummaryCard extends StatelessWidget {
Text(
CurrencyFormatter.format(order.totalAmount),
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
fontWeight: FontWeight.w800,
color: theme.colorScheme.primary,
),
),

View File

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

View File

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

View File

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

View File

@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/utils/formatters.dart';
@ -42,19 +41,15 @@ class PoLineCalculation {
required double discPct,
required double gstPct,
}) {
final qtySafe = qty < 0 ? 0.0 : qty;
final rateSafe = rate < 0 ? 0.0 : rate;
final discSafe = discPct < 0 ? 0.0 : (discPct > 100 ? 100.0 : discPct);
final gstSafe = gstPct < 0 ? 0.0 : gstPct;
final baseAmount = qtySafe * rateSafe;
final discountAmount = baseAmount * discSafe / 100;
final baseAmount = qty * rate;
final discountAmount = baseAmount * discPct / 100;
final lineAmount = baseAmount - discountAmount;
final gstAmount = lineAmount * gstSafe / 100;
final gstAmount = lineAmount * gstPct / 100;
return PoLineCalculation(
baseAmount: baseAmount,
discountAmount: discountAmount,
lineAmount: lineAmount < 0 ? 0 : lineAmount,
gstAmount: gstAmount < 0 ? 0 : gstAmount,
lineAmount: lineAmount,
gstAmount: gstAmount,
);
}
}
@ -77,7 +72,7 @@ class PoOrderTotals {
final double taxAmount;
final double grandTotal;
/// Sub Total + Freight + Other (discount cannot exceed this).
/// Sub Total + Tax + Freight + Other (discount cannot exceed this).
final double maxDiscountAmount;
static const zero = PoOrderTotals(
@ -90,7 +85,7 @@ class PoOrderTotals {
/// Sub Total = sum of line amounts
/// Taxable = Sub Total + Freight + Other − Discount
/// Tax = Taxable × effective GST rate (from line GST ÷ Sub Total)
/// Tax = sum of line GST amounts
/// Grand Total = Taxable + Tax
factory PoOrderTotals.compute({
required Iterable<PoLineCalculation> lines,
@ -99,29 +94,22 @@ class PoOrderTotals {
required double discountAmount,
}) {
var subTotal = 0.0;
var lineTax = 0.0;
var tax = 0.0;
for (final line in lines) {
subTotal += line.lineAmount;
lineTax += line.gstAmount;
tax += line.gstAmount;
}
// Keep order-level money fields non-negative.
final subSafe = subTotal < 0 ? 0.0 : subTotal;
final lineTaxSafe = lineTax < 0 ? 0.0 : lineTax;
final freightSafe = freight < 0 ? 0.0 : freight;
final otherSafe = otherCharges < 0 ? 0.0 : otherCharges;
final maxDiscount = subSafe + freightSafe + otherSafe;
final clampedDiscount = discountAmount < 0
? 0.0
: (discountAmount > maxDiscount ? maxDiscount : discountAmount);
final taxable = subSafe + freightSafe + otherSafe - clampedDiscount;
// Apply the blended line GST rate to Taxable Amount (not Sub Total),
// so freight / other / discount are included in the tax base.
final tax = subSafe > 0 ? lineTaxSafe * (taxable / subSafe) : 0.0;
final clampedDiscount = discountAmount < 0 ? 0.0 : discountAmount;
final taxableRaw = subTotal + freightSafe + otherSafe - clampedDiscount;
final taxable = taxableRaw < 0 ? 0.0 : taxableRaw;
final maxDiscount = subTotal + tax + freightSafe + otherSafe;
final grandTotal = taxable + tax;
return PoOrderTotals(
subTotal: subSafe,
taxableAmount: taxable < 0 ? 0 : taxable,
taxAmount: tax < 0 ? 0 : tax,
subTotal: subTotal,
taxableAmount: taxable,
taxAmount: tax,
grandTotal: grandTotal < 0 ? 0 : grandTotal,
maxDiscountAmount: maxDiscount < 0 ? 0 : maxDiscount,
);
@ -131,12 +119,9 @@ class PoOrderTotals {
class PoLineItemDraft {
PoLineItemDraft({
this.itemId,
this.itemName,
this.itemCode,
required this.lineNo,
TextEditingController? qtyController,
this.uomId,
this.uomName,
TextEditingController? rateController,
TextEditingController? discountController,
this.gstRateId,
@ -147,14 +132,9 @@ class PoLineItemDraft {
discountController ?? TextEditingController(text: '0');
int? itemId;
/// Kept so edit can show the label even if the item is inactive / missing
/// from the active dropdown lookups.
String? itemName;
String? itemCode;
int lineNo;
final TextEditingController qtyController;
int? uomId;
String? uomName;
final TextEditingController rateController;
final TextEditingController discountController;
int? gstRateId;
@ -163,13 +143,10 @@ class PoLineItemDraft {
factory PoLineItemDraft.fromModel(PurchaseOrderItemModel item) {
return PoLineItemDraft(
itemId: item.itemId,
itemName: item.itemName,
itemCode: item.itemCode,
lineNo: item.lineNo ?? 1,
qtyController:
TextEditingController(text: item.orderedQty?.toString() ?? ''),
uomId: item.uomId,
uomName: item.uomName,
rateController: TextEditingController(text: item.rate?.toString() ?? ''),
discountController:
TextEditingController(text: item.discountPct?.toString() ?? '0'),
@ -471,22 +448,7 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
void _onItemChanged(int? itemId) {
_updateLine(() {
widget.line.itemId = itemId;
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;
}
if (itemId == null) return;
final key = itemId.toString();
final defaultUom = _itemUomById[key];
if (defaultUom != null) {
@ -506,61 +468,6 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
});
}
List<AppDropdownOption<int>> _itemOptionsWithSelected() {
final options = widget.items
.map((e) {
final id = _parseId(e.id);
if (id == null) return null;
final code = e.slug?.trim();
return AppDropdownOption(
value: id,
label: e.name,
subtitle: (code == null || code.isEmpty) ? null : code,
);
})
.whereType<AppDropdownOption<int>>()
.toList();
final selectedId = widget.line.itemId;
if (selectedId == null) return options;
if (options.any((o) => o.value == selectedId)) return options;
final name = widget.line.itemName?.trim();
final code = widget.line.itemCode?.trim();
return [
AppDropdownOption(
value: selectedId,
label: (name != null && name.isNotEmpty) ? name : 'Item #$selectedId',
subtitle: (code == null || code.isEmpty) ? null : code,
),
...options,
];
}
List<AppDropdownOption<int>> _uomOptionsWithSelected() {
final options = widget.uom
.map((e) {
final id = _parseId(e.id);
if (id == null) return null;
return AppDropdownOption(value: id, label: e.name);
})
.whereType<AppDropdownOption<int>>()
.toList();
final selectedId = widget.line.uomId;
if (selectedId == null) return options;
if (options.any((o) => o.value == selectedId)) return options;
final name = widget.line.uomName?.trim();
return [
AppDropdownOption(
value: selectedId,
label: (name != null && name.isNotEmpty) ? name : 'UOM #$selectedId',
),
...options,
];
}
@override
void didUpdateWidget(covariant _LineItemCard oldWidget) {
super.didUpdateWidget(oldWidget);
@ -590,10 +497,29 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
alpha: isDark ? 0.18 : 0.08,
);
final itemOptions = _itemOptionsWithSelected();
final uomOptions = _uomOptionsWithSelected();
final itemOptions = widget.items
.map((e) {
final id = _parseId(e.id);
if (id == null) return null;
final code = e.slug?.trim();
return AppDropdownOption(
value: id,
label: e.name,
subtitle: (code == null || code.isEmpty) ? null : code,
);
})
.whereType<AppDropdownOption<int>>()
.toList();
final uomOptions = widget.uom
.map((e) {
final id = _parseId(e.id);
if (id == null) return null;
return AppDropdownOption(value: id, label: e.name);
})
.whereType<AppDropdownOption<int>>()
.toList();
final gstOptions = [
const AppDropdownOption<int?>(value: null, label: 'Select'),
const AppDropdownOption<int?>(value: null, label: 'Select GST Rate'),
...widget.gstRates.map((e) {
final id = _parseId(e.id);
if (id == null) return null;
@ -601,24 +527,9 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
final label = pct != null
? (pct % 1 == 0 ? '${pct.toInt()}%' : '$pct%')
: e.name.split(' — ').first.trim();
if (label.isEmpty) return null;
return AppDropdownOption<int?>(value: id, label: label);
}),
].whereType<AppDropdownOption<int?>>().toList();
final selectedGstId = line.gstRateId;
if (selectedGstId != null &&
!gstOptions.any((o) => o.value == selectedGstId)) {
final pct = widget.gstRatePctById[selectedGstId.toString()];
gstOptions.insert(
1,
AppDropdownOption<int?>(
value: selectedGstId,
label: pct != null
? (pct % 1 == 0 ? '${pct.toInt()}%' : '$pct%')
: 'GST #$selectedGstId',
),
);
}
const spacing = 8.0;
@ -630,7 +541,6 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
hint: 'Select item',
searchHint: 'Search item name or code...',
options: itemOptions,
openInSidePanel: true,
refreshLookups: () async {
ref.invalidate(purchaseOrderLookupsProvider);
await ref.read(purchaseOrderLookupsProvider.future);
@ -644,11 +554,7 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
controller: line.qtyController,
label: 'Qty *',
hint: '0',
isDense: true,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*')),
],
validator: (v) {
if (v == null || v.trim().isEmpty) return 'Required';
final qty = double.tryParse(v);
@ -664,7 +570,6 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
hint: 'Select UOM',
searchHint: 'Search UOM...',
options: uomOptions,
openInSidePanel: true,
refreshLookups: () async {
ref.invalidate(purchaseOrderLookupsProvider);
await ref.read(purchaseOrderLookupsProvider.future);
@ -679,9 +584,6 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
label: 'Rate *',
hint: '0.00',
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*')),
],
validator: (v) {
if (v == null || v.trim().isEmpty) return 'Required';
final rate = double.tryParse(v);
@ -694,19 +596,7 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
controller: line.discountController,
label: 'Disc %',
hint: '0',
isDense: true,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*')),
],
validator: (v) {
if (v == null || v.trim().isEmpty) return null;
final disc = double.tryParse(v);
if (disc == null) return 'Invalid';
if (disc < 0) return 'Cannot be negative';
if (disc > 100) return 'Max 100%';
return null;
},
);
final gstField = MasterQuickAddDropdown<int?>(
key: ValueKey('$lineKey-gst'),
@ -716,8 +606,6 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
hint: 'Select',
searchHint: 'Search GST %...',
options: gstOptions,
isDense: true,
openInSidePanel: true,
refreshLookups: () async {
ref.invalidate(purchaseOrderLookupsProvider);
await ref.read(purchaseOrderLookupsProvider.future);
@ -725,68 +613,114 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
parseCreatedId: int.tryParse,
onChanged: (v) => _updateLine(() => line.gstRateId = v),
);
final amountField = _AmountDisplay(
label: 'Amount',
value: CurrencyFormatter.format(calc.lineAmount),
final amountField = _AmountWithRemove(
amount: CurrencyFormatter.format(calc.lineAmount),
backgroundColor: amountBg,
onRemove: widget.onRemove,
);
return Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(color: borderColor),
borderRadius: BorderRadius.circular(10),
),
child: QuickAddInlineHost(
child: QuickAddBlockable(
child: SizedBox(
width: double.infinity,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(flex: 3, child: itemField),
const SizedBox(width: spacing),
// Wider than flex 1 so floating labels ("Qty *", "Disc %") aren't clipped.
Expanded(flex: 2, child: qtyField),
const SizedBox(width: spacing),
Expanded(flex: 2, child: uomField),
const SizedBox(width: spacing),
// Rate and Amount share the same flex so widths match.
Expanded(flex: 2, child: rateField),
const SizedBox(width: spacing),
Expanded(flex: 2, child: discField),
const SizedBox(width: spacing),
Expanded(flex: 2, child: gstField),
const SizedBox(width: spacing),
Expanded(flex: 2, child: amountField),
if (widget.onRemove != null) ...[
const SizedBox(width: 4),
Padding(
padding: const EdgeInsets.only(top: 20),
child: IconButton(
tooltip: 'Remove line',
onPressed: widget.onRemove,
icon: const Icon(Icons.delete_outline, size: 20),
color: theme.colorScheme.error,
visualDensity: VisualDensity.compact,
constraints: const BoxConstraints(
minWidth: 36,
minHeight: 36,
),
padding: EdgeInsets.zero,
),
),
child: LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
// Wide: single flex row
if (width >= 1100) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(flex: 3, child: itemField),
const SizedBox(width: spacing),
Expanded(flex: 1, child: qtyField),
const SizedBox(width: spacing),
Expanded(flex: 2, child: uomField),
const SizedBox(width: spacing),
Expanded(flex: 1, child: rateField),
const SizedBox(width: spacing),
Expanded(flex: 1, child: discField),
const SizedBox(width: spacing),
Expanded(flex: 2, child: gstField),
const SizedBox(width: spacing),
Expanded(flex: 2, child: amountField),
],
);
}
// Medium / narrow: wrapping grid (2–3 columns)
return ResponsiveFormGrid(
spacing: spacing,
smallColumns: 1,
mediumColumns: 2,
largeColumns: 3,
mediumBreakpoint: 520,
largeBreakpoint: 800,
children: [
itemField,
qtyField,
uomField,
rateField,
discField,
gstField,
amountField,
],
),
),
);
},
),
),
);
}
}
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,
),
),
],
],
);
}
}
class _AmountDisplay extends StatelessWidget {
const _AmountDisplay({
required this.label,
@ -819,17 +753,11 @@ class _AmountDisplay extends StatelessWidget {
borderRadius: BorderRadius.circular(8),
),
),
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
value,
maxLines: 1,
softWrap: false,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
color: theme.colorScheme.onSurface,
),
child: Text(
value,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
color: theme.colorScheme.onSurface,
),
),
),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/constants/app_constants.dart';
import '../../../../core/utils/column_search_paging.dart';
import '../../../../core/utils/table_search.dart';
import '../../../../shared/models/export_file_result.dart';
import '../../data/repositories/reports_repository_impl.dart';
import '../../domain/entities/depreciation_report.dart';
@ -73,10 +73,6 @@ final depreciationReportProvider = AsyncNotifierProvider<
class DepreciationReportNotifier
extends AsyncNotifier<DepreciationReportState> {
final _columnSearch = ColumnSearchPaging(
defaultLimit: AppConstants.defaultPageSize,
);
@override
Future<DepreciationReportState> build() async {
ref.keepAlive();
@ -149,39 +145,6 @@ class DepreciationReportNotifier
);
}
Future<void> ensureColumnSearchDataset() async {
final currentState = state.valueOrNull;
final current = currentState?.query ??
const DepreciationReportQuery(limit: AppConstants.defaultPageSize);
final limit = _columnSearch.beginFullDataset(
currentLimit: current.limit,
total: currentState?.total ?? 0,
);
if (limit == null) return;
await applyQuery(
current.copyWith(
page: 1,
limit: limit,
clearSearch: true,
),
);
}
void clearColumnSearchDataset() {
final currentState = state.valueOrNull;
final current = currentState?.query ??
const DepreciationReportQuery(limit: AppConstants.defaultPageSize);
final limit = _columnSearch.endFullDataset();
if (limit == null) return;
applyQuery(
current.copyWith(
page: 1,
limit: limit,
clearSearch: true,
),
);
}
Future<void> setLocationId(String? value) async {
final current = state.valueOrNull?.query ??
const DepreciationReportQuery(limit: AppConstants.defaultPageSize);

View File

@ -10,7 +10,6 @@ import '../../../../core/utils/responsive_utils.dart';
import '../../../../shared/providers/permissions_provider.dart';
import '../../../../shared/utils/file_download_helper.dart';
import '../../../rbac/presentation/widgets/rbac_widgets.dart';
import '../../../../shared/providers/table_column_prefs_provider.dart';
import '../../../../shared/widgets/app_data_table.dart';
import '../../../../shared/widgets/app_date_popup.dart';
import '../../../../shared/widgets/app_date_range_popup.dart';
@ -22,7 +21,6 @@ import '../../../../shared/widgets/app_pagination.dart';
import '../../../../shared/widgets/app_responsive_filter_bar.dart';
import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/app_search_filter_toggle.dart';
import '../../../../shared/widgets/app_table_column_selector.dart';
import '../../../../shared/widgets/app_table_shell.dart';
import '../../../../shared/widgets/error_view.dart';
import '../../../../shared/widgets/page_header.dart';
@ -159,11 +157,6 @@ class _DepreciationReportScreenState
() => _filtersExpanded = !_filtersExpanded,
),
),
const SizedBox(width: 8),
AppTableColumnSelectorButton(
tableId: _ReportTable.tableId,
columns: _ReportTable.columnOptions,
),
if (canExport) ...[
const SizedBox(width: 8),
OutlinedButton.icon(
@ -215,37 +208,30 @@ class _DepreciationReportScreenState
totalPages: state.totalPages,
totalItems: state.total,
pageSize: state.query.limit,
itemsOnPage: state.items.length,
itemLabel: 'assets',
onPageChanged: notifier.setPage,
onPageSizeChanged: notifier.setPageSize,
),
child: RefreshIndicator(
onRefresh: notifier.refresh,
child: context.isMobile
? (state.items.isEmpty
? ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: const [
SizedBox(
height: 260,
child: AppEmptyState(
title: 'No depreciation data',
description:
'Try adjusting filters or the as-of date.',
icon: Icons.trending_down_outlined,
),
),
],
)
: _MobileList(items: state.items))
: _ReportTable(
items: state.items,
onEnsureFullDataset: () =>
notifier.ensureColumnSearchDataset(),
onColumnSearchCleared: () =>
notifier.clearColumnSearchDataset(),
),
child: state.items.isEmpty
? ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: const [
SizedBox(
height: 260,
child: AppEmptyState(
title: 'No depreciation data',
description:
'Try adjusting filters or the as-of date.',
icon: Icons.trending_down_outlined,
),
),
],
)
: context.isMobile
? _MobileList(items: state.items)
: _ReportTable(items: state.items),
),
),
),
@ -529,7 +515,7 @@ class _FiltersBarState extends State<_FiltersBar> {
? (_moreOpen ? 'Less filters ($moreCount)' : 'More filters ($moreCount)')
: (_moreOpen ? 'Less filters' : 'More filters');
final iconColor = theme.colorScheme.secondary;
final iconColor = theme.colorScheme.primary;
final moreButton = IconButton(
tooltip: moreTooltip,
@ -593,153 +579,91 @@ class _FiltersBarState extends State<_FiltersBar> {
}
}
class _ReportTable extends ConsumerWidget {
const _ReportTable({
required this.items,
this.onEnsureFullDataset,
this.onColumnSearchCleared,
});
static const tableId = 'depreciation_report';
static List<AppTableColumnOption> get columnOptions => const [
AppTableColumnOption(
id: 'asset_code',
label: 'Asset Code',
required: true,
),
AppTableColumnOption(id: 'asset_name', label: 'Asset Name'),
AppTableColumnOption(id: 'category', label: 'Category'),
AppTableColumnOption(id: 'location', label: 'Location'),
AppTableColumnOption(id: 'purchase_date', label: 'Purchase Date'),
AppTableColumnOption(id: 'purchase_cost', label: 'Purchase Cost'),
AppTableColumnOption(id: 'annual', label: 'Annual'),
AppTableColumnOption(id: 'accumulated', label: 'Accumulated'),
AppTableColumnOption(id: 'book_value', label: 'Book Value'),
];
class _ReportTable extends StatelessWidget {
const _ReportTable({required this.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
Widget build(BuildContext context, WidgetRef ref) {
final prefs = ref.watch(tableColumnPrefsProvider(tableId));
final columns = resolveAppDataColumns(_allColumns(context), prefs);
Widget build(BuildContext context) {
return AppDataTable<DepreciationReportRow>(
wrapInCard: false,
rows: items,
onEnsureFullDataset: onEnsureFullDataset,
onColumnSearchCleared: onColumnSearchCleared,
columns: columns,
columns: [
AppDataColumn(
label: 'Asset Code',
flex: 2,
searchText: (row) => row.assetCode ?? '',
cellBuilder: (_, row) => AppTableCell.text(row.assetCode),
),
AppDataColumn(
label: 'Asset Name',
flex: 3,
searchText: (row) => row.assetName ?? '',
cellBuilder: (_, row) => AppTableCell.text(row.assetName),
),
AppDataColumn(
label: 'Category',
flex: 2,
searchText: (row) => row.categoryName ?? '',
cellBuilder: (_, row) => AppTableCell.text(row.categoryName),
),
AppDataColumn(
label: 'Location',
flex: 2,
searchText: (row) => row.locationName ?? '',
cellBuilder: (_, row) => AppTableCell.text(row.locationName),
),
AppDataColumn(
label: 'Purchase Date',
flex: 2,
searchText: (row) => DateFormatter.displayDate(row.purchaseDate),
cellBuilder: (_, row) =>
AppTableCell.text(DateFormatter.displayDate(row.purchaseDate)),
),
AppDataColumn(
label: 'Purchase Cost',
flex: 2,
alignment: Alignment.centerRight,
searchText: (row) => CurrencyFormatter.format(row.purchaseCost),
cellBuilder: (_, row) => AppTableCell.text(
CurrencyFormatter.format(row.purchaseCost),
textAlign: TextAlign.right,
),
),
AppDataColumn(
label: 'Annual',
flex: 2,
alignment: Alignment.centerRight,
searchText: (row) =>
CurrencyFormatter.format(row.annualDepreciation),
cellBuilder: (_, row) => AppTableCell.text(
CurrencyFormatter.format(row.annualDepreciation),
textAlign: TextAlign.right,
),
),
AppDataColumn(
label: 'Accumulated',
flex: 2,
alignment: Alignment.centerRight,
searchText: (row) =>
CurrencyFormatter.format(row.accumulatedDepreciation),
cellBuilder: (_, row) => AppTableCell.text(
CurrencyFormatter.format(row.accumulatedDepreciation),
textAlign: TextAlign.right,
),
),
AppDataColumn(
label: 'Book Value',
flex: 2,
alignment: Alignment.centerRight,
searchText: (row) => CurrencyFormatter.format(row.bookValue),
cellBuilder: (_, row) => AppTableCell.text(
CurrencyFormatter.format(row.bookValue),
textAlign: TextAlign.right,
),
),
],
);
}
}

View File

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

View File

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

View File

@ -6,14 +6,12 @@ import 'package:go_router/go_router.dart';
import '../../../../core/errors/failure.dart';
import '../../../../core/utils/responsive_utils.dart';
import '../../../../shared/models/user_management_models.dart';
import '../../../../shared/providers/table_column_prefs_provider.dart';
import '../../../../shared/widgets/app_data_table.dart';
import '../../../../shared/widgets/app_empty_state.dart';
import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_pagination.dart';
import '../../../../shared/widgets/app_search_field.dart';
import '../../../../shared/widgets/app_search_filter_toggle.dart';
import '../../../../shared/widgets/app_table_column_selector.dart';
import '../../../../shared/widgets/app_table_shell.dart';
import '../../../../shared/widgets/app_table_action_icon.dart';
import '../../../../shared/widgets/error_view.dart';
@ -65,11 +63,6 @@ class _RoleListScreenState extends ConsumerState<RoleListScreen> {
() => _filtersExpanded = !_filtersExpanded,
),
),
const SizedBox(width: 8),
AppTableColumnSelectorButton(
tableId: _RoleDataTable.tableId,
columns: _RoleDataTable.columnOptions,
),
],
),
Expanded(
@ -88,38 +81,30 @@ class _RoleListScreenState extends ConsumerState<RoleListScreen> {
totalPages: state.totalPages,
totalItems: state.total,
pageSize: state.limit,
itemsOnPage: state.roles.length,
itemLabel: 'roles',
onPageChanged: notifier.setPage,
onPageSizeChanged: notifier.setPageSize,
),
child: RefreshIndicator(
onRefresh: notifier.refresh,
child: context.isMobile
? (roles.isEmpty
? ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: const [
SizedBox(
height: 240,
child: AppEmptyState(
title: 'No roles found',
description:
'Roles from the API will appear here.',
icon: Icons.security_outlined,
),
),
],
)
: _RoleCardList(roles: roles, onOpen: _openRole))
: _RoleDataTable(
roles: roles,
onOpen: _openRole,
onEnsureFullDataset: () =>
notifier.ensureColumnSearchDataset(),
onColumnSearchCleared: () =>
notifier.clearColumnSearchDataset(),
),
child: roles.isEmpty
? ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: const [
SizedBox(
height: 240,
child: AppEmptyState(
title: 'No roles found',
description:
'Roles from the API will appear here.',
icon: Icons.security_outlined,
),
),
],
)
: context.isMobile
? _RoleCardList(roles: roles, onOpen: _openRole)
: _RoleDataTable(roles: roles, onOpen: _openRole),
),
),
),
@ -135,90 +120,46 @@ class _RoleListScreenState extends ConsumerState<RoleListScreen> {
}
}
class _RoleDataTable extends ConsumerWidget {
const _RoleDataTable({
required this.roles,
required this.onOpen,
this.onEnsureFullDataset,
this.onColumnSearchCleared,
});
static const tableId = 'roles_list';
static List<AppTableColumnOption> get columnOptions => const [
AppTableColumnOption(
id: 'role_name',
label: 'Role Name',
required: true,
),
AppTableColumnOption(id: 'description', label: 'Description'),
AppTableColumnOption(id: 'users_count', label: 'Users Count'),
];
class _RoleDataTable extends StatelessWidget {
const _RoleDataTable({required this.roles, required this.onOpen});
final List<RoleCardModel> roles;
final void Function(RoleCardModel role) onOpen;
final Future<void> Function()? onEnsureFullDataset;
final VoidCallback? onColumnSearchCleared;
List<AppDataColumn<RoleCardModel>> _allColumns() {
return [
AppDataColumn(
id: 'role_name',
label: 'Role Name',
sortKey: 'role_name',
locked: true,
flex: 2,
searchText: (r) => r.name,
cellBuilder: (_, r) => Text(r.name),
),
AppDataColumn(
id: 'description',
label: 'Description',
sortKey: 'description',
flex: 3,
searchText: (r) => r.description ?? '',
cellBuilder: (_, r) => Text(r.description ?? '—'),
),
AppDataColumn(
id: 'users_count',
label: 'Users Count',
sortKey: 'users_count',
flex: 1,
searchText: (r) => '${r.userCount}',
sortValue: (r) => r.userCount,
cellBuilder: (_, r) => Text('${r.userCount}'),
),
AppDataColumn(
id: 'actions',
label: 'Actions',
flex: 1,
alignment: Alignment.centerRight,
enableSearch: false,
locked: true,
includeInColumnSelector: false,
cellBuilder: (_, r) => AppTableActions(
children: [
AppTableActionIcon(
tooltip: 'View Matrix',
icon: Icons.grid_view_outlined,
onPressed: () => onOpen(r),
),
],
),
),
];
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final prefs = ref.watch(tableColumnPrefsProvider(tableId));
final columns = resolveAppDataColumns(_allColumns(), prefs);
Widget build(BuildContext context) {
return AppDataTable<RoleCardModel>(
wrapInCard: false,
onEnsureFullDataset: onEnsureFullDataset,
onColumnSearchCleared: onColumnSearchCleared,
columns: columns,
columns: [
AppDataColumn(label: 'Role Name', flex: 2, searchText: (r) => r.name, cellBuilder: (_, r) => Text(r.name)),
AppDataColumn(
label: 'Description',
flex: 3,
searchText: (r) => r.description ?? '',
cellBuilder: (_, r) => Text(r.description ?? '—'),
),
AppDataColumn(
label: 'Users Count',
flex: 1,
searchText: (r) => '${r.userCount}',
cellBuilder: (_, r) => Text('${r.userCount}'),
),
AppDataColumn(
label: 'Actions',
flex: 1,
alignment: Alignment.centerRight,
enableSearch: false,
cellBuilder: (_, r) => AppTableActions(
children: [
AppTableActionIcon(
tooltip: 'View Matrix',
icon: Icons.grid_view_outlined,
onPressed: () => onOpen(r),
),
],
),
),
],
rows: roles,
);
}

View File

@ -140,15 +140,6 @@ class AppSettingsNotifier extends StateNotifier<AppSettings> {
return result.failure;
}
/// Fetches Company Profile + Email Settings after login / session restore
/// and applies them app-wide (logo, favicon, company name, email config).
Future<void> syncCompanyAndEmailFromServer() async {
await Future.wait([
refreshCompanyProfile(),
refreshEmailSettings(),
]);
}
Future<void> _persist(AppSettings settings) async {
state = settings;
final result = await _saveSettings(settings);

View File

@ -9,75 +9,6 @@ import '../providers/settings_provider.dart';
import '../widgets/settings_widgets.dart';
import '../../../../shared/widgets/app_toast.dart';
class _BrandThemePreset {
const _BrandThemePreset({
required this.name,
required this.primary,
required this.secondary,
});
final String name;
final int primary;
final int secondary;
bool matches(BrandingConfig branding) =>
branding.primaryColorValue == primary &&
branding.secondaryColorValue == secondary;
}
const _brandThemePresets = <_BrandThemePreset>[
_BrandThemePreset(
name: 'Ocean Blue',
primary: 0xFF2563EB,
secondary: 0xFF0891B2,
),
_BrandThemePreset(
name: 'Royal Purple',
primary: 0xFF6D28D9,
secondary: 0xFF0F766E,
),
_BrandThemePreset(
name: 'Emerald Green',
primary: 0xFF15803D,
secondary: 0xFF0F766E,
),
_BrandThemePreset(
name: 'Sunset Orange',
primary: 0xFFEA580C,
secondary: 0xFFD97706,
),
_BrandThemePreset(
name: 'Crimson Red',
primary: 0xFFDC2626,
secondary: 0xFFB45309,
),
_BrandThemePreset(
name: 'Indigo Sky',
primary: 0xFF4F46E5,
secondary: 0xFF0284C7,
),
_BrandThemePreset(
name: 'Slate Blue',
primary: 0xFF334155,
secondary: 0xFF2563EB,
),
_BrandThemePreset(
name: 'Teal Mint',
primary: 0xFF0F766E,
secondary: 0xFF059669,
),
_BrandThemePreset(
name: 'Rose Pink',
primary: 0xFFDB2777,
secondary: 0xFF7C3AED,
),
_BrandThemePreset(
name: 'Charcoal Gold',
primary: 0xFF374151,
secondary: 0xFFD97706,
),
];
class AppearanceSettingsScreen extends ConsumerWidget {
const AppearanceSettingsScreen({super.key});
@ -86,9 +17,6 @@ class AppearanceSettingsScreen extends ConsumerWidget {
final themeMode = ref.watch(themeModeProvider);
final branding = ref.watch(brandingProvider);
final uiPrefs = ref.watch(appSettingsProvider).uiPreferences;
final selectedPreset = _brandThemePresets
.where((preset) => preset.matches(branding))
.firstOrNull;
return SettingsPageLayout(
title: 'Appearance',
@ -115,66 +43,58 @@ class AppearanceSettingsScreen extends ConsumerWidget {
const SizedBox(height: 16),
SettingsFormCard(
title: 'Branding',
subtitle:
'Professionally curated primary and secondary color pairs',
subtitle: 'Primary and secondary colors for the application',
children: [
_BrandingPreviewCard(
primary: branding.primaryColor,
secondary: branding.secondaryColor,
themeName: selectedPreset?.name ?? 'Custom',
primaryHex: _hex(branding.primaryColorValue),
secondaryHex: _hex(branding.secondaryColorValue),
ListTile(
contentPadding: EdgeInsets.zero,
leading: CircleAvatar(backgroundColor: branding.primaryColor),
title: const Text('Primary Color'),
subtitle: Text(
'#${branding.primaryColorValue.toRadixString(16).padLeft(8, '0').substring(2)}',
),
),
const SizedBox(height: 20),
Text(
'Color themes',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
ListTile(
contentPadding: EdgeInsets.zero,
leading: CircleAvatar(backgroundColor: branding.secondaryColor),
title: const Text('Secondary Color'),
subtitle: Text(
'#${branding.secondaryColorValue.toRadixString(16).padLeft(8, '0').substring(2)}',
),
),
const SizedBox(height: 4),
Text(
'Choose a cohesive pair optimized for light and dark modes.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 12),
LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final crossAxisCount = width >= 720
? 3
: width >= 480
? 2
: 1;
final spacing = 12.0;
final itemWidth =
(width - spacing * (crossAxisCount - 1)) / crossAxisCount;
return Wrap(
spacing: spacing,
runSpacing: spacing,
children: [
for (final preset in _brandThemePresets)
SizedBox(
width: itemWidth,
child: _ThemePresetCard(
preset: preset,
selected: preset.matches(branding),
onTap: () {
ref.read(brandingProvider.notifier).updateBranding(
branding.copyWith(
primaryColorValue: preset.primary,
secondaryColorValue: preset.secondary,
),
);
},
),
),
],
);
},
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_ColorPreset(
label: 'Blue',
primary: 0xFF1565C0,
secondary: 0xFF00897B,
branding: branding,
ref: ref,
),
_ColorPreset(
label: 'Purple',
primary: 0xFF6A1B9A,
secondary: 0xFF00838F,
branding: branding,
ref: ref,
),
_ColorPreset(
label: 'Green',
primary: 0xFF2E7D32,
secondary: 0xFF558B2F,
branding: branding,
ref: ref,
),
_ColorPreset(
label: 'Orange',
primary: 0xFFE65100,
secondary: 0xFFF57C00,
branding: branding,
ref: ref,
),
],
),
],
),
@ -192,13 +112,10 @@ class AppearanceSettingsScreen extends ConsumerWidget {
: 'Horizontal menu bar at the top',
),
value: layout,
groupValue:
NavigationLayout.fromValue(uiPrefs.navigationLayout),
groupValue: NavigationLayout.fromValue(uiPrefs.navigationLayout),
onChanged: (v) {
if (v != null) {
ref
.read(appSettingsProvider.notifier)
.updateUiPreferences(
ref.read(appSettingsProvider.notifier).updateUiPreferences(
uiPrefs.copyWith(navigationLayout: v.value),
);
}
@ -266,11 +183,8 @@ class AppearanceSettingsScreen extends ConsumerWidget {
AppButton(
label: 'Settings Auto-Saved',
onPressed: () {
showAppToastFromSnackBar(
context,
const SnackBar(
content: Text('Appearance settings are saved automatically'),
),
showAppToastFromSnackBar(context,
const SnackBar(content: Text('Appearance settings are saved automatically')),
);
},
),
@ -284,348 +198,42 @@ class AppearanceSettingsScreen extends ConsumerWidget {
ThemeModeOption.dark => 'Dark Theme',
ThemeModeOption.system => 'System Theme',
};
static String _hex(int value) =>
'#${value.toRadixString(16).padLeft(8, '0').substring(2).toUpperCase()}';
}
class _BrandingPreviewCard extends StatelessWidget {
const _BrandingPreviewCard({
required this.primary,
required this.secondary,
required this.themeName,
required this.primaryHex,
required this.secondaryHex,
});
final Color primary;
final Color secondary;
final String themeName;
final String primaryHex;
final String secondaryHex;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final onPrimary =
ThemeData.estimateBrightnessForColor(primary) == Brightness.dark
? Colors.white
: Colors.black87;
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: theme.colorScheme.outline.withValues(alpha: 0.2),
),
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.35),
),
clipBehavior: Clip.antiAlias,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
color: primary,
child: Row(
children: [
Icon(Icons.dashboard_outlined, color: onPrimary, size: 18),
const SizedBox(width: 8),
Expanded(
child: Text(
'Bharat ERP · $themeName',
style: theme.textTheme.labelLarge?.copyWith(
color: onPrimary,
fontWeight: FontWeight.w700,
),
),
),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: secondary,
borderRadius: BorderRadius.circular(999),
),
child: Text(
'Accent',
style: theme.textTheme.labelSmall?.copyWith(
color: ThemeData.estimateBrightnessForColor(secondary) ==
Brightness.dark
? Colors.white
: Colors.black87,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Live preview',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
'How headers, primary actions, and secondary accents will look.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 14),
Row(
children: [
Expanded(
child: FilledButton.icon(
onPressed: () {},
style: FilledButton.styleFrom(
backgroundColor: primary,
foregroundColor: onPrimary,
),
icon: const Icon(Icons.add, size: 18),
label: const Text('Add'),
),
),
const SizedBox(width: 10),
Expanded(
child: OutlinedButton(
onPressed: () {},
style: OutlinedButton.styleFrom(
foregroundColor: secondary,
side: BorderSide(
color: secondary.withValues(alpha: 0.55),
),
),
child: const Text('Export'),
),
),
],
),
const SizedBox(height: 14),
Row(
children: [
_ColorSwatchLabel(
color: primary,
label: 'Primary',
hex: primaryHex,
),
const SizedBox(width: 16),
_ColorSwatchLabel(
color: secondary,
label: 'Secondary',
hex: secondaryHex,
),
],
),
],
),
),
],
),
);
}
}
class _ColorSwatchLabel extends StatelessWidget {
const _ColorSwatchLabel({
required this.color,
class _ColorPreset extends StatelessWidget {
const _ColorPreset({
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,
required this.branding,
required this.ref,
});
final Color primary;
final Color secondary;
final String label;
final int primary;
final int secondary;
final BrandingConfig branding;
final WidgetRef ref;
@override
Widget build(BuildContext context) {
final outline = Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.2);
return SizedBox(
width: 56,
height: 32,
child: Stack(
return OutlinedButton(
onPressed: () {
ref.read(brandingProvider.notifier).updateBranding(
branding.copyWith(
primaryColorValue: primary,
secondaryColorValue: secondary,
),
);
},
child: Row(
mainAxisSize: MainAxisSize.min,
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),
),
),
),
CircleAvatar(radius: 8, backgroundColor: Color(primary)),
const SizedBox(width: 6),
CircleAvatar(radius: 8, backgroundColor: Color(secondary)),
const SizedBox(width: 8),
Text(label),
],
),
);

View File

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

View File

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

View File

@ -5,49 +5,23 @@ import 'package:go_router/go_router.dart';
import '../../../../core/utils/formatters.dart';
import '../../../../core/errors/failure.dart';
import '../../../../shared/models/user_management_models.dart';
import '../../../../shared/widgets/app_button.dart';
import '../../../../shared/widgets/app_confirmation_dialog.dart';
import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_status_chip.dart';
import '../../../../shared/widgets/detail_overview_widgets.dart';
import '../../../../shared/widgets/error_view.dart';
import '../../../../shared/widgets/page_header.dart';
import '../../../../shared/models/user_management_models.dart';
import '../providers/users_provider.dart';
import '../../../../shared/widgets/app_toast.dart';
class UserDetailScreen extends ConsumerStatefulWidget {
class UserDetailScreen extends ConsumerWidget {
const UserDetailScreen({super.key, required this.userId});
final String userId;
@override
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;
Widget build(BuildContext context, WidgetRef ref) {
final userAsync = ref.watch(userDetailProvider(userId));
return Padding(
@ -74,7 +48,7 @@ class _UserDetailScreenState extends ConsumerState<UserDetailScreen> {
AppButton(
label: 'Deactivate',
expand: false,
onPressed: () => _deactivate(context),
onPressed: () => _deactivate(context, ref),
),
],
),
@ -82,87 +56,27 @@ class _UserDetailScreenState extends ConsumerState<UserDetailScreen> {
AppCard(
child: Padding(
padding: const EdgeInsets.all(24),
child: DetailOverviewCard(
title: 'User Details',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DetailOverviewSection(
title: 'Summary',
child: DetailSummaryStrip(
metrics: [
DetailSummaryMetric(
icon: Icons.flag_outlined,
label: 'Status',
accent: Theme.of(context).colorScheme.secondary,
child: AppStatusChip(
status: user.status,
compact: true,
),
),
DetailSummaryMetric(
icon: Icons.badge_outlined,
label: 'Employee Code',
child: Text(
user.employeeCode,
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.w700),
),
),
DetailSummaryMetric(
icon: Icons.admin_panel_settings_outlined,
label: 'Role',
child: Text(
user.roleLabel,
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.w700),
),
),
DetailSummaryMetric(
icon: Icons.apartment_outlined,
label: 'Department',
child: Text(
user.departmentLabel,
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.w700),
),
),
],
),
_DetailRow(label: 'Email', value: user.email),
_DetailRow(label: 'Mobile', value: user.mobile),
_DetailRow(label: 'Role', value: user.roleLabel),
_DetailRow(label: 'Department', value: user.departmentLabel),
_DetailRow(
label: 'Status',
valueWidget: AppStatusChip(status: user.status),
),
DetailOverviewSection(
title: 'Contact',
child: DetailInfoGrid(
items: [
DetailInfoItem('Email', user.email),
DetailInfoItem('Mobile', user.mobile),
],
if (user.createdAt != null)
_DetailRow(
label: 'Created',
value: DateFormatter.displayDateTime(user.createdAt),
),
),
DetailOverviewSection(
title: 'Account',
showDivider: false,
child: DetailInfoGrid(
items: [
DetailInfoItem('Role', user.roleLabel),
DetailInfoItem('Department', user.departmentLabel),
if (user.createdAt != null)
DetailInfoItem(
'Created',
DateFormatter.displayDateTime(user.createdAt),
),
if (user.updatedAt != null)
DetailInfoItem(
'Updated',
DateFormatter.displayDateTime(user.updatedAt),
),
],
if (user.updatedAt != null)
_DetailRow(
label: 'Updated',
value: DateFormatter.displayDateTime(user.updatedAt),
),
),
],
),
),
@ -173,7 +87,7 @@ class _UserDetailScreenState extends ConsumerState<UserDetailScreen> {
);
}
Future<void> _deactivate(BuildContext context) async {
Future<void> _deactivate(BuildContext context, WidgetRef ref) async {
final confirmed = await showAppConfirmationDialog(
context: context,
title: 'Deactivate user',
@ -183,16 +97,48 @@ class _UserDetailScreenState extends ConsumerState<UserDetailScreen> {
);
if (confirmed != true || !context.mounted) return;
final success =
await ref.read(userDetailProvider(widget.userId).notifier).deactivate();
final success = await ref.read(userDetailProvider(userId).notifier).deactivate();
if (!context.mounted) return;
showAppToastFromSnackBar(
context,
SnackBar(
content: Text(success ? 'User deactivated' : 'Failed to deactivate'),
),
showAppToastFromSnackBar(context,
SnackBar(content: Text(success ? 'User deactivated' : 'Failed to deactivate')),
);
if (success) context.pop();
}
}
class _DetailRow extends StatelessWidget {
const _DetailRow({
required this.label,
this.value,
this.valueWidget,
});
final String label;
final String? value;
final Widget? valueWidget;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 140,
child: Text(
label,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
Expanded(
child: valueWidget ?? Text(value ?? '—'),
),
],
),
);
}
}

View File

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

View File

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

View File

@ -86,7 +86,7 @@ class _UserProfileScreenState extends ConsumerState<UserProfileScreen> {
context,
SnackBar(
content: Text(
uploadResult.failure?.message ?? 'Unable to upload profile picture.',
uploadResult.failure?.message ?? uploadResult.failure.toString(),
),
),
);
@ -131,9 +131,7 @@ class _UserProfileScreenState extends ConsumerState<UserProfileScreen> {
showAppToastFromSnackBar(
context,
SnackBar(
content: Text(
result.failure?.message ?? 'Unable to update profile.',
),
content: Text(result.failure?.message ?? result.failure.toString()),
),
);
return;

View File

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

View File

@ -1,12 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/utils/formatters.dart';
import '../../../../shared/models/user_management_models.dart';
import '../../../../shared/providers/table_column_prefs_provider.dart';
import '../../../../shared/widgets/app_data_table.dart';
import '../../../../shared/widgets/app_status_chip.dart';
import '../../../../shared/widgets/app_table_column_selector.dart';
import '../../../rbac/presentation/widgets/rbac_widgets.dart';
typedef UserTableActionsBuilder = Widget Function(
@ -14,7 +11,7 @@ typedef UserTableActionsBuilder = Widget Function(
ManagedUserModel user,
);
class UserRichDataTable extends ConsumerWidget {
class UserRichDataTable extends StatelessWidget {
const UserRichDataTable({
super.key,
required this.users,
@ -23,119 +20,84 @@ class UserRichDataTable extends ConsumerWidget {
this.sortAscending = true,
this.onSort,
this.wrapInCard = false,
this.onServerSearchChanged,
this.onEnsureFullDataset,
this.onColumnSearchCleared,
});
static const tableId = 'users_list';
final List<ManagedUserModel> users;
final UserTableActionsBuilder actionsBuilder;
final String? sortColumn;
final bool sortAscending;
final void Function(String column, bool ascending)? onSort;
final bool wrapInCard;
final ValueChanged<String>? onServerSearchChanged;
final Future<void> Function()? onEnsureFullDataset;
final VoidCallback? onColumnSearchCleared;
static List<AppTableColumnOption> get columnOptions => const [
AppTableColumnOption(id: 'user', label: 'User', required: true),
AppTableColumnOption(id: 'employee_code', label: 'Employee Code'),
AppTableColumnOption(id: 'role', label: 'Role'),
AppTableColumnOption(id: 'department', label: 'Department'),
AppTableColumnOption(id: 'last_login', label: 'Last Login'),
AppTableColumnOption(id: 'status', label: 'Status'),
];
List<AppDataColumn<ManagedUserModel>> _allColumns(ThemeData theme) {
return [
AppDataColumn(
id: 'user',
label: 'User',
sortKey: 'full_name',
locked: true,
flex: 3,
searchText: (user) => '${user.fullName} ${user.email}'.trim(),
cellBuilder: (_, user) => UserTableUserCell(user: user),
),
AppDataColumn(
id: 'employee_code',
label: 'Employee Code',
sortKey: 'employee_code',
flex: 1,
alignment: Alignment.centerRight,
searchText: (user) => user.employeeCode,
cellBuilder: (_, user) => EmployeeCodeBadge(code: user.employeeCode),
),
AppDataColumn(
id: 'role',
label: 'Role',
flex: 2,
padding: const EdgeInsets.only(left: 8),
searchText: (user) =>
'${user.roleNames.join(' ')} ${user.roleLabel}'.trim(),
cellBuilder: (_, user) => UserRolesCell(roles: user.roleNames),
),
AppDataColumn(
id: 'department',
label: 'Department',
flex: 2,
searchText: (user) => user.departmentName ?? '',
cellBuilder: (_, user) => Text(user.departmentLabel),
),
AppDataColumn(
id: 'last_login',
label: 'Last Login',
flex: 2,
searchText: (user) => DateFormatter.searchableDate(user.lastLoginAt),
cellBuilder: (_, user) => Text(
DateFormatter.formatUserLastLogin(user.lastLoginAt),
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
AppDataColumn(
id: 'status',
label: 'Status',
flex: 1,
searchText: (user) => user.status,
cellBuilder: (_, user) => AppStatusChip(
status: user.status,
compact: true,
forTable: true,
),
),
AppDataColumn(
id: 'actions',
label: 'Actions',
flex: 1,
alignment: Alignment.centerRight,
enableSearch: false,
locked: true,
includeInColumnSelector: false,
cellBuilder: (context, user) => actionsBuilder(context, user),
),
];
}
@override
Widget build(BuildContext context, WidgetRef ref) {
Widget build(BuildContext context) {
final theme = Theme.of(context);
final prefs = ref.watch(tableColumnPrefsProvider(tableId));
final columns = resolveAppDataColumns(_allColumns(theme), prefs);
return AppDataTable<ManagedUserModel>(
wrapInCard: wrapInCard,
sortColumn: sortColumn,
sortAscending: sortAscending,
onSort: onSort,
onServerSearchChanged: onServerSearchChanged,
onEnsureFullDataset: onEnsureFullDataset,
onColumnSearchCleared: onColumnSearchCleared,
columns: columns,
columns: [
AppDataColumn(
label: 'User',
sortKey: 'full_name',
flex: 3,
searchText: (user) =>
'${user.fullName} ${user.email}'.trim(),
cellBuilder: (_, user) => UserTableUserCell(user: user),
),
AppDataColumn(
label: 'Employee Code',
sortKey: 'employee_code',
flex: 1,
alignment: Alignment.centerRight,
searchText: (user) => user.employeeCode,
cellBuilder: (_, user) => EmployeeCodeBadge(code: user.employeeCode),
),
AppDataColumn(
label: 'Role',
flex: 2,
padding: const EdgeInsets.only(left: 8),
searchText: (user) => user.roleNames.join(' '),
cellBuilder: (_, user) => UserRolesCell(roles: user.roleNames),
),
AppDataColumn(
label: 'Department',
flex: 2,
searchText: (user) => user.departmentLabel,
cellBuilder: (_, user) => Text(user.departmentLabel),
),
AppDataColumn(
label: 'Last Login',
flex: 2,
searchText: (user) =>
DateFormatter.formatUserLastLogin(user.lastLoginAt),
cellBuilder: (_, user) => Text(
DateFormatter.formatUserLastLogin(user.lastLoginAt),
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
AppDataColumn(
label: 'Status',
flex: 1,
searchText: (user) => user.status,
cellBuilder: (_, user) => AppStatusChip(
status: user.status,
compact: true,
forTable: true,
),
),
AppDataColumn(
label: 'Actions',
flex: 1,
alignment: Alignment.centerRight,
enableSearch: false,
cellBuilder: (context, user) => actionsBuilder(context, user),
),
],
rows: users,
);
}

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