bug fix
This commit is contained in:
parent
ce0979d782
commit
c20b12c261
@ -164,6 +164,7 @@ class ApiEndpoints {
|
||||
static const String settingsGeneral = '/settings/general';
|
||||
static const String settingsCompany = '/settings/company';
|
||||
static String settingsCompanyLogo = '/settings/company/logo';
|
||||
static const String settingsCompanyFavicon = '/settings/company/favicon';
|
||||
static const String settingsAsset = '/settings/asset';
|
||||
static const String settingsNotifications = '/settings/notifications';
|
||||
static const String settingsEmail = '/settings/email';
|
||||
|
||||
@ -14,10 +14,9 @@ class FaviconStore {
|
||||
final value = url?.trim() ?? '';
|
||||
if (value.isEmpty) {
|
||||
await _prefs.remove(StorageKeys.faviconUrl);
|
||||
return;
|
||||
} else {
|
||||
await _prefs.setString(StorageKeys.faviconUrl, value);
|
||||
}
|
||||
|
||||
await _prefs.setString(StorageKeys.faviconUrl, value);
|
||||
}
|
||||
|
||||
void apply() {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import 'favicon_updater_stub.dart'
|
||||
if (dart.library.html) 'favicon_updater_web.dart' as impl;
|
||||
if (dart.library.js_interop) 'favicon_updater_web.dart' as impl;
|
||||
|
||||
void updateFavicon(String? url) => impl.updateFavicon(url);
|
||||
|
||||
@ -1,36 +1,10 @@
|
||||
import 'dart:html' as html;
|
||||
import 'dart:js_interop';
|
||||
|
||||
@JS('setAppFavicon')
|
||||
external void _setAppFavicon(JSString url);
|
||||
|
||||
void updateFavicon(String? url) {
|
||||
for (final link in html.document.querySelectorAll('link[rel="icon"]')) {
|
||||
link.remove();
|
||||
}
|
||||
|
||||
final faviconUrl = url?.trim();
|
||||
if (faviconUrl == null || faviconUrl.isEmpty) return;
|
||||
|
||||
final link = html.LinkElement()
|
||||
..rel = 'icon'
|
||||
..href = faviconUrl;
|
||||
|
||||
final type = _resolveMimeType(faviconUrl);
|
||||
if (type != null) {
|
||||
link.type = type;
|
||||
}
|
||||
|
||||
html.document.head?.append(link);
|
||||
}
|
||||
|
||||
String? _resolveMimeType(String url) {
|
||||
if (url.startsWith('data:image/')) {
|
||||
final end = url.indexOf(';');
|
||||
if (end > 5) return url.substring(5, end);
|
||||
}
|
||||
|
||||
final lower = url.toLowerCase();
|
||||
if (lower.endsWith('.ico')) return 'image/x-icon';
|
||||
if (lower.endsWith('.png')) return 'image/png';
|
||||
if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg';
|
||||
if (lower.endsWith('.svg')) return 'image/svg+xml';
|
||||
if (lower.endsWith('.webp')) return 'image/webp';
|
||||
return null;
|
||||
final trimmed = url?.trim();
|
||||
if (trimmed == null || trimmed.isEmpty) return;
|
||||
_setAppFavicon(trimmed.toJS);
|
||||
}
|
||||
|
||||
@ -412,7 +412,7 @@ class Validators {
|
||||
|
||||
/// Required role name — letters, numbers, and spaces only.
|
||||
static String? roleName(String? value) {
|
||||
final requiredError = required(value, fieldName: 'Role name');
|
||||
final requiredError = required(value, fieldName: 'Role Name');
|
||||
if (requiredError != null) return requiredError;
|
||||
|
||||
final trimmed = value!.trim();
|
||||
|
||||
@ -64,7 +64,6 @@ class _AssetAlertsScreenState extends ConsumerState<AssetAlertsScreen>
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TabBar(
|
||||
controller: _tabController,
|
||||
tabs: const [
|
||||
@ -72,7 +71,7 @@ class _AssetAlertsScreenState extends ConsumerState<AssetAlertsScreen>
|
||||
Tab(text: 'Service Alerts'),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 12),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: _tabController,
|
||||
@ -111,11 +110,11 @@ class _ExpiryAlertsTab extends ConsumerWidget {
|
||||
SizedBox(
|
||||
width: 160,
|
||||
child: AppDropdown<int>(
|
||||
label: 'Days ahead',
|
||||
label: 'Days Ahead',
|
||||
isDense: true,
|
||||
value: state.expiryDays,
|
||||
options: const [7, 15, 30, 60, 90]
|
||||
.map((d) => AppDropdownOption(value: d, label: '$d days'))
|
||||
.map((d) => AppDropdownOption(value: d, label: '$d Days'))
|
||||
.toList(),
|
||||
onChanged: (v) {
|
||||
if (v != null) notifier.setExpiryDays(v);
|
||||
@ -150,7 +149,7 @@ class _ExpiryAlertsTab extends ConsumerWidget {
|
||||
const SizedBox(height: 16),
|
||||
_AlertsOverview(
|
||||
total: state.expiryAlerts.length,
|
||||
label: 'Expiry alerts in selected window',
|
||||
label: 'Expiry Alerts In Selected Window',
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Expanded(
|
||||
@ -237,7 +236,7 @@ class _ServiceAlertsTab extends ConsumerWidget {
|
||||
const SizedBox(height: 16),
|
||||
_AlertsOverview(
|
||||
total: state.serviceAlerts.length,
|
||||
label: 'Service reminders',
|
||||
label: 'Service Reminders',
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Expanded(
|
||||
|
||||
@ -72,15 +72,15 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
|
||||
final statuses = _statusOptions(
|
||||
ref.watch(assetFormLookupsProvider).valueOrNull?.statuses ?? const [],
|
||||
);
|
||||
final categoryFilter = _selectedCategory ?? 'All categories';
|
||||
final plantFilter = _selectedPlant ?? 'All plants';
|
||||
final statusFilter = _selectedStatus ?? 'All statuses';
|
||||
final categoryFilter = _selectedCategory ?? 'All Categories';
|
||||
final plantFilter = _selectedPlant ?? 'All Plants';
|
||||
final statusFilter = _selectedStatus ?? 'All Statuses';
|
||||
final statusLabels = {
|
||||
for (final option
|
||||
in (ref.watch(assetFormLookupsProvider).valueOrNull?.statuses ??
|
||||
const <AssetDropdownOption>[]))
|
||||
option.value: option.label,
|
||||
'All statuses': 'All statuses',
|
||||
'All Statuses': 'All Statuses',
|
||||
};
|
||||
final page = state.query.page;
|
||||
final pageSize = state.query.limit;
|
||||
@ -110,7 +110,6 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: () => ref.read(assetsListProvider.notifier).refresh(),
|
||||
@ -154,19 +153,19 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
|
||||
onCategoryChanged: (value) {
|
||||
setState(() {
|
||||
_selectedCategory =
|
||||
value == 'All categories' ? null : value;
|
||||
value == 'All Categories' ? null : value;
|
||||
});
|
||||
},
|
||||
onPlantChanged: (value) {
|
||||
setState(() {
|
||||
_selectedPlant =
|
||||
value == 'All plants' ? null : value;
|
||||
value == 'All Plants' ? null : value;
|
||||
});
|
||||
},
|
||||
onStatusChanged: (value) {
|
||||
setState(() {
|
||||
_selectedStatus =
|
||||
value == 'All statuses' ? null : value;
|
||||
value == 'All Statuses' ? null : value;
|
||||
});
|
||||
},
|
||||
);
|
||||
@ -260,7 +259,7 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
|
||||
names.add(category.name);
|
||||
}
|
||||
final sorted = names.toList()..sort();
|
||||
return ['All categories', ...sorted];
|
||||
return ['All Categories', ...sorted];
|
||||
}
|
||||
|
||||
List<String> _plantOptions(List<AssetModel> assets) {
|
||||
@ -271,7 +270,7 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
|
||||
.toSet()
|
||||
.toList()
|
||||
..sort();
|
||||
return ['All plants', ...names];
|
||||
return ['All Plants', ...names];
|
||||
}
|
||||
|
||||
List<String> _statusOptions(List<AssetDropdownOption> apiStatuses) {
|
||||
@ -279,7 +278,7 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
|
||||
.map((option) => option.value)
|
||||
.where((value) => value.trim().isNotEmpty)
|
||||
.toList();
|
||||
return ['All statuses', ...labels];
|
||||
return ['All Statuses', ...labels];
|
||||
}
|
||||
|
||||
void _viewAsset(AssetModel asset) {
|
||||
@ -511,6 +510,8 @@ class _AssetDataTable extends StatelessWidget {
|
||||
flex: 1,
|
||||
cellBuilder: (_, asset) => AppStatusChip(
|
||||
status: asset.status?.toLowerCase().replaceAll(' ', '_') ?? 'active',
|
||||
compact: true,
|
||||
forTable: true,
|
||||
),
|
||||
),
|
||||
AppDataColumn(
|
||||
|
||||
@ -484,7 +484,7 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
|
||||
widget.isEditing ? ref.watch(assetFormProvider(widget.assetId)) : null;
|
||||
|
||||
return SidePanelScaffold(
|
||||
title: widget.isEditing ? 'Edit asset' : 'Add asset',
|
||||
title: widget.isEditing ? 'Edit Asset' : 'Add Asset',
|
||||
footer: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
@ -496,7 +496,7 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
AppButton(
|
||||
label: widget.isEditing ? 'Update asset' : 'Save asset',
|
||||
label: widget.isEditing ? 'Edit Asset' : 'Add Asset',
|
||||
expand: false,
|
||||
icon: Icons.check,
|
||||
isLoading: _isSubmitting,
|
||||
@ -567,10 +567,10 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
|
||||
label: 'Asset Name *',
|
||||
validator: (v) {
|
||||
final requiredError =
|
||||
Validators.required(v, fieldName: 'Asset name');
|
||||
Validators.required(v, fieldName: 'Asset Name');
|
||||
if (requiredError != null) return requiredError;
|
||||
return Validators.minLength(v!.trim(), 2,
|
||||
fieldName: 'Asset name');
|
||||
fieldName: 'Asset Name');
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
@ -607,7 +607,7 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
|
||||
return Validators.minLength(
|
||||
v.trim(),
|
||||
2,
|
||||
fieldName: 'Serial number',
|
||||
fieldName: 'Serial Number',
|
||||
);
|
||||
},
|
||||
),
|
||||
@ -620,7 +620,7 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
|
||||
return Validators.minLength(
|
||||
v.trim(),
|
||||
2,
|
||||
fieldName: 'Part number',
|
||||
fieldName: 'Part Number',
|
||||
);
|
||||
},
|
||||
),
|
||||
@ -636,7 +636,7 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
|
||||
return Validators.minLength(
|
||||
v.trim(),
|
||||
2,
|
||||
fieldName: 'Brand / model',
|
||||
fieldName: 'Brand / Model',
|
||||
);
|
||||
},
|
||||
),
|
||||
@ -697,7 +697,7 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
|
||||
return Validators.minLength(
|
||||
v.trim(),
|
||||
2,
|
||||
fieldName: 'Location detail',
|
||||
fieldName: 'Location Detail',
|
||||
);
|
||||
},
|
||||
),
|
||||
@ -792,7 +792,7 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
validator: (v) => Validators.optionalPositiveDouble(
|
||||
v,
|
||||
fieldName: 'Purchase cost',
|
||||
fieldName: 'Purchase Cost',
|
||||
),
|
||||
),
|
||||
right: AppTextField(
|
||||
@ -802,7 +802,7 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
|
||||
keyboardType: TextInputType.number,
|
||||
validator: (v) => Validators.optionalPositiveInt(
|
||||
v,
|
||||
fieldName: 'Useful life',
|
||||
fieldName: 'Useful Life',
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -829,7 +829,7 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
validator: (v) => Validators.optionalPercentage(
|
||||
v,
|
||||
fieldName: 'Depreciation rate',
|
||||
fieldName: 'Depreciation Rate',
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -842,7 +842,7 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
validator: (v) => Validators.optionalNonNegativeDouble(
|
||||
v,
|
||||
fieldName: 'Salvage value',
|
||||
fieldName: 'Salvage Value',
|
||||
),
|
||||
),
|
||||
right: const SizedBox.shrink(),
|
||||
@ -913,7 +913,7 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
validator: (v) => Validators.optionalNonNegativeDouble(
|
||||
v,
|
||||
fieldName: 'Disposal value',
|
||||
fieldName: 'Disposal Value',
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -927,13 +927,13 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
|
||||
final isDisposed =
|
||||
_status == 'DISPOSED' || _status == 'SCRAPPED';
|
||||
if (isDisposed) {
|
||||
return Validators.required(v, fieldName: 'Disposal reason');
|
||||
return Validators.required(v, fieldName: 'Disposal Reason');
|
||||
}
|
||||
if (v == null || v.trim().isEmpty) return null;
|
||||
return Validators.minLength(
|
||||
v.trim(),
|
||||
3,
|
||||
fieldName: 'Disposal reason',
|
||||
fieldName: 'Disposal Reason',
|
||||
);
|
||||
},
|
||||
),
|
||||
@ -951,7 +951,7 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
|
||||
return Validators.minLength(
|
||||
v.trim(),
|
||||
2,
|
||||
fieldName: 'QR code value',
|
||||
fieldName: 'QR Code Value',
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@ -317,7 +317,7 @@ class _AddAmcPanelState extends ConsumerState<AddAmcPanel> {
|
||||
keyboardType: TextInputType.number,
|
||||
validator: (v) => Validators.optionalPositiveInt(
|
||||
v,
|
||||
fieldName: 'Visits per year',
|
||||
fieldName: 'Visits Per Year',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
@ -386,7 +386,7 @@ class _AddAmcPanelState extends ConsumerState<AddAmcPanel> {
|
||||
footer: _panelFooter(
|
||||
context,
|
||||
isSubmitting: true,
|
||||
saveLabel: 'Update contract',
|
||||
saveLabel: 'Edit AMC Contract',
|
||||
onSave: () {},
|
||||
),
|
||||
child: const Center(child: CircularProgressIndicator()),
|
||||
@ -402,7 +402,7 @@ class _AddAmcPanelState extends ConsumerState<AddAmcPanel> {
|
||||
footer: _panelFooter(
|
||||
context,
|
||||
isSubmitting: _isSubmitting,
|
||||
saveLabel: 'Update contract',
|
||||
saveLabel: 'Edit AMC Contract',
|
||||
onSave: _save,
|
||||
),
|
||||
child: _buildForm(),
|
||||
@ -416,7 +416,7 @@ class _AddAmcPanelState extends ConsumerState<AddAmcPanel> {
|
||||
footer: _panelFooter(
|
||||
context,
|
||||
isSubmitting: _isSubmitting,
|
||||
saveLabel: 'Save contract',
|
||||
saveLabel: 'Add AMC Contract',
|
||||
onSave: _save,
|
||||
),
|
||||
child: _buildForm(),
|
||||
@ -670,7 +670,7 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
|
||||
keyboardType: TextInputType.number,
|
||||
validator: (v) => Validators.optionalPositiveInt(
|
||||
v,
|
||||
fieldName: 'Visit number',
|
||||
fieldName: 'Visit Number',
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -783,7 +783,7 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
validator: (v) => Validators.optionalPositiveDouble(
|
||||
v,
|
||||
fieldName: 'Downtime hours',
|
||||
fieldName: 'Downtime Hours',
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -794,7 +794,7 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
validator: (v) => Validators.optionalPositiveDouble(
|
||||
v,
|
||||
fieldName: 'Service cost',
|
||||
fieldName: 'Service Cost',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
@ -830,7 +830,7 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
|
||||
footer: _panelFooter(
|
||||
context,
|
||||
isSubmitting: true,
|
||||
saveLabel: 'Update visit',
|
||||
saveLabel: 'Edit Service Visit',
|
||||
onSave: () {},
|
||||
),
|
||||
child: const Center(child: CircularProgressIndicator()),
|
||||
@ -846,7 +846,7 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
|
||||
footer: _panelFooter(
|
||||
context,
|
||||
isSubmitting: _isSubmitting,
|
||||
saveLabel: 'Update visit',
|
||||
saveLabel: 'Edit Service Visit',
|
||||
onSave: _save,
|
||||
),
|
||||
child: _buildForm(widget.amcContracts),
|
||||
@ -860,7 +860,7 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
|
||||
footer: _panelFooter(
|
||||
context,
|
||||
isSubmitting: _isSubmitting,
|
||||
saveLabel: 'Save visit',
|
||||
saveLabel: 'Log Service Visit',
|
||||
onSave: _save,
|
||||
),
|
||||
child: _buildForm(widget.amcContracts),
|
||||
@ -1046,12 +1046,12 @@ class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
|
||||
left: AppTextField(
|
||||
controller: _policyNoController,
|
||||
label: 'Policy No *',
|
||||
validator: (v) => Validators.required(v, fieldName: 'Policy no'),
|
||||
validator: (v) => Validators.required(v, fieldName: 'Policy No'),
|
||||
),
|
||||
right: AppTextField(
|
||||
controller: _insurerNameController,
|
||||
label: 'Insurer Name *',
|
||||
validator: (v) => Validators.required(v, fieldName: 'Insurer name'),
|
||||
validator: (v) => Validators.required(v, fieldName: 'Insurer Name'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
@ -1096,7 +1096,7 @@ class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
validator: (v) => Validators.optionalNonNegativeDouble(
|
||||
v,
|
||||
fieldName: 'Sum insured',
|
||||
fieldName: 'Sum Insured',
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -1107,7 +1107,7 @@ class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
validator: (v) => Validators.optionalNonNegativeDouble(
|
||||
v,
|
||||
fieldName: 'Annual premium',
|
||||
fieldName: 'Annual Premium',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
@ -1195,7 +1195,7 @@ class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
|
||||
footer: _panelFooter(
|
||||
context,
|
||||
isSubmitting: true,
|
||||
saveLabel: 'Update policy',
|
||||
saveLabel: 'Edit Insurance Policy',
|
||||
onSave: () {},
|
||||
),
|
||||
child: const Center(child: CircularProgressIndicator()),
|
||||
@ -1211,7 +1211,7 @@ class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
|
||||
footer: _panelFooter(
|
||||
context,
|
||||
isSubmitting: _isSubmitting,
|
||||
saveLabel: 'Update policy',
|
||||
saveLabel: 'Edit Insurance Policy',
|
||||
onSave: _save,
|
||||
),
|
||||
child: _buildForm(),
|
||||
@ -1225,7 +1225,7 @@ class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
|
||||
footer: _panelFooter(
|
||||
context,
|
||||
isSubmitting: _isSubmitting,
|
||||
saveLabel: 'Save policy',
|
||||
saveLabel: 'Add Insurance Policy',
|
||||
onSave: _save,
|
||||
),
|
||||
child: _buildForm(),
|
||||
|
||||
@ -148,7 +148,6 @@ class _AuditLogsScreenState extends ConsumerState<AuditLogsScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: AppTableShell(
|
||||
toolbar: LayoutBuilder(
|
||||
@ -296,7 +295,7 @@ class _FiltersBar extends StatelessWidget {
|
||||
searchHint: 'Search table...',
|
||||
isDense: true,
|
||||
options: [
|
||||
const AppDropdownOption(value: null, label: 'All tables'),
|
||||
const AppDropdownOption(value: null, label: 'All Tables'),
|
||||
...filters.tableNames.map(
|
||||
(name) => AppDropdownOption(value: name, label: name),
|
||||
),
|
||||
@ -310,7 +309,7 @@ class _FiltersBar extends StatelessWidget {
|
||||
searchHint: 'Search action...',
|
||||
isDense: true,
|
||||
options: [
|
||||
const AppDropdownOption(value: null, label: 'All actions'),
|
||||
const AppDropdownOption(value: null, label: 'All Actions'),
|
||||
...filters.actions.map(
|
||||
(action) => AppDropdownOption(value: action, label: action),
|
||||
),
|
||||
@ -319,12 +318,12 @@ class _FiltersBar extends StatelessWidget {
|
||||
);
|
||||
|
||||
final performerDropdown = AppSearchableDropdown<int?>(
|
||||
label: 'Performed by',
|
||||
label: 'Performed By',
|
||||
value: query.performedBy,
|
||||
searchHint: 'Search user...',
|
||||
isDense: true,
|
||||
options: [
|
||||
const AppDropdownOption(value: null, label: 'All users'),
|
||||
const AppDropdownOption(value: null, label: 'All Users'),
|
||||
...filters.performers.map(
|
||||
(user) => AppDropdownOption(
|
||||
value: int.tryParse(user.id),
|
||||
@ -336,7 +335,7 @@ class _FiltersBar extends StatelessWidget {
|
||||
);
|
||||
|
||||
final dateField = AppFilterDateField(
|
||||
label: 'Date range',
|
||||
label: 'Date Range',
|
||||
value: _dateValue,
|
||||
placeholder: 'Select range',
|
||||
icon: Icons.date_range_outlined,
|
||||
@ -427,7 +426,11 @@ class _AuditDataTable extends StatelessWidget {
|
||||
label: 'Action',
|
||||
flex: 1,
|
||||
cellBuilder: (_, row) => AppTableCell.child(
|
||||
AppStatusChip(status: row.action, compact: true),
|
||||
AppStatusChip(
|
||||
status: row.action,
|
||||
compact: true,
|
||||
forTable: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
AppDataColumn(
|
||||
@ -441,7 +444,7 @@ class _AuditDataTable extends StatelessWidget {
|
||||
cellBuilder: (_, row) => AppTableCell.text(row.recordId),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Performed by',
|
||||
label: 'Performed By',
|
||||
flex: 2,
|
||||
cellBuilder: (_, row) => AppTableCell.text(row.performerLabel),
|
||||
),
|
||||
|
||||
@ -42,7 +42,7 @@ class _ChangePasswordScreenState extends State<ChangePasswordScreen> {
|
||||
controller: _currentController,
|
||||
label: 'Current Password',
|
||||
obscureText: true,
|
||||
validator: (v) => Validators.required(v, fieldName: 'Current password'),
|
||||
validator: (v) => Validators.required(v, fieldName: 'Current Password'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AppTextField(
|
||||
|
||||
@ -51,7 +51,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
||||
obscureText: true,
|
||||
validator: (v) {
|
||||
if (v != _passwordController.text) return 'Passwords do not match';
|
||||
return Validators.required(v, fieldName: 'Confirm password');
|
||||
return Validators.required(v, fieldName: 'Confirm Password');
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
@ -47,13 +47,13 @@ class _BranchFormScreenState extends State<BranchFormScreen> {
|
||||
AppTextField(
|
||||
controller: _nameController,
|
||||
label: 'Branch Name',
|
||||
validator: (v) => Validators.required(v, fieldName: 'Branch name'),
|
||||
validator: (v) => Validators.required(v, fieldName: 'Branch Name'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AppTextField(
|
||||
controller: _codeController,
|
||||
label: 'Branch Code',
|
||||
validator: (v) => Validators.required(v, fieldName: 'Branch code'),
|
||||
validator: (v) => Validators.required(v, fieldName: 'Branch Code'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AppTextField(controller: _locationController, label: 'Location'),
|
||||
|
||||
@ -51,13 +51,13 @@ class _CompanyFormScreenState extends State<CompanyFormScreen> {
|
||||
AppTextField(
|
||||
controller: _nameController,
|
||||
label: 'Company Name',
|
||||
validator: (v) => Validators.required(v, fieldName: 'Company name'),
|
||||
validator: (v) => Validators.required(v, fieldName: 'Company Name'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AppTextField(
|
||||
controller: _codeController,
|
||||
label: 'Company Code',
|
||||
validator: (v) => Validators.required(v, fieldName: 'Company code'),
|
||||
validator: (v) => Validators.required(v, fieldName: 'Company Code'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AppTextField(
|
||||
|
||||
@ -48,7 +48,6 @@ class _CompanyListScreenState extends ConsumerState<CompanyListScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: profile.companyName.isEmpty
|
||||
? const AppEmptyState(
|
||||
|
||||
@ -30,6 +30,7 @@ class GrnDetailScreen extends ConsumerStatefulWidget {
|
||||
|
||||
class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> {
|
||||
bool _isWorking = false;
|
||||
bool _isDownloadingPdf = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -60,6 +61,7 @@ class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> {
|
||||
_DetailHeader(
|
||||
grn: grn,
|
||||
isWorking: _isWorking,
|
||||
isDownloadingPdf: _isDownloadingPdf,
|
||||
canEdit: canEdit,
|
||||
canExport: canExport,
|
||||
onBack: () => context.go(RouteConstants.grn),
|
||||
@ -141,7 +143,7 @@ class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AppTextField(
|
||||
label: 'Cancellation reason *',
|
||||
label: 'Cancellation Reason *',
|
||||
controller: reasonController,
|
||||
maxLines: 3,
|
||||
),
|
||||
@ -174,7 +176,8 @@ class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> {
|
||||
}
|
||||
|
||||
Future<void> _downloadPdf(GrnModel grn) async {
|
||||
await _runWorkflow(() async {
|
||||
setState(() => _isDownloadingPdf = true);
|
||||
try {
|
||||
final bytes = await ref
|
||||
.read(grnDetailProvider(widget.grnId).notifier)
|
||||
.downloadPdf();
|
||||
@ -182,7 +185,18 @@ class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> {
|
||||
bytes: bytes,
|
||||
fileName: '${grn.grnNumber ?? 'GRN-${grn.id}'}.pdf',
|
||||
);
|
||||
}, 'PDF downloaded');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(const SnackBar(content: Text('PDF downloaded')));
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text(e.toString())));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isDownloadingPdf = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -204,6 +218,7 @@ class _DetailHeader extends StatelessWidget {
|
||||
const _DetailHeader({
|
||||
required this.grn,
|
||||
required this.isWorking,
|
||||
required this.isDownloadingPdf,
|
||||
required this.canEdit,
|
||||
required this.canExport,
|
||||
required this.onBack,
|
||||
@ -214,6 +229,7 @@ class _DetailHeader extends StatelessWidget {
|
||||
|
||||
final GrnModel grn;
|
||||
final bool isWorking;
|
||||
final bool isDownloadingPdf;
|
||||
final bool canEdit;
|
||||
final bool canExport;
|
||||
final VoidCallback onBack;
|
||||
@ -241,7 +257,8 @@ class _DetailHeader extends StatelessWidget {
|
||||
_HeaderActionButton(
|
||||
label: 'PDF',
|
||||
icon: Icons.description_outlined,
|
||||
onPressed: isWorking ? null : onPdf,
|
||||
isLoading: isDownloadingPdf,
|
||||
onPressed: (isWorking || isDownloadingPdf) ? null : onPdf,
|
||||
),
|
||||
if (canEdit && grn.canEdit)
|
||||
_HeaderActionButton(
|
||||
@ -336,12 +353,14 @@ class _HeaderActionButton extends StatelessWidget {
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.onPressed,
|
||||
this.isLoading = false,
|
||||
this.destructive = false,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final VoidCallback? onPressed;
|
||||
final bool isLoading;
|
||||
final bool destructive;
|
||||
|
||||
static const double _height = 40;
|
||||
@ -368,7 +387,17 @@ class _HeaderActionButton extends StatelessWidget {
|
||||
final child = Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 18),
|
||||
if (isLoading)
|
||||
SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: destructive ? error : null,
|
||||
),
|
||||
)
|
||||
else
|
||||
Icon(icon, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Text(label),
|
||||
],
|
||||
@ -495,11 +524,11 @@ class _ReceiptDetailsCard extends StatelessWidget {
|
||||
final width = (constraints.maxWidth - spacing * (cols - 1)) / cols;
|
||||
final items = [
|
||||
_DetailField(
|
||||
label: 'GRN date',
|
||||
label: 'GRN Date',
|
||||
value: DateFormatter.displayDate(grn.grnDate),
|
||||
),
|
||||
_DetailField(
|
||||
label: 'PO number',
|
||||
label: 'PO Number',
|
||||
value: _displayOrDash(grn.poNumber),
|
||||
),
|
||||
_DetailField(
|
||||
@ -511,37 +540,37 @@ class _ReceiptDetailsCard extends StatelessWidget {
|
||||
value: _displayOrDash(grn.warehouseName),
|
||||
),
|
||||
_DetailField(
|
||||
label: 'Vendor invoice no',
|
||||
label: 'Vendor Invoice No',
|
||||
value: _displayOrDash(grn.vendorInvoiceNo),
|
||||
),
|
||||
_DetailField(
|
||||
label: 'Vendor invoice date',
|
||||
label: 'Vendor Invoice Date',
|
||||
value: DateFormatter.displayDate(grn.vendorInvoiceDate),
|
||||
),
|
||||
_DetailField(
|
||||
label: 'Vendor invoice amount',
|
||||
label: 'Vendor Invoice Amount',
|
||||
value: grn.vendorInvoiceAmount != null
|
||||
? CurrencyFormatter.format(grn.vendorInvoiceAmount!)
|
||||
: '—',
|
||||
),
|
||||
_DetailField(
|
||||
label: 'Vehicle no',
|
||||
label: 'Vehicle No',
|
||||
value: _displayOrDash(grn.vehicleNo),
|
||||
),
|
||||
_DetailField(
|
||||
label: 'LR no',
|
||||
label: 'LR No',
|
||||
value: _displayOrDash(grn.lrNo),
|
||||
),
|
||||
_DetailField(
|
||||
label: 'LR date',
|
||||
label: 'LR Date',
|
||||
value: DateFormatter.displayDate(grn.lrDate),
|
||||
),
|
||||
_DetailField(
|
||||
label: 'Received by',
|
||||
label: 'Received By',
|
||||
value: _userLabel(lookups?.users, grn.receivedBy),
|
||||
),
|
||||
_DetailField(
|
||||
label: 'Quality checked by',
|
||||
label: 'Quality Checked By',
|
||||
value: _userLabel(lookups?.users, grn.qualityCheckedBy),
|
||||
),
|
||||
];
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -6,6 +7,7 @@ import '../../../../core/constants/route_constants.dart';
|
||||
import '../../../../core/errors/failure.dart';
|
||||
import '../../../../core/network/api_handler.dart';
|
||||
import '../../../../core/utils/formatters.dart';
|
||||
import '../../../../core/utils/validators.dart';
|
||||
import '../../../../shared/models/grn_model.dart';
|
||||
import '../../../../shared/models/purchase_order_model.dart';
|
||||
import '../../../../shared/models/user_management_models.dart';
|
||||
@ -405,7 +407,7 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
|
||||
FormRowFour(
|
||||
children: [
|
||||
_DateField(
|
||||
label: 'GRN date *',
|
||||
label: 'GRN Date *',
|
||||
value: _grnDate,
|
||||
enabled: !widget.isEditing,
|
||||
onTap: widget.isEditing
|
||||
@ -418,7 +420,7 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
|
||||
),
|
||||
if (!widget.isEditing)
|
||||
AppSearchableDropdown<String>(
|
||||
label: 'Purchase order *',
|
||||
label: 'Purchase Order *',
|
||||
value: _selectedPoId,
|
||||
hint: 'Select PO',
|
||||
searchHint: 'Search PO...',
|
||||
@ -447,12 +449,12 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
|
||||
}
|
||||
},
|
||||
validator: (v) => v == null
|
||||
? 'Purchase order is required'
|
||||
? 'Purchase Order Is Required'
|
||||
: null,
|
||||
)
|
||||
else
|
||||
_ReadOnlyField(
|
||||
label: 'Purchase order',
|
||||
label: 'Purchase Order',
|
||||
value: existing?.poNumber ?? '—',
|
||||
),
|
||||
MasterQuickAddDropdown<int>(
|
||||
@ -475,7 +477,7 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
|
||||
enabled: !widget.isEditing,
|
||||
),
|
||||
AppTextField(
|
||||
label: 'Vendor invoice no',
|
||||
label: 'Vendor Invoice No',
|
||||
controller: _vendorInvoiceNoController,
|
||||
),
|
||||
],
|
||||
@ -483,7 +485,7 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
|
||||
FormRowFour(
|
||||
children: [
|
||||
_DateField(
|
||||
label: 'Vendor invoice date',
|
||||
label: 'Vendor Invoice Date',
|
||||
value: _vendorInvoiceDate,
|
||||
onTap: () => _pickDate(
|
||||
current: _vendorInvoiceDate,
|
||||
@ -492,18 +494,27 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
|
||||
),
|
||||
),
|
||||
AppTextField(
|
||||
label: 'Vendor invoice amount',
|
||||
label: 'Vendor Invoice Amount',
|
||||
controller: _vendorInvoiceAmountController,
|
||||
keyboardType: const TextInputType.numberWithOptions(
|
||||
decimal: true,
|
||||
),
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.allow(
|
||||
RegExp(r'^\d*\.?\d{0,2}'),
|
||||
),
|
||||
],
|
||||
validator: (v) => Validators.optionalNonNegativeDouble(
|
||||
v,
|
||||
fieldName: 'Vendor Invoice Amount',
|
||||
),
|
||||
),
|
||||
AppTextField(
|
||||
label: 'Vehicle no',
|
||||
label: 'Vehicle No',
|
||||
controller: _vehicleNoController,
|
||||
),
|
||||
AppTextField(
|
||||
label: 'LR no',
|
||||
label: 'LR No',
|
||||
controller: _lrNoController,
|
||||
),
|
||||
],
|
||||
@ -511,7 +522,7 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
|
||||
FormRowFour(
|
||||
children: [
|
||||
_DateField(
|
||||
label: 'LR date',
|
||||
label: 'LR Date',
|
||||
value: _lrDate,
|
||||
onTap: () => _pickDate(
|
||||
current: _lrDate,
|
||||
@ -519,7 +530,7 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
|
||||
),
|
||||
),
|
||||
AppSearchableDropdown<int>(
|
||||
label: 'Received by',
|
||||
label: 'Received By',
|
||||
value: _dropdownValue(
|
||||
_normalizeUserId(_receivedById),
|
||||
userIds,
|
||||
@ -531,7 +542,7 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
|
||||
setState(() => _receivedById = v),
|
||||
),
|
||||
AppSearchableDropdown<int>(
|
||||
label: 'Quality checked by',
|
||||
label: 'Quality Checked By',
|
||||
value: _dropdownValue(
|
||||
_normalizeUserId(_qualityCheckedById),
|
||||
userIds,
|
||||
@ -616,7 +627,7 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
|
||||
final theme = Theme.of(context);
|
||||
final title = widget.isEditing
|
||||
? 'Edit ${existing?.grnNumber ?? 'GRN'}'
|
||||
: 'Create goods received note';
|
||||
: 'Create Goods Received Note';
|
||||
final subtitle = widget.isEditing
|
||||
? null
|
||||
: 'Select an approved purchase order, enter receipt details, then confirm quantities.';
|
||||
|
||||
@ -71,7 +71,6 @@ class _GrnListScreenState extends ConsumerState<GrnListScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: AppTableShell(
|
||||
toolbar: LayoutBuilder(
|
||||
@ -190,7 +189,7 @@ class _FiltersBar extends StatelessWidget {
|
||||
searchHint: 'Search status...',
|
||||
isDense: true,
|
||||
options: [
|
||||
const AppDropdownOption(value: null, label: 'All statuses'),
|
||||
const AppDropdownOption(value: null, label: 'All Statuses'),
|
||||
...grnStatusOptions.map(
|
||||
(e) => AppDropdownOption(value: e.$1, label: e.$2),
|
||||
),
|
||||
@ -257,15 +256,14 @@ class _GrnDataTable extends StatelessWidget {
|
||||
flex: 2,
|
||||
cellBuilder: (_, grn) => Text(grn.vendorName ?? '—'),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Warehouse',
|
||||
flex: 2,
|
||||
cellBuilder: (_, grn) => Text(grn.warehouseName ?? '—'),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Status',
|
||||
flex: 1,
|
||||
cellBuilder: (_, grn) => GrnStatusChip(status: grn.status, compact: true),
|
||||
cellBuilder: (_, grn) => GrnStatusChip(
|
||||
status: grn.status,
|
||||
compact: true,
|
||||
forTable: true,
|
||||
),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Actions',
|
||||
|
||||
@ -223,9 +223,14 @@ class _GrnLineItemCardState extends State<_GrnLineItemCard> {
|
||||
FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,4}')),
|
||||
];
|
||||
|
||||
late final TextEditingController _currentQtyController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_currentQtyController = TextEditingController(
|
||||
text: _formatQty(widget.item.currentQty),
|
||||
);
|
||||
widget.item.acceptedQtyController.addListener(_onFieldChanged);
|
||||
widget.item.rejectedQtyController.addListener(_onFieldChanged);
|
||||
}
|
||||
@ -234,10 +239,18 @@ class _GrnLineItemCardState extends State<_GrnLineItemCard> {
|
||||
void dispose() {
|
||||
widget.item.acceptedQtyController.removeListener(_onFieldChanged);
|
||||
widget.item.rejectedQtyController.removeListener(_onFieldChanged);
|
||||
_currentQtyController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onFieldChanged() {
|
||||
final next = _formatQty(widget.item.currentQty);
|
||||
if (_currentQtyController.text != next) {
|
||||
_currentQtyController.value = TextEditingValue(
|
||||
text: next,
|
||||
selection: TextSelection.collapsed(offset: next.length),
|
||||
);
|
||||
}
|
||||
widget.onChanged();
|
||||
setState(() {});
|
||||
}
|
||||
@ -319,7 +332,7 @@ class _GrnLineItemCardState extends State<_GrnLineItemCard> {
|
||||
const SizedBox(height: 8),
|
||||
FormRow(
|
||||
columnCount: 12,
|
||||
spans: const [1, 1, 1, 2, 3, 4],
|
||||
spans: const [2, 2, 2, 2, 2, 2],
|
||||
spacing: 8,
|
||||
stackBelowWidth: 1100,
|
||||
children: [
|
||||
@ -377,22 +390,21 @@ class _GrnLineItemCardState extends State<_GrnLineItemCard> {
|
||||
return null;
|
||||
},
|
||||
),
|
||||
_GrnLineReadOnlyField(
|
||||
AppTextField(
|
||||
key: ValueKey('$lineKey-current'),
|
||||
controller: _currentQtyController,
|
||||
label: 'Current',
|
||||
value: _formatQty(item.currentQty),
|
||||
backgroundColor: currentBg,
|
||||
isDense: true,
|
||||
readOnly: true,
|
||||
fillColor: currentBg,
|
||||
),
|
||||
AppTextField(
|
||||
key: ValueKey('$lineKey-rate'),
|
||||
controller: item.rateController,
|
||||
label: 'Rate',
|
||||
hint: '0.00',
|
||||
keyboardType:
|
||||
const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: _qtyFormatters,
|
||||
isDense: true,
|
||||
onChanged: (_) => widget.onChanged(),
|
||||
readOnly: true,
|
||||
),
|
||||
AppTextField(
|
||||
key: ValueKey('$lineKey-batch'),
|
||||
@ -524,43 +536,6 @@ class _LineItemTitle extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _GrnLineReadOnlyField extends StatelessWidget {
|
||||
const _GrnLineReadOnlyField({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.value,
|
||||
this.backgroundColor,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
final Color? backgroundColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: InputDecorator(
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
floatingLabelBehavior: FloatingLabelBehavior.always,
|
||||
isDense: true,
|
||||
filled: backgroundColor != null,
|
||||
fillColor: backgroundColor,
|
||||
enabled: false,
|
||||
),
|
||||
child: Text(
|
||||
value,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GrnLineDateField extends StatelessWidget {
|
||||
const _GrnLineDateField({
|
||||
super.key,
|
||||
|
||||
@ -1,20 +1,32 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../shared/models/grn_model.dart';
|
||||
import '../../../../shared/widgets/app_status_chip.dart';
|
||||
|
||||
class GrnStatusChip extends StatelessWidget {
|
||||
const GrnStatusChip({
|
||||
super.key,
|
||||
required this.status,
|
||||
this.compact = false,
|
||||
this.forTable = false,
|
||||
});
|
||||
|
||||
final String status;
|
||||
final bool compact;
|
||||
final bool forTable;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final (color, label) = _resolveStatus(status);
|
||||
if (forTable) {
|
||||
return TableStatusBadge(
|
||||
label: label,
|
||||
color: color,
|
||||
compact: compact,
|
||||
fixedWidth: kTableStatusChipWidth,
|
||||
);
|
||||
}
|
||||
|
||||
return Chip(
|
||||
label: Text(
|
||||
label,
|
||||
|
||||
@ -14,6 +14,7 @@ import '../../../../shared/widgets/app_loading_view.dart';
|
||||
import '../../../../shared/widgets/app_pagination.dart';
|
||||
import '../../../../shared/widgets/app_search_export_bar.dart';
|
||||
import '../../../../shared/widgets/app_status_chip.dart';
|
||||
import '../../../../shared/widgets/app_table_action_icon.dart';
|
||||
import '../../../../shared/widgets/app_table_shell.dart';
|
||||
import '../../../../shared/widgets/error_view.dart';
|
||||
import '../../../../shared/widgets/app_side_panel.dart';
|
||||
@ -185,7 +186,6 @@ class _MasterListScreenState extends ConsumerState<MasterListScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: AppTableShell(
|
||||
toolbar: LayoutBuilder(
|
||||
@ -286,6 +286,7 @@ class _MasterListTable extends StatelessWidget {
|
||||
cellBuilder: (_, row) => AppStatusChip(
|
||||
status: masterStatusValue(row),
|
||||
compact: true,
|
||||
forTable: true,
|
||||
),
|
||||
),
|
||||
AppDataColumn(
|
||||
@ -294,24 +295,22 @@ class _MasterListTable extends StatelessWidget {
|
||||
alignment: Alignment.centerRight,
|
||||
cellBuilder: (_, row) {
|
||||
final id = row['id']?.toString();
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
return AppTableActions(
|
||||
children: [
|
||||
if (canEdit)
|
||||
IconButton(
|
||||
AppTableActionIcon(
|
||||
tooltip: 'Edit',
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
onPressed: id == null ? null : () => onEdit(id),
|
||||
icon: Icons.edit_outlined,
|
||||
enabled: id != null,
|
||||
onPressed: () => onEdit(id!),
|
||||
),
|
||||
if (canDelete)
|
||||
IconButton(
|
||||
AppTableActionIcon(
|
||||
tooltip: 'Delete',
|
||||
icon: Icon(
|
||||
Icons.delete_outline,
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
onPressed: isDeleting ? null : () => onDelete(row),
|
||||
icon: Icons.delete_outline,
|
||||
color: theme.colorScheme.error,
|
||||
enabled: !isDeleting,
|
||||
onPressed: () => onDelete(row),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@ -334,9 +334,28 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
|
||||
|
||||
final widgets = <Widget>[];
|
||||
|
||||
for (var i = 0; i < regularFields.length; i += 2) {
|
||||
var i = 0;
|
||||
while (i < regularFields.length) {
|
||||
final left = regularFields[i];
|
||||
if (i + 1 < regularFields.length) {
|
||||
|
||||
// Multiline fields (e.g. HSN Description) always take a full row.
|
||||
if (left.multiline) {
|
||||
widgets.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: _buildField(context, field: left, formState: formState),
|
||||
),
|
||||
),
|
||||
);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
final hasRight =
|
||||
i + 1 < regularFields.length && !regularFields[i + 1].multiline;
|
||||
if (hasRight) {
|
||||
final right = regularFields[i + 1];
|
||||
widgets.add(
|
||||
SidePanelFormRow(
|
||||
@ -344,6 +363,7 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
|
||||
right: _buildField(context, field: right, formState: formState),
|
||||
),
|
||||
);
|
||||
i += 2;
|
||||
} else {
|
||||
widgets.add(
|
||||
Padding(
|
||||
@ -354,6 +374,7 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
|
||||
),
|
||||
),
|
||||
);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
@ -378,7 +399,7 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
|
||||
final isSubmitting = formAsync.valueOrNull?.isSubmitting ?? false;
|
||||
|
||||
return SidePanelScaffold(
|
||||
title: widget.isEditing ? 'Edit ${def.title.toLowerCase()}' : 'Add ${def.title.toLowerCase()}',
|
||||
title: widget.isEditing ? 'Edit ${def.title}' : 'Add ${def.title}',
|
||||
footer: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
@ -390,7 +411,7 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
AppButton(
|
||||
label: widget.isEditing ? 'Update ${def.title.toLowerCase()}' : 'Save ${def.title.toLowerCase()}',
|
||||
label: widget.isEditing ? 'Edit ${def.title}' : 'Add ${def.title}',
|
||||
expand: false,
|
||||
icon: Icons.check,
|
||||
isLoading: isSubmitting,
|
||||
|
||||
@ -392,7 +392,7 @@ class _MasterInlineCreateFormState
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Add ${def.title.toLowerCase()}',
|
||||
'Add ${def.title}',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
@ -464,7 +464,7 @@ class _MasterInlineCreateFormState
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
AppButton(
|
||||
label: 'Save',
|
||||
label: 'Add ${def.title}',
|
||||
expand: false,
|
||||
icon: Icons.check,
|
||||
isLoading: isSubmitting,
|
||||
|
||||
@ -34,6 +34,7 @@ class PurchaseOrderDetailScreen extends ConsumerStatefulWidget {
|
||||
class _PurchaseOrderDetailScreenState
|
||||
extends ConsumerState<PurchaseOrderDetailScreen> {
|
||||
bool _isWorking = false;
|
||||
bool _isDownloadingPdf = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -70,6 +71,7 @@ class _PurchaseOrderDetailScreenState
|
||||
_DetailHeader(
|
||||
order: order,
|
||||
isWorking: _isWorking,
|
||||
isDownloadingPdf: _isDownloadingPdf,
|
||||
canEdit: canEdit,
|
||||
canDelete: canDelete,
|
||||
canApprove: canApprove,
|
||||
@ -288,7 +290,7 @@ class _PurchaseOrderDetailScreenState
|
||||
}
|
||||
|
||||
Future<void> _downloadPdf(PurchaseOrderModel order) async {
|
||||
setState(() => _isWorking = true);
|
||||
setState(() => _isDownloadingPdf = true);
|
||||
try {
|
||||
final bytes = await ref
|
||||
.read(purchaseOrderDetailProvider(widget.purchaseOrderId).notifier)
|
||||
@ -304,7 +306,7 @@ class _PurchaseOrderDetailScreenState
|
||||
.showSnackBar(SnackBar(content: Text(e.toString())));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isWorking = false);
|
||||
if (mounted) setState(() => _isDownloadingPdf = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -376,6 +378,7 @@ class _DetailHeader extends StatelessWidget {
|
||||
const _DetailHeader({
|
||||
required this.order,
|
||||
required this.isWorking,
|
||||
required this.isDownloadingPdf,
|
||||
required this.canEdit,
|
||||
required this.canDelete,
|
||||
required this.canApprove,
|
||||
@ -393,6 +396,7 @@ class _DetailHeader extends StatelessWidget {
|
||||
|
||||
final PurchaseOrderModel order;
|
||||
final bool isWorking;
|
||||
final bool isDownloadingPdf;
|
||||
final bool canEdit;
|
||||
final bool canDelete;
|
||||
final bool canApprove;
|
||||
@ -426,7 +430,8 @@ class _DetailHeader extends StatelessWidget {
|
||||
_HeaderActionButton(
|
||||
label: 'PDF',
|
||||
icon: Icons.description_outlined,
|
||||
onPressed: isWorking ? null : onPdf,
|
||||
isLoading: isDownloadingPdf,
|
||||
onPressed: (isWorking || isDownloadingPdf) ? null : onPdf,
|
||||
),
|
||||
if (canEdit && order.canEdit)
|
||||
_HeaderActionButton(
|
||||
@ -557,6 +562,7 @@ class _HeaderActionButton extends StatelessWidget {
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.onPressed,
|
||||
this.isLoading = false,
|
||||
this.filled = false,
|
||||
this.destructive = false,
|
||||
});
|
||||
@ -564,6 +570,7 @@ class _HeaderActionButton extends StatelessWidget {
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final VoidCallback? onPressed;
|
||||
final bool isLoading;
|
||||
final bool filled;
|
||||
final bool destructive;
|
||||
|
||||
@ -592,7 +599,19 @@ class _HeaderActionButton extends StatelessWidget {
|
||||
final child = Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 18),
|
||||
if (isLoading)
|
||||
SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: filled
|
||||
? theme.colorScheme.onPrimary
|
||||
: (destructive ? error : null),
|
||||
),
|
||||
)
|
||||
else
|
||||
Icon(icon, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Text(label),
|
||||
],
|
||||
@ -699,11 +718,11 @@ class _OrderDetailsCard extends StatelessWidget {
|
||||
final width = (constraints.maxWidth - spacing * (cols - 1)) / cols;
|
||||
final items = [
|
||||
_DetailField(
|
||||
label: 'PO date',
|
||||
label: 'PO Date',
|
||||
value: DateFormatter.displayDate(order.poDate),
|
||||
),
|
||||
_DetailField(
|
||||
label: 'Expected delivery',
|
||||
label: 'Expected Delivery',
|
||||
value: DateFormatter.displayDate(order.expectedDeliveryDate),
|
||||
),
|
||||
_DetailField(
|
||||
@ -711,7 +730,7 @@ class _OrderDetailsCard extends StatelessWidget {
|
||||
value: _displayOrDash(order.vendorName),
|
||||
),
|
||||
_DetailField(
|
||||
label: 'PO type',
|
||||
label: 'PO Type',
|
||||
value: poTypeLabel(order.poType),
|
||||
),
|
||||
_DetailField(
|
||||
@ -723,8 +742,8 @@ class _OrderDetailsCard extends StatelessWidget {
|
||||
value: _displayOrDash(order.warehouseName),
|
||||
),
|
||||
_DetailField(label: 'Brand', value: brand),
|
||||
_DetailField(label: 'Payment term', value: paymentTerm),
|
||||
_DetailField(label: 'Delivery term', value: deliveryTerm),
|
||||
_DetailField(label: 'Payment Term', value: paymentTerm),
|
||||
_DetailField(label: 'Delivery Term', value: deliveryTerm),
|
||||
];
|
||||
|
||||
return Wrap(
|
||||
@ -1076,7 +1095,7 @@ class _AmountSummaryCard extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_SummaryRow(
|
||||
label: 'Taxable amount',
|
||||
label: 'Taxable Amount',
|
||||
value: CurrencyFormatter.format(order.taxableAmount),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
@ -1086,12 +1105,12 @@ class _AmountSummaryCard extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_SummaryRow(
|
||||
label: 'Freight charges',
|
||||
label: 'Freight Charges',
|
||||
value: CurrencyFormatter.format(order.freightCharges ?? 0),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_SummaryRow(
|
||||
label: 'Other charges',
|
||||
label: 'Other Charges',
|
||||
value: CurrencyFormatter.format(order.otherCharges ?? 0),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
@ -387,7 +387,7 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
||||
FormRowFour(
|
||||
children: [
|
||||
_DateField(
|
||||
label: 'PO date *',
|
||||
label: 'PO Date *',
|
||||
value: _poDate,
|
||||
onTap: () => _pickDate(
|
||||
current: _poDate,
|
||||
@ -395,7 +395,7 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
||||
),
|
||||
),
|
||||
AppSearchableDropdown<String>(
|
||||
label: 'PO type *',
|
||||
label: 'PO Type *',
|
||||
value: _poType,
|
||||
hint: 'Select PO type',
|
||||
searchHint: 'Search type...',
|
||||
@ -466,7 +466,7 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
||||
),
|
||||
MasterQuickAddDropdown<int?>(
|
||||
masterId: 'payment_terms',
|
||||
label: 'Payment term',
|
||||
label: 'Payment Term',
|
||||
value: _paymentTermId,
|
||||
hint: 'Select payment term',
|
||||
searchHint: 'Search payment term...',
|
||||
@ -480,7 +480,7 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
||||
),
|
||||
MasterQuickAddDropdown<int?>(
|
||||
masterId: 'delivery_terms',
|
||||
label: 'Delivery term',
|
||||
label: 'Delivery Term',
|
||||
value: _deliveryTermId,
|
||||
hint: 'Select delivery term',
|
||||
searchHint: 'Search delivery term...',
|
||||
@ -498,7 +498,7 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
||||
columnCount: 4,
|
||||
children: [
|
||||
_DateField(
|
||||
label: 'Expected delivery',
|
||||
label: 'Expected Delivery',
|
||||
value: _expectedDeliveryDate,
|
||||
onTap: () => _pickDate(
|
||||
current: _expectedDeliveryDate,
|
||||
@ -536,7 +536,7 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
||||
children: [
|
||||
AppTextField(
|
||||
controller: _termsController,
|
||||
label: 'Terms & conditions',
|
||||
label: 'Terms & Conditions',
|
||||
hint: 'Payment terms, inspection conditions, etc.',
|
||||
maxLines: 5,
|
||||
),
|
||||
@ -609,8 +609,8 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
||||
Widget _buildHeader(PurchaseOrderModel? existing) {
|
||||
final theme = Theme.of(context);
|
||||
final title = widget.isEditing
|
||||
? 'Edit ${existing?.poNo ?? 'purchase order'}'
|
||||
: 'Create purchase order';
|
||||
? 'Edit ${existing?.poNo ?? 'Purchase Order'}'
|
||||
: 'Create Purchase Order';
|
||||
final subtitle = widget.isEditing
|
||||
? null
|
||||
: 'Fill in order details, add line items, then review the totals before saving.';
|
||||
@ -625,8 +625,8 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
||||
const SizedBox(width: 12),
|
||||
AppButton(
|
||||
label: widget.isEditing
|
||||
? 'Update purchase order'
|
||||
: 'Save purchase order',
|
||||
? 'Update Purchase Order'
|
||||
: 'Save Purchase Order',
|
||||
icon: Icons.check,
|
||||
expand: false,
|
||||
isLoading: _isSubmitting,
|
||||
@ -839,7 +839,7 @@ class _AmountSummaryCard extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_SummaryReadOnlyRow(
|
||||
label: 'Taxable amount',
|
||||
label: 'Taxable Amount',
|
||||
value: CurrencyFormatter.format(totals.taxableAmount),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
@ -849,15 +849,15 @@ class _AmountSummaryCard extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
_SummaryInputRow(
|
||||
label: 'Freight charges',
|
||||
label: 'Freight Charges',
|
||||
controller: freightController,
|
||||
),
|
||||
_SummaryInputRow(
|
||||
label: 'Other charges',
|
||||
label: 'Other Charges',
|
||||
controller: otherChargesController,
|
||||
),
|
||||
_SummaryInputRow(
|
||||
label: 'Discount amount',
|
||||
label: 'Discount Amount',
|
||||
controller: discountController,
|
||||
valueColor: discountInvalid || discountValue > 0
|
||||
? AppColors.error
|
||||
|
||||
@ -98,7 +98,6 @@ class _PurchaseOrderListScreenState extends ConsumerState<PurchaseOrderListScree
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: AppTableShell(
|
||||
toolbar: LayoutBuilder(
|
||||
@ -220,7 +219,7 @@ class _PurchaseOrderListScreenState extends ConsumerState<PurchaseOrderListScree
|
||||
options.sort((a, b) => a.label.compareTo(b.label));
|
||||
|
||||
return [
|
||||
const AppDropdownOption<String?>(value: null, label: 'All statuses'),
|
||||
const AppDropdownOption<String?>(value: null, label: 'All Statuses'),
|
||||
...options,
|
||||
];
|
||||
}
|
||||
@ -277,7 +276,7 @@ class _FiltersBar extends StatelessWidget {
|
||||
searchHint: 'Search type...',
|
||||
isDense: true,
|
||||
options: [
|
||||
const AppDropdownOption(value: null, label: 'All types'),
|
||||
const AppDropdownOption(value: null, label: 'All Types'),
|
||||
...poTypeOptions.map(
|
||||
(e) => AppDropdownOption(value: e.$1, label: e.$2),
|
||||
),
|
||||
@ -345,16 +344,6 @@ class _PoDataTable extends StatelessWidget {
|
||||
flex: 2,
|
||||
cellBuilder: (_, order) => Text(order.vendorName ?? '—'),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Plant',
|
||||
flex: 2,
|
||||
cellBuilder: (_, order) => Text(order.plantName ?? '—'),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Type',
|
||||
flex: 2,
|
||||
cellBuilder: (_, order) => Text(poTypeLabel(order.poType)),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Total',
|
||||
flex: 1,
|
||||
@ -364,7 +353,11 @@ class _PoDataTable extends StatelessWidget {
|
||||
AppDataColumn(
|
||||
label: 'Status',
|
||||
flex: 1,
|
||||
cellBuilder: (_, order) => PoStatusChip(status: order.status),
|
||||
cellBuilder: (_, order) => PoStatusChip(
|
||||
status: order.status,
|
||||
compact: true,
|
||||
forTable: true,
|
||||
),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Actions',
|
||||
|
||||
@ -1,20 +1,31 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../shared/models/purchase_order_model.dart';
|
||||
import '../../../../shared/widgets/app_status_chip.dart';
|
||||
|
||||
class PoStatusChip extends StatelessWidget {
|
||||
const PoStatusChip({
|
||||
super.key,
|
||||
required this.status,
|
||||
this.compact = false,
|
||||
this.forTable = false,
|
||||
});
|
||||
|
||||
final String status;
|
||||
final bool compact;
|
||||
final bool forTable;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final (color, label) = _resolveStatus(status);
|
||||
if (forTable) {
|
||||
return TableStatusBadge(
|
||||
label: label,
|
||||
color: color,
|
||||
compact: compact,
|
||||
fixedWidth: kTableStatusChipWidth,
|
||||
);
|
||||
}
|
||||
return _PoBadge(label: label, color: color, compact: compact);
|
||||
}
|
||||
|
||||
|
||||
@ -263,7 +263,7 @@ class _PurchaseOrderLineItemsEditorState
|
||||
widget.onChanged?.call();
|
||||
},
|
||||
icon: const Icon(Icons.add, size: 18),
|
||||
label: const Text('Add line'),
|
||||
label: const Text('Add Line'),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: theme.colorScheme.primary,
|
||||
),
|
||||
@ -437,7 +437,7 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
|
||||
.whereType<AppDropdownOption<int>>()
|
||||
.toList();
|
||||
final gstOptions = [
|
||||
const AppDropdownOption<int?>(value: null, label: 'Select GST rate'),
|
||||
const AppDropdownOption<int?>(value: null, label: 'Select GST Rate'),
|
||||
...widget.gstRates.map((e) {
|
||||
final id = _parseId(e.id);
|
||||
if (id == null) return null;
|
||||
|
||||
@ -11,9 +11,9 @@ class RbacState {
|
||||
this.selectedTab = RbacTab.users,
|
||||
this.selectedRoleId = 'super_admin',
|
||||
this.searchQuery = '',
|
||||
this.roleFilter = 'All roles',
|
||||
this.departmentFilter = 'All departments',
|
||||
this.statusFilter = 'All statuses',
|
||||
this.roleFilter = 'All Roles',
|
||||
this.departmentFilter = 'All Departments',
|
||||
this.statusFilter = 'All Statuses',
|
||||
this.currentPage = 0,
|
||||
this.pageSize = 10,
|
||||
});
|
||||
@ -45,10 +45,10 @@ class RbacState {
|
||||
user.email.toLowerCase().contains(q) ||
|
||||
user.employeeCode.toLowerCase().contains(q);
|
||||
final matchesRole =
|
||||
roleFilter == 'All roles' || user.roleName == roleFilter;
|
||||
final matchesDept = departmentFilter == 'All departments' ||
|
||||
roleFilter == 'All Roles' || user.roleName == roleFilter;
|
||||
final matchesDept = departmentFilter == 'All Departments' ||
|
||||
user.department == departmentFilter;
|
||||
final matchesStatus = statusFilter == 'All statuses' ||
|
||||
final matchesStatus = statusFilter == 'All Statuses' ||
|
||||
user.status.label == statusFilter;
|
||||
return matchesSearch && matchesRole && matchesDept && matchesStatus;
|
||||
}).toList();
|
||||
|
||||
@ -15,6 +15,7 @@ import '../../../users/presentation/widgets/user_rich_data_table.dart';
|
||||
import '../../../../shared/widgets/app_pagination.dart';
|
||||
import '../../../../shared/widgets/app_dropdown.dart';
|
||||
import '../../../../shared/widgets/app_card.dart';
|
||||
import '../../../../shared/widgets/app_data_table.dart';
|
||||
import '../../../../shared/widgets/app_loading_view.dart';
|
||||
import '../../../../shared/widgets/app_searchable_dropdown.dart';
|
||||
import '../../../../shared/widgets/error_view.dart';
|
||||
@ -176,7 +177,7 @@ class _UsersRoleManagementScreenState
|
||||
SizedBox(
|
||||
width: cardWidth.clamp(160, constraints.maxWidth),
|
||||
child: RbacStatCard(
|
||||
label: 'Total users',
|
||||
label: 'Total Users',
|
||||
value: '${summary?.totalUsers ?? usersState?.total ?? 0}',
|
||||
icon: Icons.people_outline,
|
||||
color: Color(0xFF2563EB),
|
||||
@ -212,7 +213,7 @@ class _UsersRoleManagementScreenState
|
||||
SizedBox(
|
||||
width: cardWidth.clamp(160, constraints.maxWidth),
|
||||
child: RbacStatCard(
|
||||
label: 'Roles defined',
|
||||
label: 'Roles Defined',
|
||||
value: '${summary?.rolesCount ?? state.roles.length}',
|
||||
icon: Icons.shield_outlined,
|
||||
color: const Color(0xFF16A34A),
|
||||
@ -558,13 +559,13 @@ class _UsersTabState extends ConsumerState<_UsersTab> {
|
||||
final password = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('Reset password'),
|
||||
title: const Text('Reset Password'),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
obscureText: true,
|
||||
autofocus: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'New temporary password',
|
||||
labelText: 'New Temporary Password',
|
||||
hintText: 'Min. 8 characters',
|
||||
),
|
||||
),
|
||||
@ -674,27 +675,27 @@ class _UsersTabState extends ConsumerState<_UsersTab> {
|
||||
final canExport = ref.can('users', PermissionAction.export);
|
||||
final canEditUser = ref.can('users', PermissionAction.update);
|
||||
final canDeleteUser = ref.can('users', PermissionAction.delete);
|
||||
final roles = ['All roles', ...?filters?.roles.map((r) => r.name)];
|
||||
final roles = ['All Roles', ...?filters?.roles.map((r) => r.name)];
|
||||
final departments = [
|
||||
'All departments',
|
||||
'All Departments',
|
||||
...?filters?.departments.map((d) => d.name),
|
||||
];
|
||||
final statuses = [
|
||||
'All statuses',
|
||||
'All Statuses',
|
||||
...?filters?.statuses.map((s) => s.name),
|
||||
];
|
||||
final roleFilter =
|
||||
_roleNameForId(usersState.query.roleId, filters) ?? 'All roles';
|
||||
_roleNameForId(usersState.query.roleId, filters) ?? 'All Roles';
|
||||
final departmentFilter = _departmentNameForId(
|
||||
usersState.query.departmentId,
|
||||
filters,
|
||||
) ??
|
||||
'All departments';
|
||||
'All Departments';
|
||||
final statusFilter = _statusLabelForValue(
|
||||
usersState.query.status,
|
||||
filters,
|
||||
) ??
|
||||
'All statuses';
|
||||
'All Statuses';
|
||||
final page = usersState.query.page;
|
||||
final pageSize = usersState.query.limit;
|
||||
final total = usersState.total;
|
||||
@ -731,21 +732,21 @@ class _UsersTabState extends ConsumerState<_UsersTab> {
|
||||
onSearch: ref.read(usersListProvider.notifier).setSearch,
|
||||
onRoleChanged: (value) {
|
||||
ref.read(usersListProvider.notifier).setRoleFilter(
|
||||
value == 'All roles'
|
||||
value == 'All Roles'
|
||||
? null
|
||||
: _roleIdForName(value, filters),
|
||||
);
|
||||
},
|
||||
onDepartmentChanged: (value) {
|
||||
ref.read(usersListProvider.notifier).setDepartmentFilter(
|
||||
value == 'All departments'
|
||||
value == 'All Departments'
|
||||
? null
|
||||
: _departmentIdForName(value, filters),
|
||||
);
|
||||
},
|
||||
onStatusChanged: (value) {
|
||||
ref.read(usersListProvider.notifier).setStatusFilter(
|
||||
value == 'All statuses'
|
||||
value == 'All Statuses'
|
||||
? null
|
||||
: _statusValueForLabel(value, filters),
|
||||
);
|
||||
@ -994,138 +995,204 @@ class _RolesTab extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
data: (rolesState) {
|
||||
final roles = rolesState.roles;
|
||||
final roles = rolesState.pagedRoles;
|
||||
final notifier = ref.read(rolesListProvider.notifier);
|
||||
|
||||
return GridView.builder(
|
||||
key: ValueKey('$themeMode-$brightness'),
|
||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 320,
|
||||
mainAxisExtent: 190,
|
||||
crossAxisSpacing: 16,
|
||||
mainAxisSpacing: 16,
|
||||
return AppCard(
|
||||
enableHover: false,
|
||||
clipBehavior: Clip.none,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(
|
||||
color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.12),
|
||||
),
|
||||
),
|
||||
itemCount: roles.length + (canCreateRole ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
if (canCreateRole && index == roles.length) {
|
||||
return AppHoverEffect(
|
||||
onTap: onNewRole,
|
||||
showHoverBorder: false,
|
||||
child: CustomPaint(
|
||||
painter: _DashedBorderPainter(
|
||||
color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5),
|
||||
radius: 12,
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: GridView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
key: ValueKey('$themeMode-$brightness-${rolesState.page}'),
|
||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 320,
|
||||
mainAxisExtent: 168,
|
||||
crossAxisSpacing: 16,
|
||||
mainAxisSpacing: 16,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.add,
|
||||
size: 32,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'New role',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final role = roles[index];
|
||||
final appearance = roleCardAppearance(index);
|
||||
|
||||
return AppCard(
|
||||
elevation: 0,
|
||||
enableHover: true,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: appearance.color.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
itemCount: roles.length + (canCreateRole ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
if (canCreateRole && index == 0) {
|
||||
return AppHoverEffect(
|
||||
onTap: onNewRole,
|
||||
showHoverBorder: false,
|
||||
child: CustomPaint(
|
||||
painter: _DashedBorderPainter(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.outline
|
||||
.withValues(alpha: 0.5),
|
||||
radius: 12,
|
||||
),
|
||||
child: Icon(appearance.icon, color: appearance.color, size: 20),
|
||||
),
|
||||
const Spacer(),
|
||||
if (canEditRole)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit_outlined, size: 18),
|
||||
onPressed: () => onEditRole(role),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
if (canDeleteRole && !isProtectedRole(role))
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline, size: 18),
|
||||
onPressed: () => onDeleteRole(role),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
role.name,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
role.description ?? '—',
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.people_outline,
|
||||
size: 14,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${role.userCount} users',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.add,
|
||||
size: 32,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Icon(
|
||||
Icons.vpn_key_outlined,
|
||||
size: 14,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${role.permissionCount} permissions',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'New role',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
final roleIndex = canCreateRole ? index - 1 : index;
|
||||
final role = roles[roleIndex];
|
||||
final appearance = roleCardAppearance(roleIndex);
|
||||
final description = role.description?.trim();
|
||||
final displayDescription =
|
||||
(description == null || description.isEmpty)
|
||||
? '—'
|
||||
: description;
|
||||
|
||||
return AppCard(
|
||||
elevation: 0,
|
||||
enableHover: true,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: appearance.color.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
appearance.icon,
|
||||
color: appearance.color,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (canEditRole)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit_outlined, size: 18),
|
||||
onPressed: () => onEditRole(role),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
if (canDeleteRole && !isProtectedRole(role))
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline, size: 18),
|
||||
onPressed: () => onDeleteRole(role),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
role.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Tooltip(
|
||||
message: displayDescription == '—' ? '' : displayDescription,
|
||||
waitDuration: const Duration(milliseconds: 300),
|
||||
child: Text(
|
||||
displayDescription,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
height: 1.35,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.people_outline,
|
||||
size: 14,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${role.userCount} users',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Icon(
|
||||
Icons.vpn_key_outlined,
|
||||
size: 14,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${role.permissionCount} permissions',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
const Divider(height: 1),
|
||||
AppPagination(
|
||||
currentPage: rolesState.page,
|
||||
totalPages: rolesState.totalPages,
|
||||
totalItems: rolesState.total,
|
||||
pageSize: rolesState.limit,
|
||||
itemLabel: 'roles',
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
onPageChanged: notifier.setPage,
|
||||
onPageSizeChanged: notifier.setPageSize,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
@ -1345,141 +1412,76 @@ class _PermissionMatrixTable extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
final actionColumns = matrix.actionColumns.isNotEmpty
|
||||
? matrix.actionColumns
|
||||
: (catalog?.isNotEmpty == true
|
||||
? catalog!.first.actions
|
||||
: permissionMatrixActionOrder);
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final tableMinWidth = 180.0 + (actionColumns.length * 88.0);
|
||||
final tableWidth = constraints.maxWidth < tableMinWidth
|
||||
? tableMinWidth
|
||||
: constraints.maxWidth;
|
||||
return AppDataTable<PermissionMatrixModuleRow>(
|
||||
wrapInCard: false,
|
||||
columns: [
|
||||
AppDataColumn(
|
||||
label: 'Module',
|
||||
flex: 3,
|
||||
cellBuilder: (context, module) {
|
||||
final index = matrix.modules.indexOf(module);
|
||||
final appearance =
|
||||
permissionModuleAppearance(module.code, index);
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(minWidth: tableWidth - 32),
|
||||
child: Table(
|
||||
columnWidths: {
|
||||
0: const FlexColumnWidth(2.5),
|
||||
for (var i = 0; i < actionColumns.length; i++)
|
||||
i + 1: const FlexColumnWidth(1),
|
||||
},
|
||||
border: TableBorder(
|
||||
horizontalInside: BorderSide(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.outline
|
||||
.withValues(alpha: 0.12),
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: appearance.color.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Icon(
|
||||
appearance.icon,
|
||||
size: 16,
|
||||
color: appearance.color,
|
||||
),
|
||||
),
|
||||
children: [
|
||||
TableRow(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHighest
|
||||
.withValues(alpha: 0.4),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: AppTableCell.text(
|
||||
module.name,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
children: [
|
||||
const _MatrixHeader('MODULE'),
|
||||
...actionColumns.map(
|
||||
(action) => _MatrixHeader(permissionActionLabel(action)),
|
||||
),
|
||||
],
|
||||
),
|
||||
...matrix.modules.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final module = entry.value;
|
||||
final appearance =
|
||||
permissionModuleAppearance(module.code, index);
|
||||
|
||||
return TableRow(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: appearance.color.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Icon(
|
||||
appearance.icon,
|
||||
size: 16,
|
||||
color: appearance.color,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
module.name,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
...actionColumns.map((action) {
|
||||
final checked = module.granted[action] ?? false;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Center(
|
||||
child: Checkbox(
|
||||
value: checked,
|
||||
onChanged: (value) => ref
|
||||
.read(
|
||||
permissionMatrixProvider(roleId).notifier,
|
||||
)
|
||||
.toggleAction(
|
||||
module.moduleId,
|
||||
action,
|
||||
value ?? false,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
...actionColumns.map(
|
||||
(action) => AppDataColumn(
|
||||
label: permissionActionLabel(action),
|
||||
flex: 1,
|
||||
alignment: Alignment.center,
|
||||
cellBuilder: (context, module) {
|
||||
final checked = module.granted[action] ?? false;
|
||||
return Checkbox(
|
||||
value: checked,
|
||||
visualDensity: VisualDensity.compact,
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
onChanged: (value) => ref
|
||||
.read(permissionMatrixProvider(roleId).notifier)
|
||||
.toggleAction(
|
||||
module.moduleId,
|
||||
action,
|
||||
value ?? false,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MatrixHeader extends StatelessWidget {
|
||||
const _MatrixHeader(this.label);
|
||||
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 4),
|
||||
child: Text(
|
||||
label,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
rows: matrix.modules,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -228,7 +228,7 @@ class _AddUserPanelState extends ConsumerState<AddUserPanel> {
|
||||
final isSubmitting = formAsync.valueOrNull?.isSubmitting ?? false;
|
||||
|
||||
return SidePanelScaffold(
|
||||
title: widget.isEditing ? 'Edit user' : 'Add user',
|
||||
title: widget.isEditing ? 'Edit User' : 'Add User',
|
||||
footer: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
@ -269,16 +269,16 @@ class _AddUserPanelState extends ConsumerState<AddUserPanel> {
|
||||
SidePanelFormRow(
|
||||
left: AppTextField(
|
||||
controller: _nameController,
|
||||
label: 'Full name *',
|
||||
label: 'Full Name *',
|
||||
hint: 'e.g. Ravi Kumar',
|
||||
validator: (v) => Validators.required(v, fieldName: 'Name'),
|
||||
),
|
||||
right: AppTextField(
|
||||
controller: _employeeCodeController,
|
||||
label: 'Employee code *',
|
||||
label: 'Employee Code *',
|
||||
hint: 'e.g. EMP002',
|
||||
validator: (v) =>
|
||||
Validators.required(v, fieldName: 'Employee code'),
|
||||
Validators.required(v, fieldName: 'Employee Code'),
|
||||
),
|
||||
),
|
||||
SidePanelFormRow(
|
||||
@ -360,7 +360,7 @@ class _AddUserPanelState extends ConsumerState<AddUserPanel> {
|
||||
onChanged: (v) => setState(() => _selectedPlantId = v),
|
||||
),
|
||||
right: _buildDropdown(
|
||||
label: 'Reporting to',
|
||||
label: 'Reporting To',
|
||||
value: _selectedReportingToId,
|
||||
options: formState.managers,
|
||||
hint: formState.managers.isEmpty
|
||||
@ -378,8 +378,8 @@ class _AddUserPanelState extends ConsumerState<AddUserPanel> {
|
||||
AppTextField(
|
||||
controller: _passwordController,
|
||||
label: widget.isEditing
|
||||
? 'New password'
|
||||
: 'Temporary password *',
|
||||
? 'New Password'
|
||||
: 'Temporary Password *',
|
||||
hint: 'Min. 8 characters',
|
||||
obscureText: true,
|
||||
validator: widget.isEditing
|
||||
|
||||
@ -98,7 +98,7 @@ class _RoleFormPanelState extends ConsumerState<RoleFormPanel> {
|
||||
final isSubmitting = formAsync.valueOrNull?.isSubmitting ?? false;
|
||||
|
||||
return SidePanelScaffold(
|
||||
title: widget.isEditing ? 'Edit role' : 'Create new role',
|
||||
title: widget.isEditing ? 'Edit Role' : 'Create New Role',
|
||||
footer: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
@ -157,7 +157,7 @@ class _RoleFormPanelState extends ConsumerState<RoleFormPanel> {
|
||||
children: [
|
||||
AppTextField(
|
||||
controller: _nameController,
|
||||
label: 'Role name *',
|
||||
label: 'Role Name *',
|
||||
hint: 'e.g. QC Manager',
|
||||
validator: Validators.roleName,
|
||||
inputFormatters: Validators.roleNameInput,
|
||||
|
||||
@ -2,6 +2,7 @@ import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../shared/widgets/app_data_table.dart';
|
||||
import '../../../../shared/widgets/app_table_action_icon.dart';
|
||||
|
||||
import '../../../../shared/models/user_management_models.dart';
|
||||
import '../../../../shared/widgets/app_card.dart';
|
||||
@ -212,24 +213,41 @@ class UserRolesCell extends StatelessWidget {
|
||||
class EmployeeCodeBadge extends StatelessWidget {
|
||||
const EmployeeCodeBadge({super.key, required this.code});
|
||||
|
||||
/// Uniform width for employee code pills in data tables.
|
||||
static const double tableWidth = 108;
|
||||
|
||||
final String code;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.7),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: AppTableCell.text(
|
||||
code,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
final display = code.trim().isEmpty ? '—' : code.trim();
|
||||
|
||||
return SizedBox(
|
||||
width: tableWidth,
|
||||
child: Container(
|
||||
height: 22,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.7),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Center(
|
||||
child: Tooltip(
|
||||
message: display == '—' ? '' : display,
|
||||
child: Text(
|
||||
display,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
showTooltip: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -342,26 +360,24 @@ class UserTableActions extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final muted = Theme.of(context).colorScheme.onSurfaceVariant;
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
return AppTableActions(
|
||||
children: [
|
||||
if (canEdit)
|
||||
_UserActionIcon(
|
||||
AppTableActionIcon(
|
||||
tooltip: 'Edit user',
|
||||
icon: Icons.edit_outlined,
|
||||
color: muted,
|
||||
onPressed: onEdit,
|
||||
),
|
||||
if (canResetPassword)
|
||||
_UserActionIcon(
|
||||
AppTableActionIcon(
|
||||
tooltip: 'Reset password',
|
||||
icon: Icons.vpn_key_outlined,
|
||||
color: muted,
|
||||
onPressed: onResetPassword,
|
||||
),
|
||||
if (canDeactivate)
|
||||
_UserActionIcon(
|
||||
AppTableActionIcon(
|
||||
tooltip: 'Deactivate user',
|
||||
icon: Icons.person_off_outlined,
|
||||
color: muted,
|
||||
@ -407,35 +423,6 @@ class UserTableActionsCell extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _UserActionIcon extends StatelessWidget {
|
||||
const _UserActionIcon({
|
||||
required this.tooltip,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
final String tooltip;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Tooltip(
|
||||
message: tooltip,
|
||||
child: InkWell(
|
||||
onTap: onPressed,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: Icon(icon, size: 18, color: color),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class RolePill extends StatelessWidget {
|
||||
const RolePill({
|
||||
super.key,
|
||||
|
||||
@ -80,7 +80,7 @@ class _DepreciationReportScreenState
|
||||
initialDate: query.asOfDate ?? now,
|
||||
firstDate: DateTime(now.year - 20),
|
||||
lastDate: DateTime(now.year + 1),
|
||||
helpText: 'As of date',
|
||||
helpText: 'As Of Date',
|
||||
);
|
||||
if (picked == null) return;
|
||||
ref.read(depreciationReportProvider.notifier).setAsOfDate(picked);
|
||||
@ -165,7 +165,7 @@ class _DepreciationReportScreenState
|
||||
],
|
||||
),
|
||||
_SummaryStrip(summary: state.summary, asOfDate: state.asOfDate),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 12),
|
||||
Expanded(
|
||||
child: AppTableShell(
|
||||
toolbar: LayoutBuilder(
|
||||
@ -496,7 +496,7 @@ class _FiltersBarState extends State<_FiltersBar> {
|
||||
|
||||
final asOfEmpty = query.asOfDate == null;
|
||||
final asOfField = AppFilterDateField(
|
||||
label: 'As of date',
|
||||
label: 'As Of Date',
|
||||
value: asOfEmpty ? '' : DateFormatter.displayDate(query.asOfDate),
|
||||
placeholder: 'Select date',
|
||||
icon: Icons.calendar_today_outlined,
|
||||
@ -508,7 +508,7 @@ class _FiltersBarState extends State<_FiltersBar> {
|
||||
final purchaseEmpty =
|
||||
query.purchaseDateFrom == null && query.purchaseDateTo == null;
|
||||
final purchaseField = AppFilterDateField(
|
||||
label: 'Purchase dates',
|
||||
label: 'Purchase Dates',
|
||||
value: purchaseEmpty
|
||||
? ''
|
||||
: '${DateFormatter.displayDate(query.purchaseDateFrom)} – ${DateFormatter.displayDate(query.purchaseDateTo)}',
|
||||
|
||||
@ -64,7 +64,6 @@ class _PermissionMatrixScreenState extends ConsumerState<PermissionMatrixScreen>
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: context.isMobile
|
||||
? _MatrixCardList(roleId: widget.roleId, matrix: matrix)
|
||||
|
||||
@ -12,6 +12,7 @@ import '../../../../shared/widgets/app_loading_view.dart';
|
||||
import '../../../../shared/widgets/app_pagination.dart';
|
||||
import '../../../../shared/widgets/app_search_field.dart';
|
||||
import '../../../../shared/widgets/app_table_shell.dart';
|
||||
import '../../../../shared/widgets/app_table_action_icon.dart';
|
||||
import '../../../../shared/widgets/error_view.dart';
|
||||
import '../../../../shared/widgets/page_header.dart';
|
||||
import '../providers/roles_provider.dart';
|
||||
@ -54,7 +55,6 @@ class _RoleListScreenState extends ConsumerState<RoleListScreen> {
|
||||
title: 'Roles',
|
||||
subtitle: 'Manage roles and permission assignments',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: AppTableShell(
|
||||
toolbar: SizedBox(
|
||||
@ -135,12 +135,14 @@ class _RoleDataTable extends StatelessWidget {
|
||||
label: 'Actions',
|
||||
flex: 1,
|
||||
alignment: Alignment.centerRight,
|
||||
cellBuilder: (_, r) => Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton(
|
||||
onPressed: () => onOpen(r),
|
||||
child: const Text('View Matrix'),
|
||||
),
|
||||
cellBuilder: (_, r) => AppTableActions(
|
||||
children: [
|
||||
AppTableActionIcon(
|
||||
tooltip: 'View Matrix',
|
||||
icon: Icons.grid_view_outlined,
|
||||
onPressed: () => onOpen(r),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@ -17,9 +17,12 @@ class SettingsRemoteDataSource {
|
||||
if (data is Map) return Map<String, dynamic>.from(data);
|
||||
// Some responses return the entity at the root alongside success/message.
|
||||
if (root.containsKey('org_name') ||
|
||||
root.containsKey('gstin') ||
|
||||
root.containsKey('smtp_host') ||
|
||||
root.containsKey('logo_url') ||
|
||||
root.containsKey('logo')) {
|
||||
root.containsKey('logo') ||
|
||||
root.containsKey('favicon_url') ||
|
||||
root.containsKey('favicon')) {
|
||||
return root;
|
||||
}
|
||||
return null;
|
||||
@ -87,6 +90,53 @@ class SettingsRemoteDataSource {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// POST `/settings/company/favicon` (multipart field `favicon`)
|
||||
Future<String?> uploadCompanyFavicon(List<int> bytes, String filename) async {
|
||||
final formData = FormData.fromMap({
|
||||
'favicon': MultipartFile.fromBytes(bytes, filename: filename),
|
||||
});
|
||||
final response = await _dio.post(
|
||||
ApiEndpoints.settingsCompanyFavicon,
|
||||
data: formData,
|
||||
);
|
||||
return _extractFaviconUrl(response.data);
|
||||
}
|
||||
|
||||
String? _extractFaviconUrl(dynamic responseData) {
|
||||
if (responseData is String && responseData.trim().isNotEmpty) {
|
||||
return resolveMediaUrl(responseData);
|
||||
}
|
||||
if (responseData is! Map) return null;
|
||||
final root = Map<String, dynamic>.from(responseData);
|
||||
|
||||
String? fromMap(Map<String, dynamic> map) {
|
||||
for (final key in [
|
||||
'favicon_url',
|
||||
'faviconUrl',
|
||||
'favicon',
|
||||
'url',
|
||||
'path',
|
||||
]) {
|
||||
final value = map[key];
|
||||
if (value is String && value.trim().isNotEmpty) {
|
||||
return resolveMediaUrl(value);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
final data = root['data'];
|
||||
if (data is Map) {
|
||||
final nested = Map<String, dynamic>.from(data);
|
||||
final found = fromMap(nested);
|
||||
if (found != null) return found;
|
||||
}
|
||||
if (data is String && data.trim().isNotEmpty) {
|
||||
return resolveMediaUrl(data);
|
||||
}
|
||||
return fromMap(root);
|
||||
}
|
||||
|
||||
/// GET `/settings/email`
|
||||
Future<EmailConfigurationSettings?> fetchEmail() async {
|
||||
final response = await _dio.get(ApiEndpoints.settingsEmail);
|
||||
|
||||
@ -58,6 +58,7 @@ class SettingsRepositoryImpl implements SettingsRepository {
|
||||
final merged = (current?.companyProfile ?? const CompanyProfileSettings())
|
||||
.copyWith(
|
||||
companyName: remoteProfile.companyName,
|
||||
gstNumber: remoteProfile.gstNumber,
|
||||
address: remoteProfile.address,
|
||||
city: remoteProfile.city,
|
||||
state: remoteProfile.state,
|
||||
@ -68,6 +69,9 @@ class SettingsRepositoryImpl implements SettingsRepository {
|
||||
logoUrl: remoteProfile.logoUrl.isNotEmpty
|
||||
? remoteProfile.logoUrl
|
||||
: current?.companyProfile.logoUrl ?? '',
|
||||
faviconUrl: remoteProfile.faviconUrl.isNotEmpty
|
||||
? remoteProfile.faviconUrl
|
||||
: current?.companyProfile.faviconUrl ?? '',
|
||||
);
|
||||
await local.write((current ?? const AppSettings()).copyWith(
|
||||
companyProfile: merged,
|
||||
@ -97,7 +101,12 @@ class SettingsRepositoryImpl implements SettingsRepository {
|
||||
email: saved.email.isNotEmpty ? saved.email : profile.email,
|
||||
phone: saved.phone.isNotEmpty ? saved.phone : profile.phone,
|
||||
website: saved.website.isNotEmpty ? saved.website : profile.website,
|
||||
gstNumber: saved.gstNumber.isNotEmpty
|
||||
? saved.gstNumber
|
||||
: profile.gstNumber,
|
||||
logoUrl: saved.logoUrl.isNotEmpty ? saved.logoUrl : profile.logoUrl,
|
||||
faviconUrl:
|
||||
saved.faviconUrl.isNotEmpty ? saved.faviconUrl : profile.faviconUrl,
|
||||
);
|
||||
await local.write(current.copyWith(companyProfile: merged));
|
||||
return merged;
|
||||
@ -112,6 +121,16 @@ class SettingsRepositoryImpl implements SettingsRepository {
|
||||
return safeApiCall<String?>(() => remote.uploadCompanyLogo(bytes, filename));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<String?>> uploadCompanyFavicon(
|
||||
List<int> bytes,
|
||||
String filename,
|
||||
) async {
|
||||
return safeApiCall<String?>(
|
||||
() => remote.uploadCompanyFavicon(bytes, filename),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<EmailConfigurationSettings>> fetchEmailSettings() async {
|
||||
return safeApiCall(() async {
|
||||
|
||||
@ -150,6 +150,7 @@ class CompanyProfileSettings {
|
||||
/// Payload for `PUT /settings/company` ([CompanySettingsBody]).
|
||||
Map<String, dynamic> toApiJson() => {
|
||||
'org_name': companyName,
|
||||
'gstin': gstNumber,
|
||||
'mobile': phone,
|
||||
'email': email,
|
||||
'website': website,
|
||||
@ -169,7 +170,9 @@ class CompanyProfileSettings {
|
||||
'',
|
||||
companyCode: json['companyCode'] as String? ?? '',
|
||||
registrationNumber: json['registrationNumber'] as String? ?? '',
|
||||
gstNumber: json['gstNumber'] as String? ?? '',
|
||||
gstNumber: json['gstin'] as String? ??
|
||||
json['gstNumber'] as String? ??
|
||||
'',
|
||||
address: json['address'] as String? ?? '',
|
||||
city: json['city'] as String? ?? '',
|
||||
state: json['state'] as String? ?? '',
|
||||
@ -183,7 +186,12 @@ class CompanyProfileSettings {
|
||||
json['logo'] as String?,
|
||||
) ??
|
||||
'',
|
||||
faviconUrl: resolveMediaUrl(json['faviconUrl'] as String?) ?? '',
|
||||
faviconUrl: resolveMediaUrl(
|
||||
json['favicon_url'] as String? ??
|
||||
json['faviconUrl'] as String? ??
|
||||
json['favicon'] as String?,
|
||||
) ??
|
||||
'',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -12,6 +12,10 @@ abstract class SettingsRepository {
|
||||
List<int> bytes,
|
||||
String filename,
|
||||
);
|
||||
Future<Result<String?>> uploadCompanyFavicon(
|
||||
List<int> bytes,
|
||||
String filename,
|
||||
);
|
||||
Future<Result<EmailConfigurationSettings>> fetchEmailSettings();
|
||||
Future<Result<EmailConfigurationSettings>> saveEmailSettings(
|
||||
EmailConfigurationSettings email,
|
||||
|
||||
@ -6,6 +6,7 @@ import '../../../../core/network/dio_client.dart';
|
||||
import '../../../../core/theme/branding_config.dart';
|
||||
import '../../../../core/theme/theme_provider.dart';
|
||||
import '../../../../core/utils/favicon_store.dart';
|
||||
import '../../../../core/utils/favicon_updater.dart';
|
||||
import '../../../../core/utils/media_url.dart';
|
||||
import '../../data/datasources/settings_local_data_source.dart';
|
||||
import '../../data/datasources/settings_remote_data_source.dart';
|
||||
@ -92,10 +93,19 @@ class AppSettingsNotifier extends StateNotifier<AppSettings> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _syncFavicon(String? faviconUrl) async {
|
||||
final resolved = resolveMediaUrl(faviconUrl) ?? faviconUrl?.trim();
|
||||
final value = (resolved == null || resolved.isEmpty) ? null : resolved;
|
||||
await _faviconStore.write(value);
|
||||
if (value != null) {
|
||||
updateFavicon(value);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final result = await _getSettings();
|
||||
state = result.data ?? const AppSettings();
|
||||
_faviconStore.apply();
|
||||
await _syncFavicon(state.companyProfile.faviconUrl);
|
||||
await _syncMainLogo(state.companyProfile);
|
||||
}
|
||||
|
||||
@ -109,6 +119,7 @@ class AppSettingsNotifier extends StateNotifier<AppSettings> {
|
||||
if (result.failure == null && result.data != null) {
|
||||
state = state.copyWith(companyProfile: result.data!);
|
||||
await _syncMainLogo(result.data!);
|
||||
await _syncFavicon(result.data!.faviconUrl);
|
||||
}
|
||||
return result.failure;
|
||||
} finally {
|
||||
@ -146,6 +157,7 @@ class AppSettingsNotifier extends StateNotifier<AppSettings> {
|
||||
state = state.copyWith(companyProfile: result.data!);
|
||||
await _saveSettings(state);
|
||||
await _syncMainLogo(result.data!);
|
||||
await _syncFavicon(result.data!.faviconUrl);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@ -167,6 +179,25 @@ class AppSettingsNotifier extends StateNotifier<AppSettings> {
|
||||
return result;
|
||||
}
|
||||
|
||||
/// POST `/settings/company/favicon` — also updates the browser tab icon.
|
||||
Future<Result<String?>> uploadCompanyFavicon(
|
||||
List<int> bytes,
|
||||
String filename,
|
||||
) async {
|
||||
final result = await _repository.uploadCompanyFavicon(bytes, filename);
|
||||
if (result.failure == null &&
|
||||
result.data != null &&
|
||||
result.data!.isNotEmpty) {
|
||||
final faviconUrl = resolveMediaUrl(result.data!) ?? result.data!;
|
||||
final profile = state.companyProfile.copyWith(faviconUrl: faviconUrl);
|
||||
state = state.copyWith(companyProfile: profile);
|
||||
await _saveSettings(state);
|
||||
await _syncFavicon(faviconUrl);
|
||||
return (data: faviconUrl, failure: null);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Applies a local/preview logo to company profile + main app branding.
|
||||
Future<void> applyLocalCompanyLogo(String logoUrl) async {
|
||||
final resolved = resolveMediaUrl(logoUrl) ?? logoUrl;
|
||||
@ -176,6 +207,15 @@ class AppSettingsNotifier extends StateNotifier<AppSettings> {
|
||||
await _syncMainLogo(profile);
|
||||
}
|
||||
|
||||
/// Applies a local/preview favicon when upload is unavailable.
|
||||
Future<void> applyLocalCompanyFavicon(String faviconUrl) async {
|
||||
final resolved = resolveMediaUrl(faviconUrl) ?? faviconUrl;
|
||||
final profile = state.companyProfile.copyWith(faviconUrl: resolved);
|
||||
state = state.copyWith(companyProfile: profile);
|
||||
await _saveSettings(state);
|
||||
await _syncFavicon(resolved);
|
||||
}
|
||||
|
||||
Future<void> updateUiPreferences(UiPreferencesSettings prefs) async {
|
||||
await _persist(state.copyWith(uiPreferences: prefs));
|
||||
}
|
||||
|
||||
@ -128,7 +128,7 @@ class AppearanceSettingsScreen extends ConsumerWidget {
|
||||
title: 'UI Preferences',
|
||||
children: [
|
||||
SettingsSwitchTile(
|
||||
title: 'Sidebar Expanded by Default',
|
||||
title: 'Sidebar Expanded By Default',
|
||||
subtitle: 'Show full sidebar labels on login',
|
||||
value: uiPrefs.sidebarExpanded,
|
||||
onChanged: (v) => ref
|
||||
|
||||
@ -45,6 +45,7 @@ class _CompanyProfileSettingsScreenState
|
||||
bool _loading = true;
|
||||
bool _saving = false;
|
||||
bool _uploadingLogo = false;
|
||||
bool _uploadingFavicon = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@ -173,11 +174,6 @@ class _CompanyProfileSettingsScreenState
|
||||
),
|
||||
);
|
||||
|
||||
await FaviconStore(ref.read(sharedPreferencesProvider)).write(
|
||||
faviconUrl.isEmpty ? null : faviconUrl,
|
||||
);
|
||||
updateFavicon(faviconUrl.isEmpty ? null : faviconUrl);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Company profile saved')),
|
||||
@ -185,27 +181,6 @@ class _CompanyProfileSettingsScreenState
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pickImage(void Function(String dataUri) onPicked) async {
|
||||
final result = await FilePicker.pickFiles(
|
||||
type: FileType.image,
|
||||
withData: true,
|
||||
);
|
||||
|
||||
if (result == null || result.files.isEmpty) return;
|
||||
|
||||
final file = result.files.single;
|
||||
final bytes = file.bytes;
|
||||
if (bytes == null) return;
|
||||
|
||||
final ext = (file.extension ?? 'png').toLowerCase();
|
||||
final mime = ext == 'jpg' ? 'jpeg' : ext;
|
||||
final dataUri = 'data:image/$mime;base64,${base64Encode(bytes)}';
|
||||
|
||||
setState(() {
|
||||
onPicked(dataUri);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _pickLogo() async {
|
||||
final result = await FilePicker.pickFiles(
|
||||
type: FileType.image,
|
||||
@ -256,9 +231,68 @@ class _CompanyProfileSettingsScreenState
|
||||
setState(() => _logoUrlController.text = dataUri);
|
||||
}
|
||||
|
||||
Future<void> _pickFavicon() => _pickImage((dataUri) {
|
||||
_faviconUrlController.text = dataUri;
|
||||
});
|
||||
Future<void> _pickFavicon() async {
|
||||
final result = await FilePicker.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: const ['png', 'jpg', 'jpeg', 'webp', 'ico'],
|
||||
withData: true,
|
||||
);
|
||||
|
||||
if (result == null || result.files.isEmpty) return;
|
||||
|
||||
final file = result.files.single;
|
||||
final bytes = file.bytes;
|
||||
if (bytes == null) return;
|
||||
|
||||
setState(() => _uploadingFavicon = true);
|
||||
final uploadResult =
|
||||
await ref.read(appSettingsProvider.notifier).uploadCompanyFavicon(
|
||||
bytes,
|
||||
file.name,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() => _uploadingFavicon = false);
|
||||
|
||||
final uploadedUrl = uploadResult.data;
|
||||
if (uploadResult.failure == null &&
|
||||
uploadedUrl != null &&
|
||||
uploadedUrl.isNotEmpty) {
|
||||
// Apply uploaded bytes immediately so the tab icon updates without waiting
|
||||
// on cross-origin image fetch / browser favicon cache.
|
||||
final ext = (file.extension ?? 'png').toLowerCase();
|
||||
final mime = ext == 'jpg'
|
||||
? 'jpeg'
|
||||
: (ext == 'ico' ? 'x-icon' : ext);
|
||||
final previewDataUri =
|
||||
'data:image/$mime;base64,${base64Encode(bytes)}';
|
||||
updateFavicon(previewDataUri);
|
||||
|
||||
setState(() => _faviconUrlController.text = uploadedUrl);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Favicon uploaded')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (uploadResult.failure != null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(uploadResult.failure!.message),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Local preview fallback when upload is unavailable.
|
||||
final ext = (file.extension ?? 'png').toLowerCase();
|
||||
final mime = ext == 'jpg' ? 'jpeg' : (ext == 'ico' ? 'x-icon' : ext);
|
||||
final dataUri = 'data:image/$mime;base64,${base64Encode(bytes)}';
|
||||
await ref
|
||||
.read(appSettingsProvider.notifier)
|
||||
.applyLocalCompanyFavicon(dataUri);
|
||||
if (!mounted) return;
|
||||
setState(() => _faviconUrlController.text = dataUri);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -281,7 +315,7 @@ class _CompanyProfileSettingsScreenState
|
||||
controller: _nameController,
|
||||
label: 'Company Name',
|
||||
validator: (v) =>
|
||||
Validators.required(v, fieldName: 'Company name'),
|
||||
Validators.required(v, fieldName: 'Company Name'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AppTextField(
|
||||
@ -296,7 +330,7 @@ class _CompanyProfileSettingsScreenState
|
||||
const SizedBox(height: 16),
|
||||
AppTextField(
|
||||
controller: _gstController,
|
||||
label: 'GST/VAT Number',
|
||||
label: 'GSTIN',
|
||||
validator: Validators.optionalGstin,
|
||||
inputFormatters: Validators.gstinInput,
|
||||
),
|
||||
@ -333,7 +367,7 @@ class _CompanyProfileSettingsScreenState
|
||||
const SizedBox(height: 16),
|
||||
AppTextField(
|
||||
controller: _phoneController,
|
||||
label: 'Phone',
|
||||
label: 'Mobile',
|
||||
keyboardType: TextInputType.phone,
|
||||
validator: Validators.optionalMobile,
|
||||
inputFormatters: Validators.mobileInput,
|
||||
@ -388,7 +422,7 @@ class _CompanyProfileSettingsScreenState
|
||||
SettingsFormCard(
|
||||
title: 'Favicon Upload',
|
||||
subtitle:
|
||||
'Upload an image or provide a favicon URL for the browser tab',
|
||||
'Upload JPEG, PNG, WebP, or ICO for the browser tab icon',
|
||||
children: [
|
||||
Center(
|
||||
child: SidebarLogo(
|
||||
@ -409,9 +443,18 @@ class _CompanyProfileSettingsScreenState
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _pickFavicon,
|
||||
icon: const Icon(Icons.upload_file),
|
||||
label: const Text('Upload Favicon'),
|
||||
onPressed: _uploadingFavicon ? null : _pickFavicon,
|
||||
icon: _uploadingFavicon
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child:
|
||||
CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.upload_file),
|
||||
label: Text(
|
||||
_uploadingFavicon ? 'Uploading…' : 'Upload Favicon',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@ -145,7 +145,7 @@ class _EmailConfigurationScreenState
|
||||
label: 'SMTP Host',
|
||||
hint: 'smtp.gmail.com',
|
||||
validator: (v) =>
|
||||
Validators.required(v, fieldName: 'SMTP host'),
|
||||
Validators.required(v, fieldName: 'SMTP Host'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AppTextField(
|
||||
@ -153,7 +153,7 @@ class _EmailConfigurationScreenState
|
||||
label: 'SMTP Port',
|
||||
keyboardType: TextInputType.number,
|
||||
validator: (v) =>
|
||||
Validators.required(v, fieldName: 'SMTP port'),
|
||||
Validators.required(v, fieldName: 'SMTP Port'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AppTextField(
|
||||
|
||||
@ -140,7 +140,7 @@ class _SecuritySettingsScreenState extends ConsumerState<SecuritySettingsScreen>
|
||||
children: [
|
||||
AppTextField(
|
||||
controller: _sessionTimeoutController,
|
||||
label: 'Session Timeout (minutes)',
|
||||
label: 'Session Timeout (Minutes)',
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
SettingsSwitchTile(
|
||||
@ -167,7 +167,7 @@ class _SecuritySettingsScreenState extends ConsumerState<SecuritySettingsScreen>
|
||||
const SizedBox(height: 16),
|
||||
AppTextField(
|
||||
controller: _lockDurationController,
|
||||
label: 'Account Lock Duration (minutes)',
|
||||
label: 'Account Lock Duration (Minutes)',
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
@ -175,13 +175,13 @@ class _UserFormScreenState extends ConsumerState<UserFormScreen> {
|
||||
AppTextField(
|
||||
controller: _firstNameController,
|
||||
label: 'First Name',
|
||||
validator: (v) => Validators.required(v, fieldName: 'First name'),
|
||||
validator: (v) => Validators.required(v, fieldName: 'First Name'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AppTextField(
|
||||
controller: _lastNameController,
|
||||
label: 'Last Name',
|
||||
validator: (v) => Validators.required(v, fieldName: 'Last name'),
|
||||
validator: (v) => Validators.required(v, fieldName: 'Last Name'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AppTextField(
|
||||
|
||||
@ -18,6 +18,7 @@ import '../../../../shared/widgets/app_status_chip.dart';
|
||||
import '../../../../shared/widgets/error_view.dart';
|
||||
import '../../../../shared/widgets/kpi_card.dart';
|
||||
import '../../../../shared/widgets/app_table_shell.dart';
|
||||
import '../../../../shared/widgets/app_table_action_icon.dart';
|
||||
import '../../../../shared/widgets/page_header.dart';
|
||||
import '../widgets/user_rich_data_table.dart';
|
||||
import '../providers/users_provider.dart';
|
||||
@ -65,10 +66,9 @@ class _UserListScreenState extends ConsumerState<UserListScreen> {
|
||||
],
|
||||
),
|
||||
if (state.summary != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 12),
|
||||
_SummaryStrip(summary: state.summary!),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: AppTableShell(
|
||||
toolbar: _FiltersBar(
|
||||
@ -231,7 +231,7 @@ class _FiltersBar extends StatelessWidget {
|
||||
searchHint: 'Search status...',
|
||||
isDense: true,
|
||||
options: [
|
||||
const AppDropdownOption(value: null, label: 'All statuses'),
|
||||
const AppDropdownOption(value: null, label: 'All Statuses'),
|
||||
...(filters?.statuses ?? [])
|
||||
.map((s) => AppDropdownOption(value: s.id, label: s.name)),
|
||||
],
|
||||
@ -243,7 +243,7 @@ class _FiltersBar extends StatelessWidget {
|
||||
searchHint: 'Search role...',
|
||||
isDense: true,
|
||||
options: [
|
||||
const AppDropdownOption(value: null, label: 'All roles'),
|
||||
const AppDropdownOption(value: null, label: 'All Roles'),
|
||||
...(filters?.roles ?? []).map(
|
||||
(r) => AppDropdownOption(
|
||||
value: int.tryParse(r.id),
|
||||
@ -259,7 +259,7 @@ class _FiltersBar extends StatelessWidget {
|
||||
searchHint: 'Search department...',
|
||||
isDense: true,
|
||||
options: [
|
||||
const AppDropdownOption(value: null, label: 'All departments'),
|
||||
const AppDropdownOption(value: null, label: 'All Departments'),
|
||||
...(filters?.departments ?? []).map(
|
||||
(d) => AppDropdownOption(
|
||||
value: int.tryParse(d.id),
|
||||
@ -413,27 +413,31 @@ class _UserActions extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Wrap(
|
||||
spacing: 4,
|
||||
final errorColor = Theme.of(context).colorScheme.error;
|
||||
|
||||
return AppTableActions(
|
||||
children: [
|
||||
IconButton(
|
||||
AppTableActionIcon(
|
||||
tooltip: 'View',
|
||||
icon: const Icon(Icons.visibility_outlined),
|
||||
icon: Icons.visibility_outlined,
|
||||
onPressed: () => onView(user),
|
||||
),
|
||||
IconButton(
|
||||
AppTableActionIcon(
|
||||
tooltip: 'Edit',
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
icon: Icons.edit_outlined,
|
||||
onPressed: () => onEdit(user),
|
||||
),
|
||||
IconButton(
|
||||
AppTableActionIcon(
|
||||
tooltip: user.status == 'active' ? 'Deactivate' : 'Activate',
|
||||
icon: Icon(user.status == 'active' ? Icons.pause_circle : Icons.play_circle),
|
||||
icon: user.status == 'active'
|
||||
? Icons.pause_circle_outline
|
||||
: Icons.play_circle_outline,
|
||||
onPressed: () => onToggleStatus(user),
|
||||
),
|
||||
IconButton(
|
||||
AppTableActionIcon(
|
||||
tooltip: 'Delete',
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
icon: Icons.delete_outline,
|
||||
color: errorColor,
|
||||
onPressed: () => onDeactivate(user),
|
||||
),
|
||||
],
|
||||
|
||||
@ -245,7 +245,7 @@ class _UserProfileScreenState extends ConsumerState<UserProfileScreen> {
|
||||
controller: _confirmPasswordController,
|
||||
label: 'Confirm Password',
|
||||
obscureText: true,
|
||||
validator: (v) => Validators.required(v, fieldName: 'Confirm password'),
|
||||
validator: (v) => Validators.required(v, fieldName: 'Confirm Password'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AppButton(
|
||||
|
||||
@ -49,11 +49,13 @@ class UserRichDataTable extends StatelessWidget {
|
||||
label: 'Employee Code',
|
||||
sortKey: 'employee_code',
|
||||
flex: 1,
|
||||
alignment: Alignment.centerRight,
|
||||
cellBuilder: (_, user) => EmployeeCodeBadge(code: user.employeeCode),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Role',
|
||||
flex: 2,
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
cellBuilder: (_, user) => UserRolesCell(roles: user.roleNames),
|
||||
),
|
||||
AppDataColumn(
|
||||
@ -61,11 +63,6 @@ class UserRichDataTable extends StatelessWidget {
|
||||
flex: 2,
|
||||
cellBuilder: (_, user) => Text(user.departmentLabel),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Plant',
|
||||
flex: 2,
|
||||
cellBuilder: (_, user) => Text(user.plantLabel),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Last Login',
|
||||
flex: 2,
|
||||
@ -79,16 +76,17 @@ class UserRichDataTable extends StatelessWidget {
|
||||
AppDataColumn(
|
||||
label: 'Status',
|
||||
flex: 1,
|
||||
cellBuilder: (_, user) => AppStatusChip(status: user.status),
|
||||
cellBuilder: (_, user) => AppStatusChip(
|
||||
status: user.status,
|
||||
compact: true,
|
||||
forTable: true,
|
||||
),
|
||||
),
|
||||
AppDataColumn(
|
||||
label: 'Actions',
|
||||
flex: 1,
|
||||
alignment: Alignment.centerRight,
|
||||
cellBuilder: (context, user) => Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: actionsBuilder(context, user),
|
||||
),
|
||||
cellBuilder: (context, user) => actionsBuilder(context, user),
|
||||
),
|
||||
],
|
||||
rows: users,
|
||||
|
||||
@ -241,7 +241,7 @@ class _OverviewTab extends StatelessWidget {
|
||||
gstTreatmentLabel(vendor.gstTreatment),
|
||||
),
|
||||
_VendorInfo(
|
||||
'Source of Supply',
|
||||
'Source Of Supply',
|
||||
vendor.sourceOfSupply ?? '—',
|
||||
),
|
||||
_VendorInfo('GSTIN', vendor.gstin ?? '—'),
|
||||
|
||||
@ -85,7 +85,6 @@ class _VendorListScreenState extends ConsumerState<VendorListScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: AppTableShell(
|
||||
toolbar: LayoutBuilder(
|
||||
@ -204,7 +203,7 @@ class _FiltersBar extends StatelessWidget {
|
||||
searchHint: 'Search status...',
|
||||
isDense: true,
|
||||
options: [
|
||||
const AppDropdownOption(value: null, label: 'All statuses'),
|
||||
const AppDropdownOption(value: null, label: 'All Statuses'),
|
||||
...vendorStatusOptions.map(
|
||||
(e) => AppDropdownOption(value: e.$1, label: e.$2),
|
||||
),
|
||||
@ -217,7 +216,7 @@ class _FiltersBar extends StatelessWidget {
|
||||
searchHint: 'Search vendor type...',
|
||||
isDense: true,
|
||||
options: [
|
||||
const AppDropdownOption(value: null, label: 'All types'),
|
||||
const AppDropdownOption(value: null, label: 'All Types'),
|
||||
...vendorTypeOptions.map(
|
||||
(e) => AppDropdownOption(value: e.$1, label: e.$2),
|
||||
),
|
||||
@ -295,6 +294,8 @@ class _VendorDataTable extends StatelessWidget {
|
||||
flex: 1,
|
||||
cellBuilder: (_, vendor) => AppStatusChip(
|
||||
status: vendor.status ?? (vendor.isActive ? 'active' : 'inactive'),
|
||||
compact: true,
|
||||
forTable: true,
|
||||
),
|
||||
),
|
||||
AppDataColumn(
|
||||
|
||||
@ -186,7 +186,7 @@ class _VendorFormPanelState extends ConsumerState<VendorFormPanel> {
|
||||
widget.isEditing ? ref.watch(vendorFormProvider(widget.vendorId)) : null;
|
||||
|
||||
return SidePanelScaffold(
|
||||
title: widget.isEditing ? 'Edit vendor' : 'Add vendor',
|
||||
title: widget.isEditing ? 'Edit Vendor' : 'Add Vendor',
|
||||
footer: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
@ -198,7 +198,7 @@ class _VendorFormPanelState extends ConsumerState<VendorFormPanel> {
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
AppButton(
|
||||
label: widget.isEditing ? 'Update vendor' : 'Save vendor',
|
||||
label: widget.isEditing ? 'Update Vendor' : 'Save Vendor',
|
||||
expand: false,
|
||||
icon: Icons.check,
|
||||
isLoading: _isSubmitting,
|
||||
@ -240,7 +240,7 @@ class _VendorFormPanelState extends ConsumerState<VendorFormPanel> {
|
||||
AppTextField(
|
||||
controller: _nameController,
|
||||
label: 'Vendor Name *',
|
||||
validator: (v) => Validators.required(v, fieldName: 'Vendor name'),
|
||||
validator: (v) => Validators.required(v, fieldName: 'Vendor Name'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
AppDropdown<String>(
|
||||
@ -278,11 +278,11 @@ class _VendorFormPanelState extends ConsumerState<VendorFormPanel> {
|
||||
loading: () => const LinearProgressIndicator(),
|
||||
error: (_, __) => AppTextField(
|
||||
controller: TextEditingController(text: _sourceOfSupply ?? ''),
|
||||
label: 'Source of Supply',
|
||||
label: 'Source Of Supply',
|
||||
onChanged: (v) => _sourceOfSupply = v,
|
||||
),
|
||||
data: (options) => AppSearchableDropdown<String>(
|
||||
label: 'Source of Supply',
|
||||
label: 'Source Of Supply',
|
||||
value: _sourceOfSupply,
|
||||
searchHint: 'Search state...',
|
||||
options: options
|
||||
@ -316,7 +316,7 @@ class _VendorFormPanelState extends ConsumerState<VendorFormPanel> {
|
||||
),
|
||||
right: AppTextField(
|
||||
controller: _creditDaysController,
|
||||
label: 'Credit Period (days)',
|
||||
label: 'Credit Period (Days)',
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
),
|
||||
|
||||
@ -147,7 +147,7 @@ class _VendorAddressPanelState extends ConsumerState<VendorAddressPanel> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SidePanelScaffold(
|
||||
title: widget.isEditing ? 'Edit address' : 'Add address',
|
||||
title: widget.isEditing ? 'Edit Address' : 'Add Address',
|
||||
footer: _panelFooter(
|
||||
context,
|
||||
isSubmitting: _isSubmitting,
|
||||
@ -289,7 +289,7 @@ class _VendorContactPanelState extends ConsumerState<VendorContactPanel> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SidePanelScaffold(
|
||||
title: widget.isEditing ? 'Edit contact' : 'Add contact',
|
||||
title: widget.isEditing ? 'Edit Contact' : 'Add Contact',
|
||||
footer: _panelFooter(
|
||||
context,
|
||||
isSubmitting: _isSubmitting,
|
||||
@ -303,7 +303,7 @@ class _VendorContactPanelState extends ConsumerState<VendorContactPanel> {
|
||||
AppTextField(
|
||||
controller: _nameController,
|
||||
label: 'Contact Name *',
|
||||
validator: (v) => Validators.required(v, fieldName: 'Contact name'),
|
||||
validator: (v) => Validators.required(v, fieldName: 'Contact Name'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
AppTextField(controller: _designationController, label: 'Designation'),
|
||||
@ -326,7 +326,7 @@ class _VendorContactPanelState extends ConsumerState<VendorContactPanel> {
|
||||
const SizedBox(height: 12),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Primary contact'),
|
||||
title: const Text('Primary Contact'),
|
||||
value: _isPrimary,
|
||||
onChanged: (v) => setState(() => _isPrimary = v),
|
||||
),
|
||||
@ -437,7 +437,7 @@ class _VendorBankDetailPanelState extends ConsumerState<VendorBankDetailPanel> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SidePanelScaffold(
|
||||
title: widget.isEditing ? 'Edit bank detail' : 'Add bank detail',
|
||||
title: widget.isEditing ? 'Edit Bank Detail' : 'Add Bank Detail',
|
||||
footer: _panelFooter(
|
||||
context,
|
||||
isSubmitting: _isSubmitting,
|
||||
@ -451,7 +451,7 @@ class _VendorBankDetailPanelState extends ConsumerState<VendorBankDetailPanel> {
|
||||
AppTextField(
|
||||
controller: _bankNameController,
|
||||
label: 'Bank Name *',
|
||||
validator: (v) => Validators.required(v, fieldName: 'Bank name'),
|
||||
validator: (v) => Validators.required(v, fieldName: 'Bank Name'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
AppTextField(controller: _branchController, label: 'Branch'),
|
||||
@ -477,7 +477,7 @@ class _VendorBankDetailPanelState extends ConsumerState<VendorBankDetailPanel> {
|
||||
controller: _holderNameController,
|
||||
label: 'Account Holder *',
|
||||
validator: (v) =>
|
||||
Validators.required(v, fieldName: 'Account holder name'),
|
||||
Validators.required(v, fieldName: 'Account Holder Name'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
@ -492,7 +492,7 @@ class _VendorBankDetailPanelState extends ConsumerState<VendorBankDetailPanel> {
|
||||
const SizedBox(height: 12),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Primary account'),
|
||||
title: const Text('Primary Account'),
|
||||
value: _isPrimary,
|
||||
onChanged: (v) => setState(() => _isPrimary = v),
|
||||
),
|
||||
|
||||
@ -12,6 +12,7 @@ class AppDataColumn<T> {
|
||||
this.sortKey,
|
||||
this.flex = 1,
|
||||
this.alignment = Alignment.centerLeft,
|
||||
this.padding = EdgeInsets.zero,
|
||||
});
|
||||
|
||||
final String label;
|
||||
@ -19,6 +20,7 @@ class AppDataColumn<T> {
|
||||
final String? sortKey;
|
||||
final int flex;
|
||||
final Alignment alignment;
|
||||
final EdgeInsets padding;
|
||||
}
|
||||
|
||||
/// Helpers for table cell content — single-line text with ellipsis and tooltip.
|
||||
@ -99,6 +101,7 @@ class AppDataTable<T> extends StatelessWidget {
|
||||
final body = ListView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
shrinkWrap: shrinkWrap,
|
||||
clipBehavior: Clip.none,
|
||||
physics: shrinkWrap ? const NeverScrollableScrollPhysics() : null,
|
||||
itemCount: rows.length,
|
||||
itemBuilder: (context, index) => _TableDataRow<T>(
|
||||
@ -107,7 +110,8 @@ class AppDataTable<T> extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
|
||||
// Header stays fixed; only body rows scroll (when not shrink-wrapped).
|
||||
// Header stays fixed; body scrolls in the remaining space and is clipped so
|
||||
// rows cannot paint over footers (e.g. pagination) outside the table.
|
||||
final table = shrinkWrap
|
||||
? Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@ -121,7 +125,9 @@ class AppDataTable<T> extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
header,
|
||||
Expanded(child: body),
|
||||
Expanded(
|
||||
child: ClipRect(child: body),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@ -156,11 +162,7 @@ class _TableHeaderRow<T> extends StatelessWidget {
|
||||
width: double.infinity,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
// Opaque so scrolling rows never show through the sticky header.
|
||||
color: Color.alphaBlend(
|
||||
theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.55),
|
||||
theme.colorScheme.surface,
|
||||
),
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: theme.colorScheme.outline.withValues(alpha: 0.12),
|
||||
@ -211,9 +213,12 @@ class _TableHeaderRow<T> extends StatelessWidget {
|
||||
|
||||
return Expanded(
|
||||
flex: col.flex,
|
||||
child: Align(
|
||||
alignment: col.alignment,
|
||||
child: header,
|
||||
child: Padding(
|
||||
padding: col.padding,
|
||||
child: Align(
|
||||
alignment: col.alignment,
|
||||
child: header,
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
@ -242,6 +247,7 @@ class _TableDataRow<T> extends StatelessWidget {
|
||||
width: double.infinity,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surface,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: theme.colorScheme.outline.withValues(alpha: 0.08),
|
||||
@ -255,11 +261,14 @@ class _TableDataRow<T> extends StatelessWidget {
|
||||
children: columns.map((col) {
|
||||
return Expanded(
|
||||
flex: col.flex,
|
||||
child: Align(
|
||||
alignment: col.alignment,
|
||||
child: _TableCellSlot(
|
||||
child: Padding(
|
||||
padding: col.padding,
|
||||
child: Align(
|
||||
alignment: col.alignment,
|
||||
child: col.cellBuilder(context, row),
|
||||
child: _TableCellSlot(
|
||||
alignment: col.alignment,
|
||||
child: col.cellBuilder(context, row),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@ -289,6 +298,7 @@ class _TableCellSlot extends StatelessWidget {
|
||||
child: Align(
|
||||
alignment: alignment,
|
||||
widthFactor: 1,
|
||||
heightFactor: 1,
|
||||
child: _coerceTableCell(child, context),
|
||||
),
|
||||
);
|
||||
|
||||
@ -9,6 +9,8 @@ import '../../core/constants/enums.dart';
|
||||
import '../../core/constants/route_constants.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/theme_provider.dart';
|
||||
import '../../core/theme/theme_provider.dart';
|
||||
import '../../core/utils/favicon_store.dart';
|
||||
import '../../modules/settings/presentation/providers/settings_provider.dart';
|
||||
import '../models/user_model.dart';
|
||||
import '../providers/auth_provider.dart';
|
||||
@ -268,29 +270,37 @@ class _AppSidebarState extends ConsumerState<AppSidebar> {
|
||||
companyProfileLogo: companyProfile.logoUrl,
|
||||
brandingLogo: branding.logoUrl,
|
||||
);
|
||||
final title = resolveSidebarTitle(
|
||||
companyName: companyProfile.companyName,
|
||||
fallback: AppConstants.appName,
|
||||
final faviconUrl = resolveSidebarFaviconUrl(
|
||||
companyProfileFavicon: companyProfile.faviconUrl,
|
||||
storedFavicon: FaviconStore(ref.watch(sharedPreferencesProvider)).read(),
|
||||
);
|
||||
|
||||
if (isNarrow) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(6, 16, 6, 0),
|
||||
padding: const EdgeInsets.fromLTRB(8, 16, 8, 0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SidebarLogo(logoUrl: logoUrl, size: 32),
|
||||
SidebarLogo(
|
||||
logoUrl: faviconUrl,
|
||||
size: 24,
|
||||
fit: BoxFit.contain,
|
||||
showBackground: false,
|
||||
),
|
||||
if (widget.onToggleCollapse != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints.tightFor(width: 32, height: 32),
|
||||
constraints:
|
||||
const BoxConstraints.tightFor(width: 32, height: 32),
|
||||
visualDensity: VisualDensity.compact,
|
||||
icon: const Icon(Icons.chevron_right, size: 18),
|
||||
tooltip: 'Expand sidebar',
|
||||
onPressed: widget.onToggleCollapse,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
_LogoDivider(theme: theme),
|
||||
],
|
||||
),
|
||||
);
|
||||
@ -302,18 +312,19 @@ class _AppSidebarState extends ConsumerState<AppSidebar> {
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: SidebarLogo(
|
||||
logoUrl: logoUrl,
|
||||
height: 44,
|
||||
height: 40,
|
||||
width: double.infinity,
|
||||
fit: BoxFit.contain,
|
||||
showBackground: false,
|
||||
),
|
||||
),
|
||||
if (widget.onToggleCollapse != null)
|
||||
if (widget.onToggleCollapse != null) ...[
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.chevron_left, size: 20),
|
||||
tooltip: 'Collapse sidebar',
|
||||
@ -323,31 +334,11 @@ class _AppSidebarState extends ConsumerState<AppSidebar> {
|
||||
const BoxConstraints.tightFor(width: 36, height: 36),
|
||||
onPressed: widget.onToggleCollapse,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primary.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.primary.withValues(alpha: 0.24),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
title,
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.2,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
_LogoDivider(theme: theme),
|
||||
],
|
||||
),
|
||||
);
|
||||
@ -737,6 +728,39 @@ class _SidebarFlyoutFadeState extends State<_SidebarFlyoutFade>
|
||||
}
|
||||
}
|
||||
|
||||
class _LogoDivider extends StatelessWidget {
|
||||
const _LogoDivider({required this.theme});
|
||||
|
||||
final ThemeData theme;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final primary = theme.colorScheme.primary;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: SizedBox(
|
||||
height: 2,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
primary.withValues(alpha: 0),
|
||||
primary.withValues(alpha: 0.55),
|
||||
primary,
|
||||
primary.withValues(alpha: 0.55),
|
||||
primary.withValues(alpha: 0),
|
||||
],
|
||||
stops: const [0, 0.2, 0.5, 0.8, 1],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionLabel extends StatelessWidget {
|
||||
const _SectionLabel({required this.label});
|
||||
|
||||
|
||||
@ -2,19 +2,86 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/constants/enums.dart';
|
||||
|
||||
/// Uniform width for status chips inside data tables (fits "Partially Received").
|
||||
const double kTableStatusChipWidth = 132;
|
||||
|
||||
/// Pill badge with optional fixed width for table status columns.
|
||||
class TableStatusBadge extends StatelessWidget {
|
||||
const TableStatusBadge({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.color,
|
||||
this.compact = true,
|
||||
this.fixedWidth,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final Color color;
|
||||
final bool compact;
|
||||
|
||||
/// When set, every badge in a table column uses the same width.
|
||||
final double? fixedWidth;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final badge = Container(
|
||||
height: compact ? 22 : 26,
|
||||
padding: EdgeInsets.symmetric(horizontal: compact ? 8 : 10),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(color: color.withValues(alpha: 0.28)),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: compact ? 11 : 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1,
|
||||
letterSpacing: 0.1,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (fixedWidth == null) return badge;
|
||||
|
||||
return SizedBox(
|
||||
width: fixedWidth,
|
||||
child: badge,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AppStatusChip extends StatelessWidget {
|
||||
const AppStatusChip({
|
||||
super.key,
|
||||
required this.status,
|
||||
this.compact = false,
|
||||
this.forTable = false,
|
||||
});
|
||||
|
||||
final String status;
|
||||
final bool compact;
|
||||
final bool forTable;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final (color, label) = _resolveStatus(status);
|
||||
if (forTable) {
|
||||
return TableStatusBadge(
|
||||
label: label,
|
||||
color: color,
|
||||
compact: compact,
|
||||
fixedWidth: kTableStatusChipWidth,
|
||||
);
|
||||
}
|
||||
|
||||
return Chip(
|
||||
label: Text(
|
||||
label,
|
||||
|
||||
@ -1,5 +1,31 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class _TableActionInkWell extends StatelessWidget {
|
||||
const _TableActionInkWell({
|
||||
required this.onTap,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
final VoidCallback? onTap;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
hoverColor: theme.colorScheme.onSurface.withValues(alpha: 0.08),
|
||||
splashColor: theme.colorScheme.onSurface.withValues(alpha: 0.12),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Compact outline icon for data-table action columns (matches Users & Roles table).
|
||||
class AppTableActionIcon extends StatelessWidget {
|
||||
const AppTableActionIcon({
|
||||
@ -8,43 +34,136 @@ class AppTableActionIcon extends StatelessWidget {
|
||||
required this.icon,
|
||||
required this.onPressed,
|
||||
this.color,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
final String tooltip;
|
||||
final IconData icon;
|
||||
final VoidCallback onPressed;
|
||||
final Color? color;
|
||||
final bool enabled;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final iconColor = color ?? Theme.of(context).colorScheme.onSurfaceVariant;
|
||||
final theme = Theme.of(context);
|
||||
final iconColor = color ?? theme.colorScheme.onSurfaceVariant;
|
||||
final effectiveColor =
|
||||
enabled ? iconColor : iconColor.withValues(alpha: 0.38);
|
||||
|
||||
return Tooltip(
|
||||
message: tooltip,
|
||||
child: InkWell(
|
||||
onTap: onPressed,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: _TableActionInkWell(
|
||||
onTap: enabled ? onPressed : null,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: Icon(icon, size: 18, color: iconColor),
|
||||
child: Icon(icon, size: 18, color: effectiveColor),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Right-aligned row of [AppTableActionIcon] widgets for table cells.
|
||||
class AppTableActions extends StatelessWidget {
|
||||
const AppTableActions({super.key, required this.children});
|
||||
/// Collapsed three-dot trigger that expands inline action icons on hover (or tap).
|
||||
class AppTableActions extends StatefulWidget {
|
||||
const AppTableActions({
|
||||
super.key,
|
||||
required this.children,
|
||||
this.expandHitArea = true,
|
||||
});
|
||||
|
||||
final List<Widget> children;
|
||||
|
||||
/// When true, hovering anywhere in the actions column cell reveals icons.
|
||||
final bool expandHitArea;
|
||||
|
||||
@override
|
||||
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> {
|
||||
bool _hovering = false;
|
||||
bool _pinned = false;
|
||||
|
||||
bool get _expanded => _hovering || _pinned;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
final children = widget.children;
|
||||
if (children.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
final iconColor = Theme.of(context).colorScheme.onSurfaceVariant;
|
||||
|
||||
final actionRow = Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: children,
|
||||
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 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;
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final width = constraints.maxWidth;
|
||||
if (!width.isFinite || width <= 0) return interactive;
|
||||
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -33,16 +33,21 @@ class AppTableShell extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||
child: toolbar,
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(child: child),
|
||||
// Clip so scrolling rows cannot paint over the footer/pagination.
|
||||
Expanded(child: ClipRect(child: child)),
|
||||
if (footer != null) ...[
|
||||
const Divider(height: 1),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: footer!,
|
||||
Material(
|
||||
color: theme.colorScheme.surface,
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: footer!,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
|
||||
@ -17,9 +17,11 @@ class AppTextField extends StatelessWidget {
|
||||
this.maxLength,
|
||||
this.inputFormatters,
|
||||
this.enabled = true,
|
||||
this.readOnly = false,
|
||||
this.autofillHints,
|
||||
this.isDense = false,
|
||||
this.autovalidateMode,
|
||||
this.fillColor,
|
||||
});
|
||||
|
||||
final TextEditingController controller;
|
||||
@ -35,9 +37,11 @@ class AppTextField extends StatelessWidget {
|
||||
final int? maxLength;
|
||||
final List<TextInputFormatter>? inputFormatters;
|
||||
final bool enabled;
|
||||
final bool readOnly;
|
||||
final Iterable<String>? autofillHints;
|
||||
final bool isDense;
|
||||
final AutovalidateMode? autovalidateMode;
|
||||
final Color? fillColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -54,6 +58,7 @@ class AppTextField extends StatelessWidget {
|
||||
maxLength: maxLength,
|
||||
inputFormatters: inputFormatters,
|
||||
enabled: enabled,
|
||||
readOnly: readOnly,
|
||||
autofillHints: autofillHints,
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
@ -63,6 +68,9 @@ class AppTextField extends StatelessWidget {
|
||||
isDense: isDense,
|
||||
floatingLabelBehavior:
|
||||
label != null ? FloatingLabelBehavior.always : null,
|
||||
).copyWith(
|
||||
filled: fillColor != null ? true : null,
|
||||
fillColor: fillColor,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@ -271,7 +271,7 @@ class _MasterInlineQuickAddFormState
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Add ${_definition.title.toLowerCase()}',
|
||||
'Add ${_definition.title}',
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
@ -330,7 +330,7 @@ class _MasterInlineQuickAddFormState
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Text('Save'),
|
||||
: Text('Add ${_definition.title}'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@ -19,7 +19,7 @@ class PageHeader extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 24),
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: context.isMobile
|
||||
? Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
||||
@ -106,6 +106,16 @@ String? resolveSidebarLogoUrl({
|
||||
return resolveMediaUrl(brandingLogo);
|
||||
}
|
||||
|
||||
/// Resolves favicon URL from company profile settings or local cache.
|
||||
String? resolveSidebarFaviconUrl({
|
||||
required String companyProfileFavicon,
|
||||
String? storedFavicon,
|
||||
}) {
|
||||
final fromProfile = resolveMediaUrl(companyProfileFavicon);
|
||||
if (fromProfile != null && fromProfile.isNotEmpty) return fromProfile;
|
||||
return resolveMediaUrl(storedFavicon);
|
||||
}
|
||||
|
||||
/// Resolves sidebar title from company name or app tagline.
|
||||
String resolveSidebarTitle({
|
||||
required String companyName,
|
||||
|
||||
@ -43,9 +43,69 @@
|
||||
<meta name="apple-mobile-web-app-title" content="bharat_erp">
|
||||
<link rel="apple-touch-icon" href="icons/Icon-192.png">
|
||||
|
||||
<!-- Favicon -->
|
||||
<!-- Favicon (updated at runtime via setAppFavicon) -->
|
||||
<link rel="icon" type="image/png" href="favicon.png"/>
|
||||
|
||||
<script>
|
||||
window.setAppFavicon = function (url) {
|
||||
if (!url) return;
|
||||
|
||||
var busted =
|
||||
url +
|
||||
(url.indexOf('?') >= 0 ? '&' : '?') +
|
||||
'_fc=' +
|
||||
Date.now();
|
||||
|
||||
document
|
||||
.querySelectorAll(
|
||||
'link[rel="icon"], link[rel="shortcut icon"], link[rel="apple-touch-icon"]'
|
||||
)
|
||||
.forEach(function (el) {
|
||||
el.parentNode.removeChild(el);
|
||||
});
|
||||
|
||||
function applyHref(href, type) {
|
||||
var link = document.createElement('link');
|
||||
link.rel = 'icon';
|
||||
if (type) link.type = type;
|
||||
link.href = href;
|
||||
document.head.appendChild(link);
|
||||
|
||||
var shortcut = document.createElement('link');
|
||||
shortcut.rel = 'shortcut icon';
|
||||
if (type) shortcut.type = type;
|
||||
shortcut.href = href;
|
||||
document.head.appendChild(shortcut);
|
||||
}
|
||||
|
||||
// Data URLs apply instantly in the tab.
|
||||
if (url.indexOf('data:image/') === 0) {
|
||||
var dataType = url.substring(5, url.indexOf(';'));
|
||||
applyHref(url, dataType || 'image/png');
|
||||
return;
|
||||
}
|
||||
|
||||
var img = new Image();
|
||||
img.onload = function () {
|
||||
try {
|
||||
var canvas = document.createElement('canvas');
|
||||
canvas.width = 32;
|
||||
canvas.height = 32;
|
||||
var ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(img, 0, 0, 32, 32);
|
||||
applyHref(canvas.toDataURL('image/png'), 'image/png');
|
||||
} catch (e) {
|
||||
applyHref(busted, null);
|
||||
}
|
||||
};
|
||||
img.onerror = function () {
|
||||
applyHref(busted, null);
|
||||
};
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.src = busted;
|
||||
};
|
||||
</script>
|
||||
|
||||
<title>bharat_erp</title>
|
||||
<link rel="manifest" href="manifest.json">
|
||||
</head>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user