Add Kell Creations operations app foundation with feature slices and WooCommerce read-only integration #1
|
|
@ -0,0 +1,115 @@
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
import '../routing/app_routes.dart';
|
||||||
|
import 'navigation_target.dart';
|
||||||
|
|
||||||
|
/// App-level navigation helpers for cross-feature handoffs.
|
||||||
|
///
|
||||||
|
/// All navigation logic lives here in the app layer so that feature packages
|
||||||
|
/// never depend on each other's routes. Feature pages receive plain
|
||||||
|
/// `void Function(String)` callbacks that they invoke with a SKU or ID;
|
||||||
|
/// the wiring in [AppRoutes.onGenerateRoute] closes over these helpers.
|
||||||
|
abstract final class AppNavigation {
|
||||||
|
// ── Generic ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Navigate to a [NavigationTarget], replacing the current route.
|
||||||
|
static void navigateTo(BuildContext context, NavigationTarget target) {
|
||||||
|
Navigator.of(context).pushReplacementNamed(target.route, arguments: target.arguments);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Dashboard → feature handoffs ──────────────────────────────────────
|
||||||
|
|
||||||
|
/// Dashboard KPI "Total Products" / "In Stock" → Inventory page.
|
||||||
|
static void dashboardToInventory(BuildContext context, {String? filter}) {
|
||||||
|
navigateTo(
|
||||||
|
context,
|
||||||
|
NavigationTarget(route: AppRoutes.inventory, arguments: {'filter': ?filter}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dashboard KPI "Draft" → Products page.
|
||||||
|
static void dashboardToProducts(BuildContext context, {String? filter}) {
|
||||||
|
navigateTo(
|
||||||
|
context,
|
||||||
|
NavigationTarget(route: AppRoutes.products, arguments: {'filter': ?filter}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dashboard KPI "Total Orders" / "Pending" / "Active" → Orders page.
|
||||||
|
static void dashboardToOrders(BuildContext context, {String? filter}) {
|
||||||
|
navigateTo(context, NavigationTarget(route: AppRoutes.orders, arguments: {'filter': ?filter}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dashboard KPI "Revenue" → Finance page.
|
||||||
|
static void dashboardToFinance(BuildContext context) {
|
||||||
|
navigateTo(context, NavigationTarget(route: AppRoutes.finance));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Policy detail → operational page handoffs ─────────────────────────
|
||||||
|
|
||||||
|
/// Policy detail action → related operational page based on category.
|
||||||
|
static void policyToRelatedPage(BuildContext context, {required String category}) {
|
||||||
|
final route = _categoryToRoute(category);
|
||||||
|
navigateTo(
|
||||||
|
context,
|
||||||
|
NavigationTarget(route: route, arguments: {'fromPolicy': 'true', 'category': category}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Orders → Products / Inventory ─────────────────────────────────────
|
||||||
|
|
||||||
|
/// Order line-item SKU → Products page, pre-selecting that SKU.
|
||||||
|
static void orderToProduct(BuildContext context, {required String sku}) {
|
||||||
|
navigateTo(
|
||||||
|
context,
|
||||||
|
NavigationTarget(route: AppRoutes.products, arguments: {'selectedSku': sku}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Order line-item SKU → Inventory page, pre-selecting that SKU.
|
||||||
|
static void orderToInventory(BuildContext context, {required String sku}) {
|
||||||
|
navigateTo(
|
||||||
|
context,
|
||||||
|
NavigationTarget(route: AppRoutes.inventory, arguments: {'selectedSku': sku}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Products → Policy ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Product draft → Policy page filtered to "Product Compliance".
|
||||||
|
static void productToPolicy(BuildContext context) {
|
||||||
|
navigateTo(
|
||||||
|
context,
|
||||||
|
NavigationTarget(route: AppRoutes.policy, arguments: {'category': 'Product Compliance'}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Inventory → Products ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Inventory item → Products page, pre-selecting by SKU.
|
||||||
|
static void inventoryToProduct(BuildContext context, {required String sku}) {
|
||||||
|
navigateTo(
|
||||||
|
context,
|
||||||
|
NavigationTarget(route: AppRoutes.products, arguments: {'selectedSku': sku}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
static String _categoryToRoute(String category) {
|
||||||
|
switch (category) {
|
||||||
|
case 'Product Compliance':
|
||||||
|
return AppRoutes.products;
|
||||||
|
case 'Inventory Governance':
|
||||||
|
return AppRoutes.inventory;
|
||||||
|
case 'Order Operations':
|
||||||
|
return AppRoutes.orders;
|
||||||
|
case 'Finance & Tax':
|
||||||
|
return AppRoutes.finance;
|
||||||
|
case 'Customer Policy':
|
||||||
|
return AppRoutes.orders;
|
||||||
|
default:
|
||||||
|
return AppRoutes.dashboard;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
/// A lightweight value object describing a cross-feature navigation handoff.
|
||||||
|
///
|
||||||
|
/// Carries the destination route and optional arguments (e.g. a selected-item
|
||||||
|
/// ID or a preset filter) so the target page can open in the right state.
|
||||||
|
///
|
||||||
|
/// This lives in the app layer (`kell_web`) and is never imported by feature
|
||||||
|
/// packages, keeping package boundaries strict.
|
||||||
|
class NavigationTarget {
|
||||||
|
/// The route path to navigate to (one of [AppRoutes] constants).
|
||||||
|
final String route;
|
||||||
|
|
||||||
|
/// Optional key-value arguments for the destination page.
|
||||||
|
///
|
||||||
|
/// Common keys:
|
||||||
|
/// - `selectedSku` – pre-select an item by SKU on the target page
|
||||||
|
/// - `selectedId` – pre-select an item by ID on the target page
|
||||||
|
/// - `filter` – apply a preset filter (e.g. `'lowStock'`)
|
||||||
|
/// - `category` – filter by policy category
|
||||||
|
final Map<String, String> arguments;
|
||||||
|
|
||||||
|
const NavigationTarget({required this.route, this.arguments = const {}});
|
||||||
|
|
||||||
|
/// Convenience: the selected SKU, if any.
|
||||||
|
String? get selectedSku => arguments['selectedSku'];
|
||||||
|
|
||||||
|
/// Convenience: the selected ID, if any.
|
||||||
|
String? get selectedId => arguments['selectedId'];
|
||||||
|
|
||||||
|
/// Convenience: a preset filter, if any.
|
||||||
|
String? get filter => arguments['filter'];
|
||||||
|
|
||||||
|
/// Convenience: a category filter, if any.
|
||||||
|
String? get category => arguments['category'];
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
other is NavigationTarget && route == other.route && _mapsEqual(arguments, other.arguments);
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode {
|
||||||
|
// Build a stable hash from sorted keys to ensure consistency.
|
||||||
|
var argsHash = 0;
|
||||||
|
final sortedKeys = arguments.keys.toList()..sort();
|
||||||
|
for (final key in sortedKeys) {
|
||||||
|
argsHash = argsHash ^ Object.hash(key, arguments[key]);
|
||||||
|
}
|
||||||
|
return Object.hash(route, argsHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'NavigationTarget(route: $route, arguments: $arguments)';
|
||||||
|
|
||||||
|
static bool _mapsEqual(Map<String, String> a, Map<String, String> b) {
|
||||||
|
if (a.length != b.length) return false;
|
||||||
|
for (final key in a.keys) {
|
||||||
|
if (a[key] != b[key]) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../dashboard/application/dashboard_controller.dart';
|
import '../dashboard/application/dashboard_controller.dart';
|
||||||
import '../dashboard/domain/dashboard_summary.dart';
|
import '../dashboard/domain/dashboard_summary.dart';
|
||||||
|
import '../navigation/app_navigation.dart';
|
||||||
import '../routing/app_routes.dart';
|
import '../routing/app_routes.dart';
|
||||||
import '../shell/widgets/empty_state_panel.dart';
|
import '../shell/widgets/empty_state_panel.dart';
|
||||||
import '../shell/widgets/section_header.dart';
|
import '../shell/widgets/section_header.dart';
|
||||||
|
|
@ -112,48 +113,56 @@ class _DashboardPageState extends State<DashboardPage> {
|
||||||
iconColor: KcColors.denimBlue,
|
iconColor: KcColors.denimBlue,
|
||||||
label: 'Total Products',
|
label: 'Total Products',
|
||||||
value: '${summary.totalProducts}',
|
value: '${summary.totalProducts}',
|
||||||
|
onTap: () => AppNavigation.dashboardToInventory(context),
|
||||||
),
|
),
|
||||||
SummaryCard(
|
SummaryCard(
|
||||||
icon: Icons.check_circle_outline,
|
icon: Icons.check_circle_outline,
|
||||||
iconColor: KcColors.success,
|
iconColor: KcColors.success,
|
||||||
label: 'In Stock',
|
label: 'In Stock',
|
||||||
value: '${summary.inStock}',
|
value: '${summary.inStock}',
|
||||||
|
onTap: () => AppNavigation.dashboardToInventory(context, filter: 'inStock'),
|
||||||
),
|
),
|
||||||
SummaryCard(
|
SummaryCard(
|
||||||
icon: Icons.warning_amber_rounded,
|
icon: Icons.warning_amber_rounded,
|
||||||
iconColor: KcColors.warning,
|
iconColor: KcColors.warning,
|
||||||
label: 'Low Stock',
|
label: 'Low Stock',
|
||||||
value: '${summary.lowStock}',
|
value: '${summary.lowStock}',
|
||||||
|
onTap: () => AppNavigation.dashboardToInventory(context, filter: 'lowStock'),
|
||||||
),
|
),
|
||||||
SummaryCard(
|
SummaryCard(
|
||||||
icon: Icons.edit_note,
|
icon: Icons.edit_note,
|
||||||
iconColor: KcColors.neutral,
|
iconColor: KcColors.neutral,
|
||||||
label: 'Draft',
|
label: 'Draft',
|
||||||
value: '${summary.draftProducts}',
|
value: '${summary.draftProducts}',
|
||||||
|
onTap: () => AppNavigation.dashboardToProducts(context, filter: 'draft'),
|
||||||
),
|
),
|
||||||
SummaryCard(
|
SummaryCard(
|
||||||
icon: Icons.receipt_long,
|
icon: Icons.receipt_long,
|
||||||
iconColor: KcColors.denimBlue,
|
iconColor: KcColors.denimBlue,
|
||||||
label: 'Total Orders',
|
label: 'Total Orders',
|
||||||
value: '${summary.totalOrders}',
|
value: '${summary.totalOrders}',
|
||||||
|
onTap: () => AppNavigation.dashboardToOrders(context),
|
||||||
),
|
),
|
||||||
SummaryCard(
|
SummaryCard(
|
||||||
icon: Icons.hourglass_empty,
|
icon: Icons.hourglass_empty,
|
||||||
iconColor: KcColors.warning,
|
iconColor: KcColors.warning,
|
||||||
label: 'Pending Orders',
|
label: 'Pending Orders',
|
||||||
value: '${summary.pendingOrders}',
|
value: '${summary.pendingOrders}',
|
||||||
|
onTap: () => AppNavigation.dashboardToOrders(context, filter: 'pending'),
|
||||||
),
|
),
|
||||||
SummaryCard(
|
SummaryCard(
|
||||||
icon: Icons.local_shipping_outlined,
|
icon: Icons.local_shipping_outlined,
|
||||||
iconColor: KcColors.success,
|
iconColor: KcColors.success,
|
||||||
label: 'Active Orders',
|
label: 'Active Orders',
|
||||||
value: '${summary.activeOrders}',
|
value: '${summary.activeOrders}',
|
||||||
|
onTap: () => AppNavigation.dashboardToOrders(context, filter: 'active'),
|
||||||
),
|
),
|
||||||
SummaryCard(
|
SummaryCard(
|
||||||
icon: Icons.attach_money,
|
icon: Icons.attach_money,
|
||||||
iconColor: KcColors.success,
|
iconColor: KcColors.success,
|
||||||
label: 'Revenue',
|
label: 'Revenue',
|
||||||
value: '\$${summary.deliveredRevenue.toStringAsFixed(2)}',
|
value: '\$${summary.deliveredRevenue.toStringAsFixed(2)}',
|
||||||
|
onTap: () => AppNavigation.dashboardToFinance(context),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import 'package:flutter/material.dart';
|
||||||
import '../composition/app_scope.dart';
|
import '../composition/app_scope.dart';
|
||||||
import '../dashboard/application/dashboard_controller.dart';
|
import '../dashboard/application/dashboard_controller.dart';
|
||||||
import '../dashboard/application/get_dashboard_summary.dart';
|
import '../dashboard/application/get_dashboard_summary.dart';
|
||||||
|
import '../navigation/app_navigation.dart';
|
||||||
import '../pages/dashboard_page.dart';
|
import '../pages/dashboard_page.dart';
|
||||||
import '../pages/finance_placeholder_page.dart';
|
import '../pages/finance_placeholder_page.dart';
|
||||||
import '../pages/integrations_placeholder_page.dart';
|
import '../pages/integrations_placeholder_page.dart';
|
||||||
|
|
@ -45,7 +46,10 @@ abstract final class AppRoutes {
|
||||||
(context) => AppShell(
|
(context) => AppShell(
|
||||||
selectedRoute: inventory,
|
selectedRoute: inventory,
|
||||||
title: 'Inventory',
|
title: 'Inventory',
|
||||||
child: InventoryPage(repository: AppScope.of(context).inventoryRepository),
|
child: InventoryPage(
|
||||||
|
repository: AppScope.of(context).inventoryRepository,
|
||||||
|
onViewProduct: (sku) => AppNavigation.inventoryToProduct(context, sku: sku),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
case products:
|
case products:
|
||||||
|
|
@ -56,6 +60,7 @@ abstract final class AppRoutes {
|
||||||
title: 'Products',
|
title: 'Products',
|
||||||
child: ProductPublishingPage(
|
child: ProductPublishingPage(
|
||||||
repository: AppScope.of(context).productPublishingRepository,
|
repository: AppScope.of(context).productPublishingRepository,
|
||||||
|
onViewPolicy: () => AppNavigation.productToPolicy(context),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -65,7 +70,11 @@ abstract final class AppRoutes {
|
||||||
(context) => AppShell(
|
(context) => AppShell(
|
||||||
selectedRoute: orders,
|
selectedRoute: orders,
|
||||||
title: 'Orders',
|
title: 'Orders',
|
||||||
child: OrdersPage(repository: AppScope.of(context).ordersRepository),
|
child: OrdersPage(
|
||||||
|
repository: AppScope.of(context).ordersRepository,
|
||||||
|
onViewProduct: (sku) => AppNavigation.orderToProduct(context, sku: sku),
|
||||||
|
onViewInventory: (sku) => AppNavigation.orderToInventory(context, sku: sku),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
case finance:
|
case finance:
|
||||||
|
|
@ -83,7 +92,11 @@ abstract final class AppRoutes {
|
||||||
(context) => AppShell(
|
(context) => AppShell(
|
||||||
selectedRoute: policy,
|
selectedRoute: policy,
|
||||||
title: 'Policy',
|
title: 'Policy',
|
||||||
child: PolicyPage(repository: AppScope.of(context).policyRepository),
|
child: PolicyPage(
|
||||||
|
repository: AppScope.of(context).policyRepository,
|
||||||
|
onViewRelatedPage: (category) =>
|
||||||
|
AppNavigation.policyToRelatedPage(context, category: category),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
case integrations:
|
case integrations:
|
||||||
|
|
@ -101,7 +114,10 @@ abstract final class AppRoutes {
|
||||||
(context) => AppShell(
|
(context) => AppShell(
|
||||||
selectedRoute: inventory,
|
selectedRoute: inventory,
|
||||||
title: 'Inventory',
|
title: 'Inventory',
|
||||||
child: InventoryPage(repository: AppScope.of(context).inventoryRepository),
|
child: InventoryPage(
|
||||||
|
repository: AppScope.of(context).inventoryRepository,
|
||||||
|
onViewProduct: (sku) => AppNavigation.inventoryToProduct(context, sku: sku),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,26 +4,31 @@ import 'package:flutter/material.dart';
|
||||||
/// A stat / summary card that displays a [value] with a [label] and an [icon].
|
/// A stat / summary card that displays a [value] with a [label] and an [icon].
|
||||||
///
|
///
|
||||||
/// Used on the dashboard to show high-level KPIs such as total products,
|
/// Used on the dashboard to show high-level KPIs such as total products,
|
||||||
/// in-stock count, etc.
|
/// in-stock count, etc. Optionally tappable via [onTap] to navigate to a
|
||||||
|
/// related page.
|
||||||
class SummaryCard extends StatelessWidget {
|
class SummaryCard extends StatelessWidget {
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final Color iconColor;
|
final Color iconColor;
|
||||||
final String label;
|
final String label;
|
||||||
final String value;
|
final String value;
|
||||||
|
|
||||||
|
/// Optional tap handler for cross-feature navigation from dashboard KPIs.
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
const SummaryCard({
|
const SummaryCard({
|
||||||
super.key,
|
super.key,
|
||||||
required this.icon,
|
required this.icon,
|
||||||
required this.iconColor,
|
required this.iconColor,
|
||||||
required this.label,
|
required this.label,
|
||||||
required this.value,
|
required this.value,
|
||||||
|
this.onTap,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
return KcCard(
|
final card = KcCard(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
|
@ -36,5 +41,12 @@ class SummaryCard extends StatelessWidget {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (onTap == null) return card;
|
||||||
|
|
||||||
|
return MouseRegion(
|
||||||
|
cursor: SystemMouseCursors.click,
|
||||||
|
child: GestureDetector(onTap: onTap, child: card),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:kell_web/navigation/navigation_target.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('NavigationTarget', () {
|
||||||
|
test('creates with route and empty arguments by default', () {
|
||||||
|
const target = NavigationTarget(route: '/inventory');
|
||||||
|
expect(target.route, '/inventory');
|
||||||
|
expect(target.arguments, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('creates with route and arguments', () {
|
||||||
|
const target = NavigationTarget(route: '/products', arguments: {'selectedSku': 'ABC-123'});
|
||||||
|
expect(target.route, '/products');
|
||||||
|
expect(target.arguments, {'selectedSku': 'ABC-123'});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('convenience getters return correct values', () {
|
||||||
|
const target = NavigationTarget(
|
||||||
|
route: '/inventory',
|
||||||
|
arguments: {
|
||||||
|
'selectedSku': 'SKU-001',
|
||||||
|
'selectedId': 'ID-001',
|
||||||
|
'filter': 'lowStock',
|
||||||
|
'category': 'Product Compliance',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(target.selectedSku, 'SKU-001');
|
||||||
|
expect(target.selectedId, 'ID-001');
|
||||||
|
expect(target.filter, 'lowStock');
|
||||||
|
expect(target.category, 'Product Compliance');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('convenience getters return null when key is absent', () {
|
||||||
|
const target = NavigationTarget(route: '/orders');
|
||||||
|
expect(target.selectedSku, isNull);
|
||||||
|
expect(target.selectedId, isNull);
|
||||||
|
expect(target.filter, isNull);
|
||||||
|
expect(target.category, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('equality works for same route and arguments', () {
|
||||||
|
const a = NavigationTarget(route: '/products', arguments: {'selectedSku': 'X'});
|
||||||
|
const b = NavigationTarget(route: '/products', arguments: {'selectedSku': 'X'});
|
||||||
|
expect(a, equals(b));
|
||||||
|
expect(a.hashCode, equals(b.hashCode));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('inequality for different routes', () {
|
||||||
|
const a = NavigationTarget(route: '/products');
|
||||||
|
const b = NavigationTarget(route: '/orders');
|
||||||
|
expect(a, isNot(equals(b)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('inequality for different arguments', () {
|
||||||
|
const a = NavigationTarget(route: '/products', arguments: {'selectedSku': 'X'});
|
||||||
|
const b = NavigationTarget(route: '/products', arguments: {'selectedSku': 'Y'});
|
||||||
|
expect(a, isNot(equals(b)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toString includes route and arguments', () {
|
||||||
|
const target = NavigationTarget(route: '/inventory', arguments: {'filter': 'lowStock'});
|
||||||
|
expect(target.toString(), contains('/inventory'));
|
||||||
|
expect(target.toString(), contains('lowStock'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -9,7 +9,11 @@ import 'widgets/inventory_item_card.dart';
|
||||||
class InventoryPage extends StatefulWidget {
|
class InventoryPage extends StatefulWidget {
|
||||||
final InventoryRepository repository;
|
final InventoryRepository repository;
|
||||||
|
|
||||||
const InventoryPage({super.key, required this.repository});
|
/// Optional callback to navigate to the Products page for a given SKU.
|
||||||
|
/// Provided by the app layer to enable cross-feature handoffs.
|
||||||
|
final void Function(String sku)? onViewProduct;
|
||||||
|
|
||||||
|
const InventoryPage({super.key, required this.repository, this.onViewProduct});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<InventoryPage> createState() => _InventoryPageState();
|
State<InventoryPage> createState() => _InventoryPageState();
|
||||||
|
|
@ -64,7 +68,13 @@ class _InventoryPageState extends State<InventoryPage> {
|
||||||
childAspectRatio: 1.5,
|
childAspectRatio: 1.5,
|
||||||
),
|
),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
return InventoryItemCard(item: controller.items[index]);
|
final item = controller.items[index];
|
||||||
|
return InventoryItemCard(
|
||||||
|
item: item,
|
||||||
|
onViewProduct: widget.onViewProduct != null
|
||||||
|
? () => widget.onViewProduct!(item.sku)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,10 @@ import '../../domain/inventory_status.dart';
|
||||||
class InventoryItemCard extends StatelessWidget {
|
class InventoryItemCard extends StatelessWidget {
|
||||||
final InventoryItem item;
|
final InventoryItem item;
|
||||||
|
|
||||||
const InventoryItemCard({super.key, required this.item});
|
/// Optional callback to navigate to the Products page for this item's SKU.
|
||||||
|
final VoidCallback? onViewProduct;
|
||||||
|
|
||||||
|
const InventoryItemCard({super.key, required this.item, this.onViewProduct});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
@ -25,6 +28,19 @@ class InventoryItemCard extends StatelessWidget {
|
||||||
Text('Quantity on hand: ${item.quantityOnHand}'),
|
Text('Quantity on hand: ${item.quantityOnHand}'),
|
||||||
const SizedBox(height: KcSpacing.sm),
|
const SizedBox(height: KcSpacing.sm),
|
||||||
Text('Unit price: \$${item.unitPrice.toStringAsFixed(2)}'),
|
Text('Unit price: \$${item.unitPrice.toStringAsFixed(2)}'),
|
||||||
|
if (onViewProduct != null) ...[
|
||||||
|
const SizedBox(height: KcSpacing.sm),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: onViewProduct,
|
||||||
|
child: Text(
|
||||||
|
'View Product →',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: KcColors.denimBlue,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -14,7 +14,13 @@ import 'widgets/order_detail_panel.dart';
|
||||||
class OrdersPage extends StatefulWidget {
|
class OrdersPage extends StatefulWidget {
|
||||||
final OrdersRepository repository;
|
final OrdersRepository repository;
|
||||||
|
|
||||||
const OrdersPage({super.key, required this.repository});
|
/// Optional callback to navigate to the Products page for a given SKU.
|
||||||
|
final void Function(String sku)? onViewProduct;
|
||||||
|
|
||||||
|
/// Optional callback to navigate to the Inventory page for a given SKU.
|
||||||
|
final void Function(String sku)? onViewInventory;
|
||||||
|
|
||||||
|
const OrdersPage({super.key, required this.repository, this.onViewProduct, this.onViewInventory});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<OrdersPage> createState() => _OrdersPageState();
|
State<OrdersPage> createState() => _OrdersPageState();
|
||||||
|
|
@ -93,6 +99,10 @@ class _OrdersPageState extends State<OrdersPage> {
|
||||||
if (selected == null) {
|
if (selected == null) {
|
||||||
return const Center(child: Text('Select an order to view details'));
|
return const Center(child: Text('Select an order to view details'));
|
||||||
}
|
}
|
||||||
return OrderDetailPanel(order: selected);
|
return OrderDetailPanel(
|
||||||
|
order: selected,
|
||||||
|
onViewProduct: widget.onViewProduct,
|
||||||
|
onViewInventory: widget.onViewInventory,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,18 @@ import 'order_status_chip.dart';
|
||||||
class OrderDetailPanel extends StatelessWidget {
|
class OrderDetailPanel extends StatelessWidget {
|
||||||
final Order order;
|
final Order order;
|
||||||
|
|
||||||
const OrderDetailPanel({super.key, required this.order});
|
/// Optional callback to navigate to the Products page for a given SKU.
|
||||||
|
final void Function(String sku)? onViewProduct;
|
||||||
|
|
||||||
|
/// Optional callback to navigate to the Inventory page for a given SKU.
|
||||||
|
final void Function(String sku)? onViewInventory;
|
||||||
|
|
||||||
|
const OrderDetailPanel({
|
||||||
|
super.key,
|
||||||
|
required this.order,
|
||||||
|
this.onViewProduct,
|
||||||
|
this.onViewInventory,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
@ -63,6 +74,37 @@ class OrderDetailPanel extends StatelessWidget {
|
||||||
'SKU: ${item.sku}',
|
'SKU: ${item.sku}',
|
||||||
style: theme.textTheme.bodySmall?.copyWith(color: KcColors.neutral),
|
style: theme.textTheme.bodySmall?.copyWith(color: KcColors.neutral),
|
||||||
),
|
),
|
||||||
|
if (onViewProduct != null || onViewInventory != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: KcSpacing.xs),
|
||||||
|
child: Wrap(
|
||||||
|
spacing: KcSpacing.sm,
|
||||||
|
children: [
|
||||||
|
if (onViewProduct != null)
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => onViewProduct!(item.sku),
|
||||||
|
child: Text(
|
||||||
|
'View Product',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: KcColors.denimBlue,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (onViewInventory != null)
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => onViewInventory!(item.sku),
|
||||||
|
child: Text(
|
||||||
|
'View Inventory',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: KcColors.denimBlue,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,11 @@ import 'widgets/policy_detail_panel.dart';
|
||||||
class PolicyPage extends StatefulWidget {
|
class PolicyPage extends StatefulWidget {
|
||||||
final PolicyRepository repository;
|
final PolicyRepository repository;
|
||||||
|
|
||||||
const PolicyPage({super.key, required this.repository});
|
/// Optional callback to navigate to a related operational page based on
|
||||||
|
/// the policy check's category. Provided by the app layer.
|
||||||
|
final void Function(String category)? onViewRelatedPage;
|
||||||
|
|
||||||
|
const PolicyPage({super.key, required this.repository, this.onViewRelatedPage});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<PolicyPage> createState() => _PolicyPageState();
|
State<PolicyPage> createState() => _PolicyPageState();
|
||||||
|
|
@ -93,6 +97,6 @@ class _PolicyPageState extends State<PolicyPage> {
|
||||||
if (selected == null) {
|
if (selected == null) {
|
||||||
return const Center(child: Text('Select a policy check to view details'));
|
return const Center(child: Text('Select a policy check to view details'));
|
||||||
}
|
}
|
||||||
return PolicyDetailPanel(check: selected);
|
return PolicyDetailPanel(check: selected, onViewRelatedPage: widget.onViewRelatedPage);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,11 @@ import 'governance_status_chip.dart';
|
||||||
class PolicyDetailPanel extends StatelessWidget {
|
class PolicyDetailPanel extends StatelessWidget {
|
||||||
final PolicyCheckResult check;
|
final PolicyCheckResult check;
|
||||||
|
|
||||||
const PolicyDetailPanel({super.key, required this.check});
|
/// Optional callback to navigate to the related operational page
|
||||||
|
/// based on the check's category.
|
||||||
|
final void Function(String category)? onViewRelatedPage;
|
||||||
|
|
||||||
|
const PolicyDetailPanel({super.key, required this.check, this.onViewRelatedPage});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
@ -49,6 +53,21 @@ class PolicyDetailPanel extends StatelessWidget {
|
||||||
Text('Detail', style: theme.textTheme.titleLarge),
|
Text('Detail', style: theme.textTheme.titleLarge),
|
||||||
const SizedBox(height: KcSpacing.sm),
|
const SizedBox(height: KcSpacing.sm),
|
||||||
Text(check.detail, style: theme.textTheme.bodyMedium),
|
Text(check.detail, style: theme.textTheme.bodyMedium),
|
||||||
|
|
||||||
|
// ── Related page link ──────────────────────────────────────
|
||||||
|
if (onViewRelatedPage != null) ...[
|
||||||
|
const SizedBox(height: KcSpacing.lg),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => onViewRelatedPage!(check.category),
|
||||||
|
child: Text(
|
||||||
|
'Go to ${check.category} →',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: KcColors.denimBlue,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,11 @@ import 'widgets/product_preview_panel.dart';
|
||||||
class ProductPublishingPage extends StatefulWidget {
|
class ProductPublishingPage extends StatefulWidget {
|
||||||
final ProductPublishingRepository repository;
|
final ProductPublishingRepository repository;
|
||||||
|
|
||||||
const ProductPublishingPage({super.key, required this.repository});
|
/// Optional callback to navigate to the Policy page.
|
||||||
|
/// Provided by the app layer to enable cross-feature handoffs.
|
||||||
|
final VoidCallback? onViewPolicy;
|
||||||
|
|
||||||
|
const ProductPublishingPage({super.key, required this.repository, this.onViewPolicy});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ProductPublishingPage> createState() => _ProductPublishingPageState();
|
State<ProductPublishingPage> createState() => _ProductPublishingPageState();
|
||||||
|
|
@ -95,6 +99,10 @@ class _ProductPublishingPageState extends State<ProductPublishingPage> {
|
||||||
if (selected == null) {
|
if (selected == null) {
|
||||||
return const Center(child: Text('Select a product draft to preview'));
|
return const Center(child: Text('Select a product draft to preview'));
|
||||||
}
|
}
|
||||||
return ProductPreviewPanel(draft: selected, onPublish: () => controller.publish(selected.id));
|
return ProductPreviewPanel(
|
||||||
|
draft: selected,
|
||||||
|
onPublish: () => controller.publish(selected.id),
|
||||||
|
onViewPolicy: widget.onViewPolicy,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,10 @@ class ProductPreviewPanel extends StatelessWidget {
|
||||||
final ProductDraft draft;
|
final ProductDraft draft;
|
||||||
final VoidCallback? onPublish;
|
final VoidCallback? onPublish;
|
||||||
|
|
||||||
const ProductPreviewPanel({super.key, required this.draft, this.onPublish});
|
/// Optional callback to navigate to the Policy page.
|
||||||
|
final VoidCallback? onViewPolicy;
|
||||||
|
|
||||||
|
const ProductPreviewPanel({super.key, required this.draft, this.onPublish, this.onViewPolicy});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
@ -66,6 +69,21 @@ class ProductPreviewPanel extends StatelessWidget {
|
||||||
Text(draft.description, style: theme.textTheme.bodyLarge),
|
Text(draft.description, style: theme.textTheme.bodyLarge),
|
||||||
const SizedBox(height: KcSpacing.xl),
|
const SizedBox(height: KcSpacing.xl),
|
||||||
|
|
||||||
|
// ── Policy link ────────────────────────────────────────────
|
||||||
|
if (onViewPolicy != null) ...[
|
||||||
|
GestureDetector(
|
||||||
|
onTap: onViewPolicy,
|
||||||
|
child: Text(
|
||||||
|
'View Compliance Policy →',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: KcColors.denimBlue,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: KcSpacing.md),
|
||||||
|
],
|
||||||
|
|
||||||
// ── Publish button ─────────────────────────────────────────
|
// ── Publish button ─────────────────────────────────────────
|
||||||
if (draft.status != PublishStatus.published)
|
if (draft.status != PublishStatus.published)
|
||||||
SizedBox(
|
SizedBox(
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue