feat(client): 全模块 API 对接 + 修复登陆跳转 + 菜单 UI 优化
API 对接: - 入库/出库/库存/财务/往来单位/基础数据全部对接后端 REST API - 新增 repositories、providers、models 层,统一分层架构 - auth 从 flutter_secure_storage 迁移到 shared_preferences 登陆跳转修复: - 将 _RouterNotifier 提取为独立 Riverpod provider,appRouterProvider 使用 ref.read 避免依赖链导致 router 重建后跳回 /login - redirect 函数新增 initialized 守卫,防止 auth 未恢复时误重定向 - 添加调试日志(Router/Auth/ApiClient)定位 401 触发的 logout 链路 退出菜单 UI: - 去掉 ListTile,改用 Row + 自定义 padding,文字左对齐 - MouseRegion + AnimatedContainer 实现 hover 高亮(普通项蓝底/退出红底) - 菜单圆角 6px,elevation 8,分割线高度 1px Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../auth/auth_state.dart';
|
||||
|
||||
@@ -14,17 +15,34 @@ final _publicDio = Dio(BaseOptions(
|
||||
|
||||
final apiClientProvider = Provider<ApiClient>((ref) {
|
||||
final authState = ref.watch(authStateProvider);
|
||||
return ApiClient(
|
||||
token: authState.user?.accessToken,
|
||||
final myToken = authState.user?.accessToken;
|
||||
|
||||
final client = ApiClient(
|
||||
token: myToken,
|
||||
refreshToken: authState.user?.refreshToken,
|
||||
onTokenRefreshed: (newToken) =>
|
||||
ref.read(authStateProvider.notifier).updateAccessToken(newToken),
|
||||
onAuthFailed: () => ref.read(authStateProvider.notifier).logout(),
|
||||
onTokenRefreshed: (newToken) {
|
||||
debugPrint('[ApiClient] token refreshed');
|
||||
if (ref.read(authStateProvider).user?.accessToken == myToken) {
|
||||
ref.read(authStateProvider.notifier).updateAccessToken(newToken);
|
||||
}
|
||||
},
|
||||
onAuthFailed: () {
|
||||
debugPrint('[ApiClient] onAuthFailed! myToken prefix: ${(myToken ?? '').substring(0, (myToken ?? '').length.clamp(0, 20))}');
|
||||
if (ref.read(authStateProvider).user?.accessToken == myToken) {
|
||||
debugPrint('[ApiClient] calling logout()');
|
||||
ref.read(authStateProvider.notifier).logout();
|
||||
} else {
|
||||
debugPrint('[ApiClient] skipping logout (stale client)');
|
||||
}
|
||||
},
|
||||
);
|
||||
ref.onDispose(client.dispose);
|
||||
return client;
|
||||
});
|
||||
|
||||
class ApiClient {
|
||||
late final Dio _dio;
|
||||
bool _disposed = false;
|
||||
|
||||
ApiClient({
|
||||
String? token,
|
||||
@@ -42,27 +60,35 @@ class ApiClient {
|
||||
},
|
||||
));
|
||||
|
||||
// 401 auto-refresh interceptor
|
||||
// 401 auto-refresh 拦截器
|
||||
if (refreshToken != null) {
|
||||
_dio.interceptors.add(
|
||||
InterceptorsWrapper(
|
||||
onError: (DioException e, ErrorInterceptorHandler handler) async {
|
||||
// 如果这个 client 已被替换,忽略所有回调
|
||||
if (_disposed) {
|
||||
return handler.next(e);
|
||||
}
|
||||
if (e.response?.statusCode == 401 && refreshToken.isNotEmpty) {
|
||||
debugPrint('[ApiClient] got 401 on ${e.requestOptions.path}, trying refresh...');
|
||||
try {
|
||||
final resp = await _publicDio.post('/auth/refresh', data: {
|
||||
'refresh_token': refreshToken,
|
||||
});
|
||||
final newToken =
|
||||
resp.data['data']['access_token'] as String;
|
||||
onTokenRefreshed?.call(newToken);
|
||||
// Retry original request with new token
|
||||
if (!_disposed) onTokenRefreshed?.call(newToken);
|
||||
// 用新 token 重试原请求
|
||||
final opts = e.requestOptions;
|
||||
opts.headers['Authorization'] = 'Bearer $newToken';
|
||||
final retryResp = await _dio.fetch(opts);
|
||||
return handler.resolve(retryResp);
|
||||
} catch (_) {
|
||||
onAuthFailed?.call();
|
||||
} catch (refreshErr) {
|
||||
debugPrint('[ApiClient] refresh failed: $refreshErr');
|
||||
if (!_disposed) onAuthFailed?.call();
|
||||
}
|
||||
} else if (e.response?.statusCode == 401) {
|
||||
debugPrint('[ApiClient] got 401 on ${e.requestOptions.path}, no refresh token');
|
||||
}
|
||||
return handler.next(e);
|
||||
},
|
||||
@@ -71,6 +97,12 @@ class ApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// 取消所有进行中的请求,标记实例为已废弃
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
_dio.close(force: true);
|
||||
}
|
||||
|
||||
Future<Response> get(String path, {Map<String, dynamic>? params}) =>
|
||||
_dio.get(path, queryParameters: params);
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
@@ -5,24 +6,24 @@ const _kAccessToken = 'access_token';
|
||||
const _kRefreshToken = 'refresh_token';
|
||||
const _kUsername = 'username';
|
||||
const _kRealName = 'real_name';
|
||||
const _kHotelNo = 'hotel_no';
|
||||
const _kHotelId = 'hotel_id';
|
||||
const _kShopNo = 'shop_no';
|
||||
const _kShopId = 'shop_id';
|
||||
|
||||
class AuthUser {
|
||||
final String accessToken;
|
||||
final String refreshToken;
|
||||
final String username;
|
||||
final String realName;
|
||||
final String hotelNo;
|
||||
final int hotelId;
|
||||
final String shopNo;
|
||||
final int shopId;
|
||||
|
||||
const AuthUser({
|
||||
required this.accessToken,
|
||||
required this.refreshToken,
|
||||
required this.username,
|
||||
required this.realName,
|
||||
required this.hotelNo,
|
||||
required this.hotelId,
|
||||
required this.shopNo,
|
||||
required this.shopId,
|
||||
});
|
||||
|
||||
// Kept for ApiClient compatibility
|
||||
@@ -47,8 +48,8 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
||||
final refreshToken = prefs.getString(_kRefreshToken);
|
||||
final username = prefs.getString(_kUsername);
|
||||
final realName = prefs.getString(_kRealName);
|
||||
final hotelNo = prefs.getString(_kHotelNo);
|
||||
final hotelIdStr = prefs.getString(_kHotelId);
|
||||
final shopNo = prefs.getString(_kShopNo);
|
||||
final shopIdStr = prefs.getString(_kShopId);
|
||||
|
||||
if (accessToken != null && refreshToken != null && username != null) {
|
||||
state = AuthState(
|
||||
@@ -58,8 +59,8 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
||||
refreshToken: refreshToken,
|
||||
username: username,
|
||||
realName: realName ?? username,
|
||||
hotelNo: hotelNo ?? '',
|
||||
hotelId: int.tryParse(hotelIdStr ?? '') ?? 0,
|
||||
shopNo: shopNo ?? '',
|
||||
shopId: int.tryParse(shopIdStr ?? '') ?? 0,
|
||||
),
|
||||
);
|
||||
return;
|
||||
@@ -71,14 +72,17 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
||||
}
|
||||
|
||||
Future<void> login(AuthUser user) async {
|
||||
debugPrint('[Auth] login() called, username=${user.username} shopId=${user.shopId}');
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_kAccessToken, user.accessToken);
|
||||
await prefs.setString(_kRefreshToken, user.refreshToken);
|
||||
await prefs.setString(_kUsername, user.username);
|
||||
await prefs.setString(_kRealName, user.realName);
|
||||
await prefs.setString(_kHotelNo, user.hotelNo);
|
||||
await prefs.setString(_kHotelId, user.hotelId.toString());
|
||||
await prefs.setString(_kShopNo, user.shopNo);
|
||||
await prefs.setString(_kShopId, user.shopId.toString());
|
||||
debugPrint('[Auth] login() setting state, token prefix: ${user.accessToken.substring(0, user.accessToken.length.clamp(0, 20))}...');
|
||||
state = AuthState(initialized: true, user: user);
|
||||
debugPrint('[Auth] login() state set, isLoggedIn=${state.isLoggedIn}');
|
||||
}
|
||||
|
||||
void updateAccessToken(String newToken) {
|
||||
@@ -92,20 +96,21 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
||||
refreshToken: state.user!.refreshToken,
|
||||
username: state.user!.username,
|
||||
realName: state.user!.realName,
|
||||
hotelNo: state.user!.hotelNo,
|
||||
hotelId: state.user!.hotelId,
|
||||
shopNo: state.user!.shopNo,
|
||||
shopId: state.user!.shopId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
debugPrint('[Auth] logout() called! stack: ${StackTrace.current}');
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_kAccessToken);
|
||||
await prefs.remove(_kRefreshToken);
|
||||
await prefs.remove(_kUsername);
|
||||
await prefs.remove(_kRealName);
|
||||
await prefs.remove(_kHotelNo);
|
||||
await prefs.remove(_kHotelId);
|
||||
await prefs.remove(_kShopNo);
|
||||
await prefs.remove(_kShopId);
|
||||
state = const AuthState(initialized: true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
class AppException implements Exception {
|
||||
final String message;
|
||||
final int? statusCode;
|
||||
const AppException(this.message, {this.statusCode});
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
class PageResult<T> {
|
||||
final List<T> data;
|
||||
final int total;
|
||||
final int page;
|
||||
final int pageSize;
|
||||
|
||||
const PageResult({
|
||||
required this.data,
|
||||
required this.total,
|
||||
required this.page,
|
||||
required this.pageSize,
|
||||
});
|
||||
|
||||
factory PageResult.fromJson(
|
||||
Map<String, dynamic> json,
|
||||
T Function(Map<String, dynamic>) fromJson,
|
||||
) {
|
||||
return PageResult(
|
||||
data: (json['data'] as List)
|
||||
.map((e) => fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
total: json['total'] as int,
|
||||
page: json['page'] as int,
|
||||
pageSize: json['page_size'] as int,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -17,18 +17,55 @@ import '../auth/auth_state.dart';
|
||||
Page<void> _noTransition(Widget child) =>
|
||||
NoTransitionPage<void>(child: child);
|
||||
|
||||
final appRouterProvider = Provider<GoRouter>((ref) {
|
||||
final authState = ref.watch(authStateProvider);
|
||||
/// ChangeNotifier that bridges Riverpod auth state → GoRouter refreshListenable.
|
||||
class _RouterNotifier extends ChangeNotifier {
|
||||
final Ref _ref;
|
||||
|
||||
return GoRouter(
|
||||
_RouterNotifier(this._ref) {
|
||||
_ref.listen<AuthState>(authStateProvider, (prev, next) {
|
||||
debugPrint('[Router] authState changed:'
|
||||
' initialized=${next.initialized}'
|
||||
' isLoggedIn=${next.isLoggedIn}'
|
||||
' user=${next.user?.username}');
|
||||
notifyListeners();
|
||||
});
|
||||
}
|
||||
|
||||
String? redirect(BuildContext context, GoRouterState state) {
|
||||
final authState = _ref.read(authStateProvider);
|
||||
final isLoggedIn = authState.isLoggedIn;
|
||||
final isLoginRoute = state.matchedLocation == '/login';
|
||||
final result = !authState.initialized
|
||||
? null
|
||||
: (!isLoggedIn && !isLoginRoute)
|
||||
? '/login'
|
||||
: (isLoggedIn && isLoginRoute)
|
||||
? '/stock-in'
|
||||
: null;
|
||||
debugPrint('[Router] redirect: location=${state.matchedLocation}'
|
||||
' initialized=${authState.initialized}'
|
||||
' isLoggedIn=$isLoggedIn'
|
||||
' → ${result ?? "null (no redirect)"}');
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// Separate provider so that appRouterProvider has NO dependencies and
|
||||
/// is never rebuilt when auth state changes (prevents router reset to /login).
|
||||
final _routerNotifierProvider = Provider<_RouterNotifier>((ref) {
|
||||
final notifier = _RouterNotifier(ref);
|
||||
ref.onDispose(notifier.dispose);
|
||||
return notifier;
|
||||
});
|
||||
|
||||
final appRouterProvider = Provider<GoRouter>((ref) {
|
||||
// Use ref.read (not ref.watch) so appRouterProvider never rebuilds on auth change.
|
||||
final notifier = ref.read(_routerNotifierProvider);
|
||||
|
||||
final router = GoRouter(
|
||||
initialLocation: '/login',
|
||||
redirect: (context, state) {
|
||||
final isLoggedIn = authState.isLoggedIn;
|
||||
final isLoginRoute = state.matchedLocation == '/login';
|
||||
if (!isLoggedIn && !isLoginRoute) return '/login';
|
||||
if (isLoggedIn && isLoginRoute) return '/stock-in';
|
||||
return null;
|
||||
},
|
||||
refreshListenable: notifier,
|
||||
redirect: notifier.redirect,
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/login',
|
||||
@@ -70,4 +107,8 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
ref.onDispose(router.dispose);
|
||||
|
||||
return router;
|
||||
});
|
||||
|
||||
+17
-1
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'core/auth/auth_state.dart';
|
||||
@@ -5,7 +7,21 @@ import 'core/router/app_router.dart';
|
||||
import 'core/theme/app_theme.dart';
|
||||
|
||||
void main() {
|
||||
runApp(const ProviderScope(child: JiuApp()));
|
||||
FlutterError.onError = (details) {
|
||||
FlutterError.presentError(details);
|
||||
debugPrint('═══ FlutterError ════════════════════════════');
|
||||
debugPrint(details.exceptionAsString());
|
||||
debugPrint(details.stack.toString());
|
||||
};
|
||||
|
||||
runZonedGuarded(
|
||||
() => runApp(const ProviderScope(child: JiuApp())),
|
||||
(error, stack) {
|
||||
debugPrint('═══ Zone Error ═══════════════════════════════');
|
||||
debugPrint(error.toString());
|
||||
debugPrint(stack.toString());
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class JiuApp extends ConsumerWidget {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
class Inventory {
|
||||
final int warehouseId;
|
||||
final String? warehouseName;
|
||||
final int productId;
|
||||
final String? productName;
|
||||
final String? productCode;
|
||||
final String? productSpec;
|
||||
final String? productUnit;
|
||||
final String? productBrand;
|
||||
final int? minStock;
|
||||
final double quantity;
|
||||
|
||||
const Inventory({
|
||||
required this.warehouseId,
|
||||
this.warehouseName,
|
||||
required this.productId,
|
||||
this.productName,
|
||||
this.productCode,
|
||||
this.productSpec,
|
||||
this.productUnit,
|
||||
this.productBrand,
|
||||
this.minStock,
|
||||
required this.quantity,
|
||||
});
|
||||
|
||||
factory Inventory.fromJson(Map<String, dynamic> json) => Inventory(
|
||||
warehouseId: (json['warehouse_id'] as num).toInt(),
|
||||
warehouseName: json['warehouse_name'] as String?,
|
||||
productId: (json['product_id'] as num).toInt(),
|
||||
productName: json['product_name'] as String?,
|
||||
productCode: json['product_code'] as String?,
|
||||
productSpec: json['product_spec'] as String?,
|
||||
productUnit: json['product_unit'] as String?,
|
||||
productBrand: json['product_brand'] as String?,
|
||||
minStock: json['min_stock'] != null
|
||||
? (json['min_stock'] as num).toInt()
|
||||
: null,
|
||||
quantity: (json['quantity'] as num).toDouble(),
|
||||
);
|
||||
}
|
||||
|
||||
class InventoryLog {
|
||||
final int warehouseId;
|
||||
final String? warehouseName;
|
||||
final int productId;
|
||||
final String? productName;
|
||||
final String direction; // in | out
|
||||
final double quantity;
|
||||
final double? qtyBefore;
|
||||
final double? qtyAfter;
|
||||
final String? refType;
|
||||
final int? refId;
|
||||
final String? createdAt;
|
||||
|
||||
const InventoryLog({
|
||||
required this.warehouseId,
|
||||
this.warehouseName,
|
||||
required this.productId,
|
||||
this.productName,
|
||||
required this.direction,
|
||||
required this.quantity,
|
||||
this.qtyBefore,
|
||||
this.qtyAfter,
|
||||
this.refType,
|
||||
this.refId,
|
||||
this.createdAt,
|
||||
});
|
||||
|
||||
factory InventoryLog.fromJson(Map<String, dynamic> json) => InventoryLog(
|
||||
warehouseId: (json['warehouse_id'] as num).toInt(),
|
||||
warehouseName: json['warehouse_name'] as String?,
|
||||
productId: (json['product_id'] as num).toInt(),
|
||||
productName: json['product_name'] as String?,
|
||||
direction: json['direction'] as String,
|
||||
quantity: (json['quantity'] as num).toDouble(),
|
||||
qtyBefore: json['qty_before'] != null
|
||||
? (json['qty_before'] as num).toDouble()
|
||||
: null,
|
||||
qtyAfter: json['qty_after'] != null
|
||||
? (json['qty_after'] as num).toDouble()
|
||||
: null,
|
||||
refType: json['ref_type'] as String?,
|
||||
refId: json['ref_id'] != null
|
||||
? (json['ref_id'] as num).toInt()
|
||||
: null,
|
||||
createdAt: json['created_at'] as String?,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
class Partner {
|
||||
final int id;
|
||||
final String? code;
|
||||
final String name;
|
||||
final String type; // supplier | customer
|
||||
final String? contact;
|
||||
final String? phone;
|
||||
final String? address;
|
||||
final String? bankAccount;
|
||||
final String? remark;
|
||||
|
||||
const Partner({
|
||||
required this.id,
|
||||
this.code,
|
||||
required this.name,
|
||||
required this.type,
|
||||
this.contact,
|
||||
this.phone,
|
||||
this.address,
|
||||
this.bankAccount,
|
||||
this.remark,
|
||||
});
|
||||
|
||||
factory Partner.fromJson(Map<String, dynamic> json) => Partner(
|
||||
id: (json['id'] as num).toInt(),
|
||||
code: json['code'] as String?,
|
||||
name: json['name'] as String,
|
||||
type: json['type'] as String,
|
||||
contact: json['contact'] as String?,
|
||||
phone: json['phone'] as String?,
|
||||
address: json['address'] as String?,
|
||||
bankAccount: json['bank_account'] as String?,
|
||||
remark: json['remark'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'name': name,
|
||||
'type': type,
|
||||
if (code != null) 'code': code,
|
||||
if (contact != null) 'contact': contact,
|
||||
if (phone != null) 'phone': phone,
|
||||
if (address != null) 'address': address,
|
||||
if (bankAccount != null) 'bank_account': bankAccount,
|
||||
if (remark != null) 'remark': remark,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
class Product {
|
||||
final int id;
|
||||
final String code;
|
||||
final String? barcode;
|
||||
final String name;
|
||||
final String? series;
|
||||
final String? spec;
|
||||
final String unit;
|
||||
final int? categoryId;
|
||||
final String? brand;
|
||||
final double? purchasePrice;
|
||||
final double? salePrice;
|
||||
final int? minStock;
|
||||
final String? remark;
|
||||
final Map<String, dynamic>? customFields;
|
||||
|
||||
const Product({
|
||||
required this.id,
|
||||
required this.code,
|
||||
this.barcode,
|
||||
required this.name,
|
||||
this.series,
|
||||
this.spec,
|
||||
required this.unit,
|
||||
this.categoryId,
|
||||
this.brand,
|
||||
this.purchasePrice,
|
||||
this.salePrice,
|
||||
this.minStock,
|
||||
this.remark,
|
||||
this.customFields,
|
||||
});
|
||||
|
||||
factory Product.fromJson(Map<String, dynamic> json) => Product(
|
||||
id: (json['id'] as num).toInt(),
|
||||
code: json['code'] as String? ?? '',
|
||||
barcode: json['barcode'] as String?,
|
||||
name: json['name'] as String,
|
||||
series: json['series'] as String?,
|
||||
spec: json['spec'] as String?,
|
||||
unit: json['unit'] as String? ?? '个',
|
||||
categoryId: json['category_id'] != null
|
||||
? (json['category_id'] as num).toInt()
|
||||
: null,
|
||||
brand: json['brand'] as String?,
|
||||
purchasePrice: json['purchase_price'] != null
|
||||
? (json['purchase_price'] as num).toDouble()
|
||||
: null,
|
||||
salePrice: json['sale_price'] != null
|
||||
? (json['sale_price'] as num).toDouble()
|
||||
: null,
|
||||
minStock: json['min_stock'] != null
|
||||
? (json['min_stock'] as num).toInt()
|
||||
: null,
|
||||
remark: json['remark'] as String?,
|
||||
customFields: json['custom_fields'] as Map<String, dynamic>?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'code': code,
|
||||
if (barcode != null) 'barcode': barcode,
|
||||
'name': name,
|
||||
if (series != null) 'series': series,
|
||||
if (spec != null) 'spec': spec,
|
||||
'unit': unit,
|
||||
if (categoryId != null) 'category_id': categoryId,
|
||||
if (brand != null) 'brand': brand,
|
||||
if (purchasePrice != null) 'purchase_price': purchasePrice,
|
||||
if (salePrice != null) 'sale_price': salePrice,
|
||||
if (minStock != null) 'min_stock': minStock,
|
||||
if (remark != null) 'remark': remark,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
class StockInItem {
|
||||
final int? orderId;
|
||||
final int productId;
|
||||
final double quantity;
|
||||
final double unitPrice;
|
||||
final double totalPrice;
|
||||
final String? batchNo;
|
||||
// Denormalized for display
|
||||
final String? productName;
|
||||
final String? productCode;
|
||||
final String? productSpec;
|
||||
final String? productUnit;
|
||||
|
||||
const StockInItem({
|
||||
this.orderId,
|
||||
required this.productId,
|
||||
required this.quantity,
|
||||
required this.unitPrice,
|
||||
required this.totalPrice,
|
||||
this.batchNo,
|
||||
this.productName,
|
||||
this.productCode,
|
||||
this.productSpec,
|
||||
this.productUnit,
|
||||
});
|
||||
|
||||
factory StockInItem.fromJson(Map<String, dynamic> json) => StockInItem(
|
||||
orderId: json['order_id'] != null
|
||||
? (json['order_id'] as num).toInt()
|
||||
: null,
|
||||
productId: (json['product_id'] as num).toInt(),
|
||||
quantity: (json['quantity'] as num).toDouble(),
|
||||
unitPrice: (json['unit_price'] as num).toDouble(),
|
||||
totalPrice: (json['total_price'] as num).toDouble(),
|
||||
batchNo: json['batch_no'] as String?,
|
||||
productName: json['product_name'] as String?,
|
||||
productCode: json['product_code'] as String?,
|
||||
productSpec: json['product_spec'] as String?,
|
||||
productUnit: json['product_unit'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'product_id': productId,
|
||||
'quantity': quantity,
|
||||
'unit_price': unitPrice,
|
||||
'total_price': totalPrice,
|
||||
if (batchNo != null) 'batch_no': batchNo,
|
||||
};
|
||||
}
|
||||
|
||||
class StockInOrder {
|
||||
final int id;
|
||||
final String orderNo;
|
||||
final String? type;
|
||||
final int warehouseId;
|
||||
final String? warehouseName;
|
||||
final int? partnerId;
|
||||
final String? partnerName;
|
||||
final int? operatorId;
|
||||
final String status; // draft | pending | approved | rejected
|
||||
final String? orderDate;
|
||||
final double? totalAmount;
|
||||
final String? remark;
|
||||
final List<StockInItem> items;
|
||||
|
||||
const StockInOrder({
|
||||
required this.id,
|
||||
required this.orderNo,
|
||||
this.type,
|
||||
required this.warehouseId,
|
||||
this.warehouseName,
|
||||
this.partnerId,
|
||||
this.partnerName,
|
||||
this.operatorId,
|
||||
required this.status,
|
||||
this.orderDate,
|
||||
this.totalAmount,
|
||||
this.remark,
|
||||
this.items = const [],
|
||||
});
|
||||
|
||||
factory StockInOrder.fromJson(Map<String, dynamic> json) => StockInOrder(
|
||||
id: (json['id'] as num).toInt(),
|
||||
orderNo: json['order_no'] as String,
|
||||
type: json['type'] as String?,
|
||||
warehouseId: (json['warehouse_id'] as num).toInt(),
|
||||
warehouseName: json['warehouse_name'] as String?,
|
||||
partnerId: json['partner_id'] != null
|
||||
? (json['partner_id'] as num).toInt()
|
||||
: null,
|
||||
partnerName: json['partner_name'] as String?,
|
||||
operatorId: json['operator_id'] != null
|
||||
? (json['operator_id'] as num).toInt()
|
||||
: null,
|
||||
status: json['status'] as String,
|
||||
orderDate: json['order_date'] as String?,
|
||||
totalAmount: json['total_amount'] != null
|
||||
? (json['total_amount'] as num).toDouble()
|
||||
: null,
|
||||
remark: json['remark'] as String?,
|
||||
items: json['items'] != null
|
||||
? (json['items'] as List)
|
||||
.map((e) =>
|
||||
StockInItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList()
|
||||
: [],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
class StockOutItem {
|
||||
final int? orderId;
|
||||
final int productId;
|
||||
final double quantity;
|
||||
final double unitPrice;
|
||||
final double totalPrice;
|
||||
final String? productName;
|
||||
final String? productCode;
|
||||
final String? productSpec;
|
||||
final String? productUnit;
|
||||
|
||||
const StockOutItem({
|
||||
this.orderId,
|
||||
required this.productId,
|
||||
required this.quantity,
|
||||
required this.unitPrice,
|
||||
required this.totalPrice,
|
||||
this.productName,
|
||||
this.productCode,
|
||||
this.productSpec,
|
||||
this.productUnit,
|
||||
});
|
||||
|
||||
factory StockOutItem.fromJson(Map<String, dynamic> json) => StockOutItem(
|
||||
orderId: json['order_id'] != null
|
||||
? (json['order_id'] as num).toInt()
|
||||
: null,
|
||||
productId: (json['product_id'] as num).toInt(),
|
||||
quantity: (json['quantity'] as num).toDouble(),
|
||||
unitPrice: (json['unit_price'] as num).toDouble(),
|
||||
totalPrice: (json['total_price'] as num).toDouble(),
|
||||
productName: json['product_name'] as String?,
|
||||
productCode: json['product_code'] as String?,
|
||||
productSpec: json['product_spec'] as String?,
|
||||
productUnit: json['product_unit'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
class StockOutOrder {
|
||||
final int id;
|
||||
final String orderNo;
|
||||
final String? type;
|
||||
final int warehouseId;
|
||||
final String? warehouseName;
|
||||
final int? partnerId;
|
||||
final String? partnerName;
|
||||
final int? operatorId;
|
||||
final String status; // draft | pending | approved | rejected
|
||||
final String? orderDate;
|
||||
final double? totalAmount;
|
||||
final String? remark;
|
||||
final List<StockOutItem> items;
|
||||
|
||||
const StockOutOrder({
|
||||
required this.id,
|
||||
required this.orderNo,
|
||||
this.type,
|
||||
required this.warehouseId,
|
||||
this.warehouseName,
|
||||
this.partnerId,
|
||||
this.partnerName,
|
||||
this.operatorId,
|
||||
required this.status,
|
||||
this.orderDate,
|
||||
this.totalAmount,
|
||||
this.remark,
|
||||
this.items = const [],
|
||||
});
|
||||
|
||||
factory StockOutOrder.fromJson(Map<String, dynamic> json) => StockOutOrder(
|
||||
id: (json['id'] as num).toInt(),
|
||||
orderNo: json['order_no'] as String,
|
||||
type: json['type'] as String?,
|
||||
warehouseId: (json['warehouse_id'] as num).toInt(),
|
||||
warehouseName: json['warehouse_name'] as String?,
|
||||
partnerId: json['partner_id'] != null
|
||||
? (json['partner_id'] as num).toInt()
|
||||
: null,
|
||||
partnerName: json['partner_name'] as String?,
|
||||
operatorId: json['operator_id'] != null
|
||||
? (json['operator_id'] as num).toInt()
|
||||
: null,
|
||||
status: json['status'] as String,
|
||||
orderDate: json['order_date'] as String?,
|
||||
totalAmount: json['total_amount'] != null
|
||||
? (json['total_amount'] as num).toDouble()
|
||||
: null,
|
||||
remark: json['remark'] as String?,
|
||||
items: json['items'] != null
|
||||
? (json['items'] as List)
|
||||
.map((e) =>
|
||||
StockOutItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList()
|
||||
: [],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
class Warehouse {
|
||||
final int id;
|
||||
final String name;
|
||||
final String? location;
|
||||
final bool isDefault;
|
||||
|
||||
const Warehouse({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.location,
|
||||
required this.isDefault,
|
||||
});
|
||||
|
||||
factory Warehouse.fromJson(Map<String, dynamic> json) => Warehouse(
|
||||
id: (json['id'] as num).toInt(),
|
||||
name: json['name'] as String,
|
||||
location: json['location'] as String?,
|
||||
isDefault: json['is_default'] as bool? ?? false,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'name': name,
|
||||
if (location != null) 'location': location,
|
||||
'is_default': isDefault,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../core/api/api_client.dart';
|
||||
import '../core/auth/auth_state.dart';
|
||||
import '../core/models/page_result.dart';
|
||||
import '../models/inventory.dart';
|
||||
import '../repositories/inventory_repository.dart';
|
||||
|
||||
final inventoryRepositoryProvider = Provider<InventoryRepository>((ref) {
|
||||
return InventoryRepository(ref.watch(apiClientProvider));
|
||||
});
|
||||
|
||||
final inventoryListProvider =
|
||||
AsyncNotifierProvider<InventoryListNotifier, PageResult<Inventory>>(
|
||||
InventoryListNotifier.new,
|
||||
);
|
||||
|
||||
class InventoryListNotifier extends AsyncNotifier<PageResult<Inventory>> {
|
||||
int _page = 1;
|
||||
int? _warehouseId;
|
||||
String _keyword = '';
|
||||
|
||||
@override
|
||||
Future<PageResult<Inventory>> build() {
|
||||
ref.watch(authStateProvider.select((s) => s.user?.shopId));
|
||||
return _fetch();
|
||||
}
|
||||
|
||||
Future<PageResult<Inventory>> _fetch() {
|
||||
return ref.read(inventoryRepositoryProvider).listInventory(
|
||||
warehouseId: _warehouseId,
|
||||
keyword: _keyword.isEmpty ? null : _keyword,
|
||||
page: _page,
|
||||
pageSize: 50,
|
||||
);
|
||||
}
|
||||
|
||||
void setPage(int page) {
|
||||
_page = page;
|
||||
reload();
|
||||
}
|
||||
|
||||
void setWarehouseId(int? id) {
|
||||
_warehouseId = id;
|
||||
_page = 1;
|
||||
reload();
|
||||
}
|
||||
|
||||
void setKeyword(String keyword) {
|
||||
_keyword = keyword;
|
||||
_page = 1;
|
||||
reload();
|
||||
}
|
||||
|
||||
void reload() {
|
||||
state = const AsyncValue.loading();
|
||||
_fetch().then(
|
||||
(result) => state = AsyncValue.data(result),
|
||||
onError: (e, st) => state = AsyncValue.error(e, st),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final inventoryLogProvider =
|
||||
AsyncNotifierProvider<InventoryLogNotifier, PageResult<InventoryLog>>(
|
||||
InventoryLogNotifier.new,
|
||||
);
|
||||
|
||||
class InventoryLogNotifier extends AsyncNotifier<PageResult<InventoryLog>> {
|
||||
int _page = 1;
|
||||
|
||||
@override
|
||||
Future<PageResult<InventoryLog>> build() {
|
||||
ref.watch(authStateProvider.select((s) => s.user?.shopId));
|
||||
return _fetch();
|
||||
}
|
||||
|
||||
Future<PageResult<InventoryLog>> _fetch() {
|
||||
return ref.read(inventoryRepositoryProvider).listLogs(
|
||||
page: _page,
|
||||
pageSize: 50,
|
||||
);
|
||||
}
|
||||
|
||||
void setPage(int page) {
|
||||
_page = page;
|
||||
reload();
|
||||
}
|
||||
|
||||
void reload() {
|
||||
state = const AsyncValue.loading();
|
||||
_fetch().then(
|
||||
(result) => state = AsyncValue.data(result),
|
||||
onError: (e, st) => state = AsyncValue.error(e, st),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../core/api/api_client.dart';
|
||||
import '../core/auth/auth_state.dart';
|
||||
import '../core/models/page_result.dart';
|
||||
import '../models/partner.dart';
|
||||
import '../repositories/partner_repository.dart';
|
||||
|
||||
final partnerRepositoryProvider = Provider<PartnerRepository>((ref) {
|
||||
return PartnerRepository(ref.watch(apiClientProvider));
|
||||
});
|
||||
|
||||
// Supplier list provider for dropdowns
|
||||
final supplierListProvider =
|
||||
AsyncNotifierProvider<PartnerListNotifier, PageResult<Partner>>(
|
||||
() => PartnerListNotifier(type: 'supplier'),
|
||||
);
|
||||
|
||||
// Customer list provider
|
||||
final customerListProvider =
|
||||
AsyncNotifierProvider<PartnerListNotifier, PageResult<Partner>>(
|
||||
() => PartnerListNotifier(type: 'customer'),
|
||||
);
|
||||
|
||||
class PartnerListNotifier extends AsyncNotifier<PageResult<Partner>> {
|
||||
final String? type;
|
||||
int _page = 1;
|
||||
String _keyword = '';
|
||||
|
||||
PartnerListNotifier({this.type});
|
||||
|
||||
@override
|
||||
Future<PageResult<Partner>> build() {
|
||||
ref.watch(authStateProvider.select((s) => s.user?.shopId));
|
||||
return _fetch();
|
||||
}
|
||||
|
||||
Future<PageResult<Partner>> _fetch() {
|
||||
return ref.read(partnerRepositoryProvider).list(
|
||||
type: type,
|
||||
keyword: _keyword.isEmpty ? null : _keyword,
|
||||
page: _page,
|
||||
);
|
||||
}
|
||||
|
||||
void setPage(int page) {
|
||||
_page = page;
|
||||
reload();
|
||||
}
|
||||
|
||||
void setKeyword(String keyword) {
|
||||
_keyword = keyword;
|
||||
_page = 1;
|
||||
reload();
|
||||
}
|
||||
|
||||
void reload() {
|
||||
state = const AsyncValue.loading();
|
||||
_fetch().then(
|
||||
(result) => state = AsyncValue.data(result),
|
||||
onError: (e, st) => state = AsyncValue.error(e, st),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> createPartner(Map<String, dynamic> data) async {
|
||||
await ref.read(partnerRepositoryProvider).create(data);
|
||||
reload();
|
||||
}
|
||||
|
||||
Future<void> updatePartner(int id, Map<String, dynamic> data) async {
|
||||
await ref.read(partnerRepositoryProvider).update(id, data);
|
||||
reload();
|
||||
}
|
||||
|
||||
Future<void> deletePartner(int id) async {
|
||||
await ref.read(partnerRepositoryProvider).delete(id);
|
||||
reload();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../core/api/api_client.dart';
|
||||
import '../core/auth/auth_state.dart';
|
||||
import '../core/models/page_result.dart';
|
||||
import '../models/product.dart';
|
||||
import '../repositories/product_repository.dart';
|
||||
|
||||
final productRepositoryProvider = Provider<ProductRepository>((ref) {
|
||||
return ProductRepository(ref.watch(apiClientProvider));
|
||||
});
|
||||
|
||||
final productListProvider =
|
||||
AsyncNotifierProvider<ProductListNotifier, PageResult<Product>>(
|
||||
ProductListNotifier.new,
|
||||
);
|
||||
|
||||
class ProductListNotifier extends AsyncNotifier<PageResult<Product>> {
|
||||
int _page = 1;
|
||||
String _keyword = '';
|
||||
int? _categoryId;
|
||||
|
||||
@override
|
||||
Future<PageResult<Product>> build() {
|
||||
ref.watch(authStateProvider.select((s) => s.user?.shopId));
|
||||
return _fetch();
|
||||
}
|
||||
|
||||
Future<PageResult<Product>> _fetch() {
|
||||
final repo = ref.read(productRepositoryProvider);
|
||||
return repo.list(
|
||||
page: _page,
|
||||
pageSize: 20,
|
||||
keyword: _keyword.isEmpty ? null : _keyword,
|
||||
categoryId: _categoryId,
|
||||
);
|
||||
}
|
||||
|
||||
void setPage(int page) {
|
||||
_page = page;
|
||||
reload();
|
||||
}
|
||||
|
||||
void setKeyword(String keyword) {
|
||||
_keyword = keyword;
|
||||
_page = 1;
|
||||
reload();
|
||||
}
|
||||
|
||||
void setCategoryId(int? categoryId) {
|
||||
_categoryId = categoryId;
|
||||
_page = 1;
|
||||
reload();
|
||||
}
|
||||
|
||||
void reload() {
|
||||
state = const AsyncValue.loading();
|
||||
_fetch().then(
|
||||
(result) => state = AsyncValue.data(result),
|
||||
onError: (e, st) => state = AsyncValue.error(e, st),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> createProduct(Map<String, dynamic> data) async {
|
||||
final repo = ref.read(productRepositoryProvider);
|
||||
await repo.create(data);
|
||||
reload();
|
||||
}
|
||||
|
||||
Future<void> updateProduct(int id, Map<String, dynamic> data) async {
|
||||
final repo = ref.read(productRepositoryProvider);
|
||||
await repo.update(id, data);
|
||||
reload();
|
||||
}
|
||||
|
||||
Future<void> deleteProduct(int id) async {
|
||||
final repo = ref.read(productRepositoryProvider);
|
||||
await repo.delete(id);
|
||||
reload();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../core/api/api_client.dart';
|
||||
import '../core/auth/auth_state.dart';
|
||||
import '../core/models/page_result.dart';
|
||||
import '../models/stock_in.dart';
|
||||
import '../repositories/stock_in_repository.dart';
|
||||
|
||||
final stockInRepositoryProvider = Provider<StockInRepository>((ref) {
|
||||
return StockInRepository(ref.watch(apiClientProvider));
|
||||
});
|
||||
|
||||
final stockInListProvider =
|
||||
AsyncNotifierProvider<StockInListNotifier, PageResult<StockInOrder>>(
|
||||
StockInListNotifier.new,
|
||||
);
|
||||
|
||||
class StockInListNotifier extends AsyncNotifier<PageResult<StockInOrder>> {
|
||||
int _page = 1;
|
||||
String _status = '';
|
||||
String? _startDate;
|
||||
String? _endDate;
|
||||
|
||||
@override
|
||||
Future<PageResult<StockInOrder>> build() {
|
||||
ref.watch(authStateProvider.select((s) => s.user?.shopId));
|
||||
return _fetch();
|
||||
}
|
||||
|
||||
Future<PageResult<StockInOrder>> _fetch() {
|
||||
return ref.read(stockInRepositoryProvider).list(
|
||||
status: _status.isEmpty ? null : _status,
|
||||
startDate: _startDate,
|
||||
endDate: _endDate,
|
||||
page: _page,
|
||||
);
|
||||
}
|
||||
|
||||
void setPage(int page) {
|
||||
_page = page;
|
||||
reload();
|
||||
}
|
||||
|
||||
void setStatus(String status) {
|
||||
_status = status;
|
||||
_page = 1;
|
||||
reload();
|
||||
}
|
||||
|
||||
void setDateRange(String? startDate, String? endDate) {
|
||||
_startDate = startDate;
|
||||
_endDate = endDate;
|
||||
_page = 1;
|
||||
reload();
|
||||
}
|
||||
|
||||
void reload() {
|
||||
state = const AsyncValue.loading();
|
||||
_fetch().then(
|
||||
(result) => state = AsyncValue.data(result),
|
||||
onError: (e, st) => state = AsyncValue.error(e, st),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> createOrder(Map<String, dynamic> data) async {
|
||||
await ref.read(stockInRepositoryProvider).create(data);
|
||||
reload();
|
||||
}
|
||||
|
||||
Future<void> submitOrder(int id) async {
|
||||
await ref.read(stockInRepositoryProvider).submit(id);
|
||||
reload();
|
||||
}
|
||||
|
||||
Future<void> approveOrder(int id) async {
|
||||
await ref.read(stockInRepositoryProvider).approve(id);
|
||||
reload();
|
||||
}
|
||||
|
||||
Future<void> rejectOrder(int id) async {
|
||||
await ref.read(stockInRepositoryProvider).reject(id);
|
||||
reload();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../core/api/api_client.dart';
|
||||
import '../core/auth/auth_state.dart';
|
||||
import '../core/models/page_result.dart';
|
||||
import '../models/stock_out.dart';
|
||||
import '../repositories/stock_out_repository.dart';
|
||||
|
||||
final stockOutRepositoryProvider = Provider<StockOutRepository>((ref) {
|
||||
return StockOutRepository(ref.watch(apiClientProvider));
|
||||
});
|
||||
|
||||
final stockOutListProvider =
|
||||
AsyncNotifierProvider<StockOutListNotifier, PageResult<StockOutOrder>>(
|
||||
StockOutListNotifier.new,
|
||||
);
|
||||
|
||||
class StockOutListNotifier extends AsyncNotifier<PageResult<StockOutOrder>> {
|
||||
int _page = 1;
|
||||
String _status = '';
|
||||
String? _startDate;
|
||||
String? _endDate;
|
||||
|
||||
@override
|
||||
Future<PageResult<StockOutOrder>> build() {
|
||||
ref.watch(authStateProvider.select((s) => s.user?.shopId));
|
||||
return _fetch();
|
||||
}
|
||||
|
||||
Future<PageResult<StockOutOrder>> _fetch() {
|
||||
return ref.read(stockOutRepositoryProvider).list(
|
||||
status: _status.isEmpty ? null : _status,
|
||||
startDate: _startDate,
|
||||
endDate: _endDate,
|
||||
page: _page,
|
||||
);
|
||||
}
|
||||
|
||||
void setPage(int page) {
|
||||
_page = page;
|
||||
reload();
|
||||
}
|
||||
|
||||
void setStatus(String status) {
|
||||
_status = status;
|
||||
_page = 1;
|
||||
reload();
|
||||
}
|
||||
|
||||
void setDateRange(String? startDate, String? endDate) {
|
||||
_startDate = startDate;
|
||||
_endDate = endDate;
|
||||
_page = 1;
|
||||
reload();
|
||||
}
|
||||
|
||||
void reload() {
|
||||
state = const AsyncValue.loading();
|
||||
_fetch().then(
|
||||
(result) => state = AsyncValue.data(result),
|
||||
onError: (e, st) => state = AsyncValue.error(e, st),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> createOrder(Map<String, dynamic> data) async {
|
||||
await ref.read(stockOutRepositoryProvider).create(data);
|
||||
reload();
|
||||
}
|
||||
|
||||
Future<void> submitOrder(int id) async {
|
||||
await ref.read(stockOutRepositoryProvider).submit(id);
|
||||
reload();
|
||||
}
|
||||
|
||||
Future<void> approveOrder(int id) async {
|
||||
await ref.read(stockOutRepositoryProvider).approve(id);
|
||||
reload();
|
||||
}
|
||||
|
||||
Future<void> rejectOrder(int id) async {
|
||||
await ref.read(stockOutRepositoryProvider).reject(id);
|
||||
reload();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../core/api/api_client.dart';
|
||||
import '../core/auth/auth_state.dart';
|
||||
import '../models/warehouse.dart';
|
||||
import '../repositories/warehouse_repository.dart';
|
||||
|
||||
final warehouseRepositoryProvider = Provider<WarehouseRepository>((ref) {
|
||||
return WarehouseRepository(ref.watch(apiClientProvider));
|
||||
});
|
||||
|
||||
final warehouseListProvider =
|
||||
AsyncNotifierProvider<WarehouseListNotifier, List<Warehouse>>(
|
||||
WarehouseListNotifier.new,
|
||||
);
|
||||
|
||||
class WarehouseListNotifier extends AsyncNotifier<List<Warehouse>> {
|
||||
@override
|
||||
Future<List<Warehouse>> build() {
|
||||
ref.watch(authStateProvider.select((s) => s.user?.shopId));
|
||||
return ref.read(warehouseRepositoryProvider).list();
|
||||
}
|
||||
|
||||
Future<void> reload() async {
|
||||
state = const AsyncValue.loading();
|
||||
state = await AsyncValue.guard(
|
||||
() => ref.read(warehouseRepositoryProvider).list());
|
||||
}
|
||||
|
||||
Future<void> createWarehouse(Map<String, dynamic> data) async {
|
||||
await ref.read(warehouseRepositoryProvider).create(data);
|
||||
await reload();
|
||||
}
|
||||
|
||||
Future<void> updateWarehouse(int id, Map<String, dynamic> data) async {
|
||||
await ref.read(warehouseRepositoryProvider).update(id, data);
|
||||
await reload();
|
||||
}
|
||||
|
||||
Future<void> deleteWarehouse(int id) async {
|
||||
await ref.read(warehouseRepositoryProvider).delete(id);
|
||||
await reload();
|
||||
}
|
||||
}
|
||||
@@ -11,16 +11,16 @@ class AuthException implements Exception {
|
||||
|
||||
class AuthRepository {
|
||||
/// POST /api/v1/auth/login
|
||||
/// Request: { hotel_code, username, password }
|
||||
/// Request: { shop_code, username, password }
|
||||
/// Response: { data: { access_token, refresh_token, expires_in, user: { id, username, real_name, role } } }
|
||||
static Future<AuthUser> login({
|
||||
required String hotelCode,
|
||||
required String shopCode,
|
||||
required String username,
|
||||
required String password,
|
||||
}) async {
|
||||
try {
|
||||
final resp = await PublicApiClient.post('/auth/login', data: {
|
||||
'hotel_code': hotelCode,
|
||||
'shop_code': shopCode,
|
||||
'username': username,
|
||||
'password': password,
|
||||
});
|
||||
@@ -33,8 +33,8 @@ class AuthRepository {
|
||||
refreshToken: data['refresh_token'] as String,
|
||||
username: user['username'] as String,
|
||||
realName: user['real_name'] as String? ?? username,
|
||||
hotelNo: hotelCode,
|
||||
hotelId: (user['id'] as num).toInt(),
|
||||
shopNo: shopCode,
|
||||
shopId: (data['shop_id'] as num).toInt(),
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
final msg = e.response?.data?['error'] as String?;
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import '../core/api/api_client.dart';
|
||||
import '../core/exceptions.dart';
|
||||
import '../core/models/page_result.dart';
|
||||
import '../models/inventory.dart';
|
||||
|
||||
class InventoryRepository {
|
||||
final ApiClient _client;
|
||||
|
||||
const InventoryRepository(this._client);
|
||||
|
||||
Future<PageResult<Inventory>> listInventory({
|
||||
int? warehouseId,
|
||||
String? keyword,
|
||||
int page = 1,
|
||||
int pageSize = 50,
|
||||
}) async {
|
||||
try {
|
||||
final params = <String, dynamic>{
|
||||
'page': page,
|
||||
'page_size': pageSize,
|
||||
if (warehouseId != null) 'warehouse_id': warehouseId,
|
||||
if (keyword != null && keyword.isNotEmpty) 'keyword': keyword,
|
||||
};
|
||||
final resp = await _client.get('/inventory', params: params);
|
||||
return PageResult.fromJson(
|
||||
resp.data as Map<String, dynamic>,
|
||||
Inventory.fromJson,
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
throw AppException(
|
||||
e.response?.data?['error'] as String? ?? '获取库存失败',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<PageResult<InventoryLog>> listLogs({
|
||||
int? warehouseId,
|
||||
int? productId,
|
||||
int page = 1,
|
||||
int pageSize = 50,
|
||||
}) async {
|
||||
try {
|
||||
final params = <String, dynamic>{
|
||||
'page': page,
|
||||
'page_size': pageSize,
|
||||
if (warehouseId != null) 'warehouse_id': warehouseId,
|
||||
if (productId != null) 'product_id': productId,
|
||||
};
|
||||
final resp = await _client.get('/inventory/logs', params: params);
|
||||
return PageResult.fromJson(
|
||||
resp.data as Map<String, dynamic>,
|
||||
InventoryLog.fromJson,
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
throw AppException(
|
||||
e.response?.data?['error'] as String? ?? '获取流水失败',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import '../core/api/api_client.dart';
|
||||
import '../core/exceptions.dart';
|
||||
import '../core/models/page_result.dart';
|
||||
import '../models/partner.dart';
|
||||
|
||||
class PartnerRepository {
|
||||
final ApiClient _client;
|
||||
|
||||
const PartnerRepository(this._client);
|
||||
|
||||
Future<PageResult<Partner>> list({
|
||||
String? type,
|
||||
String? keyword,
|
||||
int page = 1,
|
||||
int pageSize = 20,
|
||||
}) async {
|
||||
try {
|
||||
final params = <String, dynamic>{
|
||||
'page': page,
|
||||
'page_size': pageSize,
|
||||
if (type != null && type.isNotEmpty) 'type': type,
|
||||
if (keyword != null && keyword.isNotEmpty) 'keyword': keyword,
|
||||
};
|
||||
final resp = await _client.get('/partners', params: params);
|
||||
return PageResult.fromJson(
|
||||
resp.data as Map<String, dynamic>,
|
||||
Partner.fromJson,
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
throw AppException(
|
||||
e.response?.data?['error'] as String? ?? '获取往来单位列表失败',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Partner> create(Map<String, dynamic> data) async {
|
||||
try {
|
||||
final resp = await _client.post('/partners', data: data);
|
||||
return Partner.fromJson(
|
||||
(resp.data as Map<String, dynamic>)['data'] as Map<String, dynamic>);
|
||||
} on DioException catch (e) {
|
||||
throw AppException(
|
||||
e.response?.data?['error'] as String? ?? '创建往来单位失败',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Partner> update(int id, Map<String, dynamic> data) async {
|
||||
try {
|
||||
final resp = await _client.put('/partners/$id', data: data);
|
||||
return Partner.fromJson(
|
||||
(resp.data as Map<String, dynamic>)['data'] as Map<String, dynamic>);
|
||||
} on DioException catch (e) {
|
||||
throw AppException(
|
||||
e.response?.data?['error'] as String? ?? '更新往来单位失败',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> delete(int id) async {
|
||||
try {
|
||||
await _client.delete('/partners/$id');
|
||||
} on DioException catch (e) {
|
||||
throw AppException(
|
||||
e.response?.data?['error'] as String? ?? '删除往来单位失败',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import '../core/api/api_client.dart';
|
||||
import '../core/exceptions.dart';
|
||||
import '../core/models/page_result.dart';
|
||||
import '../models/product.dart';
|
||||
|
||||
class ProductRepository {
|
||||
final ApiClient _client;
|
||||
|
||||
const ProductRepository(this._client);
|
||||
|
||||
Future<PageResult<Product>> list({
|
||||
int page = 1,
|
||||
int pageSize = 20,
|
||||
String? keyword,
|
||||
int? categoryId,
|
||||
}) async {
|
||||
try {
|
||||
final params = <String, dynamic>{
|
||||
'page': page,
|
||||
'page_size': pageSize,
|
||||
if (keyword != null && keyword.isNotEmpty) 'keyword': keyword,
|
||||
if (categoryId != null) 'category_id': categoryId,
|
||||
};
|
||||
final resp = await _client.get('/products', params: params);
|
||||
return PageResult.fromJson(
|
||||
resp.data as Map<String, dynamic>,
|
||||
Product.fromJson,
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
throw AppException(
|
||||
e.response?.data?['error'] as String? ?? '获取商品列表失败',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Product> create(Map<String, dynamic> data) async {
|
||||
try {
|
||||
final resp = await _client.post('/products', data: data);
|
||||
return Product.fromJson(
|
||||
(resp.data as Map<String, dynamic>)['data'] as Map<String, dynamic>);
|
||||
} on DioException catch (e) {
|
||||
throw AppException(
|
||||
e.response?.data?['error'] as String? ?? '创建商品失败',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Product> update(int id, Map<String, dynamic> data) async {
|
||||
try {
|
||||
final resp = await _client.put('/products/$id', data: data);
|
||||
return Product.fromJson(
|
||||
(resp.data as Map<String, dynamic>)['data'] as Map<String, dynamic>);
|
||||
} on DioException catch (e) {
|
||||
throw AppException(
|
||||
e.response?.data?['error'] as String? ?? '更新商品失败',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> delete(int id) async {
|
||||
try {
|
||||
await _client.delete('/products/$id');
|
||||
} on DioException catch (e) {
|
||||
throw AppException(
|
||||
e.response?.data?['error'] as String? ?? '删除商品失败',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import '../core/api/api_client.dart';
|
||||
import '../core/exceptions.dart';
|
||||
import '../core/models/page_result.dart';
|
||||
import '../models/stock_in.dart';
|
||||
|
||||
class StockInRepository {
|
||||
final ApiClient _client;
|
||||
|
||||
const StockInRepository(this._client);
|
||||
|
||||
Future<PageResult<StockInOrder>> list({
|
||||
String? status,
|
||||
String? startDate,
|
||||
String? endDate,
|
||||
int page = 1,
|
||||
int pageSize = 20,
|
||||
}) async {
|
||||
try {
|
||||
final params = <String, dynamic>{
|
||||
'page': page,
|
||||
'page_size': pageSize,
|
||||
if (status != null && status.isNotEmpty) 'status': status,
|
||||
if (startDate != null) 'start_date': startDate,
|
||||
if (endDate != null) 'end_date': endDate,
|
||||
};
|
||||
final resp = await _client.get('/stock-in/orders', params: params);
|
||||
return PageResult.fromJson(
|
||||
resp.data as Map<String, dynamic>,
|
||||
StockInOrder.fromJson,
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
throw AppException(
|
||||
e.response?.data?['error'] as String? ?? '获取入库单列表失败',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<StockInOrder> get(int id) async {
|
||||
try {
|
||||
final resp = await _client.get('/stock-in/orders/$id');
|
||||
return StockInOrder.fromJson(
|
||||
(resp.data as Map<String, dynamic>)['data'] as Map<String, dynamic>);
|
||||
} on DioException catch (e) {
|
||||
throw AppException(
|
||||
e.response?.data?['error'] as String? ?? '获取入库单详情失败',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<StockInOrder> create(Map<String, dynamic> data) async {
|
||||
try {
|
||||
final resp = await _client.post('/stock-in/orders', data: data);
|
||||
return StockInOrder.fromJson(
|
||||
(resp.data as Map<String, dynamic>)['data'] as Map<String, dynamic>);
|
||||
} on DioException catch (e) {
|
||||
throw AppException(
|
||||
e.response?.data?['error'] as String? ?? '创建入库单失败',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> submit(int id) async {
|
||||
try {
|
||||
await _client.put('/stock-in/orders/$id/submit');
|
||||
} on DioException catch (e) {
|
||||
throw AppException(
|
||||
e.response?.data?['error'] as String? ?? '提交审核失败',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> approve(int id) async {
|
||||
try {
|
||||
await _client.put('/stock-in/orders/$id/approve');
|
||||
} on DioException catch (e) {
|
||||
throw AppException(
|
||||
e.response?.data?['error'] as String? ?? '审核通过失败',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> reject(int id) async {
|
||||
try {
|
||||
await _client.put('/stock-in/orders/$id/reject');
|
||||
} on DioException catch (e) {
|
||||
throw AppException(
|
||||
e.response?.data?['error'] as String? ?? '拒绝失败',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import '../core/api/api_client.dart';
|
||||
import '../core/exceptions.dart';
|
||||
import '../core/models/page_result.dart';
|
||||
import '../models/stock_out.dart';
|
||||
|
||||
class StockOutRepository {
|
||||
final ApiClient _client;
|
||||
|
||||
const StockOutRepository(this._client);
|
||||
|
||||
Future<PageResult<StockOutOrder>> list({
|
||||
String? status,
|
||||
String? startDate,
|
||||
String? endDate,
|
||||
int page = 1,
|
||||
int pageSize = 20,
|
||||
}) async {
|
||||
try {
|
||||
final params = <String, dynamic>{
|
||||
'page': page,
|
||||
'page_size': pageSize,
|
||||
if (status != null && status.isNotEmpty) 'status': status,
|
||||
if (startDate != null) 'start_date': startDate,
|
||||
if (endDate != null) 'end_date': endDate,
|
||||
};
|
||||
final resp = await _client.get('/stock-out/orders', params: params);
|
||||
return PageResult.fromJson(
|
||||
resp.data as Map<String, dynamic>,
|
||||
StockOutOrder.fromJson,
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
throw AppException(
|
||||
e.response?.data?['error'] as String? ?? '获取出库单列表失败',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<StockOutOrder> get(int id) async {
|
||||
try {
|
||||
final resp = await _client.get('/stock-out/orders/$id');
|
||||
return StockOutOrder.fromJson(
|
||||
(resp.data as Map<String, dynamic>)['data'] as Map<String, dynamic>);
|
||||
} on DioException catch (e) {
|
||||
throw AppException(
|
||||
e.response?.data?['error'] as String? ?? '获取出库单详情失败',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<StockOutOrder> create(Map<String, dynamic> data) async {
|
||||
try {
|
||||
final resp = await _client.post('/stock-out/orders', data: data);
|
||||
return StockOutOrder.fromJson(
|
||||
(resp.data as Map<String, dynamic>)['data'] as Map<String, dynamic>);
|
||||
} on DioException catch (e) {
|
||||
throw AppException(
|
||||
e.response?.data?['error'] as String? ?? '创建出库单失败',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> submit(int id) async {
|
||||
try {
|
||||
await _client.put('/stock-out/orders/$id/submit');
|
||||
} on DioException catch (e) {
|
||||
throw AppException(
|
||||
e.response?.data?['error'] as String? ?? '提交审核失败',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> approve(int id) async {
|
||||
try {
|
||||
await _client.put('/stock-out/orders/$id/approve');
|
||||
} on DioException catch (e) {
|
||||
throw AppException(
|
||||
e.response?.data?['error'] as String? ?? '审核通过失败',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> reject(int id) async {
|
||||
try {
|
||||
await _client.put('/stock-out/orders/$id/reject');
|
||||
} on DioException catch (e) {
|
||||
throw AppException(
|
||||
e.response?.data?['error'] as String? ?? '拒绝失败',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import '../core/api/api_client.dart';
|
||||
import '../core/exceptions.dart';
|
||||
import '../models/warehouse.dart';
|
||||
|
||||
class WarehouseRepository {
|
||||
final ApiClient _client;
|
||||
|
||||
const WarehouseRepository(this._client);
|
||||
|
||||
Future<List<Warehouse>> list() async {
|
||||
try {
|
||||
final resp = await _client.get('/warehouses');
|
||||
final body = resp.data as Map<String, dynamic>;
|
||||
final raw = body['data'] as List;
|
||||
return raw
|
||||
.map((e) => Warehouse.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
} on DioException catch (e) {
|
||||
throw AppException(
|
||||
e.response?.data?['error'] as String? ?? '获取仓库列表失败',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Warehouse> create(Map<String, dynamic> data) async {
|
||||
try {
|
||||
final resp = await _client.post('/warehouses', data: data);
|
||||
return Warehouse.fromJson(
|
||||
(resp.data as Map<String, dynamic>)['data'] as Map<String, dynamic>);
|
||||
} on DioException catch (e) {
|
||||
throw AppException(
|
||||
e.response?.data?['error'] as String? ?? '创建仓库失败',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Warehouse> update(int id, Map<String, dynamic> data) async {
|
||||
try {
|
||||
final resp = await _client.put('/warehouses/$id', data: data);
|
||||
return Warehouse.fromJson(
|
||||
(resp.data as Map<String, dynamic>)['data'] as Map<String, dynamic>);
|
||||
} on DioException catch (e) {
|
||||
throw AppException(
|
||||
e.response?.data?['error'] as String? ?? '更新仓库失败',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> delete(int id) async {
|
||||
try {
|
||||
await _client.delete('/warehouses/$id');
|
||||
} on DioException catch (e) {
|
||||
throw AppException(
|
||||
e.response?.data?['error'] as String? ?? '删除仓库失败',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ class LoginScreen extends ConsumerStatefulWidget {
|
||||
|
||||
class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _hotelCodeCtrl = TextEditingController();
|
||||
final _shopCodeCtrl = TextEditingController();
|
||||
final _usernameCtrl = TextEditingController();
|
||||
final _passwordCtrl = TextEditingController();
|
||||
final _hotelCodeFocus = FocusNode();
|
||||
@@ -42,7 +42,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
_loadHistory();
|
||||
_hotelCodeFocus.addListener(() {
|
||||
if (_hotelCodeFocus.hasFocus && _hotelCodeHistory.isNotEmpty) {
|
||||
_openDropdown(_hotelLayerLink, _hotelCodeHistory, _hotelCodeCtrl,
|
||||
_openDropdown(_hotelLayerLink, _hotelCodeHistory, _shopCodeCtrl,
|
||||
isHotel: true);
|
||||
} else if (!_hotelCodeFocus.hasFocus) {
|
||||
_closeHotel();
|
||||
@@ -68,7 +68,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
});
|
||||
// If a field is already focused, open its dropdown now that history loaded
|
||||
if (_hotelCodeFocus.hasFocus && hotels.isNotEmpty) {
|
||||
_openDropdown(_hotelLayerLink, hotels, _hotelCodeCtrl, isHotel: true);
|
||||
_openDropdown(_hotelLayerLink, hotels, _shopCodeCtrl, isHotel: true);
|
||||
}
|
||||
if (_usernameFocus.hasFocus && users.isNotEmpty) {
|
||||
_openDropdown(_usernameLayerLink, users, _usernameCtrl, isHotel: false);
|
||||
@@ -174,7 +174,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
void dispose() {
|
||||
_hotelEntry?.remove();
|
||||
_usernameEntry?.remove();
|
||||
_hotelCodeCtrl.dispose();
|
||||
_shopCodeCtrl.dispose();
|
||||
_usernameCtrl.dispose();
|
||||
_passwordCtrl.dispose();
|
||||
_hotelCodeFocus.dispose();
|
||||
@@ -189,17 +189,21 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
_errorMessage = null;
|
||||
});
|
||||
try {
|
||||
debugPrint('[Login] calling AuthRepository.login...');
|
||||
final user = await AuthRepository.login(
|
||||
hotelCode: _hotelCodeCtrl.text.trim(),
|
||||
shopCode: _shopCodeCtrl.text.trim(),
|
||||
username: _usernameCtrl.text.trim(),
|
||||
password: _passwordCtrl.text,
|
||||
);
|
||||
debugPrint('[Login] API success, username=${user.username}');
|
||||
await LoginHistoryStorage.record(
|
||||
_hotelCodeCtrl.text.trim(),
|
||||
_shopCodeCtrl.text.trim(),
|
||||
_usernameCtrl.text.trim(),
|
||||
);
|
||||
await ref.read(authStateProvider.notifier).login(user);
|
||||
debugPrint('[Login] notifier.login done, mounted=$mounted, going to /stock-in');
|
||||
if (mounted) context.go('/stock-in');
|
||||
debugPrint('[Login] context.go called');
|
||||
} on AuthException catch (e) {
|
||||
setState(() => _errorMessage = e.message);
|
||||
} catch (e) {
|
||||
@@ -283,11 +287,11 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Hotel code field
|
||||
// Shop code field
|
||||
CompositedTransformTarget(
|
||||
link: _hotelLayerLink,
|
||||
child: TextFormField(
|
||||
controller: _hotelCodeCtrl,
|
||||
controller: _shopCodeCtrl,
|
||||
focusNode: _hotelCodeFocus,
|
||||
decoration: InputDecoration(
|
||||
labelText: '门店编号',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,12 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../widgets/page_scaffold.dart';
|
||||
import '../../widgets/data_table_card.dart';
|
||||
import '../../core/models/page_result.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../widgets/form_dialog.dart';
|
||||
import '../../models/partner.dart';
|
||||
import '../../providers/partner_provider.dart';
|
||||
import '../../widgets/data_table_card.dart';
|
||||
import '../../widgets/page_scaffold.dart';
|
||||
|
||||
class PartnersScreen extends ConsumerStatefulWidget {
|
||||
const PartnersScreen({super.key});
|
||||
@@ -13,147 +16,17 @@ class PartnersScreen extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _PartnersScreenState extends ConsumerState<PartnersScreen> {
|
||||
int _suppliersPage = 1;
|
||||
int _customersPage = 1;
|
||||
final _searchCtrl = TextEditingController();
|
||||
|
||||
final List<Map<String, dynamic>> _suppliers = [
|
||||
{
|
||||
'code': 'GYS001',
|
||||
'name': '贵州茅台酒股份有限公司',
|
||||
'type': '白酒厂商',
|
||||
'contact': '王建国',
|
||||
'phone': '0851-22886688',
|
||||
'address': '贵州省仁怀市茅台镇',
|
||||
'balance': '-86000.00',
|
||||
'status': '合作中',
|
||||
'creditDays': 30,
|
||||
},
|
||||
{
|
||||
'code': 'GYS002',
|
||||
'name': '四川五粮液股份有限公司',
|
||||
'type': '白酒厂商',
|
||||
'contact': '李明华',
|
||||
'phone': '0831-12345678',
|
||||
'address': '四川省宜宾市翠屏区',
|
||||
'balance': '-42500.00',
|
||||
'status': '合作中',
|
||||
'creditDays': 30,
|
||||
},
|
||||
{
|
||||
'code': 'GYS003',
|
||||
'name': '江苏洋河酒厂股份有限公司',
|
||||
'type': '白酒厂商',
|
||||
'contact': '张秀英',
|
||||
'phone': '0527-83999999',
|
||||
'address': '江苏省宿迁市宿城区',
|
||||
'balance': '0.00',
|
||||
'status': '合作中',
|
||||
'creditDays': 45,
|
||||
},
|
||||
{
|
||||
'code': 'GYS004',
|
||||
'name': '剑南春(集团)有限责任公司',
|
||||
'type': '白酒厂商',
|
||||
'contact': '陈志强',
|
||||
'phone': '0838-88888888',
|
||||
'address': '四川省德阳市绵竹市',
|
||||
'balance': '-28600.00',
|
||||
'status': '合作中',
|
||||
'creditDays': 30,
|
||||
},
|
||||
{
|
||||
'code': 'GYS005',
|
||||
'name': '泸州老窖股份有限公司',
|
||||
'type': '白酒厂商',
|
||||
'contact': '赵丽红',
|
||||
'phone': '0830-12233456',
|
||||
'address': '四川省泸州市江阳区',
|
||||
'balance': '-15200.00',
|
||||
'status': '合作中',
|
||||
'creditDays': 30,
|
||||
},
|
||||
{
|
||||
'code': 'GYS006',
|
||||
'name': '法国拉菲集团中国总代理',
|
||||
'type': '葡萄酒进口商',
|
||||
'contact': 'Pierre Liu',
|
||||
'phone': '021-61234567',
|
||||
'address': '上海市黄浦区外滩18号',
|
||||
'balance': '-124800.00',
|
||||
'status': '合作中',
|
||||
'creditDays': 60,
|
||||
},
|
||||
{
|
||||
'code': 'GYS007',
|
||||
'name': '人头马轩尼诗(中国)有限公司',
|
||||
'type': '洋酒进口商',
|
||||
'contact': '刘经理',
|
||||
'phone': '021-54321678',
|
||||
'address': '上海市浦东新区',
|
||||
'balance': '-30240.00',
|
||||
'status': '合作中',
|
||||
'creditDays': 60,
|
||||
},
|
||||
{
|
||||
'code': 'GYS008',
|
||||
'name': '山西汾酒集团有限责任公司',
|
||||
'type': '白酒厂商',
|
||||
'contact': '周建明',
|
||||
'phone': '0357-33666888',
|
||||
'address': '山西省吕梁市汾阳市',
|
||||
'balance': '0.00',
|
||||
'status': '暂停合作',
|
||||
'creditDays': 30,
|
||||
},
|
||||
];
|
||||
|
||||
final List<Map<String, dynamic>> _customers = [
|
||||
{
|
||||
'code': 'KH001',
|
||||
'name': '北京国贸大酒店',
|
||||
'type': '五星级酒店',
|
||||
'contact': '采购部',
|
||||
'phone': '010-65051234',
|
||||
'address': '北京市朝阳区建国门外大街1号',
|
||||
'balance': '45000.00',
|
||||
'status': '合作中',
|
||||
},
|
||||
{
|
||||
'code': 'KH002',
|
||||
'name': '上海外滩华尔道夫酒店',
|
||||
'type': '五星级酒店',
|
||||
'contact': '采购经理',
|
||||
'phone': '021-63228888',
|
||||
'address': '上海市黄浦区中山东一路2号',
|
||||
'balance': '0.00',
|
||||
'status': '合作中',
|
||||
},
|
||||
{
|
||||
'code': 'KH003',
|
||||
'name': '广州白云国际会议中心',
|
||||
'type': '会议中心',
|
||||
'contact': '餐饮总监',
|
||||
'phone': '020-86001234',
|
||||
'address': '广州市白云区云城东路1号',
|
||||
'balance': '28600.00',
|
||||
'status': '合作中',
|
||||
},
|
||||
{
|
||||
'code': 'KH004',
|
||||
'name': '深圳湾万象城购物中心',
|
||||
'type': '商超零售',
|
||||
'contact': '王采购',
|
||||
'phone': '0755-82345678',
|
||||
'address': '深圳市南山区望海路购物公园',
|
||||
'balance': '12000.00',
|
||||
'status': '合作中',
|
||||
},
|
||||
];
|
||||
final _supplierSearchCtrl = TextEditingController();
|
||||
final _customerSearchCtrl = TextEditingController();
|
||||
Timer? _supplierDebounce;
|
||||
Timer? _customerDebounce;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchCtrl.dispose();
|
||||
_supplierSearchCtrl.dispose();
|
||||
_customerSearchCtrl.dispose();
|
||||
_supplierDebounce?.cancel();
|
||||
_customerDebounce?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -166,352 +39,436 @@ class _PartnersScreenState extends ConsumerState<PartnersScreen> {
|
||||
Tab(text: '客户'),
|
||||
],
|
||||
tabViews: [
|
||||
_buildSupplierList(),
|
||||
_buildCustomerList(),
|
||||
_buildSupplierTab(),
|
||||
_buildCustomerTab(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSupplierList() {
|
||||
final q = _searchCtrl.text.toLowerCase();
|
||||
final filtered = _suppliers.where((s) {
|
||||
if (q.isEmpty) return true;
|
||||
return (s['name'] as String).toLowerCase().contains(q) ||
|
||||
(s['code'] as String).toLowerCase().contains(q);
|
||||
}).toList();
|
||||
Widget _buildSupplierTab() {
|
||||
final asyncPartners = ref.watch(supplierListProvider);
|
||||
return asyncPartners.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('加载失败:$e',
|
||||
style: const TextStyle(color: AppTheme.danger)),
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton(
|
||||
onPressed: () =>
|
||||
ref.read(supplierListProvider.notifier).reload(),
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
data: (result) => _buildPartnerList(
|
||||
result,
|
||||
isSupplier: true,
|
||||
searchCtrl: _supplierSearchCtrl,
|
||||
onSearchChanged: (v) {
|
||||
_supplierDebounce?.cancel();
|
||||
_supplierDebounce = Timer(const Duration(milliseconds: 300), () {
|
||||
ref.read(supplierListProvider.notifier).setKeyword(v);
|
||||
});
|
||||
},
|
||||
onPageChanged: (p) =>
|
||||
ref.read(supplierListProvider.notifier).setPage(p),
|
||||
onAdd: () => _showPartnerDialog(context, isSupplier: true),
|
||||
onEdit: (p) =>
|
||||
_showPartnerDialog(context, isSupplier: true, partner: p),
|
||||
onDelete: (p) => _confirmDelete(context, p, isSupplier: true),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCustomerTab() {
|
||||
final asyncPartners = ref.watch(customerListProvider);
|
||||
return asyncPartners.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('加载失败:$e',
|
||||
style: const TextStyle(color: AppTheme.danger)),
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton(
|
||||
onPressed: () =>
|
||||
ref.read(customerListProvider.notifier).reload(),
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
data: (result) => _buildPartnerList(
|
||||
result,
|
||||
isSupplier: false,
|
||||
searchCtrl: _customerSearchCtrl,
|
||||
onSearchChanged: (v) {
|
||||
_customerDebounce?.cancel();
|
||||
_customerDebounce = Timer(const Duration(milliseconds: 300), () {
|
||||
ref.read(customerListProvider.notifier).setKeyword(v);
|
||||
});
|
||||
},
|
||||
onPageChanged: (p) =>
|
||||
ref.read(customerListProvider.notifier).setPage(p),
|
||||
onAdd: () => _showPartnerDialog(context, isSupplier: false),
|
||||
onEdit: (p) =>
|
||||
_showPartnerDialog(context, isSupplier: false, partner: p),
|
||||
onDelete: (p) => _confirmDelete(context, p, isSupplier: false),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPartnerList(
|
||||
PageResult<Partner> result, {
|
||||
required bool isSupplier,
|
||||
required TextEditingController searchCtrl,
|
||||
required void Function(String) onSearchChanged,
|
||||
required void Function(int) onPageChanged,
|
||||
required VoidCallback onAdd,
|
||||
required void Function(Partner) onEdit,
|
||||
required void Function(Partner) onDelete,
|
||||
}) {
|
||||
final partners = result.data;
|
||||
return DataTableCard(
|
||||
totalCount: filtered.length,
|
||||
page: _suppliersPage,
|
||||
onPageChanged: (p) => setState(() => _suppliersPage = p),
|
||||
totalCount: result.total,
|
||||
page: result.page,
|
||||
onPageChanged: onPageChanged,
|
||||
toolbar: Row(
|
||||
children: [
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => _showAddPartnerDialog(context, isSupplier: true),
|
||||
onPressed: onAdd,
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: const Text('新增供应商'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {},
|
||||
icon: const Icon(Icons.file_download_outlined, size: 16),
|
||||
label: const Text('导出'),
|
||||
label: Text(isSupplier ? '新建' : '新建'),
|
||||
),
|
||||
const Spacer(),
|
||||
SizedBox(
|
||||
width: 200,
|
||||
child: TextField(
|
||||
controller: _searchCtrl,
|
||||
decoration: const InputDecoration(
|
||||
hintText: '搜索供应商名称/编码',
|
||||
prefixIcon: Icon(Icons.search, size: 16),
|
||||
hintStyle: TextStyle(fontSize: 13),
|
||||
controller: searchCtrl,
|
||||
decoration: InputDecoration(
|
||||
hintText: isSupplier ? '搜索供应商名称/编码' : '搜索客户名称/编码',
|
||||
prefixIcon: const Icon(Icons.search, size: 16),
|
||||
hintStyle: const TextStyle(fontSize: 13),
|
||||
),
|
||||
onChanged: (_) => setState(() {}),
|
||||
onChanged: onSearchChanged,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
columns: const [
|
||||
DataColumn(label: Text('供应商编码')),
|
||||
DataColumn(label: Text('供应商名称')),
|
||||
DataColumn(label: Text('类型')),
|
||||
DataColumn(label: Text('联系人')),
|
||||
DataColumn(label: Text('联系电话')),
|
||||
DataColumn(label: Text('账期(天)'), numeric: true),
|
||||
DataColumn(label: Text('应付余额'), numeric: true),
|
||||
DataColumn(label: Text('状态')),
|
||||
DataColumn(label: Text('操作')),
|
||||
columns: [
|
||||
const DataColumn(label: Text('编码')),
|
||||
const DataColumn(label: Text('名称')),
|
||||
const DataColumn(label: Text('联系人')),
|
||||
const DataColumn(label: Text('联系电话')),
|
||||
const DataColumn(label: Text('地址')),
|
||||
const DataColumn(label: Text('操作')),
|
||||
],
|
||||
rows: filtered
|
||||
.map((s) => DataRow(cells: [
|
||||
DataCell(Text(s['code'] as String,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
color: AppTheme.textSecondary))),
|
||||
DataCell(SizedBox(
|
||||
width: 180,
|
||||
child: Text(s['name'] as String,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
)),
|
||||
DataCell(Text(s['type'] as String)),
|
||||
DataCell(Text(s['contact'] as String)),
|
||||
DataCell(Text(s['phone'] as String)),
|
||||
DataCell(Text('${s['creditDays']}')),
|
||||
DataCell(Text(
|
||||
'¥${s['balance']}',
|
||||
style: TextStyle(
|
||||
color: (s['balance'] as String).startsWith('-')
|
||||
? AppTheme.danger
|
||||
: AppTheme.textPrimary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
)),
|
||||
DataCell(_StatusChip(s['status'] as String)),
|
||||
DataCell(Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () {},
|
||||
child: const Text('查看',
|
||||
style: TextStyle(fontSize: 12))),
|
||||
TextButton(
|
||||
onPressed: () {},
|
||||
child: const Text('编辑',
|
||||
style: TextStyle(fontSize: 12))),
|
||||
TextButton(
|
||||
onPressed: () {},
|
||||
child: const Text('对账',
|
||||
style: TextStyle(fontSize: 12))),
|
||||
],
|
||||
)),
|
||||
]))
|
||||
.toList(),
|
||||
rows: partners.isEmpty
|
||||
? [
|
||||
DataRow(cells: [
|
||||
const DataCell(SizedBox()),
|
||||
DataCell(Text(isSupplier ? '暂无供应商' : '暂无客户',
|
||||
style:
|
||||
const TextStyle(color: AppTheme.textSecondary))),
|
||||
const DataCell(SizedBox()),
|
||||
const DataCell(SizedBox()),
|
||||
const DataCell(SizedBox()),
|
||||
const DataCell(SizedBox()),
|
||||
])
|
||||
]
|
||||
: partners
|
||||
.map((p) => DataRow(
|
||||
cells: [
|
||||
DataCell(Text(p.code ?? '-',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
color: AppTheme.textSecondary))),
|
||||
DataCell(SizedBox(
|
||||
width: 180,
|
||||
child: Text(p.name,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w500)),
|
||||
)),
|
||||
DataCell(Text(p.contact ?? '-')),
|
||||
DataCell(Text(p.phone ?? '-')),
|
||||
DataCell(SizedBox(
|
||||
width: 160,
|
||||
child: Text(p.address ?? '-',
|
||||
overflow: TextOverflow.ellipsis),
|
||||
)),
|
||||
DataCell(Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextButton(
|
||||
key: Key('btn_edit_${p.id}'),
|
||||
onPressed: () => onEdit(p),
|
||||
child: const Text('编辑',
|
||||
style: TextStyle(fontSize: 12)),
|
||||
),
|
||||
TextButton(
|
||||
key: Key('btn_delete_${p.id}'),
|
||||
onPressed: () => onDelete(p),
|
||||
child: const Text('删除',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.danger)),
|
||||
),
|
||||
],
|
||||
)),
|
||||
],
|
||||
))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCustomerList() {
|
||||
return DataTableCard(
|
||||
totalCount: _customers.length,
|
||||
page: _customersPage,
|
||||
onPageChanged: (p) => setState(() => _customersPage = p),
|
||||
toolbar: Row(
|
||||
children: [
|
||||
ElevatedButton.icon(
|
||||
onPressed: () =>
|
||||
_showAddPartnerDialog(context, isSupplier: false),
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: const Text('新增客户'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {},
|
||||
icon: const Icon(Icons.file_download_outlined, size: 16),
|
||||
label: const Text('导出'),
|
||||
),
|
||||
const Spacer(),
|
||||
SizedBox(
|
||||
width: 200,
|
||||
child: TextField(
|
||||
decoration: const InputDecoration(
|
||||
hintText: '搜索客户名称/编码',
|
||||
prefixIcon: Icon(Icons.search, size: 16),
|
||||
hintStyle: TextStyle(fontSize: 13),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
columns: const [
|
||||
DataColumn(label: Text('客户编码')),
|
||||
DataColumn(label: Text('客户名称')),
|
||||
DataColumn(label: Text('类型')),
|
||||
DataColumn(label: Text('联系人')),
|
||||
DataColumn(label: Text('联系电话')),
|
||||
DataColumn(label: Text('应收余额'), numeric: true),
|
||||
DataColumn(label: Text('状态')),
|
||||
DataColumn(label: Text('操作')),
|
||||
],
|
||||
rows: _customers
|
||||
.map((c) => DataRow(cells: [
|
||||
DataCell(Text(c['code'] as String,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
color: AppTheme.textSecondary))),
|
||||
DataCell(SizedBox(
|
||||
width: 180,
|
||||
child: Text(c['name'] as String,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
)),
|
||||
DataCell(Text(c['type'] as String)),
|
||||
DataCell(Text(c['contact'] as String)),
|
||||
DataCell(Text(c['phone'] as String)),
|
||||
DataCell(Text(
|
||||
'¥${c['balance']}',
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
)),
|
||||
DataCell(_StatusChip(c['status'] as String)),
|
||||
DataCell(Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () {},
|
||||
child: const Text('查看',
|
||||
style: TextStyle(fontSize: 12))),
|
||||
TextButton(
|
||||
onPressed: () {},
|
||||
child: const Text('编辑',
|
||||
style: TextStyle(fontSize: 12))),
|
||||
],
|
||||
)),
|
||||
]))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
void _showAddPartnerDialog(BuildContext context, {required bool isSupplier}) {
|
||||
final nameCtrl = TextEditingController();
|
||||
final contactCtrl = TextEditingController();
|
||||
final phoneCtrl = TextEditingController();
|
||||
final addressCtrl = TextEditingController();
|
||||
|
||||
void _showPartnerDialog(BuildContext context,
|
||||
{required bool isSupplier, Partner? partner}) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => FormDialog(
|
||||
title: isSupplier ? '新增供应商' : '新增客户',
|
||||
width: 520,
|
||||
onConfirm: () {
|
||||
Navigator.of(ctx).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(isSupplier ? '供应商添加成功' : '客户添加成功'),
|
||||
backgroundColor: AppTheme.success,
|
||||
),
|
||||
);
|
||||
builder: (ctx) => _PartnerFormDialog(
|
||||
isSupplier: isSupplier,
|
||||
partner: partner,
|
||||
onSaved: () {
|
||||
if (isSupplier) {
|
||||
ref.read(supplierListProvider.notifier).reload();
|
||||
} else {
|
||||
ref.read(customerListProvider.notifier).reload();
|
||||
}
|
||||
},
|
||||
content: Column(
|
||||
children: [
|
||||
_DialogField(
|
||||
label: isSupplier ? '供应商名称' : '客户名称',
|
||||
required: true,
|
||||
child: TextFormField(
|
||||
controller: nameCtrl,
|
||||
decoration: InputDecoration(
|
||||
hintText: isSupplier ? '请输入供应商全称' : '请输入客户名称',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _confirmDelete(BuildContext context, Partner partner,
|
||||
{required bool isSupplier}) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('确认删除'),
|
||||
content: Text('确认删除「${partner.name}」?此操作不可恢复。'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.danger,
|
||||
foregroundColor: Colors.white),
|
||||
child: const Text('删除'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed == true && mounted) {
|
||||
try {
|
||||
if (isSupplier) {
|
||||
await ref
|
||||
.read(supplierListProvider.notifier)
|
||||
.deletePartner(partner.id);
|
||||
} else {
|
||||
await ref
|
||||
.read(customerListProvider.notifier)
|
||||
.deletePartner(partner.id);
|
||||
}
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||
content: Text('删除成功'),
|
||||
backgroundColor: AppTheme.success));
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text('删除失败:$e'),
|
||||
backgroundColor: AppTheme.danger));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _PartnerFormDialog extends ConsumerStatefulWidget {
|
||||
final bool isSupplier;
|
||||
final Partner? partner;
|
||||
final VoidCallback onSaved;
|
||||
|
||||
const _PartnerFormDialog({
|
||||
required this.isSupplier,
|
||||
this.partner,
|
||||
required this.onSaved,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<_PartnerFormDialog> createState() =>
|
||||
_PartnerFormDialogState();
|
||||
}
|
||||
|
||||
class _PartnerFormDialogState extends ConsumerState<_PartnerFormDialog> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
late final TextEditingController _nameCtrl;
|
||||
late final TextEditingController _codeCtrl;
|
||||
late final TextEditingController _contactCtrl;
|
||||
late final TextEditingController _phoneCtrl;
|
||||
late final TextEditingController _addressCtrl;
|
||||
late final TextEditingController _remarkCtrl;
|
||||
bool _saving = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final p = widget.partner;
|
||||
_nameCtrl = TextEditingController(text: p?.name ?? '');
|
||||
_codeCtrl = TextEditingController(text: p?.code ?? '');
|
||||
_contactCtrl = TextEditingController(text: p?.contact ?? '');
|
||||
_phoneCtrl = TextEditingController(text: p?.phone ?? '');
|
||||
_addressCtrl = TextEditingController(text: p?.address ?? '');
|
||||
_remarkCtrl = TextEditingController(text: p?.remark ?? '');
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameCtrl.dispose();
|
||||
_codeCtrl.dispose();
|
||||
_contactCtrl.dispose();
|
||||
_phoneCtrl.dispose();
|
||||
_addressCtrl.dispose();
|
||||
_remarkCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
setState(() => _saving = true);
|
||||
final data = {
|
||||
'name': _nameCtrl.text.trim(),
|
||||
'type': widget.isSupplier ? 'supplier' : 'customer',
|
||||
if (_codeCtrl.text.trim().isNotEmpty) 'code': _codeCtrl.text.trim(),
|
||||
if (_contactCtrl.text.trim().isNotEmpty)
|
||||
'contact': _contactCtrl.text.trim(),
|
||||
if (_phoneCtrl.text.trim().isNotEmpty) 'phone': _phoneCtrl.text.trim(),
|
||||
if (_addressCtrl.text.trim().isNotEmpty)
|
||||
'address': _addressCtrl.text.trim(),
|
||||
if (_remarkCtrl.text.trim().isNotEmpty) 'remark': _remarkCtrl.text.trim(),
|
||||
};
|
||||
try {
|
||||
final notifier = widget.isSupplier
|
||||
? ref.read(supplierListProvider.notifier)
|
||||
: ref.read(customerListProvider.notifier);
|
||||
if (widget.partner != null) {
|
||||
await notifier.updatePartner(widget.partner!.id, data);
|
||||
} else {
|
||||
await notifier.createPartner(data);
|
||||
}
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop();
|
||||
widget.onSaved();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(widget.partner != null ? '更新成功' : '创建成功'),
|
||||
backgroundColor: AppTheme.success,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('保存失败:$e'),
|
||||
backgroundColor: AppTheme.danger),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isEdit = widget.partner != null;
|
||||
final typeName = widget.isSupplier ? '供应商' : '客户';
|
||||
return Dialog(
|
||||
child: Container(
|
||||
width: 520,
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(isEdit ? '编辑$typeName' : '新建$typeName',
|
||||
style: const TextStyle(
|
||||
fontSize: 18, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 20),
|
||||
TextFormField(
|
||||
controller: _nameCtrl,
|
||||
decoration: InputDecoration(labelText: '$typeName名称'),
|
||||
validator: (v) =>
|
||||
(v == null || v.isEmpty) ? '不能为空' : null,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
Row(children: [
|
||||
Expanded(
|
||||
child: _DialogField(
|
||||
label: '联系人',
|
||||
child: TextFormField(
|
||||
controller: contactCtrl,
|
||||
decoration: const InputDecoration(hintText: '联系人姓名'),
|
||||
),
|
||||
child: TextFormField(
|
||||
controller: _codeCtrl,
|
||||
decoration: const InputDecoration(labelText: '编码'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _DialogField(
|
||||
label: '联系电话',
|
||||
child: TextFormField(
|
||||
controller: phoneCtrl,
|
||||
decoration: const InputDecoration(hintText: '手机/座机'),
|
||||
),
|
||||
child: TextFormField(
|
||||
controller: _contactCtrl,
|
||||
decoration: const InputDecoration(labelText: '联系人'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_DialogField(
|
||||
label: '地址',
|
||||
child: TextFormField(
|
||||
controller: addressCtrl,
|
||||
decoration: const InputDecoration(hintText: '详细地址'),
|
||||
),
|
||||
),
|
||||
if (isSupplier) ...[
|
||||
]),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _phoneCtrl,
|
||||
decoration: const InputDecoration(labelText: '联系电话'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _addressCtrl,
|
||||
decoration: const InputDecoration(labelText: '地址'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _remarkCtrl,
|
||||
decoration: const InputDecoration(labelText: '备注'),
|
||||
maxLines: 2,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _DialogField(
|
||||
label: '账期(天)',
|
||||
child: TextFormField(
|
||||
initialValue: '30',
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(hintText: '结款账期'),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _DialogField(
|
||||
label: '供应商类型',
|
||||
child: DropdownButtonFormField<String>(
|
||||
value: '白酒厂商',
|
||||
items: ['白酒厂商', '葡萄酒进口商', '洋酒进口商', '啤酒厂商', '其他']
|
||||
.map((s) => DropdownMenuItem(
|
||||
value: s,
|
||||
child: Text(s,
|
||||
style: const TextStyle(fontSize: 13))))
|
||||
.toList(),
|
||||
onChanged: (_) {},
|
||||
decoration: const InputDecoration(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton(
|
||||
onPressed: _saving ? null : _save,
|
||||
child: _saving
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: Colors.white))
|
||||
: const Text('保存'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DialogField extends StatelessWidget {
|
||||
final String label;
|
||||
final Widget child;
|
||||
final bool required;
|
||||
|
||||
const _DialogField({
|
||||
required this.label,
|
||||
required this.child,
|
||||
this.required = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
if (required)
|
||||
const Text('* ',
|
||||
style: TextStyle(color: AppTheme.danger, fontSize: 13)),
|
||||
Text(label,
|
||||
style: const TextStyle(
|
||||
fontSize: 13, color: AppTheme.textSecondary)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
child,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusChip extends StatelessWidget {
|
||||
final String status;
|
||||
const _StatusChip(this.status);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bool isActive = status == '合作中';
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: isActive
|
||||
? const Color(0xFFE8F5E9)
|
||||
: const Color(0xFFF5F5F5),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
status,
|
||||
style: TextStyle(
|
||||
color: isActive ? AppTheme.success : AppTheme.textSecondary,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../models/warehouse.dart';
|
||||
import '../../providers/warehouse_provider.dart';
|
||||
|
||||
class SettingsScreen extends ConsumerStatefulWidget {
|
||||
const SettingsScreen({super.key});
|
||||
@@ -144,12 +146,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
}
|
||||
|
||||
Widget _buildWarehousesTab() {
|
||||
final warehouses = [
|
||||
{'code': 'WH001', 'name': '主仓库', 'location': '一楼东侧', 'manager': '张三', 'capacity': 1000, 'used': 680, 'status': true},
|
||||
{'code': 'WH002', 'name': '副仓库', 'location': '二楼西侧', 'manager': '李四', 'capacity': 500, 'used': 210, 'status': true},
|
||||
{'code': 'WH003', 'name': '保税仓库', 'location': '地下一层', 'manager': '王五', 'capacity': 300, 'used': 0, 'status': false},
|
||||
];
|
||||
|
||||
final asyncWarehouses = ref.watch(warehouseListProvider);
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
@@ -159,87 +156,144 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
child: Row(
|
||||
children: [
|
||||
ElevatedButton.icon(
|
||||
onPressed: () {},
|
||||
onPressed: () => _showWarehouseDialog(context),
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: const Text('新增仓库'),
|
||||
label: const Text('新建'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: DataTable(
|
||||
headingRowColor: WidgetStateProperty.all(const Color(0xFFF0F4FF)),
|
||||
columns: const [
|
||||
DataColumn(label: Text('仓库编号')),
|
||||
DataColumn(label: Text('仓库名称')),
|
||||
DataColumn(label: Text('位置')),
|
||||
DataColumn(label: Text('负责人')),
|
||||
DataColumn(label: Text('使用情况')),
|
||||
DataColumn(label: Text('状态')),
|
||||
DataColumn(label: Text('操作')),
|
||||
],
|
||||
rows: warehouses
|
||||
.map((w) => DataRow(cells: [
|
||||
DataCell(Text(w['code'] as String,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace', fontSize: 12))),
|
||||
DataCell(Text(w['name'] as String,
|
||||
style: const TextStyle(fontWeight: FontWeight.w500))),
|
||||
DataCell(Text(w['location'] as String)),
|
||||
DataCell(Text(w['manager'] as String)),
|
||||
DataCell(SizedBox(
|
||||
width: 140,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${w['used']}/${w['capacity']}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.textSecondary),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
LinearProgressIndicator(
|
||||
value: (w['capacity'] as int) > 0
|
||||
? (w['used'] as int) / (w['capacity'] as int)
|
||||
: 0,
|
||||
backgroundColor: AppTheme.border,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
(w['used'] as int) / (w['capacity'] as int) > 0.8
|
||||
? AppTheme.danger
|
||||
: AppTheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)),
|
||||
DataCell(Switch(
|
||||
value: w['status'] as bool,
|
||||
onChanged: (_) {},
|
||||
materialTapTargetSize:
|
||||
MaterialTapTargetSize.shrinkWrap,
|
||||
)),
|
||||
DataCell(Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () {},
|
||||
child: const Text('编辑',
|
||||
style: TextStyle(fontSize: 12))),
|
||||
],
|
||||
)),
|
||||
]))
|
||||
.toList(),
|
||||
child: asyncWarehouses.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('加载失败:$e',
|
||||
style: const TextStyle(color: AppTheme.danger)),
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton(
|
||||
onPressed: () =>
|
||||
ref.read(warehouseListProvider.notifier).reload(),
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
data: (warehouses) {
|
||||
if (warehouses.isEmpty) {
|
||||
return const Center(
|
||||
child: Text('暂无仓库',
|
||||
style:
|
||||
TextStyle(color: AppTheme.textSecondary)));
|
||||
}
|
||||
return SingleChildScrollView(
|
||||
child: DataTable(
|
||||
headingRowColor:
|
||||
WidgetStateProperty.all(const Color(0xFFF0F4FF)),
|
||||
columns: const [
|
||||
DataColumn(label: Text('仓库名称')),
|
||||
DataColumn(label: Text('位置')),
|
||||
DataColumn(label: Text('默认仓库')),
|
||||
DataColumn(label: Text('操作')),
|
||||
],
|
||||
rows: warehouses
|
||||
.map((w) => DataRow(cells: [
|
||||
DataCell(Text(w.name,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w500))),
|
||||
DataCell(Text(w.location ?? '-')),
|
||||
DataCell(w.isDefault
|
||||
? const Icon(Icons.check_circle,
|
||||
color: AppTheme.success, size: 18)
|
||||
: const SizedBox()),
|
||||
DataCell(Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextButton(
|
||||
key: Key('btn_edit_${w.id}'),
|
||||
onPressed: () =>
|
||||
_showWarehouseDialog(context,
|
||||
warehouse: w),
|
||||
child: const Text('编辑',
|
||||
style: TextStyle(fontSize: 12)),
|
||||
),
|
||||
TextButton(
|
||||
key: Key('btn_delete_${w.id}'),
|
||||
onPressed: () =>
|
||||
_confirmDeleteWarehouse(context, w),
|
||||
child: const Text('删除',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.danger)),
|
||||
),
|
||||
],
|
||||
)),
|
||||
]))
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _confirmDeleteWarehouse(
|
||||
BuildContext context, Warehouse w) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('确认删除'),
|
||||
content: Text('确认删除仓库「${w.name}」?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text('取消')),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.danger,
|
||||
foregroundColor: Colors.white),
|
||||
child: const Text('删除'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed == true && mounted) {
|
||||
try {
|
||||
await ref
|
||||
.read(warehouseListProvider.notifier)
|
||||
.deleteWarehouse(w.id);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||
content: Text('删除成功'),
|
||||
backgroundColor: AppTheme.success));
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text('删除失败:$e'),
|
||||
backgroundColor: AppTheme.danger));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showWarehouseDialog(BuildContext context, {Warehouse? warehouse}) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => _WarehouseFormDialog(
|
||||
warehouse: warehouse,
|
||||
onSaved: () =>
|
||||
ref.read(warehouseListProvider.notifier).reload(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNumberRulesTab() {
|
||||
final rules = [
|
||||
{'type': '入库单', 'prefix': 'RK', 'format': 'RK{年}{月}{日}{序号4}', 'example': 'RK20260404001', 'currentNo': 4},
|
||||
@@ -337,12 +391,6 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
_ParamRow(label: '入库单需要审核', value: '是', isSwitch: true),
|
||||
_ParamRow(label: '出库单需要审核', value: '是', isSwitch: true),
|
||||
_ParamRow(label: '允许超量出库', value: '否', isSwitch: false),
|
||||
const SizedBox(height: 16),
|
||||
const Text('库存预警设置',
|
||||
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: AppTheme.primaryDark)),
|
||||
const Divider(height: 24),
|
||||
_ParamRow(label: '启用库存预警', value: '是', isSwitch: true),
|
||||
_ParamRow(label: '预警提醒方式', value: '系统内通知'),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -415,6 +463,133 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
class _WarehouseFormDialog extends ConsumerStatefulWidget {
|
||||
final Warehouse? warehouse;
|
||||
final VoidCallback onSaved;
|
||||
|
||||
const _WarehouseFormDialog({this.warehouse, required this.onSaved});
|
||||
|
||||
@override
|
||||
ConsumerState<_WarehouseFormDialog> createState() =>
|
||||
_WarehouseFormDialogState();
|
||||
}
|
||||
|
||||
class _WarehouseFormDialogState extends ConsumerState<_WarehouseFormDialog> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
late final TextEditingController _nameCtrl;
|
||||
late final TextEditingController _locationCtrl;
|
||||
late bool _isDefault;
|
||||
bool _saving = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_nameCtrl = TextEditingController(text: widget.warehouse?.name ?? '');
|
||||
_locationCtrl =
|
||||
TextEditingController(text: widget.warehouse?.location ?? '');
|
||||
_isDefault = widget.warehouse?.isDefault ?? false;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameCtrl.dispose();
|
||||
_locationCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
setState(() => _saving = true);
|
||||
final data = {
|
||||
'name': _nameCtrl.text.trim(),
|
||||
if (_locationCtrl.text.trim().isNotEmpty)
|
||||
'location': _locationCtrl.text.trim(),
|
||||
'is_default': _isDefault,
|
||||
};
|
||||
try {
|
||||
final notifier = ref.read(warehouseListProvider.notifier);
|
||||
if (widget.warehouse != null) {
|
||||
await notifier.updateWarehouse(widget.warehouse!.id, data);
|
||||
} else {
|
||||
await notifier.createWarehouse(data);
|
||||
}
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop();
|
||||
widget.onSaved();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
widget.warehouse != null ? '仓库更新成功' : '仓库创建成功'),
|
||||
backgroundColor: AppTheme.success,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('保存失败:$e'),
|
||||
backgroundColor: AppTheme.danger),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(widget.warehouse != null ? '编辑仓库' : '新建仓库'),
|
||||
content: SizedBox(
|
||||
width: 400,
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _nameCtrl,
|
||||
decoration: const InputDecoration(labelText: '仓库名称'),
|
||||
validator: (v) =>
|
||||
(v == null || v.isEmpty) ? '不能为空' : null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _locationCtrl,
|
||||
decoration: const InputDecoration(labelText: '位置'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
CheckboxListTile(
|
||||
title: const Text('设为默认仓库'),
|
||||
value: _isDefault,
|
||||
onChanged: (v) => setState(() => _isDefault = v ?? false),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: _saving ? null : _save,
|
||||
child: _saving
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: Colors.white))
|
||||
: const Text('保存'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RoleBadge extends StatelessWidget {
|
||||
final String role;
|
||||
const _RoleBadge(this.role);
|
||||
|
||||
@@ -72,7 +72,7 @@ class _AppShellState extends ConsumerState<AppShell> {
|
||||
const Icon(Icons.business,
|
||||
color: Colors.white70, size: 14),
|
||||
const SizedBox(width: 4),
|
||||
Text(user.hotelNo,
|
||||
Text(user.shopNo,
|
||||
style: const TextStyle(
|
||||
color: Colors.white70, fontSize: 13)),
|
||||
const SizedBox(width: 20),
|
||||
@@ -86,6 +86,11 @@ class _AppShellState extends ConsumerState<AppShell> {
|
||||
PopupMenuButton<String>(
|
||||
icon: const Icon(Icons.keyboard_arrow_down,
|
||||
color: Colors.white70),
|
||||
offset: const Offset(0, 36),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(6)),
|
||||
color: Colors.white,
|
||||
elevation: 8,
|
||||
onSelected: (v) {
|
||||
if (v == 'logout') {
|
||||
ref.read(authStateProvider.notifier).logout();
|
||||
@@ -93,21 +98,24 @@ class _AppShellState extends ConsumerState<AppShell> {
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem(
|
||||
value: 'profile',
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.manage_accounts, size: 18),
|
||||
title: Text('个人设置'),
|
||||
dense: true,
|
||||
)),
|
||||
const PopupMenuDivider(),
|
||||
const PopupMenuItem(
|
||||
value: 'logout',
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.logout, size: 18),
|
||||
title: Text('退出登录'),
|
||||
dense: true,
|
||||
)),
|
||||
const PopupMenuItem<String>(
|
||||
value: 'profile',
|
||||
padding: EdgeInsets.zero,
|
||||
child: _HoverMenuItem(
|
||||
icon: Icons.manage_accounts_outlined,
|
||||
label: '个人设置',
|
||||
),
|
||||
),
|
||||
const PopupMenuDivider(height: 1),
|
||||
const PopupMenuItem<String>(
|
||||
value: 'logout',
|
||||
padding: EdgeInsets.zero,
|
||||
child: _HoverMenuItem(
|
||||
icon: Icons.logout_outlined,
|
||||
label: '退出登录',
|
||||
danger: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
@@ -161,7 +169,7 @@ class _AppShellState extends ConsumerState<AppShell> {
|
||||
if (user != null) ...[
|
||||
_StatusItem(
|
||||
icon: Icons.store,
|
||||
text: '门店编号:${user.hotelNo}'),
|
||||
text: '门店编号:${user.shopNo}'),
|
||||
const _StatusDivider(),
|
||||
_StatusItem(
|
||||
icon: Icons.person,
|
||||
@@ -334,3 +342,61 @@ class _ClockWidgetState extends State<_ClockWidget> {
|
||||
return _StatusItem(icon: Icons.access_time, text: '当前时间:$_time');
|
||||
}
|
||||
}
|
||||
|
||||
class _HoverMenuItem extends StatefulWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final bool danger;
|
||||
|
||||
const _HoverMenuItem({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
this.danger = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_HoverMenuItem> createState() => _HoverMenuItemState();
|
||||
}
|
||||
|
||||
class _HoverMenuItemState extends State<_HoverMenuItem> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = widget.danger
|
||||
? const Color(0xFFE53935)
|
||||
: const Color(0xFF333333);
|
||||
final hoverBg = widget.danger
|
||||
? const Color(0xFFFFF0F0)
|
||||
: const Color(0xFFF0F4FF);
|
||||
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 120),
|
||||
width: 152,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: _hovered ? hoverBg : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(widget.icon, size: 16, color: color),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
widget.label,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: color,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,11 @@ import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../models/product.dart';
|
||||
import '../../providers/partner_provider.dart';
|
||||
import '../../providers/product_provider.dart';
|
||||
import '../../providers/stock_in_provider.dart';
|
||||
import '../../providers/warehouse_provider.dart';
|
||||
|
||||
class StockInFormScreen extends ConsumerStatefulWidget {
|
||||
const StockInFormScreen({super.key});
|
||||
@@ -13,37 +18,25 @@ class StockInFormScreen extends ConsumerStatefulWidget {
|
||||
|
||||
class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _orderNoCtrl = TextEditingController(text: 'RK20260404004');
|
||||
final _remarkCtrl = TextEditingController();
|
||||
String _supplier = '贵州茅台酒股份有限公司';
|
||||
String _warehouse = '主仓库';
|
||||
int? _warehouseId;
|
||||
int? _partnerId;
|
||||
DateTime _orderDate = DateTime.now();
|
||||
bool _submitting = false;
|
||||
|
||||
final List<Map<String, dynamic>> _items = [
|
||||
{
|
||||
'name': '茅台酒(飞天)53度500ml',
|
||||
'spec': '500ml/瓶',
|
||||
'unit': '瓶',
|
||||
'qty': TextEditingController(text: '10'),
|
||||
'price': TextEditingController(text: '2600.00'),
|
||||
},
|
||||
{
|
||||
'name': '五粮液(普五)52度500ml',
|
||||
'spec': '500ml/瓶',
|
||||
'unit': '瓶',
|
||||
'qty': TextEditingController(text: '20'),
|
||||
'price': TextEditingController(text: '1050.00'),
|
||||
},
|
||||
];
|
||||
final List<_ItemRow> _items = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_items.add(_ItemRow());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_orderNoCtrl.dispose();
|
||||
_remarkCtrl.dispose();
|
||||
for (final item in _items) {
|
||||
(item['qty'] as TextEditingController).dispose();
|
||||
(item['price'] as TextEditingController).dispose();
|
||||
item.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
@@ -51,60 +44,94 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
|
||||
double get _totalAmount {
|
||||
double total = 0;
|
||||
for (final item in _items) {
|
||||
final qty = double.tryParse(
|
||||
(item['qty'] as TextEditingController).text) ??
|
||||
0;
|
||||
final price = double.tryParse(
|
||||
(item['price'] as TextEditingController).text) ??
|
||||
0;
|
||||
final qty = double.tryParse(item.qtyCtrl.text) ?? 0;
|
||||
final price = double.tryParse(item.priceCtrl.text) ?? 0;
|
||||
total += qty * price;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
void _addItem() {
|
||||
setState(() {
|
||||
_items.add({
|
||||
'name': '',
|
||||
'spec': '',
|
||||
'unit': '瓶',
|
||||
'qty': TextEditingController(),
|
||||
'price': TextEditingController(),
|
||||
});
|
||||
});
|
||||
setState(() => _items.add(_ItemRow()));
|
||||
}
|
||||
|
||||
void _removeItem(int index) {
|
||||
setState(() {
|
||||
final item = _items.removeAt(index);
|
||||
(item['qty'] as TextEditingController).dispose();
|
||||
(item['price'] as TextEditingController).dispose();
|
||||
_items[index].dispose();
|
||||
_items.removeAt(index);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _submit(bool asDraft) async {
|
||||
if (!asDraft && !_formKey.currentState!.validate()) return;
|
||||
setState(() => _submitting = true);
|
||||
await Future.delayed(const Duration(milliseconds: 600));
|
||||
if (mounted) {
|
||||
if (_warehouseId == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(asDraft ? '已保存为草稿' : '入库单已提交审核'),
|
||||
backgroundColor: AppTheme.success,
|
||||
),
|
||||
const SnackBar(content: Text('请选择入库仓库'), backgroundColor: AppTheme.danger),
|
||||
);
|
||||
context.go('/stock-in');
|
||||
return;
|
||||
}
|
||||
if (_items.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('请添加商品明细'), backgroundColor: AppTheme.danger),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _submitting = true);
|
||||
|
||||
final itemsData = _items.map((item) {
|
||||
final qty = double.tryParse(item.qtyCtrl.text) ?? 0;
|
||||
final price = double.tryParse(item.priceCtrl.text) ?? 0;
|
||||
return {
|
||||
'product_id': item.productId ?? 0,
|
||||
'quantity': qty,
|
||||
'unit_price': price,
|
||||
'total_price': qty * price,
|
||||
};
|
||||
}).toList();
|
||||
|
||||
final data = {
|
||||
'warehouse_id': _warehouseId,
|
||||
if (_partnerId != null) 'partner_id': _partnerId,
|
||||
'order_date':
|
||||
'${_orderDate.year}-${_orderDate.month.toString().padLeft(2, '0')}-${_orderDate.day.toString().padLeft(2, '0')}',
|
||||
if (_remarkCtrl.text.trim().isNotEmpty) 'remark': _remarkCtrl.text.trim(),
|
||||
'items': itemsData,
|
||||
'status': asDraft ? 'draft' : 'pending',
|
||||
};
|
||||
|
||||
try {
|
||||
await ref.read(stockInListProvider.notifier).createOrder(data);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(asDraft ? '已保存为草稿' : '入库单已提交审核'),
|
||||
backgroundColor: AppTheme.success,
|
||||
),
|
||||
);
|
||||
context.go('/stock-in');
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('操作失败:$e'), backgroundColor: AppTheme.danger),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _submitting = false);
|
||||
}
|
||||
if (mounted) setState(() => _submitting = false);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final asyncWarehouses = ref.watch(warehouseListProvider);
|
||||
final asyncSuppliers = ref.watch(supplierListProvider);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppTheme.background,
|
||||
body: Column(
|
||||
children: [
|
||||
// Page header
|
||||
Container(
|
||||
height: 52,
|
||||
color: AppTheme.surface,
|
||||
@@ -155,7 +182,6 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Basic info card
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
@@ -172,71 +198,70 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
|
||||
spacing: 16,
|
||||
runSpacing: 16,
|
||||
children: [
|
||||
_FormField(
|
||||
label: '入库单号',
|
||||
child: TextFormField(
|
||||
controller: _orderNoCtrl,
|
||||
readOnly: true,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace'),
|
||||
decoration: const InputDecoration(
|
||||
suffixIcon: Icon(
|
||||
Icons.autorenew,
|
||||
size: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
_FormField(
|
||||
label: '供应商',
|
||||
required: true,
|
||||
child: DropdownButtonFormField<String>(
|
||||
value: _supplier,
|
||||
items: [
|
||||
'贵州茅台酒股份有限公司',
|
||||
'四川五粮液股份有限公司',
|
||||
'江苏洋河酒厂股份有限公司',
|
||||
'剑南春(集团)有限责任公司',
|
||||
'泸州老窖股份有限公司',
|
||||
'山西汾酒股份有限公司',
|
||||
]
|
||||
.map((s) => DropdownMenuItem(
|
||||
value: s,
|
||||
child: Text(s,
|
||||
style: const TextStyle(
|
||||
fontSize: 13))))
|
||||
.toList(),
|
||||
onChanged: (v) =>
|
||||
setState(() => _supplier = v!),
|
||||
validator: (v) => v == null || v.isEmpty
|
||||
? '请选择供应商'
|
||||
: null,
|
||||
decoration: const InputDecoration(),
|
||||
),
|
||||
),
|
||||
// Warehouse dropdown
|
||||
_FormField(
|
||||
label: '入库仓库',
|
||||
required: true,
|
||||
child: DropdownButtonFormField<String>(
|
||||
value: _warehouse,
|
||||
items: ['主仓库', '副仓库', '保税仓库']
|
||||
.map((s) => DropdownMenuItem(
|
||||
value: s,
|
||||
child: Text(s,
|
||||
style: const TextStyle(
|
||||
fontSize: 13))))
|
||||
.toList(),
|
||||
onChanged: (v) =>
|
||||
setState(() => _warehouse = v!),
|
||||
decoration: const InputDecoration(),
|
||||
child: asyncWarehouses.when(
|
||||
loading: () =>
|
||||
const LinearProgressIndicator(),
|
||||
error: (_, __) => const Text('加载失败'),
|
||||
data: (warehouses) =>
|
||||
DropdownButtonFormField<int>(
|
||||
value: _warehouseId,
|
||||
hint: const Text('请选择仓库',
|
||||
style:
|
||||
TextStyle(fontSize: 13)),
|
||||
items: warehouses
|
||||
.map((w) => DropdownMenuItem(
|
||||
value: w.id,
|
||||
child: Text(w.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 13))))
|
||||
.toList(),
|
||||
onChanged: (v) => setState(
|
||||
() => _warehouseId = v),
|
||||
validator: (v) =>
|
||||
v == null ? '不能为空' : null,
|
||||
decoration: const InputDecoration(),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Partner dropdown
|
||||
_FormField(
|
||||
label: '供应商',
|
||||
child: asyncSuppliers.when(
|
||||
loading: () =>
|
||||
const LinearProgressIndicator(),
|
||||
error: (_, __) => const Text('加载失败'),
|
||||
data: (result) =>
|
||||
DropdownButtonFormField<int>(
|
||||
value: _partnerId,
|
||||
hint: const Text('请选择供应商',
|
||||
style:
|
||||
TextStyle(fontSize: 13)),
|
||||
items: result.data
|
||||
.map((p) => DropdownMenuItem(
|
||||
value: p.id,
|
||||
child: Text(p.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 13))))
|
||||
.toList(),
|
||||
onChanged: (v) => setState(
|
||||
() => _partnerId = v),
|
||||
decoration: const InputDecoration(),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Date picker
|
||||
_FormField(
|
||||
label: '入库日期',
|
||||
required: true,
|
||||
child: InkWell(
|
||||
onTap: _pickDate,
|
||||
child: InputDecorator(
|
||||
decoration: const InputDecoration(),
|
||||
decoration:
|
||||
const InputDecoration(),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
@@ -249,7 +274,8 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
|
||||
const Icon(
|
||||
Icons.calendar_today,
|
||||
size: 16,
|
||||
color: AppTheme.textSecondary),
|
||||
color:
|
||||
AppTheme.textSecondary),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -274,7 +300,6 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Items card
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
@@ -299,17 +324,14 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Items table
|
||||
Table(
|
||||
columnWidths: const {
|
||||
0: FixedColumnWidth(36),
|
||||
1: FlexColumnWidth(3),
|
||||
2: FlexColumnWidth(2),
|
||||
3: FixedColumnWidth(60),
|
||||
2: FlexColumnWidth(1.5),
|
||||
3: FlexColumnWidth(1.5),
|
||||
4: FlexColumnWidth(1.5),
|
||||
5: FlexColumnWidth(1.5),
|
||||
6: FlexColumnWidth(1.5),
|
||||
7: FixedColumnWidth(60),
|
||||
5: FixedColumnWidth(60),
|
||||
},
|
||||
children: [
|
||||
TableRow(
|
||||
@@ -317,23 +339,24 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
|
||||
color: Color(0xFFF0F4FF)),
|
||||
children: [
|
||||
'序号',
|
||||
'商品名称',
|
||||
'规格',
|
||||
'单位',
|
||||
'商品',
|
||||
'数量',
|
||||
'单价',
|
||||
'金额',
|
||||
'操作',
|
||||
]
|
||||
.map((h) => Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 10),
|
||||
padding:
|
||||
const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 10),
|
||||
child: Text(h,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color:
|
||||
AppTheme.primaryDark)),
|
||||
fontWeight:
|
||||
FontWeight.w600,
|
||||
color: AppTheme
|
||||
.primaryDark)),
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
@@ -343,7 +366,6 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
|
||||
],
|
||||
),
|
||||
const Divider(height: 1),
|
||||
// Total
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: Row(
|
||||
@@ -379,10 +401,9 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
|
||||
|
||||
TableRow _buildItemRow(int index) {
|
||||
final item = _items[index];
|
||||
final qtyCtrl = item['qty'] as TextEditingController;
|
||||
final priceCtrl = item['price'] as TextEditingController;
|
||||
final qty = double.tryParse(qtyCtrl.text) ?? 0;
|
||||
final price = double.tryParse(priceCtrl.text) ?? 0;
|
||||
final asyncProducts = ref.watch(productListProvider);
|
||||
final qty = double.tryParse(item.qtyCtrl.text) ?? 0;
|
||||
final price = double.tryParse(item.priceCtrl.text) ?? 0;
|
||||
final amount = qty * price;
|
||||
|
||||
return TableRow(
|
||||
@@ -390,90 +411,89 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
|
||||
color: index.isEven ? Colors.white : const Color(0xFFFAFAFA),
|
||||
),
|
||||
children: [
|
||||
// Index
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
child: Text('${index + 1}',
|
||||
style: const TextStyle(
|
||||
fontSize: 13, color: AppTheme.textSecondary)),
|
||||
),
|
||||
// Name
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: asyncProducts.when(
|
||||
loading: () => const LinearProgressIndicator(),
|
||||
error: (_, __) => const Text('加载失败'),
|
||||
data: (result) => DropdownButtonFormField<int>(
|
||||
value: item.productId,
|
||||
hint: const Text('选择商品',
|
||||
style: TextStyle(fontSize: 13)),
|
||||
items: result.data
|
||||
.map((p) => DropdownMenuItem(
|
||||
value: p.id,
|
||||
child: Text('${p.name}(${p.spec ?? p.unit})',
|
||||
style: const TextStyle(fontSize: 13))))
|
||||
.toList(),
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
item.productId = v;
|
||||
// Auto-fill price if available
|
||||
if (v != null) {
|
||||
final found = result.data.firstWhere(
|
||||
(p) => p.id == v,
|
||||
orElse: () => Product(
|
||||
id: 0, code: '', name: '', unit: ''));
|
||||
if (found.purchasePrice != null) {
|
||||
item.priceCtrl.text =
|
||||
found.purchasePrice!.toStringAsFixed(2);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
validator: (v) => v == null ? '不能为空' : null,
|
||||
decoration: const InputDecoration(),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: TextFormField(
|
||||
initialValue: item['name'] as String,
|
||||
decoration:
|
||||
const InputDecoration(hintText: '商品名称'),
|
||||
style: const TextStyle(fontSize: 13),
|
||||
onChanged: (v) => item['name'] = v,
|
||||
validator: (v) =>
|
||||
(v == null || v.isEmpty) ? '必填' : null,
|
||||
),
|
||||
),
|
||||
// Spec
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: TextFormField(
|
||||
initialValue: item['spec'] as String,
|
||||
decoration: const InputDecoration(hintText: '规格'),
|
||||
style: const TextStyle(fontSize: 13),
|
||||
onChanged: (v) => item['spec'] = v,
|
||||
),
|
||||
),
|
||||
// Unit
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: DropdownButtonFormField<String>(
|
||||
value: item['unit'] as String,
|
||||
items: ['瓶', '箱', '件', '桶', '支']
|
||||
.map((u) => DropdownMenuItem(
|
||||
value: u,
|
||||
child: Text(u, style: const TextStyle(fontSize: 13))))
|
||||
.toList(),
|
||||
onChanged: (v) => setState(() => item['unit'] = v!),
|
||||
decoration: const InputDecoration(),
|
||||
),
|
||||
),
|
||||
// Qty
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: TextFormField(
|
||||
controller: qtyCtrl,
|
||||
controller: item.qtyCtrl,
|
||||
decoration: const InputDecoration(hintText: '0'),
|
||||
style: const TextStyle(fontSize: 13),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
keyboardType:
|
||||
const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}'))
|
||||
FilteringTextInputFormatter.allow(
|
||||
RegExp(r'^\d+\.?\d{0,2}'))
|
||||
],
|
||||
onChanged: (_) => setState(() {}),
|
||||
validator: (v) {
|
||||
if (v == null || v.isEmpty) return '必填';
|
||||
if (v == null || v.isEmpty) return '不能为空';
|
||||
if ((double.tryParse(v) ?? 0) <= 0) return '>0';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
// Price
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: TextFormField(
|
||||
controller: priceCtrl,
|
||||
controller: item.priceCtrl,
|
||||
decoration: const InputDecoration(
|
||||
hintText: '0.00', prefixText: '¥'),
|
||||
style: const TextStyle(fontSize: 13),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
keyboardType:
|
||||
const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}'))
|
||||
FilteringTextInputFormatter.allow(
|
||||
RegExp(r'^\d+\.?\d{0,2}'))
|
||||
],
|
||||
onChanged: (_) => setState(() {}),
|
||||
validator: (v) {
|
||||
if (v == null || v.isEmpty) return '必填';
|
||||
if (v == null || v.isEmpty) return '不能为空';
|
||||
if ((double.tryParse(v) ?? 0) <= 0) return '>0';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
// Amount
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
child: Text(
|
||||
@@ -482,13 +502,13 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
|
||||
fontSize: 13, fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
// Actions
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.delete_outline,
|
||||
size: 18, color: AppTheme.danger),
|
||||
onPressed: _items.length > 1 ? () => _removeItem(index) : null,
|
||||
onPressed:
|
||||
_items.length > 1 ? () => _removeItem(index) : null,
|
||||
tooltip: '删除',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints:
|
||||
@@ -510,6 +530,17 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
class _ItemRow {
|
||||
int? productId;
|
||||
final TextEditingController qtyCtrl = TextEditingController();
|
||||
final TextEditingController priceCtrl = TextEditingController();
|
||||
|
||||
void dispose() {
|
||||
qtyCtrl.dispose();
|
||||
priceCtrl.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class _FormField extends StatelessWidget {
|
||||
final String label;
|
||||
final Widget child;
|
||||
@@ -534,7 +565,8 @@ class _FormField extends StatelessWidget {
|
||||
children: [
|
||||
if (required)
|
||||
const Text('*',
|
||||
style: TextStyle(color: AppTheme.danger, fontSize: 13)),
|
||||
style:
|
||||
TextStyle(color: AppTheme.danger, fontSize: 13)),
|
||||
Text(label,
|
||||
style: const TextStyle(
|
||||
fontSize: 13, color: AppTheme.textSecondary)),
|
||||
|
||||
@@ -1,144 +1,43 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../widgets/page_scaffold.dart';
|
||||
import '../../widgets/data_table_card.dart';
|
||||
import '../../widgets/status_badge.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../models/stock_in.dart';
|
||||
import '../../providers/stock_in_provider.dart';
|
||||
import '../../widgets/data_table_card.dart';
|
||||
import '../../widgets/page_scaffold.dart';
|
||||
import '../../widgets/status_badge.dart';
|
||||
|
||||
class StockInListScreen extends ConsumerStatefulWidget {
|
||||
const StockInListScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<StockInListScreen> createState() =>
|
||||
_StockInListScreenState();
|
||||
ConsumerState<StockInListScreen> createState() => _StockInListScreenState();
|
||||
}
|
||||
|
||||
class _StockInListScreenState extends ConsumerState<StockInListScreen> {
|
||||
int _page = 1;
|
||||
final _searchCtrl = TextEditingController();
|
||||
String _statusFilter = '全部';
|
||||
String _statusFilter = '';
|
||||
DateTimeRange? _dateRange;
|
||||
|
||||
final List<Map<String, dynamic>> _mockOrders = [
|
||||
{
|
||||
'no': 'RK20260401001',
|
||||
'supplier': '贵州茅台酒股份有限公司',
|
||||
'warehouse': '主仓库',
|
||||
'amount': '85000.00',
|
||||
'status': OrderStatus.approved,
|
||||
'creator': '张三',
|
||||
'date': '2026-04-01',
|
||||
},
|
||||
{
|
||||
'no': 'RK20260401002',
|
||||
'supplier': '四川五粮液股份有限公司',
|
||||
'warehouse': '副仓库',
|
||||
'amount': '42500.00',
|
||||
'status': OrderStatus.pending,
|
||||
'creator': '李四',
|
||||
'date': '2026-04-01',
|
||||
},
|
||||
{
|
||||
'no': 'RK20260402001',
|
||||
'supplier': '江苏洋河酒厂股份有限公司',
|
||||
'warehouse': '主仓库',
|
||||
'amount': '36800.00',
|
||||
'status': OrderStatus.approved,
|
||||
'creator': '王五',
|
||||
'date': '2026-04-02',
|
||||
},
|
||||
{
|
||||
'no': 'RK20260402002',
|
||||
'supplier': '剑南春(集团)有限责任公司',
|
||||
'warehouse': '主仓库',
|
||||
'amount': '28600.00',
|
||||
'status': OrderStatus.draft,
|
||||
'creator': '张三',
|
||||
'date': '2026-04-02',
|
||||
},
|
||||
{
|
||||
'no': 'RK20260403001',
|
||||
'supplier': '泸州老窖股份有限公司',
|
||||
'warehouse': '副仓库',
|
||||
'amount': '55200.00',
|
||||
'status': OrderStatus.approved,
|
||||
'creator': '李四',
|
||||
'date': '2026-04-03',
|
||||
},
|
||||
{
|
||||
'no': 'RK20260403002',
|
||||
'supplier': '古井贡酒股份有限公司',
|
||||
'warehouse': '主仓库',
|
||||
'amount': '19800.00',
|
||||
'status': OrderStatus.rejected,
|
||||
'creator': '王五',
|
||||
'date': '2026-04-03',
|
||||
},
|
||||
{
|
||||
'no': 'RK20260403003',
|
||||
'supplier': '贵州茅台酒股份有限公司',
|
||||
'warehouse': '主仓库',
|
||||
'amount': '126000.00',
|
||||
'status': OrderStatus.pending,
|
||||
'creator': '张三',
|
||||
'date': '2026-04-03',
|
||||
},
|
||||
{
|
||||
'no': 'RK20260404001',
|
||||
'supplier': '山西汾酒股份有限公司',
|
||||
'warehouse': '主仓库',
|
||||
'amount': '33600.00',
|
||||
'status': OrderStatus.approved,
|
||||
'creator': '李四',
|
||||
'date': '2026-04-04',
|
||||
},
|
||||
{
|
||||
'no': 'RK20260404002',
|
||||
'supplier': '郎酒股份有限公司',
|
||||
'warehouse': '副仓库',
|
||||
'amount': '47300.00',
|
||||
'status': OrderStatus.draft,
|
||||
'creator': '王五',
|
||||
'date': '2026-04-04',
|
||||
},
|
||||
{
|
||||
'no': 'RK20260404003',
|
||||
'supplier': '四川五粮液股份有限公司',
|
||||
'warehouse': '主仓库',
|
||||
'amount': '68500.00',
|
||||
'status': OrderStatus.pending,
|
||||
'creator': '张三',
|
||||
'date': '2026-04-04',
|
||||
},
|
||||
];
|
||||
String? get _startDate => _dateRange != null
|
||||
? '${_dateRange!.start.year}-${_dateRange!.start.month.toString().padLeft(2, '0')}-${_dateRange!.start.day.toString().padLeft(2, '0')}'
|
||||
: null;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
String? get _endDate => _dateRange != null
|
||||
? '${_dateRange!.end.year}-${_dateRange!.end.month.toString().padLeft(2, '0')}-${_dateRange!.end.day.toString().padLeft(2, '0')}'
|
||||
: null;
|
||||
|
||||
List<Map<String, dynamic>> _filteredOrders({OrderStatus? forceStatus}) {
|
||||
return _mockOrders.where((o) {
|
||||
if (forceStatus != null && o['status'] != forceStatus) return false;
|
||||
if (_statusFilter != '全部' && forceStatus == null) {
|
||||
final map = {
|
||||
'草稿': OrderStatus.draft,
|
||||
'待审核': OrderStatus.pending,
|
||||
'已审核': OrderStatus.approved,
|
||||
'已拒绝': OrderStatus.rejected,
|
||||
};
|
||||
if (o['status'] != map[_statusFilter]) return false;
|
||||
}
|
||||
final q = _searchCtrl.text.toLowerCase();
|
||||
if (q.isNotEmpty) {
|
||||
final no = (o['no'] as String).toLowerCase();
|
||||
final supplier = (o['supplier'] as String).toLowerCase();
|
||||
if (!no.contains(q) && !supplier.contains(q)) return false;
|
||||
}
|
||||
return true;
|
||||
}).toList();
|
||||
OrderStatus _apiStatusToEnum(String status) {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return OrderStatus.pending;
|
||||
case 'approved':
|
||||
return OrderStatus.approved;
|
||||
case 'rejected':
|
||||
return OrderStatus.rejected;
|
||||
default:
|
||||
return OrderStatus.draft;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -147,23 +46,62 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
|
||||
title: '入库管理',
|
||||
tabs: const [
|
||||
Tab(text: '入库单'),
|
||||
Tab(text: '入库查询'),
|
||||
Tab(text: '入库审核'),
|
||||
],
|
||||
tabViews: [
|
||||
_buildOrderList(),
|
||||
_buildQueryView(),
|
||||
_buildOrderList(forceStatus: OrderStatus.pending),
|
||||
_buildListTab(filterStatus: null),
|
||||
_buildListTab(filterStatus: 'pending'),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildOrderList({OrderStatus? forceStatus}) {
|
||||
final orders = _filteredOrders(forceStatus: forceStatus);
|
||||
Widget _buildListTab({String? filterStatus}) {
|
||||
final asyncOrders = ref.watch(stockInListProvider);
|
||||
return asyncOrders.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('加载失败:$e',
|
||||
style: const TextStyle(color: AppTheme.danger)),
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton(
|
||||
onPressed: () =>
|
||||
ref.read(stockInListProvider.notifier).reload(),
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
data: (result) {
|
||||
// Client-side filter for review tab
|
||||
final orders = filterStatus != null
|
||||
? result.data
|
||||
.where((o) => o.status == filterStatus)
|
||||
.toList()
|
||||
: result.data;
|
||||
return _buildOrderTable(
|
||||
orders: orders,
|
||||
totalCount: filterStatus != null ? orders.length : result.total,
|
||||
page: result.page,
|
||||
showStatusFilter: filterStatus == null,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildOrderTable({
|
||||
required List<StockInOrder> orders,
|
||||
required int totalCount,
|
||||
required int page,
|
||||
required bool showStatusFilter,
|
||||
}) {
|
||||
return DataTableCard(
|
||||
totalCount: orders.length,
|
||||
page: _page,
|
||||
onPageChanged: (p) => setState(() => _page = p),
|
||||
totalCount: totalCount,
|
||||
page: page,
|
||||
onPageChanged: (p) =>
|
||||
ref.read(stockInListProvider.notifier).setPage(p),
|
||||
toolbar: Row(
|
||||
children: [
|
||||
ElevatedButton.icon(
|
||||
@@ -171,58 +109,41 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: const Text('新建入库单'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {},
|
||||
icon: const Icon(Icons.file_upload_outlined, size: 16),
|
||||
label: const Text('导入'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {},
|
||||
icon: const Icon(Icons.file_download_outlined, size: 16),
|
||||
label: const Text('导出'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {},
|
||||
icon: const Icon(Icons.print_outlined, size: 16),
|
||||
label: const Text('打印'),
|
||||
),
|
||||
const Spacer(),
|
||||
if (forceStatus == null) ...[
|
||||
if (showStatusFilter) ...[
|
||||
_StatusFilterDropdown(
|
||||
value: _statusFilter,
|
||||
onChanged: (v) => setState(() {
|
||||
_statusFilter = v!;
|
||||
_page = 1;
|
||||
}),
|
||||
onChanged: (v) {
|
||||
setState(() => _statusFilter = v ?? '');
|
||||
ref
|
||||
.read(stockInListProvider.notifier)
|
||||
.setStatus(v ?? '');
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
SizedBox(
|
||||
width: 180,
|
||||
child: TextField(
|
||||
controller: _searchCtrl,
|
||||
decoration: const InputDecoration(
|
||||
hintText: '搜索单号/供应商',
|
||||
prefixIcon: Icon(Icons.search, size: 16),
|
||||
hintStyle: TextStyle(fontSize: 13),
|
||||
),
|
||||
onChanged: (_) => setState(() => _page = 1),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _pickDateRange,
|
||||
icon: const Icon(Icons.date_range, size: 16),
|
||||
label: Text(
|
||||
_dateRange == null
|
||||
? '选择日期'
|
||||
: '${_dateRange!.start.toString().substring(0, 10)} ~ ${_dateRange!.end.toString().substring(0, 10)}',
|
||||
: '$_startDate ~ $_endDate',
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
),
|
||||
if (_dateRange != null) ...[
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.clear, size: 16),
|
||||
onPressed: () {
|
||||
setState(() => _dateRange = null);
|
||||
ref
|
||||
.read(stockInListProvider.notifier)
|
||||
.setDateRange(null, null);
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
columns: const [
|
||||
@@ -231,119 +152,75 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
|
||||
DataColumn(label: Text('仓库')),
|
||||
DataColumn(label: Text('金额'), numeric: true),
|
||||
DataColumn(label: Text('状态')),
|
||||
DataColumn(label: Text('录入人')),
|
||||
DataColumn(label: Text('日期')),
|
||||
DataColumn(label: Text('操作')),
|
||||
],
|
||||
rows: orders
|
||||
.map((o) => DataRow(cells: [
|
||||
DataCell(Text(o['no'] as String,
|
||||
style: const TextStyle(
|
||||
color: AppTheme.primary,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12))),
|
||||
DataCell(Text(o['supplier'] as String)),
|
||||
DataCell(Text(o['warehouse'] as String)),
|
||||
DataCell(Text('¥${o['amount']}')),
|
||||
DataCell(StatusBadge(o['status'] as OrderStatus)),
|
||||
DataCell(Text(o['creator'] as String)),
|
||||
DataCell(Text(o['date'] as String)),
|
||||
DataCell(Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () {},
|
||||
child: const Text('查看',
|
||||
style: TextStyle(fontSize: 12))),
|
||||
if ((o['status'] as OrderStatus) ==
|
||||
OrderStatus.pending)
|
||||
TextButton(
|
||||
onPressed: () {},
|
||||
child: const Text('审核',
|
||||
style: TextStyle(
|
||||
color: AppTheme.success,
|
||||
fontSize: 12))),
|
||||
if ((o['status'] as OrderStatus) ==
|
||||
OrderStatus.draft)
|
||||
TextButton(
|
||||
onPressed: () {},
|
||||
child: const Text('编辑',
|
||||
style: TextStyle(fontSize: 12))),
|
||||
if ((o['status'] as OrderStatus) ==
|
||||
OrderStatus.draft)
|
||||
TextButton(
|
||||
onPressed: () {},
|
||||
child: const Text('删除',
|
||||
style: TextStyle(
|
||||
color: AppTheme.danger,
|
||||
fontSize: 12))),
|
||||
],
|
||||
)),
|
||||
]))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildQueryView() {
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
color: AppTheme.surface,
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
const Text('查询条件:', style: TextStyle(fontSize: 13)),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
width: 200,
|
||||
child: TextField(
|
||||
controller: _searchCtrl,
|
||||
decoration: const InputDecoration(
|
||||
hintText: '单号/供应商',
|
||||
prefixIcon: Icon(Icons.search, size: 16),
|
||||
hintStyle: TextStyle(fontSize: 13),
|
||||
),
|
||||
onChanged: (_) => setState(() => _page = 1),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_StatusFilterDropdown(
|
||||
value: _statusFilter,
|
||||
onChanged: (v) => setState(() {
|
||||
_statusFilter = v!;
|
||||
_page = 1;
|
||||
}),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _pickDateRange,
|
||||
icon: const Icon(Icons.date_range, size: 16),
|
||||
label: Text(
|
||||
_dateRange == null ? '选择日期范围' : '${_dateRange!.start.toString().substring(0, 10)} ~ ${_dateRange!.end.toString().substring(0, 10)}',
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => setState(() {}),
|
||||
icon: const Icon(Icons.search, size: 16),
|
||||
label: const Text('查询'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton(
|
||||
onPressed: () => setState(() {
|
||||
_searchCtrl.clear();
|
||||
_statusFilter = '全部';
|
||||
_dateRange = null;
|
||||
}),
|
||||
child: const Text('重置'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(child: _buildOrderList()),
|
||||
],
|
||||
rows: orders.isEmpty
|
||||
? [
|
||||
const DataRow(cells: [
|
||||
DataCell(SizedBox()),
|
||||
DataCell(Text('暂无入库单',
|
||||
style: TextStyle(color: AppTheme.textSecondary))),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
])
|
||||
]
|
||||
: orders
|
||||
.map((o) => DataRow(
|
||||
cells: [
|
||||
DataCell(Text(o.orderNo,
|
||||
style: const TextStyle(
|
||||
color: AppTheme.primary,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12))),
|
||||
DataCell(Text(o.partnerName ?? '-')),
|
||||
DataCell(Text(o.warehouseName ?? '-')),
|
||||
DataCell(Text(o.totalAmount != null
|
||||
? '¥${o.totalAmount!.toStringAsFixed(2)}'
|
||||
: '-')),
|
||||
DataCell(StatusBadge(
|
||||
_apiStatusToEnum(o.status))),
|
||||
DataCell(Text(o.orderDate?.substring(0, 10) ?? '-')),
|
||||
DataCell(Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (o.status == 'draft')
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
_confirmSubmit(context, o),
|
||||
child: const Text('提交',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.primary)),
|
||||
),
|
||||
if (o.status == 'pending') ...[
|
||||
TextButton(
|
||||
key: Key('btn_approve_${o.id}'),
|
||||
onPressed: () =>
|
||||
_confirmApprove(context, o),
|
||||
child: const Text('通过',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.success)),
|
||||
),
|
||||
TextButton(
|
||||
key: Key('btn_reject_${o.id}'),
|
||||
onPressed: () =>
|
||||
_confirmReject(context, o),
|
||||
child: const Text('拒绝',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.danger)),
|
||||
),
|
||||
],
|
||||
],
|
||||
)),
|
||||
],
|
||||
))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -354,7 +231,126 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
|
||||
lastDate: DateTime(2030),
|
||||
initialDateRange: _dateRange,
|
||||
);
|
||||
if (range != null) setState(() => _dateRange = range);
|
||||
if (range != null) {
|
||||
setState(() => _dateRange = range);
|
||||
ref
|
||||
.read(stockInListProvider.notifier)
|
||||
.setDateRange(_startDate, _endDate);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _confirmSubmit(BuildContext context, StockInOrder o) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('提交审核'),
|
||||
content: Text('确认提交入库单「${o.orderNo}」?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text('取消')),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
child: const Text('提交')),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed == true && mounted) {
|
||||
try {
|
||||
await ref
|
||||
.read(stockInListProvider.notifier)
|
||||
.submitOrder(o.id);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||
content: Text('已提交审核'),
|
||||
backgroundColor: AppTheme.success));
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text('提交失败:$e'),
|
||||
backgroundColor: AppTheme.danger));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _confirmApprove(BuildContext context, StockInOrder o) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('审核确认'),
|
||||
content: Text('确认审核通过入库单「${o.orderNo}」?审核后将入库并增加库存。'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text('取消')),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.success,
|
||||
foregroundColor: Colors.white),
|
||||
child: const Text('通过'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed == true && mounted) {
|
||||
try {
|
||||
await ref
|
||||
.read(stockInListProvider.notifier)
|
||||
.approveOrder(o.id);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||
content: Text('审核通过'), backgroundColor: AppTheme.success));
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text('审核失败:$e'),
|
||||
backgroundColor: AppTheme.danger));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _confirmReject(BuildContext context, StockInOrder o) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('拒绝确认'),
|
||||
content: Text('确认拒绝入库单「${o.orderNo}」?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text('取消')),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.danger,
|
||||
foregroundColor: Colors.white),
|
||||
child: const Text('拒绝'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed == true && mounted) {
|
||||
try {
|
||||
await ref
|
||||
.read(stockInListProvider.notifier)
|
||||
.rejectOrder(o.id);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||
content: Text('已拒绝'), backgroundColor: AppTheme.accent));
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text('操作失败:$e'),
|
||||
backgroundColor: AppTheme.danger));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,15 +375,16 @@ class _StatusFilterDropdown extends StatelessWidget {
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String>(
|
||||
value: value,
|
||||
items: ['全部', '草稿', '待审核', '已审核', '已拒绝']
|
||||
.map((s) => DropdownMenuItem(
|
||||
value: s,
|
||||
child: Text(s, style: const TextStyle(fontSize: 13))))
|
||||
.toList(),
|
||||
value: value.isEmpty ? '' : value,
|
||||
items: [
|
||||
const DropdownMenuItem(value: '', child: Text('全部状态', style: TextStyle(fontSize: 13))),
|
||||
const DropdownMenuItem(value: 'draft', child: Text('草稿', style: TextStyle(fontSize: 13))),
|
||||
const DropdownMenuItem(value: 'pending', child: Text('待审核', style: TextStyle(fontSize: 13))),
|
||||
const DropdownMenuItem(value: 'approved', child: Text('已审核', style: TextStyle(fontSize: 13))),
|
||||
const DropdownMenuItem(value: 'rejected', child: Text('已拒绝', style: TextStyle(fontSize: 13))),
|
||||
],
|
||||
onChanged: onChanged,
|
||||
style: const TextStyle(
|
||||
fontSize: 13, color: AppTheme.textPrimary),
|
||||
style: const TextStyle(fontSize: 13, color: AppTheme.textPrimary),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../widgets/page_scaffold.dart';
|
||||
import '../../widgets/data_table_card.dart';
|
||||
import '../../widgets/status_badge.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../models/stock_out.dart';
|
||||
import '../../providers/stock_out_provider.dart';
|
||||
import '../../widgets/data_table_card.dart';
|
||||
import '../../widgets/page_scaffold.dart';
|
||||
import '../../widgets/status_badge.dart';
|
||||
|
||||
class StockOutListScreen extends ConsumerStatefulWidget {
|
||||
const StockOutListScreen({super.key});
|
||||
@@ -14,128 +16,28 @@ class StockOutListScreen extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
||||
int _page = 1;
|
||||
final _searchCtrl = TextEditingController();
|
||||
String _statusFilter = '全部';
|
||||
String _typeFilter = '全部';
|
||||
String _statusFilter = '';
|
||||
DateTimeRange? _dateRange;
|
||||
|
||||
final List<Map<String, dynamic>> _mockOrders = [
|
||||
{
|
||||
'no': 'CK20260401001',
|
||||
'department': '餐饮部',
|
||||
'purpose': '宴会用酒',
|
||||
'warehouse': '主仓库',
|
||||
'amount': '15600.00',
|
||||
'status': OrderStatus.approved,
|
||||
'creator': '李四',
|
||||
'date': '2026-04-01',
|
||||
'type': '领用',
|
||||
},
|
||||
{
|
||||
'no': 'CK20260401002',
|
||||
'department': '客房部',
|
||||
'purpose': '客房迷你吧补货',
|
||||
'warehouse': '主仓库',
|
||||
'amount': '8200.00',
|
||||
'status': OrderStatus.approved,
|
||||
'creator': '王五',
|
||||
'date': '2026-04-01',
|
||||
'type': '领用',
|
||||
},
|
||||
{
|
||||
'no': 'CK20260402001',
|
||||
'department': '行政部',
|
||||
'purpose': '接待用酒',
|
||||
'warehouse': '副仓库',
|
||||
'amount': '32000.00',
|
||||
'status': OrderStatus.pending,
|
||||
'creator': '张三',
|
||||
'date': '2026-04-02',
|
||||
'type': '调拨',
|
||||
},
|
||||
{
|
||||
'no': 'CK20260402002',
|
||||
'department': '餐饮部',
|
||||
'purpose': '婚宴用酒',
|
||||
'warehouse': '主仓库',
|
||||
'amount': '68500.00',
|
||||
'status': OrderStatus.approved,
|
||||
'creator': '李四',
|
||||
'date': '2026-04-02',
|
||||
'type': '领用',
|
||||
},
|
||||
{
|
||||
'no': 'CK20260403001',
|
||||
'department': '采购部',
|
||||
'purpose': '退货',
|
||||
'warehouse': '副仓库',
|
||||
'amount': '12000.00',
|
||||
'status': OrderStatus.pending,
|
||||
'creator': '王五',
|
||||
'date': '2026-04-03',
|
||||
'type': '退货',
|
||||
},
|
||||
{
|
||||
'no': 'CK20260403002',
|
||||
'department': '餐饮部',
|
||||
'purpose': '散客用酒',
|
||||
'warehouse': '主仓库',
|
||||
'amount': '5400.00',
|
||||
'status': OrderStatus.draft,
|
||||
'creator': '张三',
|
||||
'date': '2026-04-03',
|
||||
'type': '领用',
|
||||
},
|
||||
{
|
||||
'no': 'CK20260404001',
|
||||
'department': '客房部',
|
||||
'purpose': 'VIP客房补充',
|
||||
'warehouse': '主仓库',
|
||||
'amount': '18900.00',
|
||||
'status': OrderStatus.approved,
|
||||
'creator': '李四',
|
||||
'date': '2026-04-04',
|
||||
'type': '领用',
|
||||
},
|
||||
{
|
||||
'no': 'CK20260404002',
|
||||
'department': '行政部',
|
||||
'purpose': '年度总结宴',
|
||||
'warehouse': '主仓库',
|
||||
'amount': '45000.00',
|
||||
'status': OrderStatus.pending,
|
||||
'creator': '王五',
|
||||
'date': '2026-04-04',
|
||||
'type': '领用',
|
||||
},
|
||||
];
|
||||
String? get _startDate => _dateRange != null
|
||||
? '${_dateRange!.start.year}-${_dateRange!.start.month.toString().padLeft(2, '0')}-${_dateRange!.start.day.toString().padLeft(2, '0')}'
|
||||
: null;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
String? get _endDate => _dateRange != null
|
||||
? '${_dateRange!.end.year}-${_dateRange!.end.month.toString().padLeft(2, '0')}-${_dateRange!.end.day.toString().padLeft(2, '0')}'
|
||||
: null;
|
||||
|
||||
List<Map<String, dynamic>> get _filtered {
|
||||
return _mockOrders.where((o) {
|
||||
if (_typeFilter != '全部' && o['type'] != _typeFilter) return false;
|
||||
if (_statusFilter != '全部') {
|
||||
final map = {
|
||||
'草稿': OrderStatus.draft,
|
||||
'待审核': OrderStatus.pending,
|
||||
'已审核': OrderStatus.approved,
|
||||
'已拒绝': OrderStatus.rejected,
|
||||
};
|
||||
if (o['status'] != map[_statusFilter]) return false;
|
||||
}
|
||||
final q = _searchCtrl.text.toLowerCase();
|
||||
if (q.isNotEmpty) {
|
||||
final no = (o['no'] as String).toLowerCase();
|
||||
final dept = (o['department'] as String).toLowerCase();
|
||||
if (!no.contains(q) && !dept.contains(q)) return false;
|
||||
}
|
||||
return true;
|
||||
}).toList();
|
||||
OrderStatus _apiStatusToEnum(String status) {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return OrderStatus.pending;
|
||||
case 'approved':
|
||||
return OrderStatus.approved;
|
||||
case 'rejected':
|
||||
return OrderStatus.rejected;
|
||||
default:
|
||||
return OrderStatus.draft;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -144,28 +46,61 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
||||
title: '出库管理',
|
||||
tabs: const [
|
||||
Tab(text: '出库单'),
|
||||
Tab(text: '出库查询'),
|
||||
Tab(text: '出库审核'),
|
||||
],
|
||||
tabViews: [
|
||||
_buildList(),
|
||||
_buildList(),
|
||||
_buildList(forceStatus: OrderStatus.pending),
|
||||
_buildListTab(filterStatus: null),
|
||||
_buildListTab(filterStatus: 'pending'),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildList({OrderStatus? forceStatus}) {
|
||||
final orders = forceStatus != null
|
||||
? _mockOrders
|
||||
.where((o) => o['status'] == forceStatus)
|
||||
.toList()
|
||||
: _filtered;
|
||||
Widget _buildListTab({String? filterStatus}) {
|
||||
final asyncOrders = ref.watch(stockOutListProvider);
|
||||
return asyncOrders.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('加载失败:$e',
|
||||
style: const TextStyle(color: AppTheme.danger)),
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton(
|
||||
onPressed: () =>
|
||||
ref.read(stockOutListProvider.notifier).reload(),
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
data: (result) {
|
||||
final orders = filterStatus != null
|
||||
? result.data
|
||||
.where((o) => o.status == filterStatus)
|
||||
.toList()
|
||||
: result.data;
|
||||
return _buildOrderTable(
|
||||
orders: orders,
|
||||
totalCount: filterStatus != null ? orders.length : result.total,
|
||||
page: result.page,
|
||||
showStatusFilter: filterStatus == null,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildOrderTable({
|
||||
required List<StockOutOrder> orders,
|
||||
required int totalCount,
|
||||
required int page,
|
||||
required bool showStatusFilter,
|
||||
}) {
|
||||
return DataTableCard(
|
||||
totalCount: orders.length,
|
||||
page: _page,
|
||||
onPageChanged: (p) => setState(() => _page = p),
|
||||
totalCount: totalCount,
|
||||
page: page,
|
||||
onPageChanged: (p) =>
|
||||
ref.read(stockOutListProvider.notifier).setPage(p),
|
||||
toolbar: Row(
|
||||
children: [
|
||||
ElevatedButton.icon(
|
||||
@@ -173,110 +108,136 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: const Text('新建出库单'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {},
|
||||
icon: const Icon(Icons.file_download_outlined, size: 16),
|
||||
label: const Text('导出'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {},
|
||||
icon: const Icon(Icons.print_outlined, size: 16),
|
||||
label: const Text('打印'),
|
||||
),
|
||||
const Spacer(),
|
||||
// Type filter
|
||||
_DropdownFilter(
|
||||
value: _typeFilter,
|
||||
items: ['全部', '领用', '调拨', '退货'],
|
||||
onChanged: (v) => setState(() => _typeFilter = v!),
|
||||
hint: '出库类型',
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (forceStatus == null)
|
||||
_DropdownFilter(
|
||||
if (showStatusFilter) ...[
|
||||
_StatusFilterDropdown(
|
||||
value: _statusFilter,
|
||||
items: ['全部', '草稿', '待审核', '已审核', '已拒绝'],
|
||||
onChanged: (v) => setState(() => _statusFilter = v!),
|
||||
hint: '状态',
|
||||
onChanged: (v) {
|
||||
setState(() => _statusFilter = v ?? '');
|
||||
ref
|
||||
.read(stockOutListProvider.notifier)
|
||||
.setStatus(v ?? '');
|
||||
},
|
||||
),
|
||||
if (forceStatus == null) const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
width: 180,
|
||||
child: TextField(
|
||||
controller: _searchCtrl,
|
||||
decoration: const InputDecoration(
|
||||
hintText: '搜索单号/部门',
|
||||
prefixIcon: Icon(Icons.search, size: 16),
|
||||
hintStyle: TextStyle(fontSize: 13),
|
||||
),
|
||||
onChanged: (_) => setState(() => _page = 1),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
OutlinedButton.icon(
|
||||
onPressed: _pickDateRange,
|
||||
icon: const Icon(Icons.date_range, size: 16),
|
||||
label: Text(
|
||||
_dateRange == null
|
||||
? '选择日期'
|
||||
: '$_startDate ~ $_endDate',
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
),
|
||||
if (_dateRange != null) ...[
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.clear, size: 16),
|
||||
onPressed: () {
|
||||
setState(() => _dateRange = null);
|
||||
ref
|
||||
.read(stockOutListProvider.notifier)
|
||||
.setDateRange(null, null);
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
columns: const [
|
||||
DataColumn(label: Text('出库单号')),
|
||||
DataColumn(label: Text('出库类型')),
|
||||
DataColumn(label: Text('领用部门')),
|
||||
DataColumn(label: Text('用途')),
|
||||
DataColumn(label: Text('客户/往来单位')),
|
||||
DataColumn(label: Text('仓库')),
|
||||
DataColumn(label: Text('金额'), numeric: true),
|
||||
DataColumn(label: Text('状态')),
|
||||
DataColumn(label: Text('日期')),
|
||||
DataColumn(label: Text('操作')),
|
||||
],
|
||||
rows: orders
|
||||
.map((o) => DataRow(cells: [
|
||||
DataCell(Text(o['no'] as String,
|
||||
style: const TextStyle(
|
||||
color: AppTheme.primary,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12))),
|
||||
DataCell(_TypeBadge(o['type'] as String)),
|
||||
DataCell(Text(o['department'] as String)),
|
||||
DataCell(Text(o['purpose'] as String,
|
||||
overflow: TextOverflow.ellipsis)),
|
||||
DataCell(Text(o['warehouse'] as String)),
|
||||
DataCell(Text('¥${o['amount']}')),
|
||||
DataCell(StatusBadge(o['status'] as OrderStatus)),
|
||||
DataCell(Text(o['date'] as String)),
|
||||
DataCell(Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () {},
|
||||
child: const Text('查看',
|
||||
style: TextStyle(fontSize: 12))),
|
||||
if ((o['status'] as OrderStatus) ==
|
||||
OrderStatus.pending)
|
||||
TextButton(
|
||||
onPressed: () {},
|
||||
child: const Text('审核',
|
||||
style: TextStyle(
|
||||
color: AppTheme.success,
|
||||
fontSize: 12))),
|
||||
if ((o['status'] as OrderStatus) ==
|
||||
OrderStatus.draft) ...[
|
||||
TextButton(
|
||||
onPressed: () {},
|
||||
child: const Text('编辑',
|
||||
style: TextStyle(fontSize: 12))),
|
||||
TextButton(
|
||||
onPressed: () {},
|
||||
child: const Text('删除',
|
||||
style: TextStyle(
|
||||
color: AppTheme.danger,
|
||||
fontSize: 12))),
|
||||
rows: orders.isEmpty
|
||||
? [
|
||||
const DataRow(cells: [
|
||||
DataCell(SizedBox()),
|
||||
DataCell(Text('暂无出库单',
|
||||
style: TextStyle(color: AppTheme.textSecondary))),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
])
|
||||
]
|
||||
: orders
|
||||
.map((o) => DataRow(
|
||||
cells: [
|
||||
DataCell(Text(o.orderNo,
|
||||
style: const TextStyle(
|
||||
color: AppTheme.primary,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12))),
|
||||
DataCell(Text(o.partnerName ?? '-')),
|
||||
DataCell(Text(o.warehouseName ?? '-')),
|
||||
DataCell(Text(o.totalAmount != null
|
||||
? '¥${o.totalAmount!.toStringAsFixed(2)}'
|
||||
: '-')),
|
||||
DataCell(StatusBadge(
|
||||
_apiStatusToEnum(o.status))),
|
||||
DataCell(Text(o.orderDate?.substring(0, 10) ?? '-')),
|
||||
DataCell(Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (o.status == 'draft')
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
_confirmSubmit(context, o),
|
||||
child: const Text('提交',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.primary)),
|
||||
),
|
||||
if (o.status == 'pending') ...[
|
||||
TextButton(
|
||||
key: Key('btn_approve_${o.id}'),
|
||||
onPressed: () =>
|
||||
_confirmApprove(context, o),
|
||||
child: const Text('通过',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.success)),
|
||||
),
|
||||
TextButton(
|
||||
key: Key('btn_reject_${o.id}'),
|
||||
onPressed: () =>
|
||||
_confirmReject(context, o),
|
||||
child: const Text('拒绝',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.danger)),
|
||||
),
|
||||
],
|
||||
],
|
||||
)),
|
||||
],
|
||||
],
|
||||
)),
|
||||
]))
|
||||
.toList(),
|
||||
))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pickDateRange() async {
|
||||
final range = await showDateRangePicker(
|
||||
context: context,
|
||||
firstDate: DateTime(2020),
|
||||
lastDate: DateTime(2030),
|
||||
initialDateRange: _dateRange,
|
||||
);
|
||||
if (range != null) {
|
||||
setState(() => _dateRange = range);
|
||||
ref
|
||||
.read(stockOutListProvider.notifier)
|
||||
.setDateRange(_startDate, _endDate);
|
||||
}
|
||||
}
|
||||
|
||||
void _showCreateDialog(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
@@ -284,63 +245,144 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
||||
title: const Text('新建出库单'),
|
||||
content: const SizedBox(
|
||||
width: 400,
|
||||
child: Text('出库单创建功能完整实现请参考入库单流程。'),
|
||||
child: Text('出库单完整创建功能请参考入库单流程。'),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('关闭')),
|
||||
child: const Text('取消')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TypeBadge extends StatelessWidget {
|
||||
final String type;
|
||||
const _TypeBadge(this.type);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final Color color;
|
||||
switch (type) {
|
||||
case '领用':
|
||||
color = AppTheme.primary;
|
||||
break;
|
||||
case '调拨':
|
||||
color = AppTheme.accent;
|
||||
break;
|
||||
case '退货':
|
||||
color = AppTheme.danger;
|
||||
break;
|
||||
default:
|
||||
color = AppTheme.textSecondary;
|
||||
}
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
border: Border.all(color: color.withOpacity(0.3)),
|
||||
Future<void> _confirmSubmit(
|
||||
BuildContext context, StockOutOrder o) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('提交审核'),
|
||||
content: Text('确认提交出库单「${o.orderNo}」?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text('取消')),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
child: const Text('提交')),
|
||||
],
|
||||
),
|
||||
child: Text(type,
|
||||
style: TextStyle(
|
||||
color: color, fontSize: 12, fontWeight: FontWeight.w500)),
|
||||
);
|
||||
if (confirmed == true && mounted) {
|
||||
try {
|
||||
await ref
|
||||
.read(stockOutListProvider.notifier)
|
||||
.submitOrder(o.id);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||
content: Text('已提交审核'),
|
||||
backgroundColor: AppTheme.success));
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text('提交失败:$e'),
|
||||
backgroundColor: AppTheme.danger));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _confirmApprove(
|
||||
BuildContext context, StockOutOrder o) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('审核确认'),
|
||||
content: Text('确认审核通过出库单「${o.orderNo}」?审核后将出库并扣减库存。'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text('取消')),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.success,
|
||||
foregroundColor: Colors.white),
|
||||
child: const Text('通过'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed == true && mounted) {
|
||||
try {
|
||||
await ref
|
||||
.read(stockOutListProvider.notifier)
|
||||
.approveOrder(o.id);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||
content: Text('审核通过'), backgroundColor: AppTheme.success));
|
||||
}
|
||||
} catch (e) {
|
||||
// Show inventory-shortage error clearly
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text('审核失败:$e'),
|
||||
backgroundColor: AppTheme.danger,
|
||||
duration: const Duration(seconds: 5)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _confirmReject(
|
||||
BuildContext context, StockOutOrder o) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('拒绝确认'),
|
||||
content: Text('确认拒绝出库单「${o.orderNo}」?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text('取消')),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.danger,
|
||||
foregroundColor: Colors.white),
|
||||
child: const Text('拒绝'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed == true && mounted) {
|
||||
try {
|
||||
await ref
|
||||
.read(stockOutListProvider.notifier)
|
||||
.rejectOrder(o.id);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||
content: Text('已拒绝'), backgroundColor: AppTheme.accent));
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text('操作失败:$e'),
|
||||
backgroundColor: AppTheme.danger));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _DropdownFilter extends StatelessWidget {
|
||||
class _StatusFilterDropdown extends StatelessWidget {
|
||||
final String value;
|
||||
final List<String> items;
|
||||
final ValueChanged<String?> onChanged;
|
||||
final String hint;
|
||||
|
||||
const _DropdownFilter({
|
||||
const _StatusFilterDropdown({
|
||||
required this.value,
|
||||
required this.items,
|
||||
required this.onChanged,
|
||||
required this.hint,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -355,16 +397,16 @@ class _DropdownFilter extends StatelessWidget {
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String>(
|
||||
value: value,
|
||||
hint: Text(hint, style: const TextStyle(fontSize: 13)),
|
||||
items: items
|
||||
.map((s) => DropdownMenuItem(
|
||||
value: s,
|
||||
child: Text(s, style: const TextStyle(fontSize: 13))))
|
||||
.toList(),
|
||||
value: value.isEmpty ? '' : value,
|
||||
items: const [
|
||||
DropdownMenuItem(value: '', child: Text('全部状态', style: TextStyle(fontSize: 13))),
|
||||
DropdownMenuItem(value: 'draft', child: Text('草稿', style: TextStyle(fontSize: 13))),
|
||||
DropdownMenuItem(value: 'pending', child: Text('待审核', style: TextStyle(fontSize: 13))),
|
||||
DropdownMenuItem(value: 'approved', child: Text('已审核', style: TextStyle(fontSize: 13))),
|
||||
DropdownMenuItem(value: 'rejected', child: Text('已拒绝', style: TextStyle(fontSize: 13))),
|
||||
],
|
||||
onChanged: onChanged,
|
||||
style: const TextStyle(
|
||||
fontSize: 13, color: AppTheme.textPrimary),
|
||||
style: const TextStyle(fontSize: 13, color: AppTheme.textPrimary),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -29,20 +29,8 @@ class DataTableCard extends StatelessWidget {
|
||||
Container(
|
||||
height: 52,
|
||||
color: AppTheme.surface,
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
minWidth: (MediaQuery.of(context).size.width - 212 - 24).clamp(0.0, double.infinity),
|
||||
),
|
||||
child: toolbar!,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: toolbar!,
|
||||
),
|
||||
if (toolbar != null) const Divider(height: 1),
|
||||
Expanded(
|
||||
|
||||
Reference in New Issue
Block a user