This commit is contained in:
Surendiran 2026-07-22 18:17:43 +05:30
parent 1d44187942
commit 0978f9bfac
57 changed files with 3662 additions and 2080 deletions

View File

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

View File

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

View File

@ -0,0 +1,54 @@
import 'table_search.dart';
/// Tracks full-dataset mode for table column search.
///
/// First activation loads every row with `limit = total`. Further typing
/// filters client-side. Clearing restores the previous page size.
class ColumnSearchPaging {
ColumnSearchPaging({this.defaultLimit = 20});
final int defaultLimit;
int? _previousLimit;
bool _active = false;
bool get isActive => _active;
/// Marks full-dataset mode and returns the limit to fetch, or `null` if
/// already active.
int? beginFullDataset({
required int currentLimit,
required int total,
}) {
if (_active) return null;
_active = true;
_previousLimit ??= currentLimit;
return total > 0 ? total : currentLimit;
}
/// Returns the page size to restore, or `null` if not in full-dataset mode.
int? endFullDataset() {
if (!_active) return null;
_active = false;
final restored = _previousLimit ?? defaultLimit;
_previousLimit = null;
return restored;
}
/// Legacy helper used by older `setColumnSearch` call sites.
({String? search, int limit}) apply({
required String search,
required int currentLimit,
required int total,
}) {
final normalized = TableSearch.normalize(search);
if (normalized.isEmpty) {
final restored = endFullDataset() ?? defaultLimit;
return (search: null, limit: restored);
}
final limit = beginFullDataset(currentLimit: currentLimit, total: total);
return (
search: normalized,
limit: limit ?? (total > 0 ? total : currentLimit),
);
}
}

View File

@ -42,6 +42,22 @@ 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 {
@ -57,6 +73,12 @@ class CurrencyFormatter {
if (amount == null) return '-';
return _formatter.format(amount);
}
/// Formatted + raw numeric text for client-side column search.
static String searchable(double? amount) {
if (amount == null) return '';
return '${format(amount)} $amount';
}
}
/// Converts `snake_case` / `kebab-case` keys into readable Title Case labels.

View File

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

View File

@ -67,6 +67,27 @@ final assetFormLookupsProvider =
);
});
/// Lightweight lookups for Asset Master list filters only.
/// Avoids [assetFormLookupsProvider] (PO/GRN/vendors/users/options) on the list.
final assetListFilterLookupsProvider = FutureProvider<
({
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);

View File

@ -1,5 +1,6 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/utils/column_search_paging.dart';
import '../../../../core/utils/table_search.dart';
import '../../../../shared/models/asset_model.dart';
@ -59,6 +60,8 @@ final assetsListProvider =
);
class AssetsListNotifier extends AutoDisposeAsyncNotifier<AssetsListState> {
final _columnSearch = ColumnSearchPaging();
@override
Future<AssetsListState> build() async {
return _load(const AssetListQuery(limit: 20));
@ -134,6 +137,27 @@ class AssetsListNotifier extends AutoDisposeAsyncNotifier<AssetsListState> {
);
}
Future<void> ensureColumnSearchDataset() async {
final current = state.valueOrNull;
if (current == null) return;
final limit = _columnSearch.beginFullDataset(
currentLimit: current.query.limit,
total: current.total,
);
if (limit == null) return;
await applyQuery(
current.query.copyWith(search: null, page: 1, limit: limit),
);
}
void clearColumnSearchDataset() {
final current = state.valueOrNull;
if (current == null) return;
final limit = _columnSearch.endFullDataset();
if (limit == null) return;
applyQuery(current.query.copyWith(search: null, page: 1, limit: limit));
}
void setStatusFilter(String? status) {
final current = state.valueOrNull;
if (current == null) return;
@ -199,6 +223,7 @@ class AssetsListNotifier extends AutoDisposeAsyncNotifier<AssetsListState> {
return false;
}
await refresh();
ref.invalidate(myMaintenanceProvider);
final current = state.valueOrNull;
if (current != null) {
state = AsyncData(current.copyWith(actionSuccess: 'Asset deleted'));
@ -331,6 +356,7 @@ class AssetDetailNotifier extends FamilyAsyncNotifier<AssetDetailState, String>
if (result.failure != null) throw result.failure!;
await reload();
ref.invalidate(assetsListProvider);
ref.invalidate(myMaintenanceProvider);
return result.data;
}
@ -339,6 +365,7 @@ class AssetDetailNotifier extends FamilyAsyncNotifier<AssetDetailState, String>
final result = await repository.deleteAsset(arg);
if (result.failure != null) return false;
ref.invalidate(assetsListProvider);
ref.invalidate(myMaintenanceProvider);
return true;
}
@ -476,6 +503,7 @@ class AssetFormNotifier extends AutoDisposeFamilyAsyncNotifier<AssetModel?, Stri
final result = await repository.createAsset(data);
if (result.failure != null) throw result.failure!;
ref.invalidate(assetsListProvider);
ref.invalidate(myMaintenanceProvider);
return result.data;
}
@ -485,6 +513,7 @@ class AssetFormNotifier extends AutoDisposeFamilyAsyncNotifier<AssetModel?, Stri
if (result.failure != null) throw result.failure!;
ref.invalidate(assetsListProvider);
ref.invalidate(assetDetailProvider(id));
ref.invalidate(myMaintenanceProvider);
final fresh = await repository.getAssetById(id);
final asset = fresh.data ?? result.data;
@ -664,11 +693,11 @@ class MyMaintenanceState {
}
final myMaintenanceProvider =
AsyncNotifierProvider<MyMaintenanceNotifier, MyMaintenanceState>(
AsyncNotifierProvider.autoDispose<MyMaintenanceNotifier, MyMaintenanceState>(
MyMaintenanceNotifier.new,
);
class MyMaintenanceNotifier extends AsyncNotifier<MyMaintenanceState> {
class MyMaintenanceNotifier extends AutoDisposeAsyncNotifier<MyMaintenanceState> {
@override
Future<MyMaintenanceState> build() async {
return _load(const MyMaintenanceState());

View File

@ -22,7 +22,7 @@ import '../../../../shared/widgets/page_header.dart';
import '../../../../shared/widgets/app_side_panel.dart';
import '../providers/assets_provider.dart';
import '../providers/asset_form_lookups_provider.dart';
import '../widgets/asset_form_panel.dart';
import 'asset_form_screen.dart';
import '../widgets/asset_maintenance_panel.dart';
import '../widgets/asset_side_panels.dart';
import '../../../../shared/widgets/app_toast.dart';
@ -39,6 +39,7 @@ class AssetDetailScreen extends ConsumerStatefulWidget {
class _AssetDetailScreenState extends ConsumerState<AssetDetailScreen>
with SingleTickerProviderStateMixin {
late final TabController _tabController;
bool _requestedFreshLoad = false;
@override
void initState() {
@ -46,6 +47,23 @@ class _AssetDetailScreenState extends ConsumerState<AssetDetailScreen>
_tabController = TabController(length: 4, vsync: this);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_requestedFreshLoad) return;
_requestedFreshLoad = true;
// Always hit GET /assets/{id} (+ related) when opening view.
ref.invalidate(assetDetailProvider(widget.assetId));
}
@override
void didUpdateWidget(covariant AssetDetailScreen oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.assetId != widget.assetId) {
ref.invalidate(assetDetailProvider(widget.assetId));
}
}
@override
void dispose() {
_tabController.dispose();
@ -81,7 +99,7 @@ class _AssetDetailScreenState extends ConsumerState<AssetDetailScreen>
if (canEdit)
OutlinedButton.icon(
onPressed: () =>
openAssetFormPanel(context, ref, assetId: widget.assetId),
openAssetForm(context, ref, assetId: widget.assetId),
icon: const Icon(Icons.edit_outlined),
label: const Text('Edit'),
),
@ -342,6 +360,7 @@ class _OverviewTab extends ConsumerWidget {
asset: asset,
);
if (saved == true && context.mounted) {
ref.invalidate(myMaintenanceProvider);
showAppToastFromSnackBar(
context,
const SnackBar(

File diff suppressed because it is too large Load Diff

View File

@ -29,7 +29,7 @@ import '../../../../shared/utils/file_download_helper.dart';
import '../providers/asset_categories_provider.dart';
import '../providers/asset_form_lookups_provider.dart';
import '../providers/assets_provider.dart';
import '../widgets/asset_form_panel.dart';
import 'asset_form_screen.dart';
import '../../../../shared/widgets/app_toast.dart';
class AssetListScreen extends ConsumerStatefulWidget {
@ -45,6 +45,10 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
@override
Widget build(BuildContext context) {
final assetsAsync = ref.watch(assetsListProvider);
final filterLookups =
ref.watch(assetListFilterLookupsProvider).valueOrNull;
final allCategories =
ref.watch(itemCategoriesProvider).valueOrNull ?? [];
final canEdit = ref.can('assets', PermissionAction.update);
final canDelete = ref.can('assets', PermissionAction.delete);
final canExport = ref.can('assets', PermissionAction.export);
@ -69,10 +73,8 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
onRetry: () => ref.invalidate(assetsListProvider),
),
data: (state) {
final allCategories =
ref.watch(itemCategoriesProvider).valueOrNull ?? [];
final lookups = ref.watch(assetFormLookupsProvider).valueOrNull;
final allLocations = lookups?.locations ?? const [];
final allLocations = filterLookups?.locations ?? const [];
final statuses = filterLookups?.statuses ?? const [];
final notifier = ref.read(assetsListProvider.notifier);
return Column(
@ -112,7 +114,7 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
module: 'assets',
action: PermissionAction.create,
child: ElevatedButton.icon(
onPressed: () => openAssetFormPanel(context, ref),
onPressed: () => openAssetForm(context, ref),
icon: const Icon(Icons.add),
label: const Text('Add Asset'),
),
@ -151,7 +153,7 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
query: state.query,
categories: allCategories,
locations: allLocations,
statuses: lookups?.statuses ?? const [],
statuses: statuses,
onSearch: notifier.setSearch,
onCategoryChanged: notifier.setCategoryFilter,
onLocationChanged: notifier.setLocationFilter,
@ -166,7 +168,10 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
onView: _viewAsset,
onEdit: _editAsset,
onDelete: _deleteAsset,
onServerSearch: notifier.setSearch,
onEnsureFullDataset: () =>
notifier.ensureColumnSearchDataset(),
onColumnSearchCleared: () =>
notifier.clearColumnSearchDataset(),
),
),
const Divider(height: 1),
@ -200,7 +205,7 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
}
void _editAsset(AssetModel asset) {
openAssetFormPanel(context, ref, assetId: asset.id);
openAssetForm(context, ref, assetId: asset.id);
}
Future<void> _exportAssets() async {
@ -374,7 +379,8 @@ class _AssetDataTable extends StatelessWidget {
required this.onView,
required this.onEdit,
required this.onDelete,
this.onServerSearch,
this.onEnsureFullDataset,
this.onColumnSearchCleared,
});
final List<AssetModel> assets;
@ -383,13 +389,15 @@ class _AssetDataTable extends StatelessWidget {
final void Function(AssetModel asset) onView;
final void Function(AssetModel asset) onEdit;
final Future<void> Function(AssetModel asset) onDelete;
final ValueChanged<String>? onServerSearch;
final Future<void> Function()? onEnsureFullDataset;
final VoidCallback? onColumnSearchCleared;
@override
Widget build(BuildContext context) {
return AppDataTable<AssetModel>(
wrapInCard: false,
onServerSearchChanged: onServerSearch,
onEnsureFullDataset: onEnsureFullDataset,
onColumnSearchCleared: onColumnSearchCleared,
columns: [
AppDataColumn(
label: 'Asset Code',
@ -398,7 +406,10 @@ class _AssetDataTable extends StatelessWidget {
cellBuilder: (_, asset) {
final code = asset.assetCode;
if (code == null || code.isEmpty) return const Text('');
return _AssetCodeBadge(code: code);
return _AssetCodeBadge(
code: code,
onTap: () => onView(asset),
);
},
),
AppDataColumn(
@ -423,7 +434,7 @@ class _AssetDataTable extends StatelessWidget {
label: 'Warranty',
flex: 1,
searchText: (asset) =>
DateFormatter.displayDate(asset.warrantyExpiryDate),
DateFormatter.searchableDate(asset.warrantyExpiryDate),
cellBuilder: (_, asset) => Text(
DateFormatter.displayDate(asset.warrantyExpiryDate),
),
@ -528,7 +539,10 @@ class _AssetMobileList extends StatelessWidget {
),
const SizedBox(height: 4),
if (asset.assetCode != null && asset.assetCode!.isNotEmpty)
_AssetCodeBadge(code: asset.assetCode!)
_AssetCodeBadge(
code: asset.assetCode!,
onTap: () => onView(asset),
)
else
const Text(''),
Text('${asset.assetCategoryName ?? ''} · ${asset.locationName ?? ''}'),
@ -565,26 +579,33 @@ class _AssetMobileList extends StatelessWidget {
}
class _AssetCodeBadge extends StatelessWidget {
const _AssetCodeBadge({required this.code});
const _AssetCodeBadge({required this.code, this.onTap});
final String code;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
final badge = 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(
child: AppTableCell.link(
code,
onTap: onTap,
underlined: false,
style: theme.textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurfaceVariant,
),
),
);
if (onTap == null) return badge;
return MouseRegion(
cursor: SystemMouseCursors.click,
child: badge,
);
}
}

View File

@ -20,11 +20,29 @@ import '../../../../shared/widgets/page_header.dart';
import '../providers/assets_provider.dart';
import '../widgets/asset_maintenance_panel.dart';
class AssetMaintenanceScreen extends ConsumerWidget {
class AssetMaintenanceScreen extends ConsumerStatefulWidget {
const AssetMaintenanceScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<AssetMaintenanceScreen> createState() =>
_AssetMaintenanceScreenState();
}
class _AssetMaintenanceScreenState
extends ConsumerState<AssetMaintenanceScreen> {
bool _requestedFreshLoad = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_requestedFreshLoad) return;
_requestedFreshLoad = true;
// Always refetch My Maintenance when opening this screen.
ref.invalidate(myMaintenanceProvider);
}
@override
Widget build(BuildContext context) {
final maintenanceAsync = ref.watch(myMaintenanceProvider);
return Padding(
@ -53,7 +71,10 @@ class _MyMaintenanceBody extends ConsumerWidget {
AssetModel asset,
) async {
final saved = await openSubmitMaintenancePanel(context, ref, asset: asset);
if (saved == true && context.mounted) {
if (!context.mounted) return;
if (saved == true) {
await ref.read(myMaintenanceProvider.notifier).refresh();
if (!context.mounted) return;
showAppToastFromSnackBar(
context,
const SnackBar(content: Text('Maintenance log submitted')),
@ -174,7 +195,10 @@ class _MaintenanceTable extends StatelessWidget {
label: 'Asset Code',
flex: 1,
searchText: (asset) => asset.assetCode ?? '',
cellBuilder: (_, asset) => Text(asset.assetCode ?? ''),
cellBuilder: (_, asset) => AppTableCell.link(
asset.assetCode,
onTap: () => context.push('${RouteConstants.assets}/${asset.id}'),
),
),
AppDataColumn(
label: 'Asset Name',

File diff suppressed because it is too large Load Diff

View File

@ -45,13 +45,14 @@ class SubmitMaintenanceLogPanel extends ConsumerStatefulWidget {
class _ChecklistRowState {
_ChecklistRowState({
required this.keyName,
required this.label,
});
required this.required,
}) : status = required ? null : 'OK';
final String keyName;
final String label;
String status = 'OK';
final bool required;
/// Null until the user picks a status (required items must choose explicitly).
String? status;
final TextEditingController remarksController = TextEditingController();
void dispose() => remarksController.dispose();
@ -74,10 +75,11 @@ class _SubmitMaintenanceLogPanelState
super.initState();
final checklist = checklistForAsset(widget.asset);
_rows = checklist
.where((item) => item.label.trim().isNotEmpty)
.map(
(item) => _ChecklistRowState(
keyName: item.key.isNotEmpty ? item.key : item.label,
label: item.label.isNotEmpty ? item.label : item.key,
label: item.label.trim(),
required: item.required,
),
)
.toList();
@ -133,17 +135,34 @@ class _SubmitMaintenanceLogPanelState
return;
}
final missingRequired = _rows.where((row) {
if (!row.required) return false;
final statusMissing = row.status == null || row.status!.trim().isEmpty;
final remarksMissing = row.remarksController.text.trim().isEmpty;
return statusMissing || remarksMissing;
}).toList();
if (missingRequired.isNotEmpty) {
showSidePanelSnackBar(
context,
'Complete required checklist items: ${missingRequired.map((r) => r.label).join(', ')}',
);
return;
}
setState(() => _isSubmitting = true);
try {
final payload = <String, dynamic>{
'performed_date': DateFormatter.toApiDate(_performedDate),
'checklist_json': _rows
.map(
(row) => {
'key': row.keyName,
'status': row.status,
if (row.remarksController.text.trim().isNotEmpty)
'remarks': row.remarksController.text.trim(),
(row) {
final remarks = row.remarksController.text.trim();
return <String, dynamic>{
'label': row.label,
'status': row.status,
'remarks': remarks.isEmpty ? null : remarks,
'required': row.required,
};
},
)
.toList(),
@ -376,27 +395,37 @@ class _ChecklistItemCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
row.label,
row.required ? '${row.label} *' : row.label,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 10),
AppDropdown<String>(
label: 'Status',
label: row.required ? 'Status *' : 'Status',
isDense: true,
value: row.status,
hint: 'Select status',
options: const [
AppDropdownOption(value: 'OK', label: 'OK'),
AppDropdownOption(value: 'NOT_OK', label: 'Not OK'),
AppDropdownOption(value: 'NA', label: 'N/A'),
],
onChanged: onStatusChanged,
validator: row.required
? (v) =>
(v == null || v.trim().isEmpty) ? 'Status is required' : null
: null,
),
const SizedBox(height: 10),
AppTextField(
controller: row.remarksController,
label: 'Item remarks',
label: row.required ? 'Item remarks *' : 'Item remarks',
validator: row.required
? (v) => (v == null || v.trim().isEmpty)
? 'Remarks are required for this checklist item'
: null
: null,
),
],
),
@ -454,12 +483,19 @@ class _MaintenanceLogTile extends StatelessWidget {
spacing: 6,
runSpacing: 4,
children: log.checklistJson.map((item) {
final key = item['key']?.toString() ?? 'Item';
final status = item['status']?.toString() ?? '';
final label = item['label']?.toString().trim();
final rawStatus = item['status']?.toString().trim() ?? '';
final status = rawStatus.replaceAll('_', ' ');
final remarks = item['remarks']?.toString().trim();
final title =
(label != null && label.isNotEmpty) ? label : 'Item';
final chipText = (remarks != null && remarks.isNotEmpty)
? '$title: $status$remarks'
: '$title: $status';
return Chip(
visualDensity: VisualDensity.compact,
label: Text(
'$key: $status',
chipText,
style: theme.textTheme.labelSmall,
),
padding: EdgeInsets.zero,

View File

@ -135,12 +135,20 @@ class _AddAmcPanelState extends ConsumerState<AddAmcPanel> {
Future<void> _pickDate({
required DateTime? current,
required void Function(DateTime date) onPicked,
DateTime? firstDate,
DateTime? lastDate,
}) async {
final first = firstDate ?? DateTime(2000);
final last = lastDate ?? DateTime(2100);
var initial = current ?? DateTime.now();
if (initial.isBefore(first)) initial = first;
if (initial.isAfter(last)) initial = last;
final picked = await showAppDatePopup(
context: context,
initialDate: current ?? DateTime.now(),
firstDate: DateTime(2000),
lastDate: DateTime(2100),
initialDate: initial,
firstDate: first,
lastDate: last,
helpText: 'Select date',
);
if (picked != null) {
@ -148,12 +156,38 @@ class _AddAmcPanelState extends ConsumerState<AddAmcPanel> {
}
}
void _onStartDatePicked(DateTime date) {
final clearedEnd = _endDate != null &&
DateTime(_endDate!.year, _endDate!.month, _endDate!.day)
.isBefore(DateTime(date.year, date.month, date.day));
_startDate = date;
if (clearedEnd) {
_endDate = null;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
showSidePanelSnackBar(
context,
'End date cleared. Please select an end date on or after the start date.',
);
});
}
}
Future<void> _save() async {
if (!_formKey.currentState!.validate()) return;
if (_startDate == null || _endDate == null) {
showSidePanelSnackBar(context, 'Please select start and end dates');
return;
}
if (DateTime(_endDate!.year, _endDate!.month, _endDate!.day).isBefore(
DateTime(_startDate!.year, _startDate!.month, _startDate!.day),
)) {
showSidePanelSnackBar(
context,
'End date cannot be earlier than start date',
);
return;
}
if (_vendorId == null) {
showSidePanelSnackBar(context, 'Please select a vendor');
@ -262,7 +296,7 @@ class _AddAmcPanelState extends ConsumerState<AddAmcPanel> {
value: _startDate,
onPick: () => _pickDate(
current: _startDate,
onPicked: (date) => setState(() => _startDate = date),
onPicked: _onStartDatePicked,
),
),
right: _SidePanelDateField(
@ -271,7 +305,8 @@ class _AddAmcPanelState extends ConsumerState<AddAmcPanel> {
value: _endDate,
onPick: () => _pickDate(
current: _endDate,
onPicked: (date) => setState(() => _endDate = date),
firstDate: _startDate ?? DateTime(2000),
onPicked: (date) => _endDate = date,
),
),
),
@ -282,7 +317,7 @@ class _AddAmcPanelState extends ConsumerState<AddAmcPanel> {
value: _renewalDate,
onPick: () => _pickDate(
current: _renewalDate,
onPicked: (date) => setState(() => _renewalDate = date),
onPicked: (date) => _renewalDate = date,
),
),
right: AppTextField(
@ -928,12 +963,20 @@ class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
Future<void> _pickDate({
required DateTime? current,
required void Function(DateTime date) onPicked,
DateTime? firstDate,
DateTime? lastDate,
}) async {
final first = firstDate ?? DateTime(2000);
final last = lastDate ?? DateTime(2100);
var initial = current ?? DateTime.now();
if (initial.isBefore(first)) initial = first;
if (initial.isAfter(last)) initial = last;
final picked = await showAppDatePopup(
context: context,
initialDate: current ?? DateTime.now(),
firstDate: DateTime(2000),
lastDate: DateTime(2100),
initialDate: initial,
firstDate: first,
lastDate: last,
helpText: 'Select date',
);
if (picked != null) {
@ -941,6 +984,23 @@ class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
}
}
void _onStartDatePicked(DateTime date) {
final clearedEnd = _endDate != null &&
DateTime(_endDate!.year, _endDate!.month, _endDate!.day)
.isBefore(DateTime(date.year, date.month, date.day));
_startDate = date;
if (clearedEnd) {
_endDate = null;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
showSidePanelSnackBar(
context,
'End date cleared. Please select an end date on or after the start date.',
);
});
}
}
Map<String, dynamic> _buildPayload() {
final sumInsured = double.tryParse(_sumInsuredController.text.trim());
final annualPremium = double.tryParse(_annualPremiumController.text.trim());
@ -977,6 +1037,15 @@ class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
showSidePanelSnackBar(context, 'Please select start and end dates');
return;
}
if (DateTime(_endDate!.year, _endDate!.month, _endDate!.day).isBefore(
DateTime(_startDate!.year, _startDate!.month, _startDate!.day),
)) {
showSidePanelSnackBar(
context,
'End date cannot be earlier than start date',
);
return;
}
setState(() => _isSubmitting = true);
try {
@ -1096,7 +1165,7 @@ class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
value: _startDate,
onPick: () => _pickDate(
current: _startDate,
onPicked: (date) => setState(() => _startDate = date),
onPicked: _onStartDatePicked,
),
),
right: _SidePanelDateField(
@ -1105,7 +1174,8 @@ class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
value: _endDate,
onPick: () => _pickDate(
current: _endDate,
onPicked: (date) => setState(() => _endDate = date),
firstDate: _startDate ?? DateTime(2000),
onPicked: (date) => _endDate = date,
),
),
),
@ -1116,7 +1186,7 @@ class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
value: _renewalDate,
onPick: () => _pickDate(
current: _renewalDate,
onPicked: (date) => setState(() => _renewalDate = date),
onPicked: (date) => _renewalDate = date,
),
),
right: _SidePanelDateField(
@ -1124,7 +1194,7 @@ class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
value: _premiumPaidDate,
onPick: () => _pickDate(
current: _premiumPaidDate,
onPicked: (date) => setState(() => _premiumPaidDate = date),
onPicked: (date) => _premiumPaidDate = date,
),
),
),

View File

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

View File

@ -230,15 +230,10 @@ class _AuditLogsScreenState extends ConsumerState<AuditLogsScreen> {
: _AuditDataTable(
items: state.items,
onView: _viewLog,
onServerSearch: (value) {
_searchController.value = TextEditingValue(
text: value,
selection: TextSelection.collapsed(
offset: value.length,
),
);
notifier.setSearch(value);
},
onEnsureFullDataset: () =>
notifier.ensureColumnSearchDataset(),
onColumnSearchCleared: () =>
notifier.clearColumnSearchDataset(),
),
),
),
@ -379,12 +374,14 @@ class _AuditDataTable extends StatelessWidget {
const _AuditDataTable({
required this.items,
required this.onView,
this.onServerSearch,
this.onEnsureFullDataset,
this.onColumnSearchCleared,
});
final List<AuditLogEntryModel> items;
final void Function(AuditLogEntryModel log) onView;
final ValueChanged<String>? onServerSearch;
final Future<void> Function()? onEnsureFullDataset;
final VoidCallback? onColumnSearchCleared;
@override
Widget build(BuildContext context) {
@ -392,12 +389,13 @@ class _AuditDataTable extends StatelessWidget {
wrapInCard: false,
rows: items,
emptyMessage: 'No audit logs found',
onServerSearchChanged: onServerSearch,
onEnsureFullDataset: onEnsureFullDataset,
onColumnSearchCleared: onColumnSearchCleared,
columns: [
AppDataColumn(
label: 'When',
flex: 2,
searchText: (row) => DateFormatter.displayDateTime(row.performedAt),
searchText: (row) => DateFormatter.searchableDate(row.performedAt),
cellBuilder: (_, row) => AppTableCell.text(
DateFormatter.displayDateTime(row.performedAt),
),

View File

@ -1,5 +1,6 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/utils/column_search_paging.dart';
import '../../../../core/utils/table_search.dart';
import '../../../../shared/models/export_file_result.dart';
@ -61,6 +62,8 @@ final grnListProvider =
);
class GrnListNotifier extends AutoDisposeAsyncNotifier<GrnListState> {
final _columnSearch = ColumnSearchPaging();
@override
Future<GrnListState> build() async {
return _load(const GrnListQuery(limit: 20));
@ -172,6 +175,27 @@ class GrnListNotifier extends AutoDisposeAsyncNotifier<GrnListState> {
applyQuery(current.query.copyWith(search: TableSearch.normalize(search), page: 1));
}
Future<void> ensureColumnSearchDataset() async {
final current = state.valueOrNull;
if (current == null) return;
final limit = _columnSearch.beginFullDataset(
currentLimit: current.query.limit,
total: current.total,
);
if (limit == null) return;
await applyQuery(
current.query.copyWith(search: null, page: 1, limit: limit),
);
}
void clearColumnSearchDataset() {
final current = state.valueOrNull;
if (current == null) return;
final limit = _columnSearch.endFullDataset();
if (limit == null) return;
applyQuery(current.query.copyWith(search: null, page: 1, limit: limit));
}
void setStatusFilter(String? status) {
final current = state.valueOrNull;
if (current == null) return;

View File

@ -32,6 +32,24 @@ class GrnDetailScreen extends ConsumerStatefulWidget {
class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> {
bool _isWorking = false;
bool _isDownloadingPdf = false;
bool _requestedFreshLoad = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_requestedFreshLoad) return;
_requestedFreshLoad = true;
// Always hit GET /grn/{id} when opening view.
ref.invalidate(grnDetailProvider(widget.grnId));
}
@override
void didUpdateWidget(covariant GrnDetailScreen oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.grnId != widget.grnId) {
ref.invalidate(grnDetailProvider(widget.grnId));
}
}
@override
Widget build(BuildContext context) {
@ -52,13 +70,10 @@ class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> {
data: (grn) {
final lookups = lookupsAsync.asData?.value;
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 32),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1200),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_DetailHeader(
grn: grn,
isWorking: _isWorking,
@ -100,8 +115,6 @@ class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> {
const SizedBox(height: 20),
_DetailFooter(grn: grn),
],
),
),
),
);
},

View File

@ -56,18 +56,29 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
final List<GrnLineItemDraft> _lines = [];
bool _isSubmitting = false;
String? _populatedSignature;
bool _requestedFreshLoad = false;
@override
void initState() {
super.initState();
if (!widget.isEditing) {
_grnDate = DateTime.now();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) ref.invalidate(grnLookupsProvider);
});
}
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_requestedFreshLoad) return;
_requestedFreshLoad = true;
if (!widget.isEditing) {
ref.invalidate(grnLookupsProvider);
return;
}
// Always hit GET /grn/{id} when opening edit.
ref.invalidate(grnFormProvider(widget.grnId));
}
@override
void dispose() {
_scrollController.dispose();

View File

@ -158,15 +158,12 @@ class _GrnListScreenState extends ConsumerState<GrnListScreen> {
grns: state.grns,
onView: _viewGrn,
onEdit: canEdit ? _editGrn : null,
onServerSearch: (value) {
_searchController.value = TextEditingValue(
text: value,
selection: TextSelection.collapsed(
offset: value.length,
),
);
ref.read(grnListProvider.notifier).setSearch(value);
},
onEnsureFullDataset: () => ref
.read(grnListProvider.notifier)
.ensureColumnSearchDataset(),
onColumnSearchCleared: () => ref
.read(grnListProvider.notifier)
.clearColumnSearchDataset(),
),
),
),
@ -297,37 +294,50 @@ class _GrnDataTable extends StatelessWidget {
required this.grns,
required this.onView,
this.onEdit,
this.onServerSearch,
this.onEnsureFullDataset,
this.onColumnSearchCleared,
});
final List<GrnModel> grns;
final ValueChanged<GrnModel> onView;
final ValueChanged<GrnModel>? onEdit;
final ValueChanged<String>? onServerSearch;
final Future<void> Function()? onEnsureFullDataset;
final VoidCallback? onColumnSearchCleared;
@override
Widget build(BuildContext context) {
return AppDataTable<GrnModel>(
wrapInCard: false,
onServerSearchChanged: onServerSearch,
onEnsureFullDataset: onEnsureFullDataset,
onColumnSearchCleared: onColumnSearchCleared,
columns: [
AppDataColumn(
label: 'GRN Number',
flex: 2,
searchText: (grn) => grn.grnNumber ?? '',
cellBuilder: (_, grn) => Text(grn.grnNumber ?? ''),
cellBuilder: (_, grn) => AppTableCell.link(
grn.grnNumber,
onTap: () => onView(grn),
),
),
AppDataColumn(
label: 'Date',
flex: 1,
searchText: (grn) => DateFormatter.displayDate(grn.grnDate),
searchText: (grn) => DateFormatter.searchableDate(grn.grnDate),
cellBuilder: (_, grn) => Text(DateFormatter.displayDate(grn.grnDate)),
),
AppDataColumn(
label: 'PO Number',
flex: 2,
searchText: (grn) => grn.poNumber ?? '',
cellBuilder: (_, grn) => Text(grn.poNumber ?? ''),
cellBuilder: (_, grn) => AppTableCell.link(
grn.poNumber,
onTap: grn.poId == null
? null
: () => context.push(
'${RouteConstants.purchaseOrders}/${grn.poId}',
),
),
),
AppDataColumn(
label: 'Vendor',
@ -404,18 +414,26 @@ class _GrnCardList extends StatelessWidget {
Row(
children: [
Expanded(
child: Text(
child: AppTableCell.link(
grn.grnNumber ?? 'GRN #${grn.id}',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
onTap: () => onView(grn),
),
),
GrnStatusChip(status: grn.status, compact: true),
],
),
const SizedBox(height: 8),
Text('PO: ${grn.poNumber ?? ''}'),
if (grn.poNumber != null && grn.poNumber!.trim().isNotEmpty)
AppTableCell.link(
'PO: ${grn.poNumber}',
onTap: grn.poId == null
? null
: () => context.push(
'${RouteConstants.purchaseOrders}/${grn.poId}',
),
)
else
const Text('PO: —'),
Text('Vendor: ${grn.vendorName ?? ''}'),
Text('Location: ${grn.locationName ?? ''}'),
Text('Date: ${DateFormatter.displayDate(grn.grnDate)}'),

View File

@ -226,13 +226,14 @@ const masterDefinitions = <MasterDefinition>[
showInList: true,
showInForm: false,
),
MasterFieldDef(key: 'item_name', label: 'Item Name', required: true, showInList: true),
MasterFieldDef(
key: 'is_asset_item',
label: 'Asset Item',
type: MasterFieldType.boolean,
required: true,
showInList: true,
),
MasterFieldDef(key: 'item_name', label: 'Item Name', required: true, showInList: true),
MasterFieldDef(
key: 'item_category_id',
label: 'Category',
@ -591,6 +592,10 @@ String masterCellValue(Map<String, dynamic> row, MasterFieldDef field) {
final value = row[field.key];
if (value == null || value == '') return '';
if (field.key == 'is_asset_item') {
return masterIsAssetItem(value) ? 'Asset' : 'Stock';
}
if (field.key == 'tags') {
if (value is List) {
final tags = value
@ -670,6 +675,13 @@ String masterStatusValue(Map<String, dynamic> row) {
return 'active';
}
bool masterIsAssetItem(Object? value) {
if (value == true || value == 1) return true;
if (value == false || value == 0) return false;
final text = value?.toString().trim().toLowerCase() ?? '';
return text == 'true' || text == '1' || text == 'yes';
}
/// Category list filter for Items form: ASSET when Asset Item is checked.
String itemCategoryTypeForValues(Map<String, dynamic> values) =>
values['is_asset_item'] == true ? 'ASSET' : 'STOCK';

View File

@ -1,6 +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/pagination_meta.dart';
import '../../../../core/utils/table_search.dart';
import '../../../../shared/models/export_file_result.dart';
@ -95,6 +96,10 @@ final masterListProvider = AsyncNotifierProvider.family<
MasterListNotifier, MasterListState, String>(MasterListNotifier.new);
class MasterListNotifier extends FamilyAsyncNotifier<MasterListState, String> {
final _columnSearch = ColumnSearchPaging(
defaultLimit: AppConstants.defaultPageSize,
);
MasterDefinition get _definition {
final def = masterDefinitionById(arg);
if (def == null) throw StateError('Unknown master: $arg');
@ -184,6 +189,25 @@ 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;

View File

@ -264,15 +264,10 @@ class _MasterListScreenState extends ConsumerState<MasterListScreen> {
canDelete: canDelete,
onEdit: (id) => _openFormPanel(recordId: id),
onDelete: _deleteRecord,
onServerSearch: (value) {
_searchController.value = TextEditingValue(
text: value,
selection: TextSelection.collapsed(
offset: value.length,
),
);
notifier.setSearch(value);
},
onEnsureFullDataset: () =>
notifier.ensureColumnSearchDataset(),
onColumnSearchCleared: () =>
notifier.clearColumnSearchDataset(),
),
),
),
@ -294,7 +289,8 @@ class _MasterListTable extends StatelessWidget {
required this.canDelete,
required this.onEdit,
required this.onDelete,
this.onServerSearch,
this.onEnsureFullDataset,
this.onColumnSearchCleared,
});
final MasterDefinition definition;
@ -304,7 +300,8 @@ class _MasterListTable extends StatelessWidget {
final bool canDelete;
final ValueChanged<String> onEdit;
final ValueChanged<Map<String, dynamic>> onDelete;
final ValueChanged<String>? onServerSearch;
final Future<void> Function()? onEnsureFullDataset;
final VoidCallback? onColumnSearchCleared;
@override
Widget build(BuildContext context) {
@ -312,15 +309,28 @@ class _MasterListTable extends StatelessWidget {
return AppDataTable<Map<String, dynamic>>(
wrapInCard: false,
onServerSearchChanged: onServerSearch,
onEnsureFullDataset: onEnsureFullDataset,
onColumnSearchCleared: onColumnSearchCleared,
emptyMessage: 'No ${definition.title.toLowerCase()} found',
columns: [
...definition.listFields.map(
(field) => AppDataColumn<Map<String, dynamic>>(
label: field.label,
label: field.key == 'is_asset_item' ? 'Type' : field.label,
flex: _columnFlex(field),
searchText: (row) => masterCellValue(row, field),
cellBuilder: (_, row) => Text(masterCellValue(row, field)),
cellBuilder: (_, row) {
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(
@ -369,6 +379,7 @@ class _MasterListTable extends StatelessWidget {
int _columnFlex(MasterFieldDef field) {
return switch (field.key) {
'code' || 'item_code' || 'series_code' || 'hsn_code_id' => 1,
'is_asset_item' => 1,
'name' || 'item_name' || 'description' || 'term_name' => 3,
_ => 2,
};

View File

@ -106,6 +106,39 @@ class MasterRemoteDataSource {
Future<List<FilterOptionModel>> listPaymentTerms() =>
_listOptions(ApiEndpoints.paymentTerms);
/// Payment terms with `credit_days` for vendor credit-period autofill.
Future<
({
List<FilterOptionModel> options,
Map<int, int> creditDaysById,
})> listPaymentTermsWithCreditDays() async {
final rows = await _listAllMaps(ApiEndpoints.paymentTerms);
final options = <FilterOptionModel>[];
final creditDaysById = <int, int>{};
for (final item in rows) {
if (!isActiveOptionRow(item)) continue;
final id = item['id']?.toString() ?? '';
if (id.isEmpty) continue;
final name = _optionLabel(item);
if (name.isEmpty) continue;
final idInt = int.tryParse(id);
final creditDays = _asInt(
item['credit_days'] ??
item['credit_period_days'] ??
item['days'],
);
if (idInt != null && creditDays != null) {
creditDaysById[idInt] = creditDays;
}
options.add(FilterOptionModel(id: id, name: name));
}
return (options: options, creditDaysById: creditDaysById);
}
Future<List<FilterOptionModel>> listDeliveryTerms() =>
_listOptions(ApiEndpoints.deliveryTerms);

View File

@ -171,6 +171,25 @@ class PurchaseOrderRemoteDataSource {
);
}
/// POST /notifications/trigger send PO_SUBMIT_APPROVAL emails.
Future<String> triggerApprovalNotification(String poId) async {
final response = await dio.post(
ApiEndpoints.notificationsTrigger,
data: {
'template_code': 'PO_SUBMIT_APPROVAL',
'po_id': int.tryParse(poId) ?? poId,
},
);
final body = response.data;
if (body is Map) {
final message = body['message'];
if (message is String && message.trim().isNotEmpty) {
return message.trim();
}
}
return 'Approval notification sent';
}
Future<List<int>> downloadPurchaseOrderPdf(String id) async {
final response = await dio.get<List<int>>(
ApiEndpoints.purchaseOrderPdf(id),

View File

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

View File

@ -34,6 +34,11 @@ abstract class PurchaseOrderRepository {
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,14 +1,25 @@
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';
import 'purchase_order_lookups_provider.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({
@ -68,6 +79,8 @@ final pendingApprovalPurchaseOrdersListProvider =
class PurchaseOrdersListNotifier
extends AutoDisposeAsyncNotifier<PurchaseOrdersListState> {
final _columnSearch = ColumnSearchPaging();
@override
Future<PurchaseOrdersListState> build() async {
return _load(const PurchaseOrderListQuery(limit: 20));
@ -120,6 +133,28 @@ class PurchaseOrdersListNotifier
applyQuery(current.query.copyWith(search: TableSearch.normalize(search), page: 1));
}
/// Column search: load all rows once (`limit = total`), then filter client-side.
Future<void> ensureColumnSearchDataset() async {
final current = state.valueOrNull;
if (current == null) return;
final limit = _columnSearch.beginFullDataset(
currentLimit: current.query.limit,
total: current.total,
);
if (limit == null) return;
await applyQuery(
current.query.copyWith(search: null, page: 1, limit: limit),
);
}
void clearColumnSearchDataset() {
final current = state.valueOrNull;
if (current == null) return;
final limit = _columnSearch.endFullDataset();
if (limit == null) return;
applyQuery(current.query.copyWith(search: null, page: 1, limit: limit));
}
void setStatusFilter(String? status) {
final current = state.valueOrNull;
if (current == null) return;
@ -181,10 +216,34 @@ class PurchaseOrdersListNotifier
}
return true;
}
Future<bool> approvePurchaseOrder(String id, {String? remarks}) async {
final repository = ref.read(purchaseOrderRepositoryProvider);
final result = await repository.approvePurchaseOrder(id, remarks: remarks);
if (result.failure != null) {
final current = state.valueOrNull;
if (current != null) {
state = AsyncData(current.copyWith(actionError: result.failure!.message));
}
return false;
}
ref.invalidate(pendingApprovalPurchaseOrdersListProvider);
ref.invalidate(grnLookupsProvider);
await refresh();
final current = state.valueOrNull;
if (current != null) {
state = AsyncData(
current.copyWith(actionSuccess: 'Purchase order approved'),
);
}
return true;
}
}
class PendingApprovalPurchaseOrdersListNotifier
extends AutoDisposeAsyncNotifier<PurchaseOrdersListState> {
final _columnSearch = ColumnSearchPaging();
@override
Future<PurchaseOrdersListState> build() async {
return _load(const PurchaseOrderListQuery(limit: 20));
@ -237,6 +296,28 @@ class PendingApprovalPurchaseOrdersListNotifier
applyQuery(current.query.copyWith(search: TableSearch.normalize(search), page: 1));
}
/// Column search: load all rows once (`limit = total`), then filter client-side.
Future<void> ensureColumnSearchDataset() async {
final current = state.valueOrNull;
if (current == null) return;
final limit = _columnSearch.beginFullDataset(
currentLimit: current.query.limit,
total: current.total,
);
if (limit == null) return;
await applyQuery(
current.query.copyWith(search: null, page: 1, limit: limit),
);
}
void clearColumnSearchDataset() {
final current = state.valueOrNull;
if (current == null) return;
final limit = _columnSearch.endFullDataset();
if (limit == null) return;
applyQuery(current.query.copyWith(search: null, page: 1, limit: limit));
}
void setPage(int page) {
final current = state.valueOrNull;
if (current == null) return;
@ -346,6 +427,14 @@ class PurchaseOrderDetailNotifier
return result.data!;
}
/// Sends PO_SUBMIT_APPROVAL notification emails to approvers.
Future<String> triggerApprovalNotification() async {
final repository = ref.read(purchaseOrderRepositoryProvider);
final result = await repository.triggerApprovalNotification(arg);
if (result.failure != null) throw result.failure!;
return result.data!;
}
Future<List<int>> downloadPdf() async {
final repository = ref.read(purchaseOrderRepositoryProvider);
final result = await repository.downloadPurchaseOrderPdf(arg);
@ -455,10 +544,17 @@ Future<List<PurchaseOrderModel>> _enrichPurchaseOrderSearch({
var merged = List<PurchaseOrderModel>.from(items);
try {
final lookups = await ref.read(purchaseOrderLookupsProvider.future);
// 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 lookups.vendors) {
if (!TableSearch.matches(search, [vendor.name])) continue;
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;
@ -474,7 +570,7 @@ Future<List<PurchaseOrderModel>> _enrichPurchaseOrderSearch({
}
}
} catch (_) {
// Lookups/vendor enrichment is best-effort.
// Vendor enrichment is best-effort.
}
return merged;

View File

@ -38,6 +38,26 @@ class _PurchaseOrderDetailScreenState
extends ConsumerState<PurchaseOrderDetailScreen> {
bool _isWorking = false;
bool _isDownloadingPdf = false;
bool _requestedFreshLoad = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_requestedFreshLoad) return;
_requestedFreshLoad = true;
// Always hit GET /purchase-orders/{id} when opening view.
ref.invalidate(purchaseOrderDetailProvider(widget.purchaseOrderId));
ref.invalidate(purchaseOrderAttachmentsProvider(widget.purchaseOrderId));
}
@override
void didUpdateWidget(covariant PurchaseOrderDetailScreen oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.purchaseOrderId != widget.purchaseOrderId) {
ref.invalidate(purchaseOrderDetailProvider(widget.purchaseOrderId));
ref.invalidate(purchaseOrderAttachmentsProvider(widget.purchaseOrderId));
}
}
@override
Widget build(BuildContext context) {
@ -82,6 +102,7 @@ class _PurchaseOrderDetailScreenState
'${RouteConstants.purchaseOrders}/${order.id}/edit',
),
onSubmit: () => _submit(order),
onNotify: () => _notifyApprovers(order),
onApprove: () => _approve(order),
onReject: () => _reject(order),
onAmend: () => _amend(order),
@ -162,6 +183,37 @@ class _PurchaseOrderDetailScreenState
);
}
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) {
final message = e is Failure ? e.message : e.toString();
showAppToastFromSnackBar(
context,
SnackBar(content: Text(message)),
);
}
} finally {
if (mounted) setState(() => _isWorking = false);
}
}
Future<void> _approve(PurchaseOrderModel order) async {
await _runWorkflow(
() => ref
@ -386,6 +438,7 @@ class _DetailHeader extends StatelessWidget {
required this.onPdf,
required this.onEdit,
required this.onSubmit,
required this.onNotify,
required this.onApprove,
required this.onReject,
required this.onAmend,
@ -404,6 +457,7 @@ class _DetailHeader extends StatelessWidget {
final VoidCallback onPdf;
final VoidCallback onEdit;
final VoidCallback onSubmit;
final VoidCallback onNotify;
final VoidCallback onApprove;
final VoidCallback onReject;
final VoidCallback onAmend;
@ -446,6 +500,12 @@ class _DetailHeader extends StatelessWidget {
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',

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
@ -62,6 +63,7 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
bool _isSubmitting = false;
String? _populatedSignature;
bool _defaultTermsApplied = false;
bool _requestedFreshLoad = false;
@override
void initState() {
@ -75,6 +77,15 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
_otherChargesController.addListener(_onChargesChanged);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_requestedFreshLoad || !widget.isEditing) return;
_requestedFreshLoad = true;
// Always hit GET /purchase-orders/{id} when opening edit.
ref.invalidate(purchaseOrderFormProvider(widget.purchaseOrderId));
}
@override
void dispose() {
_discountController.removeListener(_onChargesChanged);
@ -206,6 +217,12 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
}
Map<String, dynamic> _buildPayload() {
final discount =
(double.tryParse(_discountController.text.trim()) ?? 0).clamp(0, double.infinity);
final freight =
(double.tryParse(_freightController.text.trim()) ?? 0).clamp(0, double.infinity);
final other =
(double.tryParse(_otherChargesController.text.trim()) ?? 0).clamp(0, double.infinity);
return {
'po_date': DateFormatter.toApiDate(_poDate ?? DateTime.now()),
'vendor_id': _vendorId,
@ -216,12 +233,9 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
if (_expectedDeliveryDate != null)
'expected_delivery_date':
DateFormatter.toApiDate(_expectedDeliveryDate!),
'discount_amount':
double.tryParse(_discountController.text.trim()) ?? 0,
'freight_charges':
double.tryParse(_freightController.text.trim()) ?? 0,
'other_charges':
double.tryParse(_otherChargesController.text.trim()) ?? 0,
'discount_amount': discount,
'freight_charges': freight,
'other_charges': other,
if (_termsController.text.trim().isNotEmpty)
'terms_and_conditions': _termsController.text.trim(),
if (_remarksController.text.trim().isNotEmpty)
@ -242,6 +256,28 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
if (rate == null || rate < 0) {
return 'Enter a valid rate for line ${line.lineNo}';
}
final discPct = double.tryParse(line.discountController.text.trim()) ?? 0;
if (discPct < 0 || discPct > 100) {
return 'Discount % on line ${line.lineNo} must be between 0 and 100';
}
}
return null;
}
String? _chargesError(PoOrderTotals totals) {
final freight = double.tryParse(_freightController.text.trim());
if (freight == null) return 'Enter a valid freight amount';
if (freight < 0) return 'Freight charges cannot be negative';
final other = double.tryParse(_otherChargesController.text.trim());
if (other == null) return 'Enter a valid other charges amount';
if (other < 0) return 'Other charges cannot be negative';
final discount = double.tryParse(_discountController.text.trim());
if (discount == null) return 'Enter a valid discount amount';
if (discount < 0) return 'Discount amount cannot be negative';
if (discount > totals.maxDiscountAmount) {
return 'Discount cannot exceed ${CurrencyFormatter.format(totals.maxDiscountAmount)}';
}
return null;
}
@ -260,6 +296,20 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
return;
}
if (_poDate != null &&
_expectedDeliveryDate != null &&
_expectedDeliveryDate!.isBefore(_poDate!)) {
showAppToastFromSnackBar(
context,
const SnackBar(
content: Text(
'Expected delivery date cannot be earlier than PO date',
),
),
);
return;
}
if (_vendorId == null || _billingId == null || _shippingId == null) {
showAppToastFromSnackBar(context,
const SnackBar(content: Text('Please complete all required fields')),
@ -282,6 +332,14 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
return;
}
final lookups = ref.read(purchaseOrderLookupsProvider).valueOrNull;
final totals = _computeTotals(lookups?.gstRatePctById ?? const {});
final chargesError = _chargesError(totals);
if (chargesError != null) {
showAppToastFromSnackBar(context, SnackBar(content: Text(chargesError)));
return;
}
setState(() => _isSubmitting = true);
try {
final payload = _buildPayload();
@ -330,12 +388,14 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
Future<void> _pickDate({
required DateTime? current,
required ValueChanged<DateTime?> onPicked,
DateTime? firstDate,
DateTime? lastDate,
}) async {
final picked = await showAppDatePopup(
context: context,
initialDate: current ?? DateTime.now(),
firstDate: DateTime(2020),
lastDate: DateTime(2100),
firstDate: firstDate ?? DateTime(2020),
lastDate: lastDate ?? DateTime(2100),
helpText: 'Select date',
);
if (picked != null) onPicked(picked);
@ -398,9 +458,11 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
child: QuickAddInlineHost(
child: QuickAddBlockable(
child: ResponsiveFormGrid(
xsColumns: 1,
smallColumns: 1,
mediumColumns: 2,
largeColumns: 4,
smallBreakpoint: 640,
mediumBreakpoint: 640,
largeBreakpoint: 1100,
children: [
@ -529,6 +591,7 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
value: _expectedDeliveryDate,
onTap: () => _pickDate(
current: _expectedDeliveryDate,
firstDate: _poDate ?? DateTime(2020),
onPicked: (d) => setState(
() => _expectedDeliveryDate = d,
),
@ -852,6 +915,15 @@ class _AmountSummaryCard extends StatelessWidget {
final bool isEditing;
final bool isInterState;
String? _validateNonNegativeAmount(String? value, String fieldName) {
final text = value?.trim() ?? '';
if (text.isEmpty) return null;
final amount = double.tryParse(text);
if (amount == null) return 'Enter a valid amount';
if (amount < 0) return 'Cannot be negative';
return null;
}
String? _validateDiscount(String? value) {
final text = value?.trim() ?? '';
if (text.isEmpty) return null;
@ -915,10 +987,14 @@ class _AmountSummaryCard extends StatelessWidget {
_SummaryInputRow(
label: 'Freight Charges',
controller: freightController,
validator: (v) => _validateNonNegativeAmount(v, 'Freight'),
autovalidateMode: AutovalidateMode.onUserInteraction,
),
_SummaryInputRow(
label: 'Other Charges',
controller: otherChargesController,
validator: (v) => _validateNonNegativeAmount(v, 'Other charges'),
autovalidateMode: AutovalidateMode.onUserInteraction,
),
_SummaryInputRow(
label: 'Discount Amount',
@ -1057,6 +1133,9 @@ class _SummaryInputRow extends StatelessWidget {
controller: controller,
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*')),
],
textAlign: TextAlign.right,
autovalidateMode: autovalidateMode,
validator: validator,

View File

@ -27,6 +27,7 @@ import '../../../../shared/widgets/app_table_shell.dart';
import '../../../../shared/widgets/error_view.dart';
import '../../../../shared/widgets/page_header.dart';
import '../../../../shared/utils/file_download_helper.dart';
import '../../data/repositories/purchase_order_repository_impl.dart';
import '../providers/purchase_orders_provider.dart';
import '../widgets/po_status_chip.dart';
import '../../../../shared/widgets/app_toast.dart';
@ -76,8 +77,10 @@ class _PurchaseOrderListScreenState extends ConsumerState<PurchaseOrderListScree
!pendingOnly && ref.can('purchase_orders', PermissionAction.delete);
final canExport =
!pendingOnly && ref.can('purchase_orders', PermissionAction.export);
final canApprove =
pendingOnly && ref.can('purchase_orders', PermissionAction.approve);
final canApprove = ref.can('purchase_orders', PermissionAction.approve);
// API: notifications/trigger requires PURCHASE_ORDER edit.
final canNotify =
ref.can('purchase_orders', PermissionAction.update);
void listenListMessages(
AsyncValue<PurchaseOrdersListState>? prev,
@ -241,6 +244,8 @@ class _PurchaseOrderListScreenState extends ConsumerState<PurchaseOrderListScree
onEdit: canEdit ? _editOrder : null,
onDelete: canDelete ? _deleteOrder : null,
onApprove: canApprove ? _approveOrder : null,
onNotify:
canNotify ? _notifyApprovers : null,
))
: _PoDataTable(
orders: state.orders,
@ -248,24 +253,32 @@ class _PurchaseOrderListScreenState extends ConsumerState<PurchaseOrderListScree
onEdit: canEdit ? _editOrder : null,
onDelete: canDelete ? _deleteOrder : null,
onApprove: canApprove ? _approveOrder : null,
onServerSearch: (value) {
_searchController.value = TextEditingValue(
text: value,
selection: TextSelection.collapsed(
offset: value.length,
),
);
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,
)
.setSearch(value);
.clearColumnSearchDataset();
} else {
ref
.read(purchaseOrdersListProvider.notifier)
.setSearch(value);
.clearColumnSearchDataset();
}
},
),
@ -280,8 +293,6 @@ class _PurchaseOrderListScreenState extends ConsumerState<PurchaseOrderListScree
}
void _viewOrder(PurchaseOrderModel order) {
// Always hit GET /purchase-orders/{id} for the detail screen.
ref.invalidate(purchaseOrderDetailProvider(order.id));
context.push('${RouteConstants.purchaseOrders}/${order.id}');
}
@ -342,9 +353,40 @@ class _PurchaseOrderListScreenState extends ConsumerState<PurchaseOrderListScree
confirmLabel: 'Approve',
);
if (confirmed != true || !mounted) return;
await ref
.read(pendingApprovalPurchaseOrdersListProvider.notifier)
.approvePurchaseOrder(order.id);
if (widget.pendingApprovalOnly) {
await ref
.read(pendingApprovalPurchaseOrdersListProvider.notifier)
.approvePurchaseOrder(order.id);
return;
}
await ref.read(purchaseOrdersListProvider.notifier).approvePurchaseOrder(order.id);
}
Future<void> _notifyApprovers(PurchaseOrderModel order) async {
if (!order.canNotifyApprovers) {
showAppToastFromSnackBar(
context,
const SnackBar(
content: Text('Notifications can only be sent for pending approval POs'),
),
);
return;
}
final result = await ref
.read(purchaseOrderRepositoryProvider)
.triggerApprovalNotification(order.id);
if (!mounted) return;
if (result.failure != null) {
showAppToastFromSnackBar(
context,
SnackBar(content: Text(result.failure!.message)),
);
return;
}
showAppToastFromSnackBar(
context,
SnackBar(content: Text(result.data ?? 'Approval notification sent')),
);
}
Future<void> _deleteOrder(PurchaseOrderModel order) async {
@ -447,7 +489,9 @@ class _PoDataTable extends StatelessWidget {
this.onEdit,
this.onDelete,
this.onApprove,
this.onServerSearch,
this.onNotify,
this.onEnsureFullDataset,
this.onColumnSearchCleared,
});
final List<PurchaseOrderModel> orders;
@ -455,25 +499,31 @@ class _PoDataTable extends StatelessWidget {
final ValueChanged<PurchaseOrderModel>? onEdit;
final ValueChanged<PurchaseOrderModel>? onDelete;
final ValueChanged<PurchaseOrderModel>? onApprove;
final ValueChanged<String>? onServerSearch;
final ValueChanged<PurchaseOrderModel>? onNotify;
final Future<void> Function()? onEnsureFullDataset;
final VoidCallback? onColumnSearchCleared;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AppDataTable<PurchaseOrderModel>(
wrapInCard: false,
onServerSearchChanged: onServerSearch,
onEnsureFullDataset: onEnsureFullDataset,
onColumnSearchCleared: onColumnSearchCleared,
columns: [
AppDataColumn(
label: 'PO Number',
flex: 2,
searchText: (order) => order.poNo ?? '',
cellBuilder: (_, order) => Text(order.poNo ?? ''),
cellBuilder: (_, order) => AppTableCell.link(
order.poNo,
onTap: () => onView(order),
),
),
AppDataColumn(
label: 'Date',
flex: 1,
searchText: (order) => DateFormatter.displayDate(order.poDate),
searchText: (order) => DateFormatter.searchableDate(order.poDate),
cellBuilder: (_, order) => Text(DateFormatter.displayDate(order.poDate)),
),
AppDataColumn(
@ -485,7 +535,7 @@ class _PoDataTable extends StatelessWidget {
AppDataColumn(
label: 'Total',
flex: 1,
searchText: (order) => CurrencyFormatter.format(order.totalAmount),
searchText: (order) => CurrencyFormatter.searchable(order.totalAmount),
cellBuilder: (_, order) => SizedBox(
width: double.infinity,
child: AppTableCell.text(
@ -506,7 +556,7 @@ class _PoDataTable extends StatelessWidget {
),
AppDataColumn(
label: 'Actions',
flex: onApprove != null ? 2 : 1,
width: 88,
alignment: Alignment.centerRight,
enableSearch: false,
cellBuilder: (_, order) => AppTableActions(
@ -516,6 +566,12 @@ class _PoDataTable extends StatelessWidget {
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',
@ -551,6 +607,7 @@ class _PoCardList extends StatelessWidget {
this.onEdit,
this.onDelete,
this.onApprove,
this.onNotify,
});
final List<PurchaseOrderModel> orders;
@ -558,6 +615,7 @@ class _PoCardList extends StatelessWidget {
final ValueChanged<PurchaseOrderModel>? onEdit;
final ValueChanged<PurchaseOrderModel>? onDelete;
final ValueChanged<PurchaseOrderModel>? onApprove;
final ValueChanged<PurchaseOrderModel>? onNotify;
@override
Widget build(BuildContext context) {
@ -568,14 +626,26 @@ class _PoCardList extends StatelessWidget {
final order = orders[index];
return AppCard(
child: ListTile(
title: Text(order.poNo ?? 'PO #${order.id}'),
title: AppTableCell.link(
order.poNo ?? 'PO #${order.id}',
onTap: () => onView(order),
),
subtitle: Text(
'${order.vendorName ?? ''} · ${vendorTypeLabel(order.vendorType)}',
),
onTap: () => onView(order),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
PoStatusChip(status: order.status, compact: true),
if (onNotify != null && order.canNotifyApprovers) ...[
const SizedBox(width: 4),
IconButton(
tooltip: 'Notify approvers',
icon: const Icon(Icons.notifications_outlined),
onPressed: () => onNotify!(order),
),
],
if (onApprove != null && order.canApprove) ...[
const SizedBox(width: 4),
IconButton(
@ -589,7 +659,6 @@ class _PoCardList extends StatelessWidget {
],
],
),
onTap: () => onView(order),
),
);
},

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/utils/formatters.dart';
@ -41,15 +42,19 @@ class PoLineCalculation {
required double discPct,
required double gstPct,
}) {
final baseAmount = qty * rate;
final discountAmount = baseAmount * discPct / 100;
final qtySafe = qty < 0 ? 0.0 : qty;
final rateSafe = rate < 0 ? 0.0 : rate;
final discSafe = discPct < 0 ? 0.0 : (discPct > 100 ? 100.0 : discPct);
final gstSafe = gstPct < 0 ? 0.0 : gstPct;
final baseAmount = qtySafe * rateSafe;
final discountAmount = baseAmount * discSafe / 100;
final lineAmount = baseAmount - discountAmount;
final gstAmount = lineAmount * gstPct / 100;
final gstAmount = lineAmount * gstSafe / 100;
return PoLineCalculation(
baseAmount: baseAmount,
discountAmount: discountAmount,
lineAmount: lineAmount,
gstAmount: gstAmount,
lineAmount: lineAmount < 0 ? 0 : lineAmount,
gstAmount: gstAmount < 0 ? 0 : gstAmount,
);
}
}
@ -72,7 +77,7 @@ class PoOrderTotals {
final double taxAmount;
final double grandTotal;
/// Sub Total + Tax + Freight + Other (discount cannot exceed this).
/// Sub Total + Freight + Other (discount cannot exceed this).
final double maxDiscountAmount;
static const zero = PoOrderTotals(
@ -99,20 +104,24 @@ class PoOrderTotals {
subTotal += line.lineAmount;
lineTax += line.gstAmount;
}
// Keep order-level money fields non-negative.
final subSafe = subTotal < 0 ? 0.0 : subTotal;
final lineTaxSafe = lineTax < 0 ? 0.0 : lineTax;
final freightSafe = freight < 0 ? 0.0 : freight;
final otherSafe = otherCharges < 0 ? 0.0 : otherCharges;
final clampedDiscount = discountAmount < 0 ? 0.0 : discountAmount;
final taxableRaw = subTotal + freightSafe + otherSafe - clampedDiscount;
final taxable = taxableRaw < 0 ? 0.0 : taxableRaw;
final maxDiscount = 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 = subTotal > 0 ? lineTax * (taxable / subTotal) : 0.0;
final maxDiscount = subTotal + lineTax + freightSafe + otherSafe;
final tax = subSafe > 0 ? lineTaxSafe * (taxable / subSafe) : 0.0;
final grandTotal = taxable + tax;
return PoOrderTotals(
subTotal: subTotal,
taxableAmount: taxable,
taxAmount: tax,
subTotal: subSafe,
taxableAmount: taxable < 0 ? 0 : taxable,
taxAmount: tax < 0 ? 0 : tax,
grandTotal: grandTotal < 0 ? 0 : grandTotal,
maxDiscountAmount: maxDiscount < 0 ? 0 : maxDiscount,
);
@ -122,9 +131,12 @@ class PoOrderTotals {
class PoLineItemDraft {
PoLineItemDraft({
this.itemId,
this.itemName,
this.itemCode,
required this.lineNo,
TextEditingController? qtyController,
this.uomId,
this.uomName,
TextEditingController? rateController,
TextEditingController? discountController,
this.gstRateId,
@ -135,9 +147,14 @@ class PoLineItemDraft {
discountController ?? TextEditingController(text: '0');
int? itemId;
/// Kept so edit can show the label even if the item is inactive / missing
/// from the active dropdown lookups.
String? itemName;
String? itemCode;
int lineNo;
final TextEditingController qtyController;
int? uomId;
String? uomName;
final TextEditingController rateController;
final TextEditingController discountController;
int? gstRateId;
@ -146,10 +163,13 @@ class PoLineItemDraft {
factory PoLineItemDraft.fromModel(PurchaseOrderItemModel item) {
return PoLineItemDraft(
itemId: item.itemId,
itemName: item.itemName,
itemCode: item.itemCode,
lineNo: item.lineNo ?? 1,
qtyController:
TextEditingController(text: item.orderedQty?.toString() ?? ''),
uomId: item.uomId,
uomName: item.uomName,
rateController: TextEditingController(text: item.rate?.toString() ?? ''),
discountController:
TextEditingController(text: item.discountPct?.toString() ?? '0'),
@ -451,7 +471,22 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
void _onItemChanged(int? itemId) {
_updateLine(() {
widget.line.itemId = itemId;
if (itemId == null) return;
if (itemId == null) {
widget.line.itemName = null;
widget.line.itemCode = null;
return;
}
FilterOptionModel? selected;
for (final e in widget.items) {
if (_parseId(e.id) == itemId) {
selected = e;
break;
}
}
if (selected != null) {
widget.line.itemName = selected.name;
widget.line.itemCode = selected.slug;
}
final key = itemId.toString();
final defaultUom = _itemUomById[key];
if (defaultUom != null) {
@ -471,6 +506,61 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
});
}
List<AppDropdownOption<int>> _itemOptionsWithSelected() {
final options = widget.items
.map((e) {
final id = _parseId(e.id);
if (id == null) return null;
final code = e.slug?.trim();
return AppDropdownOption(
value: id,
label: e.name,
subtitle: (code == null || code.isEmpty) ? null : code,
);
})
.whereType<AppDropdownOption<int>>()
.toList();
final selectedId = widget.line.itemId;
if (selectedId == null) return options;
if (options.any((o) => o.value == selectedId)) return options;
final name = widget.line.itemName?.trim();
final code = widget.line.itemCode?.trim();
return [
AppDropdownOption(
value: selectedId,
label: (name != null && name.isNotEmpty) ? name : 'Item #$selectedId',
subtitle: (code == null || code.isEmpty) ? null : code,
),
...options,
];
}
List<AppDropdownOption<int>> _uomOptionsWithSelected() {
final options = widget.uom
.map((e) {
final id = _parseId(e.id);
if (id == null) return null;
return AppDropdownOption(value: id, label: e.name);
})
.whereType<AppDropdownOption<int>>()
.toList();
final selectedId = widget.line.uomId;
if (selectedId == null) return options;
if (options.any((o) => o.value == selectedId)) return options;
final name = widget.line.uomName?.trim();
return [
AppDropdownOption(
value: selectedId,
label: (name != null && name.isNotEmpty) ? name : 'UOM #$selectedId',
),
...options,
];
}
@override
void didUpdateWidget(covariant _LineItemCard oldWidget) {
super.didUpdateWidget(oldWidget);
@ -500,27 +590,8 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
alpha: isDark ? 0.18 : 0.08,
);
final itemOptions = widget.items
.map((e) {
final id = _parseId(e.id);
if (id == null) return null;
final code = e.slug?.trim();
return AppDropdownOption(
value: id,
label: e.name,
subtitle: (code == null || code.isEmpty) ? null : code,
);
})
.whereType<AppDropdownOption<int>>()
.toList();
final uomOptions = widget.uom
.map((e) {
final id = _parseId(e.id);
if (id == null) return null;
return AppDropdownOption(value: id, label: e.name);
})
.whereType<AppDropdownOption<int>>()
.toList();
final itemOptions = _itemOptionsWithSelected();
final uomOptions = _uomOptionsWithSelected();
final gstOptions = [
const AppDropdownOption<int?>(value: null, label: 'Select GST Rate'),
...widget.gstRates.map((e) {
@ -559,6 +630,9 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
label: 'Qty *',
hint: '0',
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);
@ -589,6 +663,9 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
label: 'Rate *',
hint: '0.00',
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*')),
],
validator: (v) {
if (v == null || v.trim().isEmpty) return 'Required';
final rate = double.tryParse(v);
@ -602,6 +679,17 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
label: 'Disc %',
hint: '0',
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'),
@ -662,9 +750,11 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
// Medium / narrow: wrapping grid (23 columns)
return ResponsiveFormGrid(
spacing: spacing,
xsColumns: 1,
smallColumns: 1,
mediumColumns: 2,
largeColumns: 3,
smallBreakpoint: 520,
mediumBreakpoint: 520,
largeBreakpoint: 800,
children: [

View File

@ -837,13 +837,12 @@ class _UsersTabState extends ConsumerState<_UsersTab> {
child: UserRichDataTable(
wrapInCard: false,
users: usersState.users,
onServerSearchChanged: (value) {
_searchController.value = TextEditingValue(
text: value,
selection: TextSelection.collapsed(offset: value.length),
);
ref.read(usersListProvider.notifier).setSearch(value);
},
onEnsureFullDataset: () => ref
.read(usersListProvider.notifier)
.ensureColumnSearchDataset(),
onColumnSearchCleared: () => ref
.read(usersListProvider.notifier)
.clearColumnSearchDataset(),
actionsBuilder: (_, user) => UserTableActions(
user: user,
canEdit: canEditUser,

View File

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

View File

@ -234,15 +234,10 @@ class _DepreciationReportScreenState
: _MobileList(items: state.items))
: _ReportTable(
items: state.items,
onServerSearch: (value) {
_searchController.value = TextEditingValue(
text: value,
selection: TextSelection.collapsed(
offset: value.length,
),
);
notifier.setSearch(value);
},
onEnsureFullDataset: () =>
notifier.ensureColumnSearchDataset(),
onColumnSearchCleared: () =>
notifier.clearColumnSearchDataset(),
),
),
),
@ -594,24 +589,32 @@ class _FiltersBarState extends State<_FiltersBar> {
class _ReportTable extends StatelessWidget {
const _ReportTable({
required this.items,
this.onServerSearch,
this.onEnsureFullDataset,
this.onColumnSearchCleared,
});
final List<DepreciationReportRow> items;
final ValueChanged<String>? onServerSearch;
final Future<void> Function()? onEnsureFullDataset;
final VoidCallback? onColumnSearchCleared;
@override
Widget build(BuildContext context) {
return AppDataTable<DepreciationReportRow>(
wrapInCard: false,
rows: items,
onServerSearchChanged: onServerSearch,
onEnsureFullDataset: onEnsureFullDataset,
onColumnSearchCleared: onColumnSearchCleared,
columns: [
AppDataColumn(
label: 'Asset Code',
flex: 2,
searchText: (row) => row.assetCode ?? '',
cellBuilder: (_, row) => AppTableCell.text(row.assetCode),
cellBuilder: (_, row) => AppTableCell.link(
row.assetCode,
onTap: row.id.trim().isEmpty
? null
: () => context.push('${RouteConstants.assets}/${row.id}'),
),
),
AppDataColumn(
label: 'Asset Name',
@ -634,7 +637,7 @@ class _ReportTable extends StatelessWidget {
AppDataColumn(
label: 'Purchase Date',
flex: 2,
searchText: (row) => DateFormatter.displayDate(row.purchaseDate),
searchText: (row) => DateFormatter.searchableDate(row.purchaseDate),
cellBuilder: (_, row) =>
AppTableCell.text(DateFormatter.displayDate(row.purchaseDate)),
),
@ -642,7 +645,7 @@ class _ReportTable extends StatelessWidget {
label: 'Purchase Cost',
flex: 2,
alignment: Alignment.centerRight,
searchText: (row) => CurrencyFormatter.format(row.purchaseCost),
searchText: (row) => CurrencyFormatter.searchable(row.purchaseCost),
cellBuilder: (_, row) => AppTableCell.text(
CurrencyFormatter.format(row.purchaseCost),
textAlign: TextAlign.right,
@ -653,7 +656,7 @@ class _ReportTable extends StatelessWidget {
flex: 2,
alignment: Alignment.centerRight,
searchText: (row) =>
CurrencyFormatter.format(row.annualDepreciation),
CurrencyFormatter.searchable(row.annualDepreciation),
cellBuilder: (_, row) => AppTableCell.text(
CurrencyFormatter.format(row.annualDepreciation),
textAlign: TextAlign.right,
@ -664,7 +667,7 @@ class _ReportTable extends StatelessWidget {
flex: 2,
alignment: Alignment.centerRight,
searchText: (row) =>
CurrencyFormatter.format(row.accumulatedDepreciation),
CurrencyFormatter.searchable(row.accumulatedDepreciation),
cellBuilder: (_, row) => AppTableCell.text(
CurrencyFormatter.format(row.accumulatedDepreciation),
textAlign: TextAlign.right,
@ -674,7 +677,7 @@ class _ReportTable extends StatelessWidget {
label: 'Book Value',
flex: 2,
alignment: Alignment.centerRight,
searchText: (row) => CurrencyFormatter.format(row.bookValue),
searchText: (row) => CurrencyFormatter.searchable(row.bookValue),
cellBuilder: (_, row) => AppTableCell.text(
CurrencyFormatter.format(row.bookValue),
textAlign: TextAlign.right,

View File

@ -1,5 +1,6 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/utils/column_search_paging.dart';
import '../../../../core/utils/table_search.dart';
import '../../../../shared/models/permission_matrix_models.dart';
@ -86,6 +87,8 @@ final rolesListProvider =
AsyncNotifierProvider<RolesListNotifier, RolesListState>(RolesListNotifier.new);
class RolesListNotifier extends AsyncNotifier<RolesListState> {
final _columnSearch = ColumnSearchPaging(defaultLimit: 10);
@override
Future<RolesListState> build() async {
ref.keepAlive();
@ -130,6 +133,36 @@ class RolesListNotifier extends AsyncNotifier<RolesListState> {
}
}
Future<void> ensureColumnSearchDataset() async {
final current = state.valueOrNull;
if (current == null) return;
final limit = _columnSearch.beginFullDataset(
currentLimit: current.limit,
total: current.total,
);
if (limit == null) return;
state = const AsyncLoading();
try {
final loaded = await _load(search: '');
state = AsyncData(
loaded.copyWith(
page: 1,
limit: loaded.total > 0 ? loaded.total : limit,
),
);
} catch (e, st) {
state = AsyncError(e, st);
}
}
void clearColumnSearchDataset() {
final current = state.valueOrNull;
if (current == null) return;
final limit = _columnSearch.endFullDataset();
if (limit == null) return;
state = AsyncData(current.copyWith(page: 1, limit: limit, search: ''));
}
void setPage(int page) {
final current = state.valueOrNull;
if (current == null) return;
@ -175,9 +208,16 @@ class PermissionMatrixNotifier
final granted = Map<String, bool>.from(row.granted);
granted[normalizedAction] = value;
// CREATE / EDIT imply VIEW.
// CREATE / EDIT / DELETE / APPROVE / EXPORT imply VIEW.
const impliesView = {
'create',
'edit',
'delete',
'approve',
'export',
};
if (value &&
(normalizedAction == 'create' || normalizedAction == 'edit') &&
impliesView.contains(normalizedAction) &&
isPermissionActionApplicable(
row.code,
'view',

View File

@ -108,15 +108,10 @@ class _RoleListScreenState extends ConsumerState<RoleListScreen> {
: _RoleDataTable(
roles: roles,
onOpen: _openRole,
onServerSearch: (value) {
_searchController.value = TextEditingValue(
text: value,
selection: TextSelection.collapsed(
offset: value.length,
),
);
notifier.setSearch(value);
},
onEnsureFullDataset: () =>
notifier.ensureColumnSearchDataset(),
onColumnSearchCleared: () =>
notifier.clearColumnSearchDataset(),
),
),
),
@ -137,18 +132,21 @@ class _RoleDataTable extends StatelessWidget {
const _RoleDataTable({
required this.roles,
required this.onOpen,
this.onServerSearch,
this.onEnsureFullDataset,
this.onColumnSearchCleared,
});
final List<RoleCardModel> roles;
final void Function(RoleCardModel role) onOpen;
final ValueChanged<String>? onServerSearch;
final Future<void> Function()? onEnsureFullDataset;
final VoidCallback? onColumnSearchCleared;
@override
Widget build(BuildContext context) {
return AppDataTable<RoleCardModel>(
wrapInCard: false,
onServerSearchChanged: onServerSearch,
onEnsureFullDataset: onEnsureFullDataset,
onColumnSearchCleared: onColumnSearchCleared,
columns: [
AppDataColumn(label: 'Role Name', flex: 2, searchText: (r) => r.name, cellBuilder: (_, r) => Text(r.name)),
AppDataColumn(

View File

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

View File

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

View File

@ -15,13 +15,38 @@ import '../../../../shared/models/user_management_models.dart';
import '../providers/users_provider.dart';
import '../../../../shared/widgets/app_toast.dart';
class UserDetailScreen extends ConsumerWidget {
class UserDetailScreen extends ConsumerStatefulWidget {
const UserDetailScreen({super.key, required this.userId});
final String userId;
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<UserDetailScreen> createState() => _UserDetailScreenState();
}
class _UserDetailScreenState extends ConsumerState<UserDetailScreen> {
bool _requestedFreshLoad = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_requestedFreshLoad) return;
_requestedFreshLoad = true;
// Always hit GET user-by-id when opening view.
ref.invalidate(userDetailProvider(widget.userId));
}
@override
void didUpdateWidget(covariant UserDetailScreen oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.userId != widget.userId) {
ref.invalidate(userDetailProvider(widget.userId));
}
}
@override
Widget build(BuildContext context) {
final userId = widget.userId;
final userAsync = ref.watch(userDetailProvider(userId));
return Padding(
@ -48,7 +73,7 @@ class UserDetailScreen extends ConsumerWidget {
AppButton(
label: 'Deactivate',
expand: false,
onPressed: () => _deactivate(context, ref),
onPressed: () => _deactivate(context),
),
],
),
@ -87,7 +112,7 @@ class UserDetailScreen extends ConsumerWidget {
);
}
Future<void> _deactivate(BuildContext context, WidgetRef ref) async {
Future<void> _deactivate(BuildContext context) async {
final confirmed = await showAppConfirmationDialog(
context: context,
title: 'Deactivate user',
@ -97,11 +122,15 @@ class UserDetailScreen extends ConsumerWidget {
);
if (confirmed != true || !context.mounted) return;
final success = await ref.read(userDetailProvider(userId).notifier).deactivate();
final success =
await ref.read(userDetailProvider(widget.userId).notifier).deactivate();
if (!context.mounted) return;
showAppToastFromSnackBar(context,
SnackBar(content: Text(success ? 'User deactivated' : 'Failed to deactivate')),
showAppToastFromSnackBar(
context,
SnackBar(
content: Text(success ? 'User deactivated' : 'Failed to deactivate'),
),
);
if (success) context.pop();
}

View File

@ -37,9 +37,19 @@ class _UserFormScreenState extends ConsumerState<UserFormScreen> {
String _selectedStatus = 'active';
bool _isSubmitting = false;
bool _prefilled = false;
bool _requestedFreshLoad = false;
bool get isEditing => widget.userId != null;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_requestedFreshLoad || !isEditing) return;
_requestedFreshLoad = true;
// Always hit GET user-by-id when opening edit.
ref.invalidate(userFormProvider(widget.userId));
}
@override
void dispose() {
_employeeIdController.dispose();

View File

@ -143,17 +143,12 @@ class _UserListScreenState extends ConsumerState<UserListScreen> {
onEdit: _editUser,
onToggleStatus: _toggleStatus,
onDeactivate: _deactivateUser,
onServerSearch: (value) {
_searchController.value = TextEditingValue(
text: value,
selection: TextSelection.collapsed(
offset: value.length,
),
);
ref
.read(usersListProvider.notifier)
.setSearch(value);
},
onEnsureFullDataset: () => ref
.read(usersListProvider.notifier)
.ensureColumnSearchDataset(),
onColumnSearchCleared: () => ref
.read(usersListProvider.notifier)
.clearColumnSearchDataset(),
),
),
),
@ -312,7 +307,8 @@ class _UserDataTable extends StatelessWidget {
required this.onEdit,
required this.onToggleStatus,
required this.onDeactivate,
this.onServerSearch,
this.onEnsureFullDataset,
this.onColumnSearchCleared,
});
final List<ManagedUserModel> users;
@ -323,7 +319,8 @@ class _UserDataTable extends StatelessWidget {
final void Function(ManagedUserModel user) onEdit;
final Future<void> Function(ManagedUserModel user) onToggleStatus;
final Future<void> Function(ManagedUserModel user) onDeactivate;
final ValueChanged<String>? onServerSearch;
final Future<void> Function()? onEnsureFullDataset;
final VoidCallback? onColumnSearchCleared;
@override
Widget build(BuildContext context) {
@ -333,7 +330,8 @@ class _UserDataTable extends StatelessWidget {
sortAscending: sortOrder == 'asc',
onSort: onSort,
wrapInCard: false,
onServerSearchChanged: onServerSearch,
onEnsureFullDataset: onEnsureFullDataset,
onColumnSearchCleared: onColumnSearchCleared,
actionsBuilder: (_, user) => _UserActions(
user: user,
onView: onView,

View File

@ -21,6 +21,8 @@ class UserRichDataTable extends StatelessWidget {
this.onSort,
this.wrapInCard = false,
this.onServerSearchChanged,
this.onEnsureFullDataset,
this.onColumnSearchCleared,
});
final List<ManagedUserModel> users;
@ -30,6 +32,8 @@ class UserRichDataTable extends StatelessWidget {
final void Function(String column, bool ascending)? onSort;
final bool wrapInCard;
final ValueChanged<String>? onServerSearchChanged;
final Future<void> Function()? onEnsureFullDataset;
final VoidCallback? onColumnSearchCleared;
@override
Widget build(BuildContext context) {
@ -41,6 +45,8 @@ class UserRichDataTable extends StatelessWidget {
sortAscending: sortAscending,
onSort: onSort,
onServerSearchChanged: onServerSearchChanged,
onEnsureFullDataset: onEnsureFullDataset,
onColumnSearchCleared: onColumnSearchCleared,
columns: [
AppDataColumn(
label: 'User',
@ -62,20 +68,21 @@ class UserRichDataTable extends StatelessWidget {
label: 'Role',
flex: 2,
padding: const EdgeInsets.only(left: 8),
searchText: (user) => user.roleNames.join(' '),
searchText: (user) =>
'${user.roleNames.join(' ')} ${user.roleLabel}'.trim(),
cellBuilder: (_, user) => UserRolesCell(roles: user.roleNames),
),
AppDataColumn(
label: 'Department',
flex: 2,
searchText: (user) => user.departmentLabel,
searchText: (user) => user.departmentName ?? '',
cellBuilder: (_, user) => Text(user.departmentLabel),
),
AppDataColumn(
label: 'Last Login',
flex: 2,
searchText: (user) =>
DateFormatter.formatUserLastLogin(user.lastLoginAt),
DateFormatter.searchableDate(user.lastLoginAt),
cellBuilder: (_, user) => Text(
DateFormatter.formatUserLastLogin(user.lastLoginAt),
style: theme.textTheme.bodyMedium?.copyWith(

View File

@ -1,5 +1,6 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/utils/column_search_paging.dart';
import '../../../../core/utils/table_search.dart';
import '../../../../shared/models/export_file_result.dart';
@ -57,6 +58,8 @@ final vendorsListProvider =
);
class VendorsListNotifier extends AutoDisposeAsyncNotifier<VendorsListState> {
final _columnSearch = ColumnSearchPaging();
@override
Future<VendorsListState> build() async {
return _load(const VendorListQuery(limit: 20));
@ -103,6 +106,27 @@ class VendorsListNotifier extends AutoDisposeAsyncNotifier<VendorsListState> {
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

@ -31,6 +31,7 @@ class VendorDetailScreen extends ConsumerStatefulWidget {
class _VendorDetailScreenState extends ConsumerState<VendorDetailScreen>
with SingleTickerProviderStateMixin {
late final TabController _tabController;
bool _requestedFreshLoad = false;
@override
void initState() {
@ -38,6 +39,23 @@ class _VendorDetailScreenState extends ConsumerState<VendorDetailScreen>
_tabController = TabController(length: 4, vsync: this);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_requestedFreshLoad) return;
_requestedFreshLoad = true;
// Always hit GET /vendors/{id} when opening view.
ref.invalidate(vendorDetailProvider(widget.vendorId));
}
@override
void didUpdateWidget(covariant VendorDetailScreen oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.vendorId != widget.vendorId) {
ref.invalidate(vendorDetailProvider(widget.vendorId));
}
}
@override
void dispose() {
_tabController.dispose();

View File

@ -164,17 +164,12 @@ class _VendorListScreenState extends ConsumerState<VendorListScreen> {
onView: _viewVendor,
onEdit: canEdit ? _editVendor : null,
onDelete: canDelete ? _deleteVendor : null,
onServerSearch: (value) {
_searchController.value = TextEditingValue(
text: value,
selection: TextSelection.collapsed(
offset: value.length,
),
);
ref
.read(vendorsListProvider.notifier)
.setSearch(value);
},
onEnsureFullDataset: () => ref
.read(vendorsListProvider.notifier)
.ensureColumnSearchDataset(),
onColumnSearchCleared: () => ref
.read(vendorsListProvider.notifier)
.clearColumnSearchDataset(),
),
),
),
@ -299,26 +294,32 @@ class _VendorDataTable extends StatelessWidget {
required this.onView,
this.onEdit,
this.onDelete,
this.onServerSearch,
this.onEnsureFullDataset,
this.onColumnSearchCleared,
});
final List<VendorModel> vendors;
final ValueChanged<VendorModel> onView;
final ValueChanged<VendorModel>? onEdit;
final ValueChanged<VendorModel>? onDelete;
final ValueChanged<String>? onServerSearch;
final Future<void> Function()? onEnsureFullDataset;
final VoidCallback? onColumnSearchCleared;
@override
Widget build(BuildContext context) {
return AppDataTable<VendorModel>(
wrapInCard: false,
onServerSearchChanged: onServerSearch,
onEnsureFullDataset: onEnsureFullDataset,
onColumnSearchCleared: onColumnSearchCleared,
columns: [
AppDataColumn(
label: 'Code',
flex: 1,
searchText: (vendor) => vendor.vendorCode ?? '',
cellBuilder: (_, vendor) => Text(vendor.vendorCode ?? ''),
cellBuilder: (_, vendor) => AppTableCell.link(
vendor.vendorCode,
onTap: () => onView(vendor),
),
),
AppDataColumn(
label: 'Name',
@ -405,9 +406,13 @@ class _VendorCardList extends StatelessWidget {
return AppCard(
child: ListTile(
title: Text(vendor.vendorName),
subtitle: Text(
subtitle: AppTableCell.link(
'${vendor.vendorCode ?? ''} · ${vendorTypeLabel(vendor.vendorType)}',
onTap: vendor.vendorCode == null || vendor.vendorCode!.isEmpty
? null
: () => onView(vendor),
),
onTap: () => onView(vendor),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
@ -437,7 +442,6 @@ class _VendorCardList extends StatelessWidget {
),
],
),
onTap: () => onView(vendor),
),
);
},

View File

@ -235,7 +235,12 @@ class _VendorFormPanelState extends ConsumerState<VendorFormPanel> {
}
Widget _buildForm(
AsyncValue<List<FilterOptionModel>> paymentTermsAsync,
AsyncValue<
({
List<FilterOptionModel> options,
Map<int, int> creditDaysById,
})>
paymentTermsAsync,
AsyncValue<List<FilterOptionModel>> gstTreatmentsAsync,
AsyncValue<List<FilterOptionModel>> sourceOfSupplyAsync,
) {
@ -319,7 +324,7 @@ class _VendorFormPanelState extends ConsumerState<VendorFormPanel> {
left: paymentTermsAsync.when(
loading: () => const LinearProgressIndicator(),
error: (_, __) => const Text('Failed to load payment terms'),
data: (terms) => _paymentTermDropdown(terms),
data: (terms) => _paymentTermDropdown(terms.options),
),
right: AppTextField(
controller: _creditDaysController,
@ -346,6 +351,23 @@ class _VendorFormPanelState extends ConsumerState<VendorFormPanel> {
);
}
void _applyPaymentTerm(int? termId) {
final creditDaysById =
ref.read(vendorPaymentTermsProvider).valueOrNull?.creditDaysById ??
const <int, int>{};
setState(() {
_paymentTermId = termId;
if (termId == null) {
_creditDaysController.clear();
return;
}
final days = creditDaysById[termId];
if (days != null) {
_creditDaysController.text = days.toString();
}
});
}
Widget _paymentTermDropdown(List<FilterOptionModel> terms) {
final termIds = terms.map((t) => int.tryParse(t.id)).whereType<int>().toList();
final value = _paymentTermId != null && termIds.contains(_paymentTermId)
@ -365,17 +387,23 @@ class _VendorFormPanelState extends ConsumerState<VendorFormPanel> {
)
.where((option) => option.value != 0)
.toList(),
refreshLookups: () => ref.invalidate(vendorPaymentTermsProvider),
refreshLookups: () async {
ref.invalidate(vendorPaymentTermsProvider);
await ref.read(vendorPaymentTermsProvider.future);
},
parseCreatedId: int.tryParse,
onChanged: (v) => setState(() => _paymentTermId = v),
onChanged: _applyPaymentTerm,
);
}
}
final vendorPaymentTermsProvider =
FutureProvider<List<FilterOptionModel>>((ref) async {
final vendorPaymentTermsProvider = FutureProvider<
({
List<FilterOptionModel> options,
Map<int, int> creditDaysById,
})>((ref) async {
final dataSource = ref.watch(masterRemoteDataSourceProvider);
return dataSource.listPaymentTerms();
return dataSource.listPaymentTermsWithCreditDays();
});
final vendorGstTreatmentsProvider =

View File

@ -225,25 +225,23 @@ class AssetModel with _$AssetModel {
class AssetMaintenanceChecklistItem {
const AssetMaintenanceChecklistItem({
required this.key,
required this.label,
this.required = false,
this.required = true,
});
final String key;
final String label;
final bool required;
factory AssetMaintenanceChecklistItem.fromJson(Map<String, dynamic> json) {
final label = json['label']?.toString().trim() ?? '';
return AssetMaintenanceChecklistItem(
key: json['key']?.toString() ?? '',
label: json['label']?.toString() ?? '',
required: json['required'] == true,
label: label,
// API default is true when omitted.
required: json['required'] == null ? true : json['required'] == true,
);
}
Map<String, dynamic> toJson() => {
'key': key,
'label': label,
'required': required,
};
@ -580,7 +578,7 @@ List<AssetMaintenanceChecklistItem>? _checklistFromJson(Object? value) {
Map<String, dynamic>.from(item),
),
)
.where((item) => item.key.isNotEmpty)
.where((item) => item.label.isNotEmpty)
.toList();
}

View File

@ -76,6 +76,14 @@ Object? _readBillingName(Map<dynamic, dynamic> json, String key) =>
Object? _readShippingName(Map<dynamic, dynamic> json, String key) =>
_readLocationDisplayName(json, 'shipping_name', 'shipping');
Object? _readItemId(Map<dynamic, dynamic> json, String key) {
final flat = json['item_id'];
if (flat != null) return flat;
final nested = json['item'];
if (nested is Map) return nested['id'] ?? nested['item_id'];
return null;
}
Object? _readItemName(Map<dynamic, dynamic> json, String key) {
final flat = json['item_name'];
if (flat is String && flat.isNotEmpty) return flat;
@ -210,8 +218,7 @@ class PurchaseOrderModel with _$PurchaseOrderModel {
factory PurchaseOrderModel.fromJson(Map<String, dynamic> json) =>
_$PurchaseOrderModelFromJson(json);
bool get canEdit =>
status.toUpperCase() == 'DRAFT' || status.toUpperCase() == 'REJECTED';
bool get canEdit => status.toUpperCase() == 'DRAFT';
bool get canDelete => canEdit;
@ -219,9 +226,15 @@ class PurchaseOrderModel with _$PurchaseOrderModel {
bool get canApprove {
final s = status.toUpperCase();
return s == 'SUBMITTED' || s == 'PENDING_APPROVAL' || s == 'PENDING';
return s == 'SUBMITTED' ||
s == 'PENDING_APPROVAL' ||
s == 'PENDING' ||
s == 'REJECTED';
}
/// Notify approvers via `POST /notifications/trigger` (PO_SUBMIT_APPROVAL).
bool get canNotifyApprovers => status.toUpperCase() == 'PENDING_APPROVAL';
bool get canReject => canApprove;
bool get canAmend => status.toUpperCase() == 'APPROVED';
@ -237,7 +250,12 @@ class PurchaseOrderItemModel with _$PurchaseOrderItemModel {
const factory PurchaseOrderItemModel({
@JsonKey(fromJson: _idFromJson) required String id,
@JsonKey(name: 'po_id', fromJson: _idFromJson) String? poId,
@JsonKey(name: 'item_id', fromJson: _intFromJsonNullable) int? itemId,
@JsonKey(
name: 'item_id',
readValue: _readItemId,
fromJson: _intFromJsonNullable,
)
int? itemId,
@JsonKey(name: 'item_code', readValue: _readItemCode) String? itemCode,
@JsonKey(name: 'item_name', readValue: _readItemName) String? itemName,
@JsonKey(name: 'line_no', fromJson: _intFromJsonNullable) int? lineNo,

View File

@ -85,7 +85,7 @@ _$PurchaseOrderItemModelImpl _$$PurchaseOrderItemModelImplFromJson(
) => _$PurchaseOrderItemModelImpl(
id: _idFromJson(json['id']),
poId: _idFromJson(json['po_id']),
itemId: _intFromJsonNullable(json['item_id']),
itemId: _intFromJsonNullable(_readItemId(json, 'item_id')),
itemCode: _readItemCode(json, 'item_code') as String?,
itemName: _readItemName(json, 'item_name') as String?,
lineNo: _intFromJsonNullable(json['line_no']),

View File

@ -1,8 +1,11 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/config/dev_config.dart';
import '../../modules/auth/data/repositories/auth_repository_impl.dart';
import '../../modules/auth/domain/repositories/auth_repository.dart';
import '../../modules/settings/presentation/providers/settings_provider.dart';
import '../models/user_model.dart';
import 'dev_user.dart';
@ -33,15 +36,28 @@ class AuthState {
}
final authStateProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
return AuthNotifier(ref.watch(authRepositoryProvider));
return AuthNotifier(ref);
});
class AuthNotifier extends StateNotifier<AuthState> {
AuthNotifier(this._repository) : super(const AuthState()) {
AuthNotifier(this._ref) : super(const AuthState()) {
checkAuth();
}
final AuthRepository _repository;
final Ref _ref;
AuthRepository get _repository => _ref.read(authRepositoryProvider);
/// Pull Company + Email settings once the session is authenticated.
Future<void> _syncAppSettingsAfterAuth() async {
try {
await _ref
.read(appSettingsProvider.notifier)
.syncCompanyAndEmailFromServer();
} catch (_) {
// Settings sync must not block login / session restore.
}
}
Future<void> checkAuth() async {
state = state.copyWith(status: AuthStatus.loading);
@ -57,10 +73,13 @@ class AuthNotifier extends StateNotifier<AuthState> {
return;
}
state = AuthState(status: AuthStatus.authenticated, user: result.data);
unawaited(_syncAppSettingsAfterAuth());
}
void loginAsDemo() {
state = const AuthState(status: AuthStatus.authenticated, user: demoUser);
// Demo has no backend session still try sync (may no-op / fail quietly).
unawaited(_syncAppSettingsAfterAuth());
}
Future<bool> login(LoginRequest request) async {
@ -93,6 +112,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
status: AuthStatus.authenticated,
user: loginResponse.user,
);
unawaited(_syncAppSettingsAfterAuth());
return true;
}

View File

@ -7,6 +7,7 @@ import '../../core/constants/route_constants.dart';
import '../../modules/dashboard/presentation/screens/dashboard_screen.dart';
import '../../modules/assets/presentation/screens/asset_alerts_screen.dart';
import '../../modules/assets/presentation/screens/asset_detail_screen.dart';
import '../../modules/assets/presentation/screens/asset_form_screen.dart';
import '../../modules/assets/presentation/screens/asset_list_screen.dart';
import '../../modules/assets/presentation/screens/asset_maintenance_screen.dart';
import '../../modules/auth/presentation/screens/change_password_screen.dart';
@ -310,6 +311,16 @@ final routerProvider = Provider<GoRouter>((ref) {
path: 'maintenance',
builder: (context, state) => const AssetMaintenanceScreen(),
),
GoRoute(
path: 'add',
builder: (context, state) => const AssetFormScreen(),
),
GoRoute(
path: ':id/edit',
builder: (context, state) => AssetFormScreen(
assetId: state.pathParameters['id']!,
),
),
GoRoute(
path: ':id',
builder: (context, state) =>

View File

@ -18,6 +18,7 @@ class AppDataColumn<T> {
required this.cellBuilder,
this.sortKey,
this.flex = 1,
this.width,
this.alignment = Alignment.centerLeft,
this.padding = EdgeInsets.zero,
this.searchText,
@ -28,6 +29,10 @@ class AppDataColumn<T> {
final Widget Function(BuildContext context, T row) cellBuilder;
final String? sortKey;
final int flex;
/// When set, column uses a fixed width instead of [flex].
final double? width;
final Alignment alignment;
final EdgeInsets padding;
@ -41,6 +46,19 @@ class AppDataColumn<T> {
bool get isSearchable => enableSearch ?? searchText != null;
}
/// Shared column sizing: fixed [AppDataColumn.width] or flexible [AppDataColumn.flex].
Widget _appTableColumnSlot<T>({
required AppDataColumn<T> column,
required Widget child,
}) {
final padded = Padding(padding: column.padding, child: child);
final width = column.width;
if (width != null) {
return SizedBox(width: width, child: padded);
}
return Expanded(flex: column.flex, child: padded);
}
/// Helpers for table cell content single-line text with ellipsis and tooltip.
class AppTableCell {
AppTableCell._();
@ -63,6 +81,54 @@ class AppTableCell {
);
}
/// Clickable sequential number / code that navigates to a detail view.
static Widget link(
String? value, {
required VoidCallback? onTap,
TextStyle? style,
String placeholder = '',
TextAlign? textAlign,
bool underlined = true,
}) {
final display =
(value == null || value.trim().isEmpty) ? placeholder : value.trim();
if (onTap == null || display == placeholder) {
return text(
display,
style: style,
placeholder: placeholder,
textAlign: textAlign,
);
}
return Builder(
builder: (context) {
final theme = Theme.of(context);
final linkStyle = (style ?? theme.textTheme.bodyMedium)?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
decoration: underlined ? TextDecoration.underline : TextDecoration.none,
decorationColor: underlined
? theme.colorScheme.primary.withValues(alpha: 0.45)
: null,
);
return MouseRegion(
cursor: SystemMouseCursors.click,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(4),
child: _EllipsisTooltipText(
text: display,
style: linkStyle,
textAlign: textAlign,
showTooltip: true,
),
),
);
},
);
}
/// Wraps non-text cell widgets (chips, actions) inside the row height budget.
static Widget child(Widget widget) => widget;
}
@ -80,6 +146,8 @@ class AppDataTable<T> extends StatefulWidget {
this.wrapInCard = true,
this.shrinkWrap = false,
this.onServerSearchChanged,
this.onEnsureFullDataset,
this.onColumnSearchCleared,
});
final List<AppDataColumn<T>> columns;
@ -94,13 +162,18 @@ class AppDataTable<T> extends StatefulWidget {
/// Set true when the table is placed inside another scrollable.
final bool shrinkWrap;
/// When set, column filters are sent to the parent for API search instead of
/// filtering only the currently loaded [rows] (current page).
///
/// The callback receives a normalized query (trimmed). Empty string means
/// clear search and reload the full paginated list.
/// Deprecated: prefer [onEnsureFullDataset] + [onColumnSearchCleared].
/// Kept so older call sites still compile; ignored for filtering.
final ValueChanged<String>? onServerSearchChanged;
/// Called once when the first column filter becomes active.
/// Parent should load all rows (`limit = total`) with no search query.
final Future<void> Function()? onEnsureFullDataset;
/// Called when all column filters are cleared (or the table is disposed
/// while filters were active). Parent should restore normal page size.
final VoidCallback? onColumnSearchCleared;
@override
State<AppDataTable<T>> createState() => _AppDataTableState<T>();
}
@ -109,15 +182,21 @@ class _AppDataTableState<T> extends State<AppDataTable<T>> {
/// Column index search query (raw, including spaces until applied).
final Map<int, String> _queries = {};
final Map<int, TextEditingController> _controllers = {};
final SearchDebouncer _serverSearchDebouncer = SearchDebouncer();
int? _lastEditedColumnIndex;
String _lastEmittedServerSearch = '';
final SearchDebouncer _ensureDatasetDebouncer = SearchDebouncer(
duration: const Duration(milliseconds: 150),
);
bool _fullDatasetActive = false;
bool _ensureInFlight = false;
bool get _serverSideSearch => widget.onServerSearchChanged != null;
bool get _usesFullDatasetMode =>
widget.onEnsureFullDataset != null || widget.onColumnSearchCleared != null;
@override
void dispose() {
_serverSearchDebouncer.dispose();
_ensureDatasetDebouncer.dispose();
if (_fullDatasetActive || _ensureInFlight) {
widget.onColumnSearchCleared?.call();
}
for (final c in _controllers.values) {
c.dispose();
}
@ -128,38 +207,48 @@ class _AppDataTableState<T> extends State<AppDataTable<T>> {
return _controllers.putIfAbsent(index, TextEditingController.new);
}
String _composeServerSearch() {
if (_lastEditedColumnIndex != null) {
final latest = (_queries[_lastEditedColumnIndex!] ?? '').trim();
if (latest.isNotEmpty) return latest;
bool get _hasActiveColumnFilters =>
_queries.values.any((q) => q.trim().isNotEmpty);
Future<void> _syncFullDatasetMode() async {
if (!_usesFullDatasetMode) return;
if (_hasActiveColumnFilters && !_fullDatasetActive) {
final ensure = widget.onEnsureFullDataset;
if (ensure != null && !_ensureInFlight) {
_ensureInFlight = true;
try {
await ensure();
if (mounted && _hasActiveColumnFilters) {
setState(() => _fullDatasetActive = true);
}
} finally {
_ensureInFlight = false;
}
}
return;
}
for (final entry in _queries.entries) {
final q = entry.value.trim();
if (q.isNotEmpty) return q;
if (!_hasActiveColumnFilters && _fullDatasetActive) {
_fullDatasetActive = false;
widget.onColumnSearchCleared?.call();
}
return '';
}
void _onColumnQueryChanged(int index, String value) {
setState(() {
_queries[index] = value;
_lastEditedColumnIndex = index;
});
if (!_serverSideSearch) return;
if (!_usesFullDatasetMode) return;
_serverSearchDebouncer.run(value, (_) {
final composed = _composeServerSearch();
if (composed == _lastEmittedServerSearch) return;
_lastEmittedServerSearch = composed;
widget.onServerSearchChanged!(composed);
// Debounce ensure/clear so rapid typing doesn't thrash the API.
_ensureDatasetDebouncer.run(value, (_) {
_syncFullDatasetMode();
});
}
List<T> get _filteredRows {
// Server-side mode: parent already fetched matching rows from the API.
if (_serverSideSearch) return widget.rows;
final active = <int, String>{};
for (final entry in _queries.entries) {
final q = entry.value.trim().toLowerCase();
@ -180,9 +269,6 @@ class _AppDataTableState<T> extends State<AppDataTable<T>> {
}).toList();
}
bool get _hasActiveColumnFilters =>
_queries.values.any((q) => q.trim().isNotEmpty);
@override
Widget build(BuildContext context) {
final showFilterRow = widget.columns.any((c) => c.isSearchable);
@ -228,7 +314,7 @@ class _AppDataTableState<T> extends State<AppDataTable<T>> {
child: Padding(
padding: const EdgeInsets.all(32),
child: Text(
_hasActiveColumnFilters || _lastEmittedServerSearch.isNotEmpty
_hasActiveColumnFilters
? widget.noMatchMessage
: widget.emptyMessage,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
@ -324,19 +410,16 @@ class _TableHeaderRow<T> extends StatelessWidget {
children: [
for (var i = 0; i < columns.length; i++) ...[
if (i > 0) const SizedBox(width: kAppTableColumnGap),
Expanded(
flex: columns[i].flex,
child: Padding(
padding: columns[i].padding,
child: Align(
alignment: columns[i].alignment,
child: _buildHeaderCell(
theme: theme,
col: columns[i],
sortColumn: sortColumn,
sortAscending: sortAscending,
onSort: onSort,
),
_appTableColumnSlot(
column: columns[i],
child: Align(
alignment: columns[i].alignment,
child: _buildHeaderCell(
theme: theme,
col: columns[i],
sortColumn: sortColumn,
sortAscending: sortAscending,
onSort: onSort,
),
),
),
@ -430,18 +513,15 @@ class _TableFilterRow<T> extends StatelessWidget {
children: [
for (var i = 0; i < columns.length; i++) ...[
if (i > 0) const SizedBox(width: kAppTableColumnGap),
Expanded(
flex: columns[i].flex,
child: Padding(
padding: columns[i].padding,
child: columns[i].isSearchable
? _ColumnSearchField(
controller: controllerFor(i),
query: queryFor(i),
onChanged: (v) => onQueryChanged(i, v),
)
: const SizedBox.shrink(),
),
_appTableColumnSlot(
column: columns[i],
child: columns[i].isSearchable
? _ColumnSearchField(
controller: controllerFor(i),
query: queryFor(i),
onChanged: (v) => onQueryChanged(i, v),
)
: const SizedBox.shrink(),
),
],
],
@ -584,21 +664,18 @@ class _TableDataRow<T> extends StatelessWidget {
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
for (var i = 0; i < columns.length; i++) ...[
if (i > 0) const SizedBox(width: kAppTableColumnGap),
Expanded(
flex: columns[i].flex,
child: Padding(
padding: columns[i].padding,
child: Align(
_appTableColumnSlot(
column: columns[i],
child: Align(
alignment: columns[i].alignment,
child: _TableCellSlot(
alignment: columns[i].alignment,
child: _TableCellSlot(
alignment: columns[i].alignment,
child: columns[i].cellBuilder(context, row),
),
child: columns[i].cellBuilder(context, row),
),
),
),

View File

@ -45,14 +45,33 @@ class _AppDateDialogState extends State<_AppDateDialog> {
@override
void initState() {
super.initState();
_selected = DateTime(
widget.initialDate.year,
widget.initialDate.month,
widget.initialDate.day,
final clamped = _clampDate(
DateTime(
widget.initialDate.year,
widget.initialDate.month,
widget.initialDate.day,
),
);
_selected = clamped;
_displayedMonth = DateTime(_selected.year, _selected.month);
}
DateTime _clampDate(DateTime d) {
final first = DateTime(
widget.firstDate.year,
widget.firstDate.month,
widget.firstDate.day,
);
final last = DateTime(
widget.lastDate.year,
widget.lastDate.month,
widget.lastDate.day,
);
if (d.isBefore(first)) return first;
if (d.isAfter(last)) return last;
return d;
}
void _shiftMonth(int delta) {
setState(() {
_displayedMonth = DateTime(
@ -106,7 +125,7 @@ class _AppDateDialogState extends State<_AppDateDialog> {
firstDate: widget.firstDate,
lastDate: widget.lastDate,
selected: _selected,
onSelected: (day) => setState(() => _selected = day),
onSelected: (day) => setState(() => _selected = _clampDate(day)),
),
const SizedBox(height: 16),
Row(

View File

@ -59,10 +59,14 @@ class _AppShellState extends ConsumerState<AppShell> {
: <menu.MenuItem>[];
if (context.isMobile) {
final companyName =
ref.watch(appSettingsProvider).companyProfile.companyName.trim();
return Scaffold(
key: _scaffoldKey,
appBar: AppBar(
title: const Text(AppConstants.appName),
title: Text(
companyName.isNotEmpty ? companyName : AppConstants.appName,
),
leading: IconButton(
icon: const Icon(Icons.menu),
onPressed: () => _scaffoldKey.currentState?.openDrawer(),

View File

@ -452,6 +452,7 @@ class SidePanelSection extends StatelessWidget {
child is FormRow ||
child is FormRowThree ||
child is FormRowFour ||
child is ResponsiveFormGrid ||
child is QuickAddInlineHost;
}
@ -570,6 +571,9 @@ class FormRowThree extends StatelessWidget {
}
/// Responsive row with up to four equal-width form fields.
///
/// Columns by width: 4 (large) 3 (medium) 2 (small) 1 (xs).
/// When [spans] is set, falls back to a fixed 4-col [FormRow].
class FormRowFour extends StatelessWidget {
const FormRowFour({
super.key,
@ -588,37 +592,48 @@ class FormRowFour extends StatelessWidget {
@override
Widget build(BuildContext context) {
return FormRow(
columnCount: 4,
spans: spans,
if (spans != null) {
return FormRow(
columnCount: 4,
spans: spans,
spacing: spacing,
horizontalPadding: horizontalPadding,
stackBelowWidth: stackBelowWidth,
children: children,
);
}
return ResponsiveFormGrid(
spacing: spacing,
horizontalPadding: horizontalPadding,
stackBelowWidth: stackBelowWidth,
children: children,
);
}
}
/// Responsive form grid: 4 cols (medium+), 2 cols (small).
/// Responsive form grid: 4 / 3 / 2 / 1 columns by viewport width.
class ResponsiveFormGrid extends StatelessWidget {
const ResponsiveFormGrid({
super.key,
required this.children,
this.fullWidthChildren = const [],
this.spacing = 12,
this.xsColumns = 1,
this.smallColumns = 2,
this.mediumColumns = 4,
this.mediumColumns = 3,
this.largeColumns = 4,
this.mediumBreakpoint = AppBreakpoints.tablet,
this.largeBreakpoint = AppBreakpoints.desktop,
this.smallBreakpoint = AppBreakpoints.formSmall,
this.mediumBreakpoint = AppBreakpoints.formMedium,
this.largeBreakpoint = AppBreakpoints.formLarge,
});
final List<Widget> children;
final List<Widget> fullWidthChildren;
final double spacing;
final int xsColumns;
final int smallColumns;
final int mediumColumns;
final int largeColumns;
final double smallBreakpoint;
final double mediumBreakpoint;
final double largeBreakpoint;
@ -634,53 +649,59 @@ class ResponsiveFormGrid extends StatelessWidget {
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth.isFinite && constraints.maxWidth > 0
? constraints.maxWidth
: MediaQuery.sizeOf(context).width;
final columns = formGridColumnsForWidth(
width,
smallColumns: smallColumns,
mediumColumns: mediumColumns,
largeColumns: largeColumns,
mediumBreakpoint: mediumBreakpoint,
largeBreakpoint: largeBreakpoint,
);
final rows = _chunk(children, columns);
final columnWidth = (width - (columns - 1) * spacing) / columns;
return QuickAddInlineHost(
child: LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth.isFinite && constraints.maxWidth > 0
? constraints.maxWidth
: MediaQuery.sizeOf(context).width;
final columns = formGridColumnsForWidth(
width,
xsColumns: xsColumns,
smallColumns: smallColumns,
mediumColumns: mediumColumns,
largeColumns: largeColumns,
smallBreakpoint: smallBreakpoint,
mediumBreakpoint: mediumBreakpoint,
largeBreakpoint: largeBreakpoint,
);
final rows = _chunk(children, columns);
final columnWidth = columns <= 0
? width
: (width - (columns - 1) * spacing) / columns;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final row in rows)
Padding(
padding: EdgeInsets.only(bottom: spacing),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (var col = 0; col < columns; col++) ...[
if (col > 0) SizedBox(width: spacing),
SizedBox(
width: columnWidth,
child: col < row.length
? row[col]
: const SizedBox.shrink(),
),
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final row in rows)
Padding(
padding: EdgeInsets.only(bottom: spacing),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (var col = 0; col < columns; col++) ...[
if (col > 0) SizedBox(width: spacing),
SizedBox(
width: columnWidth,
child: col < row.length
? QuickAddBlockable(child: row[col])
: const SizedBox.shrink(),
),
],
],
],
),
),
),
for (var i = 0; i < fullWidthChildren.length; i++)
Padding(
padding: EdgeInsets.only(
bottom: i == fullWidthChildren.length - 1 ? 0 : spacing,
for (var i = 0; i < fullWidthChildren.length; i++)
Padding(
padding: EdgeInsets.only(
bottom: i == fullWidthChildren.length - 1 ? 0 : spacing,
),
child: QuickAddBlockable(child: fullWidthChildren[i]),
),
child: fullWidthChildren[i],
),
],
);
},
],
);
},
),
);
}
}

View File

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
class _TableActionInkWell extends StatelessWidget {
@ -14,7 +16,7 @@ class _TableActionInkWell extends StatelessWidget {
final theme = Theme.of(context);
return Material(
color: Colors.transparent,
type: MaterialType.transparency,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(6),
@ -52,6 +54,8 @@ class AppTableActionIcon extends StatelessWidget {
return Tooltip(
message: tooltip,
waitDuration: const Duration(milliseconds: 400),
preferBelow: true,
child: _TableActionInkWell(
onTap: enabled ? onPressed : null,
child: Padding(
@ -63,7 +67,27 @@ class AppTableActionIcon extends StatelessWidget {
}
}
/// Ensures only one row's action chip is expanded at a time (fast hover).
class _AppTableActionsGate {
static _AppTableActionsState? _active;
static void claim(_AppTableActionsState next) {
final prev = _active;
if (prev != null && !identical(prev, next)) {
prev._forceClose(releaseGate: false);
}
_active = next;
}
static void release(_AppTableActionsState state) {
if (identical(_active, state)) _active = null;
}
}
/// Collapsed three-dot trigger that expands inline action icons on hover (or tap).
///
/// Expanded icons are shown in an [Overlay] so they stay hoverable/clickable
/// even when they paint over neighboring columns (status, etc.).
class AppTableActions extends StatefulWidget {
const AppTableActions({
super.key,
@ -80,86 +104,209 @@ class AppTableActions extends StatefulWidget {
State<AppTableActions> createState() => _AppTableActionsState();
}
/// Room for expanded icons to grow left of the trigger without clipping.
const double _kExpandedActionsOverflow = 108;
class _AppTableActionsState extends State<AppTableActions> {
final LayerLink _link = LayerLink();
final OverlayPortalController _portalController = OverlayPortalController();
bool _hovering = false;
bool _pinned = false;
Timer? _closeTimer;
bool get _expanded => _hovering || _pinned;
@override
void dispose() {
_closeTimer?.cancel();
_AppTableActionsGate.release(this);
if (_portalController.isShowing) {
_portalController.hide();
}
super.dispose();
}
void _forceClose({bool releaseGate = true}) {
_closeTimer?.cancel();
_pinned = false;
_hovering = false;
if (_portalController.isShowing) {
_portalController.hide();
}
if (releaseGate) _AppTableActionsGate.release(this);
if (mounted) setState(() {});
}
void _open() {
_AppTableActionsGate.claim(this);
_closeTimer?.cancel();
if (!_hovering) {
setState(() => _hovering = true);
}
if (!_portalController.isShowing) {
_portalController.show();
}
}
void _onEnter() => _open();
void _onExit() {
if (_pinned) return;
_closeTimer?.cancel();
// Brief delay so the pointer can move from the cell trigger into the overlay chip.
_closeTimer = Timer(const Duration(milliseconds: 80), () {
if (!mounted || _pinned) return;
// Another row may have claimed the gate already.
if (!identical(_AppTableActionsGate._active, this)) return;
setState(() => _hovering = false);
if (_portalController.isShowing) {
_portalController.hide();
}
_AppTableActionsGate.release(this);
});
}
void _togglePinned() {
_closeTimer?.cancel();
if (_pinned) {
_forceClose();
return;
}
_AppTableActionsGate.claim(this);
setState(() {
_pinned = true;
_hovering = true;
});
if (!_portalController.isShowing) {
_portalController.show();
}
}
Widget _buildChip({required bool expanded, required ColorScheme scheme}) {
final iconColor = scheme.onSurfaceVariant;
return Material(
type: MaterialType.transparency,
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
padding: EdgeInsets.symmetric(
horizontal: expanded ? 4 : 0,
vertical: expanded ? 2 : 0,
),
decoration: BoxDecoration(
color: expanded ? scheme.surface : Colors.transparent,
borderRadius: BorderRadius.circular(8),
boxShadow: expanded
? [
BoxShadow(
color: Colors.black.withValues(alpha: 0.12),
blurRadius: 10,
offset: const Offset(0, 2),
),
BoxShadow(
color: Colors.black.withValues(alpha: 0.06),
blurRadius: 2,
offset: const Offset(0, 1),
),
]
: const [],
),
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.end,
children: [
AnimatedSize(
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
alignment: Alignment.centerRight,
clipBehavior: Clip.none,
child: expanded
? Row(
mainAxisSize: MainAxisSize.min,
children: widget.children,
)
: const SizedBox.shrink(),
),
Tooltip(
message: 'Actions',
waitDuration: const Duration(milliseconds: 400),
preferBelow: true,
child: _TableActionInkWell(
onTap: _togglePinned,
child: Padding(
padding: const EdgeInsets.all(6),
child: Icon(Icons.more_vert, size: 18, color: iconColor),
),
),
),
],
),
),
);
}
@override
Widget build(BuildContext context) {
final children = widget.children;
if (children.isEmpty) return const SizedBox.shrink();
final iconColor = Theme.of(context).colorScheme.onSurfaceVariant;
final scheme = Theme.of(context).colorScheme;
final actionRow = Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.end,
children: [
AnimatedSize(
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
alignment: Alignment.centerRight,
clipBehavior: Clip.none,
child: _expanded
? Row(
mainAxisSize: MainAxisSize.min,
children: children,
)
: const SizedBox.shrink(),
),
Tooltip(
message: 'Actions',
child: _TableActionInkWell(
onTap: () => setState(() => _pinned = !_pinned),
child: Padding(
padding: const EdgeInsets.all(6),
child: Icon(Icons.more_vert, size: 18, color: iconColor),
final portal = OverlayPortal(
controller: _portalController,
overlayChildBuilder: (context) {
// Overlay gives full-screen constraints; shrink-wrap to the chip only.
return CompositedTransformFollower(
link: _link,
showWhenUnlinked: false,
targetAnchor: Alignment.centerRight,
followerAnchor: Alignment.centerRight,
child: UnconstrainedBox(
alignment: Alignment.centerRight,
child: TapRegion(
onTapOutside: (_) {
if (_pinned) _forceClose();
},
child: MouseRegion(
onEnter: (_) => _onEnter(),
onExit: (_) => _onExit(),
child: _buildChip(expanded: true, scheme: scheme),
),
),
),
);
},
child: CompositedTransformTarget(
link: _link,
child: MouseRegion(
onEnter: (_) => _onEnter(),
onExit: (_) => _onExit(),
// Keep a stable hit target; hide when overlay chip is showing.
child: Opacity(
opacity: _expanded ? 0 : 1,
child: IgnorePointer(
ignoring: _expanded,
child: _buildChip(expanded: false, scheme: scheme),
),
),
),
],
);
final interactive = TapRegion(
onTapOutside: (_) {
if (_pinned) setState(() => _pinned = false);
},
child: MouseRegion(
onEnter: (_) => setState(() => _hovering = true),
onExit: (_) => setState(() => _hovering = false),
child: actionRow,
),
);
if (!widget.expandHitArea) return interactive;
if (!widget.expandHitArea) return portal;
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
if (!width.isFinite || width <= 0) return interactive;
if (!width.isFinite || width <= 0) return portal;
return SizedBox(
width: width,
child: OverflowBox(
maxWidth: width + _kExpandedActionsOverflow,
alignment: Alignment.centerRight,
child: TapRegion(
onTapOutside: (_) {
if (_pinned) setState(() => _pinned = false);
},
child: MouseRegion(
onEnter: (_) => setState(() => _hovering = true),
onExit: (_) => setState(() => _hovering = false),
child: Align(
alignment: Alignment.centerRight,
child: actionRow,
),
),
child: MouseRegion(
onEnter: (_) => _onEnter(),
onExit: (_) => _onExit(),
child: Align(
alignment: Alignment.centerRight,
child: portal,
),
),
);