feat(client): 初版 Flutter UI 框架,仿照参考截图实现
- 蓝色顶栏(#1565C0)+ 深蓝侧边栏(#0D47A1),含折叠/展开 - 底部状态栏:门店编号、登录用户、登录时间、实时时钟、版本号 - 登录页:居中卡片,支持密码显示/隐藏 - 入库单列表:筛选/搜索/日期选择/分页,三个 Tab(入库单/查询/审核) - 新建入库单:基本信息 + 商品明细动态增删,实时计算合计金额 - 出库单列表:同入库单风格 - 库存查询:4 张统计卡片 + 颜色高亮缺货/预警行,库存预警 Tab - 往来单位、财务、商品、系统设置(用户/仓库/编号规则/系统参数) - 使用 Riverpod 状态管理 + go_router 路由 + Dio HTTP Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../auth/auth_state.dart';
|
||||
|
||||
final apiClientProvider = Provider<ApiClient>((ref) {
|
||||
final authState = ref.watch(authStateProvider);
|
||||
return ApiClient(token: authState.user?.token);
|
||||
});
|
||||
|
||||
class ApiClient {
|
||||
late final Dio _dio;
|
||||
|
||||
ApiClient({String? token}) {
|
||||
_dio = Dio(BaseOptions(
|
||||
baseUrl: 'http://localhost:8080/api/v1',
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
receiveTimeout: const Duration(seconds: 30),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
if (token != null) 'Authorization': 'Bearer $token',
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
Future<Response> get(String path, {Map<String, dynamic>? params}) =>
|
||||
_dio.get(path, queryParameters: params);
|
||||
|
||||
Future<Response> post(String path, {dynamic data}) =>
|
||||
_dio.post(path, data: data);
|
||||
|
||||
Future<Response> put(String path, {dynamic data}) =>
|
||||
_dio.put(path, data: data);
|
||||
|
||||
Future<Response> delete(String path) => _dio.delete(path);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class AuthUser {
|
||||
final String token;
|
||||
final String username;
|
||||
final String hotelName;
|
||||
final String hotelNo;
|
||||
final int hotelId;
|
||||
|
||||
const AuthUser({
|
||||
required this.token,
|
||||
required this.username,
|
||||
required this.hotelName,
|
||||
required this.hotelNo,
|
||||
required this.hotelId,
|
||||
});
|
||||
}
|
||||
|
||||
class AuthState {
|
||||
final AuthUser? user;
|
||||
const AuthState({this.user});
|
||||
bool get isLoggedIn => user != null;
|
||||
}
|
||||
|
||||
class AuthNotifier extends StateNotifier<AuthState> {
|
||||
AuthNotifier() : super(const AuthState());
|
||||
|
||||
void login(AuthUser user) => state = AuthState(user: user);
|
||||
void logout() => state = const AuthState();
|
||||
}
|
||||
|
||||
final authStateProvider = StateNotifierProvider<AuthNotifier, AuthState>(
|
||||
(ref) => AuthNotifier(),
|
||||
);
|
||||
@@ -0,0 +1,63 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../screens/auth/login_screen.dart';
|
||||
import '../../screens/shell/app_shell.dart';
|
||||
import '../../screens/stock_in/stock_in_list_screen.dart';
|
||||
import '../../screens/stock_in/stock_in_form_screen.dart';
|
||||
import '../../screens/stock_out/stock_out_list_screen.dart';
|
||||
import '../../screens/inventory/inventory_list_screen.dart';
|
||||
import '../../screens/inventory/inventory_check_screen.dart';
|
||||
import '../../screens/partners/partners_screen.dart';
|
||||
import '../../screens/finance/finance_screen.dart';
|
||||
import '../../screens/products/products_screen.dart';
|
||||
import '../../screens/settings/settings_screen.dart';
|
||||
import '../auth/auth_state.dart';
|
||||
|
||||
final appRouterProvider = Provider<GoRouter>((ref) {
|
||||
final authState = ref.watch(authStateProvider);
|
||||
|
||||
return 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;
|
||||
},
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/login',
|
||||
builder: (context, state) => const LoginScreen(),
|
||||
),
|
||||
ShellRoute(
|
||||
builder: (context, state, child) => AppShell(child: child),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/stock-in',
|
||||
builder: (_, __) => const StockInListScreen()),
|
||||
GoRoute(
|
||||
path: '/stock-in/new',
|
||||
builder: (_, __) => const StockInFormScreen()),
|
||||
GoRoute(
|
||||
path: '/stock-out',
|
||||
builder: (_, __) => const StockOutListScreen()),
|
||||
GoRoute(
|
||||
path: '/inventory',
|
||||
builder: (_, __) => const InventoryListScreen()),
|
||||
GoRoute(
|
||||
path: '/inventory/check',
|
||||
builder: (_, __) => const InventoryCheckScreen()),
|
||||
GoRoute(
|
||||
path: '/partners', builder: (_, __) => const PartnersScreen()),
|
||||
GoRoute(
|
||||
path: '/finance', builder: (_, __) => const FinanceScreen()),
|
||||
GoRoute(
|
||||
path: '/products', builder: (_, __) => const ProductsScreen()),
|
||||
GoRoute(
|
||||
path: '/settings', builder: (_, __) => const SettingsScreen()),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AppTheme {
|
||||
static const Color primary = Color(0xFF1565C0);
|
||||
static const Color primaryDark = Color(0xFF0D47A1);
|
||||
static const Color primaryLight = Color(0xFF1976D2);
|
||||
static const Color accent = Color(0xFFFF6F00);
|
||||
static const Color success = Color(0xFF2E7D32);
|
||||
static const Color danger = Color(0xFFC62828);
|
||||
static const Color background = Color(0xFFF5F5F5);
|
||||
static const Color surface = Color(0xFFFFFFFF);
|
||||
static const Color border = Color(0xFFE0E0E0);
|
||||
static const Color textPrimary = Color(0xFF212121);
|
||||
static const Color textSecondary = Color(0xFF757575);
|
||||
|
||||
static ThemeData light() {
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: primary,
|
||||
brightness: Brightness.light,
|
||||
).copyWith(
|
||||
primary: primary,
|
||||
surface: surface,
|
||||
onPrimary: Colors.white,
|
||||
),
|
||||
scaffoldBackgroundColor: background,
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: primary,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
centerTitle: false,
|
||||
toolbarHeight: 56,
|
||||
),
|
||||
cardTheme: CardTheme(
|
||||
color: surface,
|
||||
elevation: 1,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
side: const BorderSide(color: border, width: 0.5),
|
||||
),
|
||||
),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: primary,
|
||||
foregroundColor: Colors.white,
|
||||
minimumSize: const Size(0, 36),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
|
||||
),
|
||||
),
|
||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: primary,
|
||||
minimumSize: const Size(0, 36),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
|
||||
side: const BorderSide(color: primary),
|
||||
),
|
||||
),
|
||||
textButtonTheme: TextButtonThemeData(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: primary,
|
||||
minimumSize: const Size(0, 36),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
borderSide: const BorderSide(color: border),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
borderSide: const BorderSide(color: border),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
borderSide: const BorderSide(color: primary, width: 1.5),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
isDense: true,
|
||||
filled: true,
|
||||
fillColor: surface,
|
||||
),
|
||||
dividerTheme: const DividerThemeData(color: border, thickness: 0.5),
|
||||
textTheme: const TextTheme(
|
||||
bodyLarge: TextStyle(fontSize: 14, color: textPrimary),
|
||||
bodyMedium: TextStyle(fontSize: 14, color: textPrimary),
|
||||
bodySmall: TextStyle(fontSize: 12, color: textSecondary),
|
||||
titleMedium: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: textPrimary),
|
||||
labelMedium: TextStyle(fontSize: 12, color: textSecondary),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'core/router/app_router.dart';
|
||||
import 'core/theme/app_theme.dart';
|
||||
|
||||
void main() {
|
||||
runApp(const ProviderScope(child: JiuApp()));
|
||||
}
|
||||
|
||||
class JiuApp extends ConsumerWidget {
|
||||
const JiuApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final router = ref.watch(appRouterProvider);
|
||||
return MaterialApp.router(
|
||||
title: '酒库管理系统',
|
||||
theme: AppTheme.light(),
|
||||
routerConfig: router,
|
||||
debugShowCheckedModeBanner: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../core/auth/auth_state.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
|
||||
class LoginScreen extends ConsumerStatefulWidget {
|
||||
const LoginScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<LoginScreen> createState() => _LoginScreenState();
|
||||
}
|
||||
|
||||
class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _usernameCtrl = TextEditingController();
|
||||
final _passwordCtrl = TextEditingController();
|
||||
bool _loading = false;
|
||||
bool _obscure = true;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_usernameCtrl.dispose();
|
||||
_passwordCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _login() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
setState(() => _loading = true);
|
||||
await Future.delayed(const Duration(milliseconds: 600));
|
||||
// Mock login for UI demo
|
||||
ref.read(authStateProvider.notifier).login(AuthUser(
|
||||
token: 'demo-token',
|
||||
username: _usernameCtrl.text,
|
||||
hotelName: '示范大酒店',
|
||||
hotelNo: 'H001',
|
||||
hotelId: 1,
|
||||
));
|
||||
if (mounted) {
|
||||
context.go('/stock-in');
|
||||
}
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppTheme.primaryDark,
|
||||
body: Stack(
|
||||
children: [
|
||||
// Background pattern
|
||||
Positioned.fill(
|
||||
child: CustomPaint(painter: _BackgroundPainter()),
|
||||
),
|
||||
Center(
|
||||
child: Card(
|
||||
elevation: 12,
|
||||
shadowColor: Colors.black38,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8)),
|
||||
child: Container(
|
||||
width: 400,
|
||||
padding: const EdgeInsets.all(40),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Logo
|
||||
Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [AppTheme.primaryLight, AppTheme.primaryDark],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppTheme.primary.withOpacity(0.4),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Icon(Icons.wine_bar,
|
||||
color: Colors.white, size: 44),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const Text(
|
||||
'酒库管理系统',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppTheme.primaryDark,
|
||||
letterSpacing: 1,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
const Text(
|
||||
'酒店仓库管理解决方案',
|
||||
style: TextStyle(
|
||||
fontSize: 13, color: AppTheme.textSecondary),
|
||||
),
|
||||
const SizedBox(height: 36),
|
||||
TextFormField(
|
||||
controller: _usernameCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '用户名',
|
||||
hintText: '请输入用户名',
|
||||
prefixIcon:
|
||||
Icon(Icons.person_outline, size: 20),
|
||||
),
|
||||
validator: (v) =>
|
||||
(v == null || v.isEmpty) ? '请输入用户名' : null,
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _passwordCtrl,
|
||||
obscureText: _obscure,
|
||||
decoration: InputDecoration(
|
||||
labelText: '密码',
|
||||
hintText: '请输入密码',
|
||||
prefixIcon:
|
||||
const Icon(Icons.lock_outline, size: 20),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscure
|
||||
? Icons.visibility_off
|
||||
: Icons.visibility,
|
||||
size: 20),
|
||||
onPressed: () =>
|
||||
setState(() => _obscure = !_obscure),
|
||||
),
|
||||
),
|
||||
validator: (v) =>
|
||||
(v == null || v.isEmpty) ? '请输入密码' : null,
|
||||
onFieldSubmitted: (_) => _login(),
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 46,
|
||||
child: ElevatedButton(
|
||||
onPressed: _loading ? null : _login,
|
||||
style: ElevatedButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(4)),
|
||||
),
|
||||
child: _loading
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: Colors.white))
|
||||
: const Text('登 录',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
letterSpacing: 2)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextButton(
|
||||
onPressed: () {},
|
||||
child: const Text('忘记密码?'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Bottom version info
|
||||
Positioned(
|
||||
bottom: 16,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'© 2026 酒库管理系统 v1.0.0',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.4), fontSize: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BackgroundPainter extends CustomPainter {
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = const Color(0xFF1976D2).withOpacity(0.15)
|
||||
..style = PaintingStyle.fill;
|
||||
canvas.drawCircle(
|
||||
Offset(size.width * 0.1, size.height * 0.2), 180, paint);
|
||||
canvas.drawCircle(
|
||||
Offset(size.width * 0.9, size.height * 0.8), 220, paint);
|
||||
paint.color = const Color(0xFF0D47A1).withOpacity(0.1);
|
||||
canvas.drawCircle(
|
||||
Offset(size.width * 0.8, size.height * 0.1), 140, paint);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||
}
|
||||
@@ -0,0 +1,881 @@
|
||||
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/theme/app_theme.dart';
|
||||
|
||||
class FinanceScreen extends ConsumerStatefulWidget {
|
||||
const FinanceScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<FinanceScreen> createState() => _FinanceScreenState();
|
||||
}
|
||||
|
||||
class _FinanceScreenState extends ConsumerState<FinanceScreen> {
|
||||
int _page = 1;
|
||||
String _monthFilter = '2026-04';
|
||||
String _typeFilter = '全部';
|
||||
|
||||
final List<Map<String, dynamic>> _transactions = [
|
||||
{
|
||||
'no': 'FN20260401001',
|
||||
'type': '应付账款',
|
||||
'partner': '贵州茅台酒股份有限公司',
|
||||
'orderNo': 'RK20260401001',
|
||||
'amount': '85000.00',
|
||||
'paid': '85000.00',
|
||||
'balance': '0.00',
|
||||
'date': '2026-04-01',
|
||||
'dueDate': '2026-05-01',
|
||||
'status': '已结清',
|
||||
},
|
||||
{
|
||||
'no': 'FN20260401002',
|
||||
'type': '应付账款',
|
||||
'partner': '四川五粮液股份有限公司',
|
||||
'orderNo': 'RK20260401002',
|
||||
'amount': '42500.00',
|
||||
'paid': '0.00',
|
||||
'balance': '42500.00',
|
||||
'date': '2026-04-01',
|
||||
'dueDate': '2026-05-01',
|
||||
'status': '未付款',
|
||||
},
|
||||
{
|
||||
'no': 'FN20260402001',
|
||||
'type': '应付账款',
|
||||
'partner': '江苏洋河酒厂股份有限公司',
|
||||
'orderNo': 'RK20260402001',
|
||||
'amount': '36800.00',
|
||||
'paid': '20000.00',
|
||||
'balance': '16800.00',
|
||||
'date': '2026-04-02',
|
||||
'dueDate': '2026-05-17',
|
||||
'status': '部分付款',
|
||||
},
|
||||
{
|
||||
'no': 'FN20260402002',
|
||||
'type': '应付账款',
|
||||
'partner': '泸州老窖股份有限公司',
|
||||
'orderNo': 'RK20260403001',
|
||||
'amount': '55200.00',
|
||||
'paid': '55200.00',
|
||||
'balance': '0.00',
|
||||
'date': '2026-04-03',
|
||||
'dueDate': '2026-05-03',
|
||||
'status': '已结清',
|
||||
},
|
||||
{
|
||||
'no': 'FN20260403001',
|
||||
'type': '应付账款',
|
||||
'partner': '法国拉菲集团中国总代理',
|
||||
'orderNo': 'RK20260403002',
|
||||
'amount': '124800.00',
|
||||
'paid': '0.00',
|
||||
'balance': '124800.00',
|
||||
'date': '2026-04-03',
|
||||
'dueDate': '2026-06-02',
|
||||
'status': '未付款',
|
||||
},
|
||||
{
|
||||
'no': 'FN20260404001',
|
||||
'type': '应付账款',
|
||||
'partner': '人头马轩尼诗(中国)有限公司',
|
||||
'orderNo': 'RK20260404001',
|
||||
'amount': '30240.00',
|
||||
'paid': '0.00',
|
||||
'balance': '30240.00',
|
||||
'date': '2026-04-04',
|
||||
'dueDate': '2026-06-03',
|
||||
'status': '未付款',
|
||||
},
|
||||
{
|
||||
'no': 'FN20260404002',
|
||||
'type': '费用报销',
|
||||
'partner': '张三',
|
||||
'orderNo': 'BX20260404001',
|
||||
'amount': '1280.00',
|
||||
'paid': '1280.00',
|
||||
'balance': '0.00',
|
||||
'date': '2026-04-04',
|
||||
'dueDate': '2026-04-04',
|
||||
'status': '已结清',
|
||||
},
|
||||
];
|
||||
|
||||
// Summary stats
|
||||
double get _totalPayable => _transactions
|
||||
.where((t) => t['type'] == '应付账款')
|
||||
.fold(0.0, (s, t) => s + (double.tryParse(t['balance'] as String) ?? 0));
|
||||
|
||||
double get _totalPaid => _transactions.fold(
|
||||
0.0, (s, t) => s + (double.tryParse(t['paid'] as String) ?? 0));
|
||||
|
||||
double get _totalAmount => _transactions.fold(
|
||||
0.0, (s, t) => s + (double.tryParse(t['amount'] as String) ?? 0));
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PageScaffold(
|
||||
title: '财务管理',
|
||||
tabs: const [
|
||||
Tab(text: '应付账款'),
|
||||
Tab(text: '付款记录'),
|
||||
Tab(text: '财务报表'),
|
||||
],
|
||||
tabViews: [
|
||||
_buildPayableList(),
|
||||
_buildPaymentHistory(),
|
||||
_buildFinanceReport(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPayableList() {
|
||||
final filtered = _typeFilter == '全部'
|
||||
? _transactions
|
||||
: _transactions.where((t) => t['type'] == _typeFilter).toList();
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// Summary bar
|
||||
Container(
|
||||
color: AppTheme.background,
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
_FinanceSummaryCard(
|
||||
title: '本月采购总额',
|
||||
value: '¥${(_totalAmount / 10000).toStringAsFixed(1)}万',
|
||||
icon: Icons.shopping_bag,
|
||||
color: AppTheme.primary,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
_FinanceSummaryCard(
|
||||
title: '已付款',
|
||||
value: '¥${(_totalPaid / 10000).toStringAsFixed(1)}万',
|
||||
icon: Icons.check_circle,
|
||||
color: AppTheme.success,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
_FinanceSummaryCard(
|
||||
title: '未付款',
|
||||
value: '¥${(_totalPayable / 10000).toStringAsFixed(1)}万',
|
||||
icon: Icons.pending_actions,
|
||||
color: AppTheme.danger,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
_FinanceSummaryCard(
|
||||
title: '即将到期(7天)',
|
||||
value: '¥16.8万',
|
||||
icon: Icons.alarm,
|
||||
color: AppTheme.accent,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: DataTableCard(
|
||||
totalCount: filtered.length,
|
||||
page: _page,
|
||||
onPageChanged: (p) => setState(() => _page = p),
|
||||
toolbar: Row(
|
||||
children: [
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => _showPaymentDialog(context),
|
||||
icon: const Icon(Icons.payment, 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(),
|
||||
// Month filter
|
||||
_MonthSelector(
|
||||
value: _monthFilter,
|
||||
onChanged: (v) => setState(() => _monthFilter = v),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_DropdownFilter(
|
||||
value: _typeFilter,
|
||||
items: ['全部', '应付账款', '费用报销'],
|
||||
onChanged: (v) => setState(() => _typeFilter = v!),
|
||||
hint: '类型',
|
||||
),
|
||||
],
|
||||
),
|
||||
columns: const [
|
||||
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('余额'), numeric: true),
|
||||
DataColumn(label: Text('到期日')),
|
||||
DataColumn(label: Text('状态')),
|
||||
DataColumn(label: Text('操作')),
|
||||
],
|
||||
rows: filtered
|
||||
.map((t) => DataRow(
|
||||
color: WidgetStateProperty.resolveWith((states) {
|
||||
if (t['status'] == '未付款') {
|
||||
final due = DateTime.tryParse(t['dueDate'] as String);
|
||||
if (due != null &&
|
||||
due.difference(DateTime.now()).inDays <= 7) {
|
||||
return AppTheme.accent.withOpacity(0.05);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
cells: [
|
||||
DataCell(Text(t['no'] as String,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 11,
|
||||
color: AppTheme.textSecondary))),
|
||||
DataCell(Text(t['type'] as String)),
|
||||
DataCell(SizedBox(
|
||||
width: 160,
|
||||
child: Text(t['partner'] as String,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
)),
|
||||
DataCell(Text(t['orderNo'] as String,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 11,
|
||||
color: AppTheme.primary))),
|
||||
DataCell(Text('¥${t['amount']}',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w500))),
|
||||
DataCell(Text('¥${t['paid']}',
|
||||
style: const TextStyle(
|
||||
color: AppTheme.success))),
|
||||
DataCell(Text(
|
||||
'¥${t['balance']}',
|
||||
style: TextStyle(
|
||||
color: (t['balance'] as String) != '0.00'
|
||||
? AppTheme.danger
|
||||
: AppTheme.textSecondary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
)),
|
||||
DataCell(Text(t['dueDate'] as String)),
|
||||
DataCell(_PaymentStatusBadge(t['status'] as String)),
|
||||
DataCell(Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () {},
|
||||
child: const Text('详情',
|
||||
style: TextStyle(fontSize: 12))),
|
||||
if (t['status'] != '已结清')
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
_showPaymentDialog(context),
|
||||
child: const Text('付款',
|
||||
style: TextStyle(
|
||||
color: AppTheme.success,
|
||||
fontSize: 12))),
|
||||
],
|
||||
)),
|
||||
],
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPaymentHistory() {
|
||||
final payments = [
|
||||
{
|
||||
'date': '2026-04-01',
|
||||
'no': 'ZF20260401001',
|
||||
'partner': '贵州茅台酒股份有限公司',
|
||||
'amount': '85000.00',
|
||||
'method': '银行转账',
|
||||
'bank': '招商银行',
|
||||
'remark': '4月货款结清',
|
||||
},
|
||||
{
|
||||
'date': '2026-04-02',
|
||||
'no': 'ZF20260402001',
|
||||
'partner': '泸州老窖股份有限公司',
|
||||
'amount': '55200.00',
|
||||
'method': '银行转账',
|
||||
'bank': '工商银行',
|
||||
'remark': '4月货款',
|
||||
},
|
||||
{
|
||||
'date': '2026-04-02',
|
||||
'no': 'ZF20260402002',
|
||||
'partner': '江苏洋河酒厂股份有限公司',
|
||||
'amount': '20000.00',
|
||||
'method': '银行转账',
|
||||
'bank': '建设银行',
|
||||
'remark': '部分预付',
|
||||
},
|
||||
{
|
||||
'date': '2026-04-04',
|
||||
'no': 'ZF20260404001',
|
||||
'partner': '张三',
|
||||
'amount': '1280.00',
|
||||
'method': '现金',
|
||||
'bank': '-',
|
||||
'remark': '差旅报销',
|
||||
},
|
||||
];
|
||||
|
||||
return DataTableCard(
|
||||
totalCount: payments.length,
|
||||
page: 1,
|
||||
toolbar: Row(
|
||||
children: [
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => _showPaymentDialog(context),
|
||||
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(),
|
||||
_MonthSelector(
|
||||
value: _monthFilter,
|
||||
onChanged: (v) => setState(() => _monthFilter = v),
|
||||
),
|
||||
],
|
||||
),
|
||||
columns: const [
|
||||
DataColumn(label: Text('付款日期')),
|
||||
DataColumn(label: Text('付款单号')),
|
||||
DataColumn(label: Text('收款方')),
|
||||
DataColumn(label: Text('金额'), numeric: true),
|
||||
DataColumn(label: Text('付款方式')),
|
||||
DataColumn(label: Text('银行')),
|
||||
DataColumn(label: Text('备注')),
|
||||
DataColumn(label: Text('操作')),
|
||||
],
|
||||
rows: payments
|
||||
.map((p) => DataRow(cells: [
|
||||
DataCell(Text(p['date'] as String)),
|
||||
DataCell(Text(p['no'] as String,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 11,
|
||||
color: AppTheme.textSecondary))),
|
||||
DataCell(SizedBox(
|
||||
width: 160,
|
||||
child: Text(p['partner'] as String,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
)),
|
||||
DataCell(Text(
|
||||
'¥${p['amount']}',
|
||||
style: const TextStyle(
|
||||
color: AppTheme.danger, fontWeight: FontWeight.w600),
|
||||
)),
|
||||
DataCell(Text(p['method'] as String)),
|
||||
DataCell(Text(p['bank'] as String)),
|
||||
DataCell(Text(p['remark'] as String)),
|
||||
DataCell(TextButton(
|
||||
onPressed: () {},
|
||||
child: const Text('查看',
|
||||
style: TextStyle(fontSize: 12)))),
|
||||
]))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFinanceReport() {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Month/period selector
|
||||
Row(
|
||||
children: [
|
||||
const Text('报表周期:',
|
||||
style: TextStyle(fontSize: 14)),
|
||||
const SizedBox(width: 8),
|
||||
_MonthSelector(
|
||||
value: _monthFilter,
|
||||
onChanged: (v) => setState(() => _monthFilter = v),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () {},
|
||||
icon: const Icon(Icons.picture_as_pdf, size: 16),
|
||||
label: const Text('导出报表'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Metrics row
|
||||
Row(
|
||||
children: [
|
||||
_ReportCard(
|
||||
title: '本月采购总额',
|
||||
value: '¥${(_totalAmount / 10000).toStringAsFixed(2)}万',
|
||||
change: '+12.5%',
|
||||
positive: false,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
_ReportCard(
|
||||
title: '本月付款总额',
|
||||
value: '¥${(_totalPaid / 10000).toStringAsFixed(2)}万',
|
||||
change: '+8.3%',
|
||||
positive: true,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
_ReportCard(
|
||||
title: '应付账款余额',
|
||||
value: '¥${(_totalPayable / 10000).toStringAsFixed(2)}万',
|
||||
change: '-5.2%',
|
||||
positive: true,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
_ReportCard(
|
||||
title: '库存总价值',
|
||||
value: '¥103.17万',
|
||||
change: '+3.8%',
|
||||
positive: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Category breakdown
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('按品类采购金额',
|
||||
style: TextStyle(
|
||||
fontSize: 14, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 16),
|
||||
...[
|
||||
('白酒', 238600.0, AppTheme.primary),
|
||||
('葡萄酒', 124800.0, const Color(0xFF7B1FA2)),
|
||||
('洋酒', 30240.0, AppTheme.accent),
|
||||
('啤酒', 1970.0, const Color(0xFF0097A7)),
|
||||
].map((item) {
|
||||
final total = 395610.0;
|
||||
final pct = item.$2 / total;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 60,
|
||||
child: Text(item.$1,
|
||||
style: const TextStyle(fontSize: 13))),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: LinearProgressIndicator(
|
||||
value: pct,
|
||||
backgroundColor: AppTheme.border,
|
||||
color: item.$3,
|
||||
minHeight: 8,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
width: 100,
|
||||
child: Text(
|
||||
'¥${(item.$2 / 10000).toStringAsFixed(1)}万 (${(pct * 100).toStringAsFixed(1)}%)',
|
||||
style: const TextStyle(fontSize: 12),
|
||||
textAlign: TextAlign.right,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showPaymentDialog(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => FormDialog(
|
||||
title: '登记付款',
|
||||
width: 480,
|
||||
onConfirm: () {
|
||||
Navigator.of(ctx).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('付款记录已登记'),
|
||||
backgroundColor: AppTheme.success,
|
||||
),
|
||||
);
|
||||
},
|
||||
content: Column(
|
||||
children: [
|
||||
_DialogRow(
|
||||
children: [
|
||||
_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(),
|
||||
)),
|
||||
_DialogField(
|
||||
label: '付款金额',
|
||||
required: true,
|
||||
child: TextFormField(
|
||||
decoration: const InputDecoration(
|
||||
hintText: '0.00', prefixText: '¥'),
|
||||
keyboardType:
|
||||
const TextInputType.numberWithOptions(decimal: true))),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_DialogRow(
|
||||
children: [
|
||||
_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(),
|
||||
)),
|
||||
_DialogField(
|
||||
label: '付款日期',
|
||||
child: InputDecorator(
|
||||
decoration: const InputDecoration(),
|
||||
child: const Text('2026-04-04',
|
||||
style: TextStyle(fontSize: 13)),
|
||||
)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_DialogField(
|
||||
label: '备注',
|
||||
fullWidth: true,
|
||||
child: TextFormField(
|
||||
maxLines: 2,
|
||||
decoration: const InputDecoration(hintText: '付款说明'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DialogRow extends StatelessWidget {
|
||||
final List<Widget> children;
|
||||
const _DialogRow({required this.children});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: children
|
||||
.expand((child) => [
|
||||
Expanded(child: child),
|
||||
if (child != children.last) const SizedBox(width: 12),
|
||||
])
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DialogField extends StatelessWidget {
|
||||
final String label;
|
||||
final Widget child;
|
||||
final bool required;
|
||||
final bool fullWidth;
|
||||
|
||||
const _DialogField({
|
||||
required this.label,
|
||||
required this.child,
|
||||
this.required = false,
|
||||
this.fullWidth = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget content = 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,
|
||||
],
|
||||
);
|
||||
return fullWidth ? SizedBox(width: double.infinity, child: content) : content;
|
||||
}
|
||||
}
|
||||
|
||||
class _FinanceSummaryCard extends StatelessWidget {
|
||||
final String title;
|
||||
final String value;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
|
||||
const _FinanceSummaryCard({
|
||||
required this.title,
|
||||
required this.value,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Expanded(
|
||||
child: Container(
|
||||
height: 72,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.surface,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: AppTheme.border, width: 0.5),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(icon, color: color, size: 22),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(title,
|
||||
style: const TextStyle(
|
||||
fontSize: 12, color: AppTheme.textSecondary)),
|
||||
const SizedBox(height: 4),
|
||||
Text(value,
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: color)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PaymentStatusBadge extends StatelessWidget {
|
||||
final String status;
|
||||
const _PaymentStatusBadge(this.status);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Color bg, fg;
|
||||
switch (status) {
|
||||
case '已结清':
|
||||
bg = const Color(0xFFE8F5E9);
|
||||
fg = AppTheme.success;
|
||||
break;
|
||||
case '部分付款':
|
||||
bg = const Color(0xFFFFF3E0);
|
||||
fg = AppTheme.accent;
|
||||
break;
|
||||
case '未付款':
|
||||
bg = const Color(0xFFFFEBEE);
|
||||
fg = AppTheme.danger;
|
||||
break;
|
||||
default:
|
||||
bg = const Color(0xFFF5F5F5);
|
||||
fg = AppTheme.textSecondary;
|
||||
}
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration:
|
||||
BoxDecoration(color: bg, borderRadius: BorderRadius.circular(3)),
|
||||
child: Text(status,
|
||||
style: TextStyle(
|
||||
color: fg, fontSize: 12, fontWeight: FontWeight.w500)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ReportCard extends StatelessWidget {
|
||||
final String title;
|
||||
final String value;
|
||||
final String change;
|
||||
final bool positive;
|
||||
|
||||
const _ReportCard({
|
||||
required this.title,
|
||||
required this.value,
|
||||
required this.change,
|
||||
required this.positive,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Expanded(
|
||||
child: Container(
|
||||
height: 88,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.surface,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: AppTheme.border, width: 0.5),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title,
|
||||
style: const TextStyle(
|
||||
fontSize: 12, color: AppTheme.textSecondary)),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(value,
|
||||
style: const TextStyle(
|
||||
fontSize: 18, fontWeight: FontWeight.w700)),
|
||||
const Spacer(),
|
||||
Text(
|
||||
change,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: positive ? AppTheme.success : AppTheme.danger,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MonthSelector extends StatelessWidget {
|
||||
final String value;
|
||||
final ValueChanged<String> onChanged;
|
||||
|
||||
const _MonthSelector({required this.value, required this.onChanged});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final months = [
|
||||
'2026-04',
|
||||
'2026-03',
|
||||
'2026-02',
|
||||
'2026-01',
|
||||
'2025-12',
|
||||
'2025-11',
|
||||
];
|
||||
return Container(
|
||||
height: 36,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: AppTheme.border),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
color: AppTheme.surface,
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String>(
|
||||
value: value,
|
||||
items: months
|
||||
.map((m) => DropdownMenuItem(
|
||||
value: m,
|
||||
child: Text(m, style: const TextStyle(fontSize: 13))))
|
||||
.toList(),
|
||||
onChanged: (v) => onChanged(v!),
|
||||
style: const TextStyle(
|
||||
fontSize: 13, color: AppTheme.textPrimary),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DropdownFilter extends StatelessWidget {
|
||||
final String value;
|
||||
final List<String> items;
|
||||
final ValueChanged<String?> onChanged;
|
||||
final String hint;
|
||||
|
||||
const _DropdownFilter({
|
||||
required this.value,
|
||||
required this.items,
|
||||
required this.onChanged,
|
||||
required this.hint,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 36,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: AppTheme.border),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
color: AppTheme.surface,
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String>(
|
||||
value: value,
|
||||
items: items
|
||||
.map((s) => DropdownMenuItem(
|
||||
value: s,
|
||||
child: Text(s, style: const TextStyle(fontSize: 13))))
|
||||
.toList(),
|
||||
onChanged: onChanged,
|
||||
style: const TextStyle(
|
||||
fontSize: 13, color: AppTheme.textPrimary),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,490 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
|
||||
class InventoryCheckScreen extends ConsumerStatefulWidget {
|
||||
const InventoryCheckScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<InventoryCheckScreen> createState() =>
|
||||
_InventoryCheckScreenState();
|
||||
}
|
||||
|
||||
class _InventoryCheckScreenState
|
||||
extends ConsumerState<InventoryCheckScreen> {
|
||||
final _checkNoCtrl =
|
||||
TextEditingController(text: 'PD20260404001');
|
||||
String _warehouse = '主仓库';
|
||||
String _checkType = '全盘';
|
||||
bool _submitting = false;
|
||||
|
||||
// Mock inventory items for checking
|
||||
final List<Map<String, dynamic>> _checkItems = [
|
||||
{
|
||||
'code': 'SP001',
|
||||
'name': '茅台酒(飞天)53度500ml',
|
||||
'unit': '瓶',
|
||||
'systemQty': 286,
|
||||
'actualQtyCtrl': TextEditingController(text: '286'),
|
||||
'remark': TextEditingController(),
|
||||
},
|
||||
{
|
||||
'code': 'SP002',
|
||||
'name': '五粮液(普五)52度500ml',
|
||||
'unit': '瓶',
|
||||
'systemQty': 152,
|
||||
'actualQtyCtrl': TextEditingController(text: '150'),
|
||||
'remark': TextEditingController(text: '破损2瓶'),
|
||||
},
|
||||
{
|
||||
'code': 'SP003',
|
||||
'name': '洋河梦之蓝M6+ 45度500ml',
|
||||
'unit': '瓶',
|
||||
'systemQty': 88,
|
||||
'actualQtyCtrl': TextEditingController(text: '88'),
|
||||
'remark': TextEditingController(),
|
||||
},
|
||||
{
|
||||
'code': 'SP005',
|
||||
'name': '泸州老窖(国窖1573)52度500ml',
|
||||
'unit': '瓶',
|
||||
'systemQty': 68,
|
||||
'actualQtyCtrl': TextEditingController(text: '70'),
|
||||
'remark': TextEditingController(text: '盘盈2瓶'),
|
||||
},
|
||||
{
|
||||
'code': 'SP006',
|
||||
'name': '汾酒(青花30)53度500ml',
|
||||
'unit': '瓶',
|
||||
'systemQty': 45,
|
||||
'actualQtyCtrl': TextEditingController(text: '45'),
|
||||
'remark': TextEditingController(),
|
||||
},
|
||||
{
|
||||
'code': 'SP010',
|
||||
'name': '青岛啤酒(经典)500ml',
|
||||
'unit': '箱',
|
||||
'systemQty': 35,
|
||||
'actualQtyCtrl': TextEditingController(text: '35'),
|
||||
'remark': TextEditingController(),
|
||||
},
|
||||
];
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_checkNoCtrl.dispose();
|
||||
for (final item in _checkItems) {
|
||||
(item['actualQtyCtrl'] as TextEditingController).dispose();
|
||||
(item['remark'] as TextEditingController).dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
int _getDiff(Map<String, dynamic> item) {
|
||||
final actual = int.tryParse(
|
||||
(item['actualQtyCtrl'] as TextEditingController).text) ??
|
||||
0;
|
||||
return actual - (item['systemQty'] as int);
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
setState(() => _submitting = true);
|
||||
await Future.delayed(const Duration(milliseconds: 800));
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('盘点单已提交,待审核'),
|
||||
backgroundColor: AppTheme.success,
|
||||
),
|
||||
);
|
||||
context.go('/inventory');
|
||||
}
|
||||
if (mounted) setState(() => _submitting = false);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppTheme.background,
|
||||
body: Column(
|
||||
children: [
|
||||
// Header
|
||||
Container(
|
||||
height: 52,
|
||||
color: AppTheme.surface,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_back, size: 20),
|
||||
onPressed: () => context.go('/inventory'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text('库存盘点',
|
||||
style: TextStyle(
|
||||
fontSize: 16, fontWeight: FontWeight.w600)),
|
||||
const Spacer(),
|
||||
OutlinedButton(
|
||||
onPressed: () {},
|
||||
child: const Text('保存草稿'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _submitting ? null : _submit,
|
||||
icon: _submitting
|
||||
? const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: Colors.white))
|
||||
: const Icon(Icons.check_circle_outline, size: 16),
|
||||
label: const Text('提交盘点'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton(
|
||||
onPressed: () => context.go('/inventory'),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
// Basic info
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('盘点基本信息',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.primaryDark)),
|
||||
const SizedBox(height: 16),
|
||||
Wrap(
|
||||
spacing: 16,
|
||||
runSpacing: 16,
|
||||
children: [
|
||||
_InfoField(
|
||||
label: '盘点单号',
|
||||
child: TextFormField(
|
||||
controller: _checkNoCtrl,
|
||||
readOnly: true,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace'),
|
||||
decoration: const InputDecoration(),
|
||||
),
|
||||
),
|
||||
_InfoField(
|
||||
label: '盘点仓库',
|
||||
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(),
|
||||
),
|
||||
),
|
||||
_InfoField(
|
||||
label: '盘点类型',
|
||||
child: DropdownButtonFormField<String>(
|
||||
value: _checkType,
|
||||
items: ['全盘', '抽盘', '循环盘点']
|
||||
.map((s) => DropdownMenuItem(
|
||||
value: s,
|
||||
child: Text(s,
|
||||
style: const TextStyle(
|
||||
fontSize: 13))))
|
||||
.toList(),
|
||||
onChanged: (v) =>
|
||||
setState(() => _checkType = v!),
|
||||
decoration: const InputDecoration(),
|
||||
),
|
||||
),
|
||||
_InfoField(
|
||||
label: '盘点日期',
|
||||
child: InputDecorator(
|
||||
decoration: const InputDecoration(),
|
||||
child: Text(
|
||||
'2026-04-04',
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Check items table
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Text('盘点明细',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.primaryDark)),
|
||||
const SizedBox(width: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.accent.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
'差异 ${_checkItems.where((i) => _getDiff(i) != 0).length} 项',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.accent),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Table
|
||||
Table(
|
||||
columnWidths: const {
|
||||
0: FixedColumnWidth(36),
|
||||
1: FixedColumnWidth(80),
|
||||
2: FlexColumnWidth(3),
|
||||
3: FixedColumnWidth(50),
|
||||
4: FixedColumnWidth(80),
|
||||
5: FixedColumnWidth(120),
|
||||
6: FixedColumnWidth(80),
|
||||
7: FlexColumnWidth(2),
|
||||
},
|
||||
children: [
|
||||
TableRow(
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFFF0F4FF)),
|
||||
children: [
|
||||
'序号',
|
||||
'商品编码',
|
||||
'商品名称',
|
||||
'单位',
|
||||
'账面数量',
|
||||
'实际数量',
|
||||
'差异',
|
||||
'备注',
|
||||
]
|
||||
.map((h) => Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 10),
|
||||
child: Text(h,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight:
|
||||
FontWeight.w600,
|
||||
color: AppTheme
|
||||
.primaryDark)),
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
...List.generate(
|
||||
_checkItems.length,
|
||||
(i) => _buildCheckRow(i)),
|
||||
],
|
||||
),
|
||||
const Divider(height: 1),
|
||||
// Summary
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
_SummaryItem(
|
||||
label: '盘点商品',
|
||||
value: '${_checkItems.length}种',
|
||||
color: AppTheme.primary,
|
||||
),
|
||||
const SizedBox(width: 24),
|
||||
_SummaryItem(
|
||||
label: '盘盈',
|
||||
value:
|
||||
'${_checkItems.where((i) => _getDiff(i) > 0).length}种',
|
||||
color: AppTheme.success,
|
||||
),
|
||||
const SizedBox(width: 24),
|
||||
_SummaryItem(
|
||||
label: '盘亏',
|
||||
value:
|
||||
'${_checkItems.where((i) => _getDiff(i) < 0).length}种',
|
||||
color: AppTheme.danger,
|
||||
),
|
||||
const SizedBox(width: 24),
|
||||
_SummaryItem(
|
||||
label: '相符',
|
||||
value:
|
||||
'${_checkItems.where((i) => _getDiff(i) == 0).length}种',
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
TableRow _buildCheckRow(int index) {
|
||||
final item = _checkItems[index];
|
||||
final diff = _getDiff(item);
|
||||
|
||||
return TableRow(
|
||||
decoration: BoxDecoration(
|
||||
color: diff != 0
|
||||
? (diff > 0
|
||||
? AppTheme.success.withOpacity(0.04)
|
||||
: AppTheme.danger.withOpacity(0.04))
|
||||
: (index.isEven ? Colors.white : const Color(0xFFFAFAFA)),
|
||||
),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
|
||||
child: Text('${index + 1}',
|
||||
style: const TextStyle(
|
||||
fontSize: 13, color: AppTheme.textSecondary)),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
|
||||
child: Text(item['code'] as String,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
color: AppTheme.textSecondary)),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
|
||||
child: Text(item['name'] as String,
|
||||
style: const TextStyle(fontSize: 13),
|
||||
overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
|
||||
child: Text(item['unit'] as String,
|
||||
style: const TextStyle(fontSize: 13)),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
|
||||
child: Text('${item['systemQty']}',
|
||||
style: const TextStyle(fontSize: 13)),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: TextFormField(
|
||||
controller: item['actualQtyCtrl'] as TextEditingController,
|
||||
decoration: const InputDecoration(),
|
||||
style: const TextStyle(fontSize: 13),
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
|
||||
child: Text(
|
||||
diff == 0 ? '0' : (diff > 0 ? '+$diff' : '$diff'),
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: diff == 0
|
||||
? AppTheme.textSecondary
|
||||
: (diff > 0 ? AppTheme.success : AppTheme.danger),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: TextFormField(
|
||||
controller: item['remark'] as TextEditingController,
|
||||
decoration: const InputDecoration(hintText: '备注'),
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoField extends StatelessWidget {
|
||||
final String label;
|
||||
final Widget child;
|
||||
|
||||
const _InfoField({required this.label, required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: 220,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label,
|
||||
style: const TextStyle(
|
||||
fontSize: 13, color: AppTheme.textSecondary)),
|
||||
const SizedBox(height: 6),
|
||||
child,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SummaryItem extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
final Color color;
|
||||
|
||||
const _SummaryItem(
|
||||
{required this.label,
|
||||
required this.value,
|
||||
required this.color});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Text(label,
|
||||
style: const TextStyle(
|
||||
fontSize: 13, color: AppTheme.textSecondary)),
|
||||
const SizedBox(width: 4),
|
||||
Text(value,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: color)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,700 @@
|
||||
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 '../../core/theme/app_theme.dart';
|
||||
|
||||
class InventoryListScreen extends ConsumerStatefulWidget {
|
||||
const InventoryListScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<InventoryListScreen> createState() =>
|
||||
_InventoryListScreenState();
|
||||
}
|
||||
|
||||
class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
||||
int _page = 1;
|
||||
final _searchCtrl = TextEditingController();
|
||||
String _categoryFilter = '全部';
|
||||
String _warehouseFilter = '全部';
|
||||
|
||||
final List<Map<String, dynamic>> _mockInventory = [
|
||||
{
|
||||
'code': 'SP001',
|
||||
'name': '茅台酒(飞天)53度500ml',
|
||||
'category': '白酒',
|
||||
'brand': '茅台',
|
||||
'spec': '500ml/瓶',
|
||||
'unit': '瓶',
|
||||
'warehouse': '主仓库',
|
||||
'qty': 286,
|
||||
'available': 280,
|
||||
'reserved': 6,
|
||||
'cost': 2100.00,
|
||||
'price': 2600.00,
|
||||
'totalValue': '600600.00',
|
||||
'minQty': 50,
|
||||
'status': '正常',
|
||||
},
|
||||
{
|
||||
'code': 'SP002',
|
||||
'name': '五粮液(普五)52度500ml',
|
||||
'category': '白酒',
|
||||
'brand': '五粮液',
|
||||
'spec': '500ml/瓶',
|
||||
'unit': '瓶',
|
||||
'warehouse': '主仓库',
|
||||
'qty': 152,
|
||||
'available': 148,
|
||||
'reserved': 4,
|
||||
'cost': 850.00,
|
||||
'price': 1050.00,
|
||||
'totalValue': '129200.00',
|
||||
'minQty': 30,
|
||||
'status': '正常',
|
||||
},
|
||||
{
|
||||
'code': 'SP003',
|
||||
'name': '洋河梦之蓝M6+ 45度500ml',
|
||||
'category': '白酒',
|
||||
'brand': '洋河',
|
||||
'spec': '500ml/瓶',
|
||||
'unit': '瓶',
|
||||
'warehouse': '主仓库',
|
||||
'qty': 88,
|
||||
'available': 85,
|
||||
'reserved': 3,
|
||||
'cost': 480.00,
|
||||
'price': 598.00,
|
||||
'totalValue': '42240.00',
|
||||
'minQty': 20,
|
||||
'status': '正常',
|
||||
},
|
||||
{
|
||||
'code': 'SP004',
|
||||
'name': '剑南春(水晶剑)52度500ml',
|
||||
'category': '白酒',
|
||||
'brand': '剑南春',
|
||||
'spec': '500ml/瓶',
|
||||
'unit': '瓶',
|
||||
'warehouse': '副仓库',
|
||||
'qty': 12,
|
||||
'available': 12,
|
||||
'reserved': 0,
|
||||
'cost': 288.00,
|
||||
'price': 368.00,
|
||||
'totalValue': '3456.00',
|
||||
'minQty': 20,
|
||||
'status': '库存不足',
|
||||
},
|
||||
{
|
||||
'code': 'SP005',
|
||||
'name': '泸州老窖(国窖1573)52度500ml',
|
||||
'category': '白酒',
|
||||
'brand': '泸州老窖',
|
||||
'spec': '500ml/瓶',
|
||||
'unit': '瓶',
|
||||
'warehouse': '主仓库',
|
||||
'qty': 68,
|
||||
'available': 65,
|
||||
'reserved': 3,
|
||||
'cost': 680.00,
|
||||
'price': 860.00,
|
||||
'totalValue': '46240.00',
|
||||
'minQty': 20,
|
||||
'status': '正常',
|
||||
},
|
||||
{
|
||||
'code': 'SP006',
|
||||
'name': '汾酒(青花30)53度500ml',
|
||||
'category': '白酒',
|
||||
'brand': '汾酒',
|
||||
'spec': '500ml/瓶',
|
||||
'unit': '瓶',
|
||||
'warehouse': '主仓库',
|
||||
'qty': 45,
|
||||
'available': 45,
|
||||
'reserved': 0,
|
||||
'cost': 320.00,
|
||||
'price': 418.00,
|
||||
'totalValue': '14400.00',
|
||||
'minQty': 15,
|
||||
'status': '正常',
|
||||
},
|
||||
{
|
||||
'code': 'SP007',
|
||||
'name': '拉菲古堡正牌红葡萄酒2018',
|
||||
'category': '葡萄酒',
|
||||
'brand': '拉菲',
|
||||
'spec': '750ml/瓶',
|
||||
'unit': '瓶',
|
||||
'warehouse': '副仓库',
|
||||
'qty': 24,
|
||||
'available': 24,
|
||||
'reserved': 0,
|
||||
'cost': 5200.00,
|
||||
'price': 6800.00,
|
||||
'totalValue': '124800.00',
|
||||
'minQty': 6,
|
||||
'status': '正常',
|
||||
},
|
||||
{
|
||||
'code': 'SP008',
|
||||
'name': '人头马XO特优香槟干邑700ml',
|
||||
'category': '洋酒',
|
||||
'brand': '人头马',
|
||||
'spec': '700ml/瓶',
|
||||
'unit': '瓶',
|
||||
'warehouse': '副仓库',
|
||||
'qty': 18,
|
||||
'available': 16,
|
||||
'reserved': 2,
|
||||
'cost': 1680.00,
|
||||
'price': 2180.00,
|
||||
'totalValue': '30240.00',
|
||||
'minQty': 6,
|
||||
'status': '正常',
|
||||
},
|
||||
{
|
||||
'code': 'SP009',
|
||||
'name': '百威啤酒330ml',
|
||||
'category': '啤酒',
|
||||
'brand': '百威',
|
||||
'spec': '330ml×24罐',
|
||||
'unit': '箱',
|
||||
'warehouse': '主仓库',
|
||||
'qty': 8,
|
||||
'available': 6,
|
||||
'reserved': 2,
|
||||
'cost': 58.00,
|
||||
'price': 88.00,
|
||||
'totalValue': '464.00',
|
||||
'minQty': 20,
|
||||
'status': '库存不足',
|
||||
},
|
||||
{
|
||||
'code': 'SP010',
|
||||
'name': '青岛啤酒(经典)500ml',
|
||||
'category': '啤酒',
|
||||
'brand': '青岛',
|
||||
'spec': '500ml×12瓶',
|
||||
'unit': '箱',
|
||||
'warehouse': '主仓库',
|
||||
'qty': 35,
|
||||
'available': 35,
|
||||
'reserved': 0,
|
||||
'cost': 42.00,
|
||||
'price': 68.00,
|
||||
'totalValue': '1470.00',
|
||||
'minQty': 20,
|
||||
'status': '正常',
|
||||
},
|
||||
{
|
||||
'code': 'SP011',
|
||||
'name': '芝华士12年苏格兰威士忌700ml',
|
||||
'category': '洋酒',
|
||||
'brand': '芝华士',
|
||||
'spec': '700ml/瓶',
|
||||
'unit': '瓶',
|
||||
'warehouse': '副仓库',
|
||||
'qty': 30,
|
||||
'available': 28,
|
||||
'reserved': 2,
|
||||
'cost': 288.00,
|
||||
'price': 398.00,
|
||||
'totalValue': '8640.00',
|
||||
'minQty': 10,
|
||||
'status': '正常',
|
||||
},
|
||||
{
|
||||
'code': 'SP012',
|
||||
'name': '郎酒红花郎15年53度500ml',
|
||||
'category': '白酒',
|
||||
'brand': '郎酒',
|
||||
'spec': '500ml/瓶',
|
||||
'unit': '瓶',
|
||||
'warehouse': '主仓库',
|
||||
'qty': 0,
|
||||
'available': 0,
|
||||
'reserved': 0,
|
||||
'cost': 620.00,
|
||||
'price': 798.00,
|
||||
'totalValue': '0.00',
|
||||
'minQty': 10,
|
||||
'status': '缺货',
|
||||
},
|
||||
];
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> get _filtered {
|
||||
return _mockInventory.where((item) {
|
||||
if (_categoryFilter != '全部' &&
|
||||
item['category'] != _categoryFilter) return false;
|
||||
if (_warehouseFilter != '全部' &&
|
||||
item['warehouse'] != _warehouseFilter) return false;
|
||||
final q = _searchCtrl.text.toLowerCase();
|
||||
if (q.isNotEmpty) {
|
||||
final name = (item['name'] as String).toLowerCase();
|
||||
final code = (item['code'] as String).toLowerCase();
|
||||
final brand = (item['brand'] as String).toLowerCase();
|
||||
if (!name.contains(q) && !code.contains(q) && !brand.contains(q)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
// Summary stats
|
||||
double get _totalInventoryValue {
|
||||
return _mockInventory.fold(0, (sum, item) {
|
||||
return sum + (double.tryParse(item['totalValue'] as String) ?? 0);
|
||||
});
|
||||
}
|
||||
|
||||
int get _lowStockCount {
|
||||
return _mockInventory.where((item) {
|
||||
return (item['qty'] as int) < (item['minQty'] as int);
|
||||
}).length;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PageScaffold(
|
||||
title: '库存管理',
|
||||
tabs: const [
|
||||
Tab(text: '库存查询'),
|
||||
Tab(text: '库存预警'),
|
||||
Tab(text: '库存盘点'),
|
||||
],
|
||||
tabViews: [
|
||||
_buildInventoryList(),
|
||||
_buildWarningList(),
|
||||
_buildCheckTab(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInventoryList() {
|
||||
final items = _filtered;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// Summary cards
|
||||
Container(
|
||||
color: AppTheme.background,
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
_SummaryCard(
|
||||
title: '商品总数',
|
||||
value: '${_mockInventory.length}',
|
||||
unit: '种',
|
||||
icon: Icons.inventory_2,
|
||||
color: AppTheme.primary),
|
||||
const SizedBox(width: 12),
|
||||
_SummaryCard(
|
||||
title: '库存总价值',
|
||||
value: '¥${(_totalInventoryValue / 10000).toStringAsFixed(1)}万',
|
||||
unit: '',
|
||||
icon: Icons.monetization_on,
|
||||
color: AppTheme.success),
|
||||
const SizedBox(width: 12),
|
||||
_SummaryCard(
|
||||
title: '库存预警',
|
||||
value: '$_lowStockCount',
|
||||
unit: '种',
|
||||
icon: Icons.warning_amber,
|
||||
color: AppTheme.accent),
|
||||
const SizedBox(width: 12),
|
||||
_SummaryCard(
|
||||
title: '缺货商品',
|
||||
value:
|
||||
'${_mockInventory.where((i) => (i['qty'] as int) == 0).length}',
|
||||
unit: '种',
|
||||
icon: Icons.remove_shopping_cart,
|
||||
color: AppTheme.danger),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: DataTableCard(
|
||||
totalCount: items.length,
|
||||
page: _page,
|
||||
onPageChanged: (p) => setState(() => _page = p),
|
||||
toolbar: Row(
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => context.go('/inventory/check'),
|
||||
icon: const Icon(Icons.fact_check, 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(),
|
||||
_DropdownFilter(
|
||||
value: _categoryFilter,
|
||||
items: ['全部', '白酒', '葡萄酒', '洋酒', '啤酒'],
|
||||
onChanged: (v) => setState(() => _categoryFilter = v!),
|
||||
hint: '商品分类'),
|
||||
const SizedBox(width: 8),
|
||||
_DropdownFilter(
|
||||
value: _warehouseFilter,
|
||||
items: ['全部', '主仓库', '副仓库'],
|
||||
onChanged: (v) =>
|
||||
setState(() => _warehouseFilter = v!),
|
||||
hint: '仓库'),
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
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('预留'), numeric: true),
|
||||
DataColumn(label: Text('成本价'), numeric: true),
|
||||
DataColumn(label: Text('库存价值'), numeric: true),
|
||||
DataColumn(label: Text('状态')),
|
||||
],
|
||||
rows: items
|
||||
.map((item) => DataRow(
|
||||
color: WidgetStateProperty.resolveWith((states) {
|
||||
if ((item['qty'] as int) == 0) {
|
||||
return AppTheme.danger.withOpacity(0.04);
|
||||
}
|
||||
if ((item['qty'] as int) < (item['minQty'] as int)) {
|
||||
return AppTheme.accent.withOpacity(0.04);
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
cells: [
|
||||
DataCell(Text(item['code'] as String,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
color: AppTheme.textSecondary))),
|
||||
DataCell(SizedBox(
|
||||
width: 200,
|
||||
child: Text(item['name'] as String,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 13)),
|
||||
)),
|
||||
DataCell(Text(item['category'] as String)),
|
||||
DataCell(Text(item['brand'] as String)),
|
||||
DataCell(Text(item['warehouse'] as String)),
|
||||
DataCell(Text('${item['qty']}',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: (item['qty'] as int) == 0
|
||||
? AppTheme.danger
|
||||
: (item['qty'] as int) <
|
||||
(item['minQty'] as int)
|
||||
? AppTheme.accent
|
||||
: AppTheme.textPrimary))),
|
||||
DataCell(Text('${item['available']}')),
|
||||
DataCell(Text('${item['reserved']}',
|
||||
style: TextStyle(
|
||||
color: (item['reserved'] as int) > 0
|
||||
? AppTheme.accent
|
||||
: AppTheme.textSecondary))),
|
||||
DataCell(Text(
|
||||
'¥${(item['cost'] as double).toStringAsFixed(2)}')),
|
||||
DataCell(Text('¥${item['totalValue']}',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w500))),
|
||||
DataCell(_InventoryStatusBadge(
|
||||
item['status'] as String)),
|
||||
],
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWarningList() {
|
||||
final warnings = _mockInventory
|
||||
.where((item) => (item['qty'] as int) < (item['minQty'] as int))
|
||||
.toList();
|
||||
return DataTableCard(
|
||||
totalCount: warnings.length,
|
||||
page: 1,
|
||||
toolbar: Row(
|
||||
children: [
|
||||
const Icon(Icons.warning_amber, color: AppTheme.accent, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'共 ${warnings.length} 个商品库存低于安全库存',
|
||||
style: const TextStyle(
|
||||
fontSize: 13, color: AppTheme.accent),
|
||||
),
|
||||
const Spacer(),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () {},
|
||||
icon: const Icon(Icons.mail_outline, size: 16),
|
||||
label: const Text('发送预警通知'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {},
|
||||
icon: const Icon(Icons.shopping_cart_checkout, size: 16),
|
||||
label: const Text('一键补货申请'),
|
||||
),
|
||||
],
|
||||
),
|
||||
columns: const [
|
||||
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('缺口'), numeric: true),
|
||||
DataColumn(label: Text('状态')),
|
||||
DataColumn(label: Text('操作')),
|
||||
],
|
||||
rows: warnings
|
||||
.map((item) => DataRow(
|
||||
color: WidgetStateProperty.all(
|
||||
(item['qty'] as int) == 0
|
||||
? AppTheme.danger.withOpacity(0.05)
|
||||
: AppTheme.accent.withOpacity(0.04)),
|
||||
cells: [
|
||||
DataCell(Text(item['code'] as String,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace', fontSize: 12))),
|
||||
DataCell(SizedBox(
|
||||
width: 180,
|
||||
child: Text(item['name'] as String,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
)),
|
||||
DataCell(Text(item['category'] as String)),
|
||||
DataCell(Text(item['warehouse'] as String)),
|
||||
DataCell(Text('${item['qty']}',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: (item['qty'] as int) == 0
|
||||
? AppTheme.danger
|
||||
: AppTheme.accent))),
|
||||
DataCell(Text('${item['minQty']}')),
|
||||
DataCell(Text(
|
||||
'${(item['minQty'] as int) - (item['qty'] as int)}',
|
||||
style: const TextStyle(
|
||||
color: AppTheme.danger,
|
||||
fontWeight: FontWeight.w600))),
|
||||
DataCell(_InventoryStatusBadge(item['status'] as String)),
|
||||
DataCell(
|
||||
ElevatedButton(
|
||||
onPressed: () {},
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size(0, 28)),
|
||||
child: const Text('申请补货',
|
||||
style: TextStyle(fontSize: 12)),
|
||||
),
|
||||
),
|
||||
],
|
||||
))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCheckTab() {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.fact_check_outlined,
|
||||
size: 64, color: AppTheme.textSecondary),
|
||||
const SizedBox(height: 16),
|
||||
const Text('点击下方按钮发起新的库存盘点',
|
||||
style: TextStyle(fontSize: 15, color: AppTheme.textSecondary)),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => context.go('/inventory/check'),
|
||||
icon: const Icon(Icons.add, size: 20),
|
||||
label: const Text('新建盘点单', style: TextStyle(fontSize: 15)),
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size(160, 44)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SummaryCard extends StatelessWidget {
|
||||
final String title;
|
||||
final String value;
|
||||
final String unit;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
|
||||
const _SummaryCard({
|
||||
required this.title,
|
||||
required this.value,
|
||||
required this.unit,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Expanded(
|
||||
child: Container(
|
||||
height: 72,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.surface,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: AppTheme.border, width: 0.5),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(icon, color: color, size: 22),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(title,
|
||||
style: const TextStyle(
|
||||
fontSize: 12, color: AppTheme.textSecondary)),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Text(value,
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: color)),
|
||||
if (unit.isNotEmpty) ...[
|
||||
const SizedBox(width: 2),
|
||||
Text(unit,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.textSecondary)),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InventoryStatusBadge extends StatelessWidget {
|
||||
final String status;
|
||||
const _InventoryStatusBadge(this.status);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final Color bg;
|
||||
final Color fg;
|
||||
switch (status) {
|
||||
case '正常':
|
||||
bg = const Color(0xFFE8F5E9);
|
||||
fg = AppTheme.success;
|
||||
break;
|
||||
case '库存不足':
|
||||
bg = const Color(0xFFFFF3E0);
|
||||
fg = AppTheme.accent;
|
||||
break;
|
||||
case '缺货':
|
||||
bg = const Color(0xFFFFEBEE);
|
||||
fg = AppTheme.danger;
|
||||
break;
|
||||
default:
|
||||
bg = const Color(0xFFF5F5F5);
|
||||
fg = AppTheme.textSecondary;
|
||||
}
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: bg, borderRadius: BorderRadius.circular(3)),
|
||||
child: Text(status,
|
||||
style: TextStyle(
|
||||
color: fg, fontSize: 12, fontWeight: FontWeight.w500)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DropdownFilter extends StatelessWidget {
|
||||
final String value;
|
||||
final List<String> items;
|
||||
final ValueChanged<String?> onChanged;
|
||||
final String hint;
|
||||
|
||||
const _DropdownFilter({
|
||||
required this.value,
|
||||
required this.items,
|
||||
required this.onChanged,
|
||||
required this.hint,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 36,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: AppTheme.border),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
color: AppTheme.surface,
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String>(
|
||||
value: value,
|
||||
items: items
|
||||
.map((s) => DropdownMenuItem(
|
||||
value: s,
|
||||
child: Text(s, style: const TextStyle(fontSize: 13))))
|
||||
.toList(),
|
||||
onChanged: onChanged,
|
||||
style: const TextStyle(
|
||||
fontSize: 13, color: AppTheme.textPrimary),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,519 @@
|
||||
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/theme/app_theme.dart';
|
||||
import '../../widgets/form_dialog.dart';
|
||||
|
||||
class PartnersScreen extends ConsumerStatefulWidget {
|
||||
const PartnersScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<PartnersScreen> createState() => _PartnersScreenState();
|
||||
}
|
||||
|
||||
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': '合作中',
|
||||
},
|
||||
];
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PageScaffold(
|
||||
title: '往来单位',
|
||||
tabs: const [
|
||||
Tab(text: '供应商'),
|
||||
Tab(text: '客户'),
|
||||
],
|
||||
tabViews: [
|
||||
_buildSupplierList(),
|
||||
_buildCustomerList(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
return DataTableCard(
|
||||
totalCount: filtered.length,
|
||||
page: _suppliersPage,
|
||||
onPageChanged: (p) => setState(() => _suppliersPage = p),
|
||||
toolbar: Row(
|
||||
children: [
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => _showAddPartnerDialog(context, isSupplier: true),
|
||||
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(
|
||||
controller: _searchCtrl,
|
||||
decoration: const InputDecoration(
|
||||
hintText: '搜索供应商名称/编码',
|
||||
prefixIcon: Icon(Icons.search, size: 16),
|
||||
hintStyle: TextStyle(fontSize: 13),
|
||||
),
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
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('操作')),
|
||||
],
|
||||
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(),
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
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,
|
||||
),
|
||||
);
|
||||
},
|
||||
content: Column(
|
||||
children: [
|
||||
_DialogField(
|
||||
label: isSupplier ? '供应商名称' : '客户名称',
|
||||
required: true,
|
||||
child: TextFormField(
|
||||
controller: nameCtrl,
|
||||
decoration: InputDecoration(
|
||||
hintText: isSupplier ? '请输入供应商全称' : '请输入客户名称',
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _DialogField(
|
||||
label: '联系人',
|
||||
child: TextFormField(
|
||||
controller: contactCtrl,
|
||||
decoration: const InputDecoration(hintText: '联系人姓名'),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _DialogField(
|
||||
label: '联系电话',
|
||||
child: TextFormField(
|
||||
controller: phoneCtrl,
|
||||
decoration: const InputDecoration(hintText: '手机/座机'),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_DialogField(
|
||||
label: '地址',
|
||||
child: TextFormField(
|
||||
controller: addressCtrl,
|
||||
decoration: const InputDecoration(hintText: '详细地址'),
|
||||
),
|
||||
),
|
||||
if (isSupplier) ...[
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _DialogField(
|
||||
label: '账期(天)',
|
||||
child: TextFormField(
|
||||
initialValue: '30',
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(hintText: '结款账期'),
|
||||
),
|
||||
),
|
||||
),
|
||||
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(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,758 @@
|
||||
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/theme/app_theme.dart';
|
||||
import '../../widgets/form_dialog.dart';
|
||||
|
||||
class ProductsScreen extends ConsumerStatefulWidget {
|
||||
const ProductsScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ProductsScreen> createState() => _ProductsScreenState();
|
||||
}
|
||||
|
||||
class _ProductsScreenState extends ConsumerState<ProductsScreen> {
|
||||
int _productsPage = 1;
|
||||
int _categoriesPage = 1;
|
||||
int _warehousesPage = 1;
|
||||
final _searchCtrl = TextEditingController();
|
||||
String _categoryFilter = '全部';
|
||||
|
||||
final List<Map<String, dynamic>> _products = [
|
||||
{
|
||||
'code': 'SP001',
|
||||
'name': '茅台酒(飞天)53度500ml',
|
||||
'category': '白酒',
|
||||
'brand': '茅台',
|
||||
'spec': '500ml/瓶',
|
||||
'unit': '瓶',
|
||||
'barcode': '6901735001534',
|
||||
'costPrice': '2100.00',
|
||||
'salePrice': '2600.00',
|
||||
'minStock': 50,
|
||||
'maxStock': 500,
|
||||
'status': '启用',
|
||||
},
|
||||
{
|
||||
'code': 'SP002',
|
||||
'name': '五粮液(普五)52度500ml',
|
||||
'category': '白酒',
|
||||
'brand': '五粮液',
|
||||
'spec': '500ml/瓶',
|
||||
'unit': '瓶',
|
||||
'barcode': '6901234567890',
|
||||
'costPrice': '850.00',
|
||||
'salePrice': '1050.00',
|
||||
'minStock': 30,
|
||||
'maxStock': 300,
|
||||
'status': '启用',
|
||||
},
|
||||
{
|
||||
'code': 'SP003',
|
||||
'name': '洋河梦之蓝M6+ 45度500ml',
|
||||
'category': '白酒',
|
||||
'brand': '洋河',
|
||||
'spec': '500ml/瓶',
|
||||
'unit': '瓶',
|
||||
'barcode': '6902701234567',
|
||||
'costPrice': '480.00',
|
||||
'salePrice': '598.00',
|
||||
'minStock': 20,
|
||||
'maxStock': 200,
|
||||
'status': '启用',
|
||||
},
|
||||
{
|
||||
'code': 'SP004',
|
||||
'name': '剑南春(水晶剑)52度500ml',
|
||||
'category': '白酒',
|
||||
'brand': '剑南春',
|
||||
'spec': '500ml/瓶',
|
||||
'unit': '瓶',
|
||||
'barcode': '6921234567890',
|
||||
'costPrice': '288.00',
|
||||
'salePrice': '368.00',
|
||||
'minStock': 20,
|
||||
'maxStock': 200,
|
||||
'status': '启用',
|
||||
},
|
||||
{
|
||||
'code': 'SP005',
|
||||
'name': '泸州老窖(国窖1573)52度500ml',
|
||||
'category': '白酒',
|
||||
'brand': '泸州老窖',
|
||||
'spec': '500ml/瓶',
|
||||
'unit': '瓶',
|
||||
'barcode': '6910123456789',
|
||||
'costPrice': '680.00',
|
||||
'salePrice': '860.00',
|
||||
'minStock': 20,
|
||||
'maxStock': 200,
|
||||
'status': '启用',
|
||||
},
|
||||
{
|
||||
'code': 'SP006',
|
||||
'name': '汾酒(青花30)53度500ml',
|
||||
'category': '白酒',
|
||||
'brand': '汾酒',
|
||||
'spec': '500ml/瓶',
|
||||
'unit': '瓶',
|
||||
'barcode': '6936789012345',
|
||||
'costPrice': '320.00',
|
||||
'salePrice': '418.00',
|
||||
'minStock': 15,
|
||||
'maxStock': 150,
|
||||
'status': '启用',
|
||||
},
|
||||
{
|
||||
'code': 'SP007',
|
||||
'name': '拉菲古堡正牌红葡萄酒2018',
|
||||
'category': '葡萄酒',
|
||||
'brand': '拉菲',
|
||||
'spec': '750ml/瓶',
|
||||
'unit': '瓶',
|
||||
'barcode': '3760040234567',
|
||||
'costPrice': '5200.00',
|
||||
'salePrice': '6800.00',
|
||||
'minStock': 6,
|
||||
'maxStock': 60,
|
||||
'status': '启用',
|
||||
},
|
||||
{
|
||||
'code': 'SP008',
|
||||
'name': '人头马XO特优香槟干邑700ml',
|
||||
'category': '洋酒',
|
||||
'brand': '人头马',
|
||||
'spec': '700ml/瓶',
|
||||
'unit': '瓶',
|
||||
'barcode': '5010677012345',
|
||||
'costPrice': '1680.00',
|
||||
'salePrice': '2180.00',
|
||||
'minStock': 6,
|
||||
'maxStock': 60,
|
||||
'status': '启用',
|
||||
},
|
||||
{
|
||||
'code': 'SP009',
|
||||
'name': '百威啤酒330ml×24罐',
|
||||
'category': '啤酒',
|
||||
'brand': '百威',
|
||||
'spec': '330ml×24罐/箱',
|
||||
'unit': '箱',
|
||||
'barcode': '6901234000123',
|
||||
'costPrice': '58.00',
|
||||
'salePrice': '88.00',
|
||||
'minStock': 20,
|
||||
'maxStock': 200,
|
||||
'status': '启用',
|
||||
},
|
||||
{
|
||||
'code': 'SP010',
|
||||
'name': '青岛啤酒(经典)500ml×12瓶',
|
||||
'category': '啤酒',
|
||||
'brand': '青岛',
|
||||
'spec': '500ml×12瓶/箱',
|
||||
'unit': '箱',
|
||||
'barcode': '6901234000456',
|
||||
'costPrice': '42.00',
|
||||
'salePrice': '68.00',
|
||||
'minStock': 20,
|
||||
'maxStock': 200,
|
||||
'status': '启用',
|
||||
},
|
||||
{
|
||||
'code': 'SP011',
|
||||
'name': '芝华士12年苏格兰威士忌700ml',
|
||||
'category': '洋酒',
|
||||
'brand': '芝华士',
|
||||
'spec': '700ml/瓶',
|
||||
'unit': '瓶',
|
||||
'barcode': '5000299606230',
|
||||
'costPrice': '288.00',
|
||||
'salePrice': '398.00',
|
||||
'minStock': 10,
|
||||
'maxStock': 100,
|
||||
'status': '启用',
|
||||
},
|
||||
{
|
||||
'code': 'SP012',
|
||||
'name': '郎酒红花郎15年53度500ml',
|
||||
'category': '白酒',
|
||||
'brand': '郎酒',
|
||||
'spec': '500ml/瓶',
|
||||
'unit': '瓶',
|
||||
'barcode': '6914987012345',
|
||||
'costPrice': '620.00',
|
||||
'salePrice': '798.00',
|
||||
'minStock': 10,
|
||||
'maxStock': 100,
|
||||
'status': '禁用',
|
||||
},
|
||||
];
|
||||
|
||||
final List<Map<String, dynamic>> _categories = [
|
||||
{'code': 'BJ', 'name': '白酒', 'parent': '酒类', 'count': 7, 'status': '启用'},
|
||||
{'code': 'PTJ', 'name': '葡萄酒', 'parent': '酒类', 'count': 1, 'status': '启用'},
|
||||
{'code': 'YJ', 'name': '洋酒', 'parent': '酒类', 'count': 2, 'status': '启用'},
|
||||
{'code': 'PJ', 'name': '啤酒', 'parent': '酒类', 'count': 2, 'status': '启用'},
|
||||
{'code': 'HJ', 'name': '黄酒', 'parent': '酒类', 'count': 0, 'status': '启用'},
|
||||
{'code': 'MLJ', 'name': '米露酒', 'parent': '酒类', 'count': 0, 'status': '启用'},
|
||||
];
|
||||
|
||||
final List<Map<String, dynamic>> _warehouses = [
|
||||
{
|
||||
'code': 'CK001',
|
||||
'name': '主仓库',
|
||||
'type': '常规仓',
|
||||
'location': 'B1层西区',
|
||||
'capacity': 1000,
|
||||
'used': 720,
|
||||
'manager': '仓库主任',
|
||||
'status': '启用',
|
||||
},
|
||||
{
|
||||
'code': 'CK002',
|
||||
'name': '副仓库',
|
||||
'type': '常规仓',
|
||||
'location': 'B1层东区',
|
||||
'capacity': 500,
|
||||
'used': 150,
|
||||
'manager': '仓库副主任',
|
||||
'status': '启用',
|
||||
},
|
||||
{
|
||||
'code': 'CK003',
|
||||
'name': '保税仓库',
|
||||
'type': '保税仓',
|
||||
'location': 'B2层',
|
||||
'capacity': 200,
|
||||
'used': 0,
|
||||
'manager': '待指派',
|
||||
'status': '禁用',
|
||||
},
|
||||
];
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> get _filteredProducts {
|
||||
return _products.where((p) {
|
||||
if (_categoryFilter != '全部' && p['category'] != _categoryFilter) {
|
||||
return false;
|
||||
}
|
||||
final q = _searchCtrl.text.toLowerCase();
|
||||
if (q.isNotEmpty) {
|
||||
final name = (p['name'] as String).toLowerCase();
|
||||
final code = (p['code'] as String).toLowerCase();
|
||||
final brand = (p['brand'] as String).toLowerCase();
|
||||
if (!name.contains(q) && !code.contains(q) && !brand.contains(q)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PageScaffold(
|
||||
title: '基础数据',
|
||||
tabs: const [
|
||||
Tab(text: '商品档案'),
|
||||
Tab(text: '商品分类'),
|
||||
Tab(text: '仓库管理'),
|
||||
],
|
||||
tabViews: [
|
||||
_buildProductList(),
|
||||
_buildCategoryList(),
|
||||
_buildWarehouseList(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProductList() {
|
||||
final products = _filteredProducts;
|
||||
return DataTableCard(
|
||||
totalCount: products.length,
|
||||
page: _productsPage,
|
||||
onPageChanged: (p) => setState(() => _productsPage = p),
|
||||
toolbar: Row(
|
||||
children: [
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => _showProductDialog(context),
|
||||
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 Spacer(),
|
||||
_DropdownFilter(
|
||||
value: _categoryFilter,
|
||||
items: ['全部', '白酒', '葡萄酒', '洋酒', '啤酒', '黄酒'],
|
||||
onChanged: (v) => setState(() => _categoryFilter = v!),
|
||||
hint: '分类',
|
||||
),
|
||||
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(() {}),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
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('销售价'), numeric: true),
|
||||
DataColumn(label: Text('安全库存'), numeric: true),
|
||||
DataColumn(label: Text('状态')),
|
||||
DataColumn(label: Text('操作')),
|
||||
],
|
||||
rows: products
|
||||
.map((p) => DataRow(
|
||||
color: WidgetStateProperty.resolveWith((_) =>
|
||||
p['status'] == '禁用'
|
||||
? AppTheme.textSecondary.withOpacity(0.04)
|
||||
: null),
|
||||
cells: [
|
||||
DataCell(Text(p['code'] as String,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
color: AppTheme.textSecondary))),
|
||||
DataCell(SizedBox(
|
||||
width: 180,
|
||||
child: Text(p['name'] as String,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
)),
|
||||
DataCell(Text(p['category'] as String)),
|
||||
DataCell(Text(p['brand'] as String)),
|
||||
DataCell(Text(p['spec'] as String)),
|
||||
DataCell(Text(p['unit'] as String)),
|
||||
DataCell(Text('¥${p['costPrice']}')),
|
||||
DataCell(Text('¥${p['salePrice']}',
|
||||
style: const TextStyle(color: AppTheme.primary))),
|
||||
DataCell(Text('${p['minStock']}')),
|
||||
DataCell(_StatusBadge(p['status'] as String)),
|
||||
DataCell(Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () {},
|
||||
child: const Text('编辑',
|
||||
style: TextStyle(fontSize: 12))),
|
||||
TextButton(
|
||||
onPressed: () => setState(() {
|
||||
p['status'] = p['status'] == '启用' ? '禁用' : '启用';
|
||||
}),
|
||||
child: Text(
|
||||
p['status'] == '启用' ? '禁用' : '启用',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: p['status'] == '启用'
|
||||
? AppTheme.danger
|
||||
: AppTheme.success),
|
||||
)),
|
||||
],
|
||||
)),
|
||||
],
|
||||
))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCategoryList() {
|
||||
return DataTableCard(
|
||||
totalCount: _categories.length,
|
||||
page: _categoriesPage,
|
||||
onPageChanged: (p) => setState(() => _categoriesPage = p),
|
||||
toolbar: Row(
|
||||
children: [
|
||||
ElevatedButton.icon(
|
||||
onPressed: () {},
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: const Text('新增分类'),
|
||||
),
|
||||
],
|
||||
),
|
||||
columns: const [
|
||||
DataColumn(label: Text('分类编码')),
|
||||
DataColumn(label: Text('分类名称')),
|
||||
DataColumn(label: Text('上级分类')),
|
||||
DataColumn(label: Text('商品数量'), numeric: true),
|
||||
DataColumn(label: Text('状态')),
|
||||
DataColumn(label: Text('操作')),
|
||||
],
|
||||
rows: _categories
|
||||
.map((c) => DataRow(cells: [
|
||||
DataCell(Text(c['code'] as String,
|
||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 12))),
|
||||
DataCell(Text(c['name'] as String,
|
||||
style: const TextStyle(fontWeight: FontWeight.w500))),
|
||||
DataCell(Text(c['parent'] as String)),
|
||||
DataCell(Text('${c['count']}')),
|
||||
DataCell(_StatusBadge(c['status'] as String)),
|
||||
DataCell(Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () {},
|
||||
child: const Text('编辑',
|
||||
style: TextStyle(fontSize: 12))),
|
||||
if ((c['count'] as int) == 0)
|
||||
TextButton(
|
||||
onPressed: () {},
|
||||
child: const Text('删除',
|
||||
style: TextStyle(
|
||||
fontSize: 12, color: AppTheme.danger))),
|
||||
],
|
||||
)),
|
||||
]))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWarehouseList() {
|
||||
return DataTableCard(
|
||||
totalCount: _warehouses.length,
|
||||
page: _warehousesPage,
|
||||
onPageChanged: (p) => setState(() => _warehousesPage = p),
|
||||
toolbar: Row(
|
||||
children: [
|
||||
ElevatedButton.icon(
|
||||
onPressed: () {},
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: const Text('新增仓库'),
|
||||
),
|
||||
],
|
||||
),
|
||||
columns: const [
|
||||
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('使用率'), numeric: true),
|
||||
DataColumn(label: Text('负责人')),
|
||||
DataColumn(label: Text('状态')),
|
||||
DataColumn(label: Text('操作')),
|
||||
],
|
||||
rows: _warehouses
|
||||
.map((w) {
|
||||
final usageRate =
|
||||
((w['used'] as int) / (w['capacity'] as int) * 100)
|
||||
.toStringAsFixed(1);
|
||||
return 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['type'] as String)),
|
||||
DataCell(Text(w['location'] as String)),
|
||||
DataCell(Text('${w['capacity']}')),
|
||||
DataCell(Text('${w['used']}')),
|
||||
DataCell(Stack(
|
||||
alignment: Alignment.centerLeft,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 60,
|
||||
child: LinearProgressIndicator(
|
||||
value: (w['used'] as int) / (w['capacity'] as int),
|
||||
backgroundColor: AppTheme.border,
|
||||
color: (w['used'] as int) / (w['capacity'] as int) > 0.8
|
||||
? AppTheme.danger
|
||||
: AppTheme.primary,
|
||||
minHeight: 6,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: Text('$usageRate%',
|
||||
style: const TextStyle(fontSize: 11)),
|
||||
),
|
||||
],
|
||||
)),
|
||||
DataCell(Text(w['manager'] as String)),
|
||||
DataCell(_StatusBadge(w['status'] as String)),
|
||||
DataCell(Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () {},
|
||||
child: const Text('编辑',
|
||||
style: TextStyle(fontSize: 12))),
|
||||
],
|
||||
)),
|
||||
]);
|
||||
})
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
void _showProductDialog(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => FormDialog(
|
||||
title: '新增商品',
|
||||
width: 600,
|
||||
onConfirm: () {
|
||||
Navigator.of(ctx).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('商品添加成功'),
|
||||
backgroundColor: AppTheme.success,
|
||||
),
|
||||
);
|
||||
},
|
||||
content: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _Field(
|
||||
label: '商品名称',
|
||||
required: true,
|
||||
child: TextFormField(
|
||||
decoration:
|
||||
const InputDecoration(hintText: '请输入商品全称')),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _Field(
|
||||
label: '商品分类',
|
||||
required: true,
|
||||
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(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _Field(
|
||||
label: '品牌',
|
||||
child: TextFormField(
|
||||
decoration: const InputDecoration(hintText: '品牌名称')),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _Field(
|
||||
label: '规格',
|
||||
child: TextFormField(
|
||||
decoration:
|
||||
const InputDecoration(hintText: '如:500ml/瓶')),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _Field(
|
||||
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(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _Field(
|
||||
label: '成本价',
|
||||
child: TextFormField(
|
||||
decoration: const InputDecoration(
|
||||
hintText: '0.00', prefixText: '¥'),
|
||||
keyboardType:
|
||||
const TextInputType.numberWithOptions(decimal: true),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _Field(
|
||||
label: '销售价',
|
||||
child: TextFormField(
|
||||
decoration: const InputDecoration(
|
||||
hintText: '0.00', prefixText: '¥'),
|
||||
keyboardType:
|
||||
const TextInputType.numberWithOptions(decimal: true),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _Field(
|
||||
label: '安全库存',
|
||||
child: TextFormField(
|
||||
decoration: const InputDecoration(hintText: '最低库存量'),
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_Field(
|
||||
label: '条形码',
|
||||
fullWidth: true,
|
||||
child: TextFormField(
|
||||
decoration: const InputDecoration(hintText: '商品条形码(选填)')),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Field extends StatelessWidget {
|
||||
final String label;
|
||||
final Widget child;
|
||||
final bool required;
|
||||
final bool fullWidth;
|
||||
|
||||
const _Field({
|
||||
required this.label,
|
||||
required this.child,
|
||||
this.required = false,
|
||||
this.fullWidth = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget content = 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,
|
||||
],
|
||||
);
|
||||
return fullWidth
|
||||
? SizedBox(width: double.infinity, child: content)
|
||||
: content;
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusBadge extends StatelessWidget {
|
||||
final String status;
|
||||
const _StatusBadge(this.status);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bool enabled = status == '启用';
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: enabled
|
||||
? const Color(0xFFE8F5E9)
|
||||
: const Color(0xFFF5F5F5),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
status,
|
||||
style: TextStyle(
|
||||
color: enabled ? AppTheme.success : AppTheme.textSecondary,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DropdownFilter extends StatelessWidget {
|
||||
final String value;
|
||||
final List<String> items;
|
||||
final ValueChanged<String?> onChanged;
|
||||
final String hint;
|
||||
|
||||
const _DropdownFilter({
|
||||
required this.value,
|
||||
required this.items,
|
||||
required this.onChanged,
|
||||
required this.hint,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 36,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: AppTheme.border),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
color: AppTheme.surface,
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String>(
|
||||
value: value,
|
||||
items: items
|
||||
.map((s) => DropdownMenuItem(
|
||||
value: s,
|
||||
child: Text(s, style: const TextStyle(fontSize: 13))))
|
||||
.toList(),
|
||||
onChanged: onChanged,
|
||||
style: const TextStyle(
|
||||
fontSize: 13, color: AppTheme.textPrimary),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
|
||||
class SettingsScreen extends ConsumerStatefulWidget {
|
||||
const SettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<SettingsScreen> createState() => _SettingsScreenState();
|
||||
}
|
||||
|
||||
class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DefaultTabController(
|
||||
length: 4,
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
color: AppTheme.surface,
|
||||
child: const TabBar(
|
||||
isScrollable: true,
|
||||
labelColor: AppTheme.primary,
|
||||
unselectedLabelColor: AppTheme.textSecondary,
|
||||
indicatorColor: AppTheme.primary,
|
||||
indicatorWeight: 2,
|
||||
labelStyle:
|
||||
TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
|
||||
tabs: [
|
||||
Tab(text: '用户管理'),
|
||||
Tab(text: '仓库管理'),
|
||||
Tab(text: '编号规则'),
|
||||
Tab(text: '系统参数'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
children: [
|
||||
_buildUsersTab(),
|
||||
_buildWarehousesTab(),
|
||||
_buildNumberRulesTab(),
|
||||
_buildSystemParamsTab(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildUsersTab() {
|
||||
final users = [
|
||||
{'name': '张三', 'username': 'zhangsan', 'role': '管理员', 'status': true, 'lastLogin': '2026-04-04 09:15'},
|
||||
{'name': '李四', 'username': 'lisi', 'role': '操作员', 'status': true, 'lastLogin': '2026-04-04 08:30'},
|
||||
{'name': '王五', 'username': 'wangwu', 'role': '操作员', 'status': true, 'lastLogin': '2026-04-03 17:45'},
|
||||
{'name': '赵六', 'username': 'zhaoliu', 'role': '只读', 'status': false, 'lastLogin': '2026-03-28 10:20'},
|
||||
];
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
height: 52,
|
||||
color: AppTheme.surface,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => _showAddUserDialog(context),
|
||||
icon: const Icon(Icons.person_add, size: 16),
|
||||
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('操作')),
|
||||
],
|
||||
rows: users
|
||||
.map((u) => DataRow(cells: [
|
||||
DataCell(Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 14,
|
||||
backgroundColor: AppTheme.primary.withOpacity(0.15),
|
||||
child: Text(
|
||||
(u['name'] as String).substring(0, 1),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.primary,
|
||||
fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(u['name'] as String),
|
||||
],
|
||||
)),
|
||||
DataCell(Text(u['username'] as String,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace', fontSize: 12))),
|
||||
DataCell(_RoleBadge(u['role'] as String)),
|
||||
DataCell(Switch(
|
||||
value: u['status'] as bool,
|
||||
onChanged: (_) {},
|
||||
materialTapTargetSize:
|
||||
MaterialTapTargetSize.shrinkWrap,
|
||||
)),
|
||||
DataCell(Text(u['lastLogin'] as String,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.textSecondary))),
|
||||
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(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
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},
|
||||
];
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
height: 52,
|
||||
color: AppTheme.surface,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
ElevatedButton.icon(
|
||||
onPressed: () {},
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
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(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNumberRulesTab() {
|
||||
final rules = [
|
||||
{'type': '入库单', 'prefix': 'RK', 'format': 'RK{年}{月}{日}{序号4}', 'example': 'RK20260404001', 'currentNo': 4},
|
||||
{'type': '出库单', 'prefix': 'CK', 'format': 'CK{年}{月}{日}{序号4}', 'example': 'CK20260404001', 'currentNo': 2},
|
||||
{'type': '盘点单', 'prefix': 'PD', 'format': 'PD{年}{月}{日}{序号4}', 'example': 'PD20260404001', 'currentNo': 1},
|
||||
{'type': '商品编码', 'prefix': 'SP', 'format': 'SP{序号3}', 'example': 'SP001', 'currentNo': 12},
|
||||
];
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('编号规则配置',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 4),
|
||||
const Text('配置各类单据的自动编号规则,修改后对新建单据生效',
|
||||
style: TextStyle(fontSize: 13, color: AppTheme.textSecondary)),
|
||||
const SizedBox(height: 16),
|
||||
Card(
|
||||
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('当前序号'), numeric: true),
|
||||
DataColumn(label: Text('操作')),
|
||||
],
|
||||
rows: rules
|
||||
.map((r) => DataRow(cells: [
|
||||
DataCell(Text(r['type'] as String,
|
||||
style: const TextStyle(fontWeight: FontWeight.w500))),
|
||||
DataCell(Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(r['prefix'] as String,
|
||||
style: const TextStyle(
|
||||
color: AppTheme.primary,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600)),
|
||||
)),
|
||||
DataCell(Text(r['format'] as String,
|
||||
style: const TextStyle(
|
||||
fontSize: 12, color: AppTheme.textSecondary))),
|
||||
DataCell(Text(r['example'] as String,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace', fontSize: 12))),
|
||||
DataCell(Text('${r['currentNo']}')),
|
||||
DataCell(TextButton(
|
||||
onPressed: () {},
|
||||
child: const Text('编辑', style: TextStyle(fontSize: 12)))),
|
||||
]))
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSystemParamsTab() {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('系统参数',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 16),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('基本设置',
|
||||
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: AppTheme.primaryDark)),
|
||||
const Divider(height: 24),
|
||||
_ParamRow(label: '系统名称', value: '酒库管理系统'),
|
||||
_ParamRow(label: '货币单位', value: '人民币(CNY)'),
|
||||
_ParamRow(label: '日期格式', value: 'YYYY-MM-DD'),
|
||||
_ParamRow(label: '时区', value: 'Asia/Shanghai (UTC+8)'),
|
||||
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: '是', 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: '系统内通知'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
ElevatedButton(
|
||||
onPressed: () {},
|
||||
child: const Text('保存设置'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton(
|
||||
onPressed: () {},
|
||||
child: const Text('重置默认'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showAddUserDialog(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('新增用户'),
|
||||
content: SizedBox(
|
||||
width: 400,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const TextField(
|
||||
decoration: InputDecoration(labelText: '姓名', hintText: '请输入姓名'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const TextField(
|
||||
decoration: InputDecoration(labelText: '用户名', hintText: '请输入登录用户名'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const TextField(
|
||||
obscureText: true,
|
||||
decoration: InputDecoration(labelText: '初始密码', hintText: '请设置初始密码'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
DropdownButtonFormField<String>(
|
||||
value: '操作员',
|
||||
items: ['管理员', '操作员', '只读']
|
||||
.map((r) => DropdownMenuItem(value: r, child: Text(r)))
|
||||
.toList(),
|
||||
onChanged: (_) {},
|
||||
decoration: const InputDecoration(labelText: '角色'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('确定'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RoleBadge extends StatelessWidget {
|
||||
final String role;
|
||||
const _RoleBadge(this.role);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final Color bg;
|
||||
final Color fg;
|
||||
switch (role) {
|
||||
case '管理员':
|
||||
bg = const Color(0xFFE3F2FD);
|
||||
fg = AppTheme.primary;
|
||||
break;
|
||||
case '操作员':
|
||||
bg = const Color(0xFFE8F5E9);
|
||||
fg = AppTheme.success;
|
||||
break;
|
||||
default:
|
||||
bg = const Color(0xFFF5F5F5);
|
||||
fg = AppTheme.textSecondary;
|
||||
}
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(3)),
|
||||
child: Text(role, style: TextStyle(color: fg, fontSize: 12, fontWeight: FontWeight.w500)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ParamRow extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
final bool? isSwitch;
|
||||
|
||||
const _ParamRow({required this.label, required this.value, this.isSwitch});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 180,
|
||||
child: Text(label,
|
||||
style: const TextStyle(fontSize: 14, color: AppTheme.textSecondary)),
|
||||
),
|
||||
if (isSwitch != null)
|
||||
Switch(
|
||||
value: isSwitch!,
|
||||
onChanged: (_) {},
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
)
|
||||
else
|
||||
Text(value,
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
|
||||
const Spacer(),
|
||||
TextButton(onPressed: () {}, child: const Text('修改', style: TextStyle(fontSize: 12))),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'dart:async';
|
||||
import '../../core/auth/auth_state.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
|
||||
class AppShell extends ConsumerStatefulWidget {
|
||||
final Widget child;
|
||||
const AppShell({super.key, required this.child});
|
||||
|
||||
@override
|
||||
ConsumerState<AppShell> createState() => _AppShellState();
|
||||
}
|
||||
|
||||
class _AppShellState extends ConsumerState<AppShell> {
|
||||
bool _sidebarExpanded = true;
|
||||
late Timer _timer;
|
||||
late String _currentTime;
|
||||
final String _loginTime =
|
||||
DateFormat('HH:mm:ss').format(DateTime.now());
|
||||
|
||||
final List<_NavItem> _navItems = const [
|
||||
_NavItem(icon: Icons.input, label: '入库管理', path: '/stock-in'),
|
||||
_NavItem(icon: Icons.output, label: '出库管理', path: '/stock-out'),
|
||||
_NavItem(icon: Icons.inventory_2, label: '库存管理', path: '/inventory'),
|
||||
_NavItem(
|
||||
icon: Icons.account_balance_wallet,
|
||||
label: '财务管理',
|
||||
path: '/finance'),
|
||||
_NavItem(icon: Icons.people, label: '往来单位', path: '/partners'),
|
||||
_NavItem(icon: Icons.category, label: '基础数据', path: '/products'),
|
||||
_NavItem(icon: Icons.settings, label: '系统设置', path: '/settings'),
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_currentTime = DateFormat('HH:mm:ss').format(DateTime.now());
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
if (mounted) {
|
||||
setState(() =>
|
||||
_currentTime = DateFormat('HH:mm:ss').format(DateTime.now()));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final user = ref.watch(authStateProvider).user;
|
||||
final location = GoRouterState.of(context).matchedLocation;
|
||||
final sidebarWidth = _sidebarExpanded ? 200.0 : 56.0;
|
||||
|
||||
return Scaffold(
|
||||
body: Column(
|
||||
children: [
|
||||
// Top Bar
|
||||
Container(
|
||||
height: 56,
|
||||
color: AppTheme.primary,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
_sidebarExpanded ? Icons.menu_open : Icons.menu,
|
||||
color: Colors.white),
|
||||
onPressed: () =>
|
||||
setState(() => _sidebarExpanded = !_sidebarExpanded),
|
||||
tooltip: _sidebarExpanded ? '收起侧边栏' : '展开侧边栏',
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Icon(Icons.wine_bar, color: Colors.white, size: 22),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'酒库管理系统',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.5),
|
||||
),
|
||||
const Spacer(),
|
||||
if (user != null) ...[
|
||||
const Icon(Icons.business,
|
||||
color: Colors.white70, size: 14),
|
||||
const SizedBox(width: 4),
|
||||
Text(user.hotelName,
|
||||
style: const TextStyle(
|
||||
color: Colors.white70, fontSize: 13)),
|
||||
const SizedBox(width: 20),
|
||||
const Icon(Icons.person_outline,
|
||||
color: Colors.white70, size: 14),
|
||||
const SizedBox(width: 4),
|
||||
Text(user.username,
|
||||
style: const TextStyle(
|
||||
color: Colors.white70, fontSize: 13)),
|
||||
const SizedBox(width: 8),
|
||||
PopupMenuButton<String>(
|
||||
icon: const Icon(Icons.keyboard_arrow_down,
|
||||
color: Colors.white70),
|
||||
onSelected: (v) {
|
||||
if (v == 'logout') {
|
||||
ref.read(authStateProvider.notifier).logout();
|
||||
context.go('/login');
|
||||
}
|
||||
},
|
||||
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 SizedBox(width: 8),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
// Main area
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
// Sidebar
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeInOut,
|
||||
width: sidebarWidth,
|
||||
color: AppTheme.primaryDark,
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
children: _navItems.map((item) {
|
||||
final isActive =
|
||||
location.startsWith(item.path);
|
||||
return _SidebarItem(
|
||||
item: item,
|
||||
isActive: isActive,
|
||||
expanded: _sidebarExpanded,
|
||||
onTap: () => context.go(item.path),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Content area
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(child: widget.child),
|
||||
// Status bar
|
||||
Container(
|
||||
height: 28,
|
||||
color: const Color(0xFF37474F),
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
if (user != null) ...[
|
||||
_StatusItem(
|
||||
icon: Icons.store,
|
||||
text: '门店编号:${user.hotelNo}'),
|
||||
const _StatusDivider(),
|
||||
_StatusItem(
|
||||
icon: Icons.person,
|
||||
text: '登录用户:${user.username}'),
|
||||
const _StatusDivider(),
|
||||
_StatusItem(
|
||||
icon: Icons.login,
|
||||
text: '登录时间:$_loginTime'),
|
||||
const _StatusDivider(),
|
||||
],
|
||||
_StatusItem(
|
||||
icon: Icons.access_time,
|
||||
text: '当前时间:$_currentTime'),
|
||||
const Spacer(),
|
||||
const _StatusItem(
|
||||
icon: Icons.info_outline,
|
||||
text: 'v1.0.0'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NavItem {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String path;
|
||||
const _NavItem(
|
||||
{required this.icon, required this.label, required this.path});
|
||||
}
|
||||
|
||||
class _SidebarItem extends StatefulWidget {
|
||||
final _NavItem item;
|
||||
final bool isActive;
|
||||
final bool expanded;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _SidebarItem({
|
||||
required this.item,
|
||||
required this.isActive,
|
||||
required this.expanded,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_SidebarItem> createState() => _SidebarItemState();
|
||||
}
|
||||
|
||||
class _SidebarItemState extends State<_SidebarItem> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isActive = widget.isActive;
|
||||
final expanded = widget.expanded;
|
||||
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
height: 48,
|
||||
color: isActive
|
||||
? AppTheme.primary.withOpacity(0.3)
|
||||
: _hovered
|
||||
? Colors.white.withOpacity(0.05)
|
||||
: Colors.transparent,
|
||||
padding: EdgeInsets.symmetric(horizontal: expanded ? 0 : 0),
|
||||
child: Row(
|
||||
children: [
|
||||
// Active indicator
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
width: 3,
|
||||
height: isActive ? 28 : 0,
|
||||
color: Colors.white,
|
||||
margin: EdgeInsets.only(right: expanded ? 13 : 0),
|
||||
),
|
||||
if (!isActive) SizedBox(width: expanded ? 16 : 3),
|
||||
Expanded(
|
||||
child: Row(
|
||||
mainAxisAlignment: expanded
|
||||
? MainAxisAlignment.start
|
||||
: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
widget.item.icon,
|
||||
color: isActive ? Colors.white : Colors.white60,
|
||||
size: 20,
|
||||
),
|
||||
if (expanded) ...[
|
||||
const SizedBox(width: 12),
|
||||
Flexible(
|
||||
child: Text(
|
||||
widget.item.label,
|
||||
style: TextStyle(
|
||||
color: isActive
|
||||
? Colors.white
|
||||
: Colors.white70,
|
||||
fontSize: 14,
|
||||
fontWeight: isActive
|
||||
? FontWeight.w500
|
||||
: FontWeight.normal,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusItem extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String text;
|
||||
const _StatusItem({required this.icon, required this.text});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 11, color: Colors.white54),
|
||||
const SizedBox(width: 4),
|
||||
Text(text,
|
||||
style: const TextStyle(color: Colors.white54, fontSize: 11)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusDivider extends StatelessWidget {
|
||||
const _StatusDivider();
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 1,
|
||||
height: 12,
|
||||
color: Colors.white24,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 10),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,549 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
|
||||
class StockInFormScreen extends ConsumerStatefulWidget {
|
||||
const StockInFormScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<StockInFormScreen> createState() => _StockInFormScreenState();
|
||||
}
|
||||
|
||||
class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _orderNoCtrl = TextEditingController(text: 'RK20260404004');
|
||||
final _remarkCtrl = TextEditingController();
|
||||
String _supplier = '贵州茅台酒股份有限公司';
|
||||
String _warehouse = '主仓库';
|
||||
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'),
|
||||
},
|
||||
];
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_orderNoCtrl.dispose();
|
||||
_remarkCtrl.dispose();
|
||||
for (final item in _items) {
|
||||
(item['qty'] as TextEditingController).dispose();
|
||||
(item['price'] as TextEditingController).dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
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;
|
||||
total += qty * price;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
void _addItem() {
|
||||
setState(() {
|
||||
_items.add({
|
||||
'name': '',
|
||||
'spec': '',
|
||||
'unit': '瓶',
|
||||
'qty': TextEditingController(),
|
||||
'price': TextEditingController(),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void _removeItem(int index) {
|
||||
setState(() {
|
||||
final item = _items.removeAt(index);
|
||||
(item['qty'] as TextEditingController).dispose();
|
||||
(item['price'] as TextEditingController).dispose();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _submit(bool asDraft) async {
|
||||
if (!asDraft && !_formKey.currentState!.validate()) return;
|
||||
setState(() => _submitting = true);
|
||||
await Future.delayed(const Duration(milliseconds: 600));
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(asDraft ? '已保存为草稿' : '入库单已提交审核'),
|
||||
backgroundColor: AppTheme.success,
|
||||
),
|
||||
);
|
||||
context.go('/stock-in');
|
||||
}
|
||||
if (mounted) setState(() => _submitting = false);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppTheme.background,
|
||||
body: Column(
|
||||
children: [
|
||||
// Page header
|
||||
Container(
|
||||
height: 52,
|
||||
color: AppTheme.surface,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_back, size: 20),
|
||||
onPressed: () => context.go('/stock-in'),
|
||||
tooltip: '返回',
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text('新建入库单',
|
||||
style: TextStyle(
|
||||
fontSize: 16, fontWeight: FontWeight.w600)),
|
||||
const Spacer(),
|
||||
OutlinedButton(
|
||||
onPressed: _submitting ? null : () => _submit(true),
|
||||
child: const Text('保存草稿'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _submitting ? null : () => _submit(false),
|
||||
icon: _submitting
|
||||
? const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: Colors.white))
|
||||
: const Icon(Icons.send, size: 16),
|
||||
label: const Text('提交审核'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => context.go('/stock-in'),
|
||||
icon: const Icon(Icons.cancel_outlined, size: 16),
|
||||
label: const Text('取消'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Basic info card
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('基本信息',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.primaryDark)),
|
||||
const SizedBox(height: 16),
|
||||
Wrap(
|
||||
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(),
|
||||
),
|
||||
),
|
||||
_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(),
|
||||
),
|
||||
),
|
||||
_FormField(
|
||||
label: '入库日期',
|
||||
required: true,
|
||||
child: InkWell(
|
||||
onTap: _pickDate,
|
||||
child: InputDecorator(
|
||||
decoration: const InputDecoration(),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${_orderDate.year}-${_orderDate.month.toString().padLeft(2, '0')}-${_orderDate.day.toString().padLeft(2, '0')}',
|
||||
style: const TextStyle(
|
||||
fontSize: 13),
|
||||
),
|
||||
),
|
||||
const Icon(
|
||||
Icons.calendar_today,
|
||||
size: 16,
|
||||
color: AppTheme.textSecondary),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_FormField(
|
||||
label: '备注',
|
||||
width: double.infinity,
|
||||
child: TextFormField(
|
||||
controller: _remarkCtrl,
|
||||
maxLines: 2,
|
||||
decoration: const InputDecoration(
|
||||
hintText: '选填,如有特殊说明请在此注明',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Items card
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Text('商品明细',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.primaryDark)),
|
||||
const Spacer(),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _addItem,
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: const Text('添加商品'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size(0, 32)),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Items table
|
||||
Table(
|
||||
columnWidths: const {
|
||||
0: FixedColumnWidth(36),
|
||||
1: FlexColumnWidth(3),
|
||||
2: FlexColumnWidth(2),
|
||||
3: FixedColumnWidth(60),
|
||||
4: FlexColumnWidth(1.5),
|
||||
5: FlexColumnWidth(1.5),
|
||||
6: FlexColumnWidth(1.5),
|
||||
7: FixedColumnWidth(60),
|
||||
},
|
||||
children: [
|
||||
TableRow(
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFFF0F4FF)),
|
||||
children: [
|
||||
'序号',
|
||||
'商品名称',
|
||||
'规格',
|
||||
'单位',
|
||||
'数量',
|
||||
'单价',
|
||||
'金额',
|
||||
'操作',
|
||||
]
|
||||
.map((h) => Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 10),
|
||||
child: Text(h,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color:
|
||||
AppTheme.primaryDark)),
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
...List.generate(
|
||||
_items.length,
|
||||
(i) => _buildItemRow(i)),
|
||||
],
|
||||
),
|
||||
const Divider(height: 1),
|
||||
// Total
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
const Text('合计金额:',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500)),
|
||||
Text(
|
||||
'¥${_totalAmount.toStringAsFixed(2)}',
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppTheme.danger),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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 amount = qty * price;
|
||||
|
||||
return TableRow(
|
||||
decoration: BoxDecoration(
|
||||
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: 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,
|
||||
decoration: const InputDecoration(hintText: '0'),
|
||||
style: const TextStyle(fontSize: 13),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}'))
|
||||
],
|
||||
onChanged: (_) => setState(() {}),
|
||||
validator: (v) {
|
||||
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,
|
||||
decoration: const InputDecoration(
|
||||
hintText: '0.00', prefixText: '¥'),
|
||||
style: const TextStyle(fontSize: 13),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}'))
|
||||
],
|
||||
onChanged: (_) => setState(() {}),
|
||||
validator: (v) {
|
||||
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(
|
||||
'¥${amount.toStringAsFixed(2)}',
|
||||
style: const TextStyle(
|
||||
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,
|
||||
tooltip: '删除',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints:
|
||||
const BoxConstraints(minWidth: 28, minHeight: 28),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pickDate() async {
|
||||
final date = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _orderDate,
|
||||
firstDate: DateTime(2020),
|
||||
lastDate: DateTime(2030),
|
||||
);
|
||||
if (date != null) setState(() => _orderDate = date);
|
||||
}
|
||||
}
|
||||
|
||||
class _FormField extends StatelessWidget {
|
||||
final String label;
|
||||
final Widget child;
|
||||
final bool required;
|
||||
final double width;
|
||||
|
||||
const _FormField({
|
||||
required this.label,
|
||||
required this.child,
|
||||
this.required = false,
|
||||
this.width = 240,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: width,
|
||||
child: 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,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
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';
|
||||
|
||||
class StockInListScreen extends ConsumerStatefulWidget {
|
||||
const StockInListScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<StockInListScreen> createState() =>
|
||||
_StockInListScreenState();
|
||||
}
|
||||
|
||||
class _StockInListScreenState extends ConsumerState<StockInListScreen> {
|
||||
int _page = 1;
|
||||
final _searchCtrl = TextEditingController();
|
||||
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',
|
||||
},
|
||||
];
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PageScaffold(
|
||||
title: '入库管理',
|
||||
tabs: const [
|
||||
Tab(text: '入库单'),
|
||||
Tab(text: '入库查询'),
|
||||
Tab(text: '入库审核'),
|
||||
],
|
||||
tabViews: [
|
||||
_buildOrderList(),
|
||||
_buildQueryView(),
|
||||
_buildOrderList(forceStatus: OrderStatus.pending),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildOrderList({OrderStatus? forceStatus}) {
|
||||
final orders = _filteredOrders(forceStatus: forceStatus);
|
||||
return DataTableCard(
|
||||
totalCount: orders.length,
|
||||
page: _page,
|
||||
onPageChanged: (p) => setState(() => _page = p),
|
||||
toolbar: Row(
|
||||
children: [
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => context.go('/stock-in/new'),
|
||||
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) ...[
|
||||
_StatusFilterDropdown(
|
||||
value: _statusFilter,
|
||||
onChanged: (v) => setState(() {
|
||||
_statusFilter = v!;
|
||||
_page = 1;
|
||||
}),
|
||||
),
|
||||
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)}',
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
columns: const [
|
||||
DataColumn(label: Text('入库单号')),
|
||||
DataColumn(label: Text('供应商')),
|
||||
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()),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pickDateRange() async {
|
||||
final range = await showDateRangePicker(
|
||||
context: context,
|
||||
firstDate: DateTime(2020),
|
||||
lastDate: DateTime(2030),
|
||||
initialDateRange: _dateRange,
|
||||
);
|
||||
if (range != null) setState(() => _dateRange = range);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusFilterDropdown extends StatelessWidget {
|
||||
final String value;
|
||||
final ValueChanged<String?> onChanged;
|
||||
|
||||
const _StatusFilterDropdown({
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 36,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: AppTheme.border),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
color: AppTheme.surface,
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String>(
|
||||
value: value,
|
||||
items: ['全部', '草稿', '待审核', '已审核', '已拒绝']
|
||||
.map((s) => DropdownMenuItem(
|
||||
value: s,
|
||||
child: Text(s, style: const TextStyle(fontSize: 13))))
|
||||
.toList(),
|
||||
onChanged: onChanged,
|
||||
style: const TextStyle(
|
||||
fontSize: 13, color: AppTheme.textPrimary),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
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';
|
||||
|
||||
class StockOutListScreen extends ConsumerStatefulWidget {
|
||||
const StockOutListScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<StockOutListScreen> createState() =>
|
||||
_StockOutListScreenState();
|
||||
}
|
||||
|
||||
class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
||||
int _page = 1;
|
||||
final _searchCtrl = TextEditingController();
|
||||
String _statusFilter = '全部';
|
||||
String _typeFilter = '全部';
|
||||
|
||||
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': '领用',
|
||||
},
|
||||
];
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PageScaffold(
|
||||
title: '出库管理',
|
||||
tabs: const [
|
||||
Tab(text: '出库单'),
|
||||
Tab(text: '出库查询'),
|
||||
Tab(text: '出库审核'),
|
||||
],
|
||||
tabViews: [
|
||||
_buildList(),
|
||||
_buildList(),
|
||||
_buildList(forceStatus: OrderStatus.pending),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildList({OrderStatus? forceStatus}) {
|
||||
final orders = forceStatus != null
|
||||
? _mockOrders
|
||||
.where((o) => o['status'] == forceStatus)
|
||||
.toList()
|
||||
: _filtered;
|
||||
|
||||
return DataTableCard(
|
||||
totalCount: orders.length,
|
||||
page: _page,
|
||||
onPageChanged: (p) => setState(() => _page = p),
|
||||
toolbar: Row(
|
||||
children: [
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => _showCreateDialog(context),
|
||||
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(
|
||||
value: _statusFilter,
|
||||
items: ['全部', '草稿', '待审核', '已审核', '已拒绝'],
|
||||
onChanged: (v) => setState(() => _statusFilter = v!),
|
||||
hint: '状态',
|
||||
),
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
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('日期')),
|
||||
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))),
|
||||
],
|
||||
],
|
||||
)),
|
||||
]))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
void _showCreateDialog(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('新建出库单'),
|
||||
content: const SizedBox(
|
||||
width: 400,
|
||||
child: Text('出库单创建功能完整实现请参考入库单流程。'),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
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)),
|
||||
),
|
||||
child: Text(type,
|
||||
style: TextStyle(
|
||||
color: color, fontSize: 12, fontWeight: FontWeight.w500)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DropdownFilter extends StatelessWidget {
|
||||
final String value;
|
||||
final List<String> items;
|
||||
final ValueChanged<String?> onChanged;
|
||||
final String hint;
|
||||
|
||||
const _DropdownFilter({
|
||||
required this.value,
|
||||
required this.items,
|
||||
required this.onChanged,
|
||||
required this.hint,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 36,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: AppTheme.border),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
color: AppTheme.surface,
|
||||
),
|
||||
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(),
|
||||
onChanged: onChanged,
|
||||
style: const TextStyle(
|
||||
fontSize: 13, color: AppTheme.textPrimary),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../core/theme/app_theme.dart';
|
||||
|
||||
class DataTableCard extends StatelessWidget {
|
||||
final List<DataColumn> columns;
|
||||
final List<DataRow> rows;
|
||||
final Widget? toolbar;
|
||||
final int? totalCount;
|
||||
final int page;
|
||||
final int pageSize;
|
||||
final ValueChanged<int>? onPageChanged;
|
||||
|
||||
const DataTableCard({
|
||||
super.key,
|
||||
required this.columns,
|
||||
required this.rows,
|
||||
this.toolbar,
|
||||
this.totalCount,
|
||||
this.page = 1,
|
||||
this.pageSize = 20,
|
||||
this.onPageChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
if (toolbar != null)
|
||||
Container(
|
||||
height: 52,
|
||||
color: AppTheme.surface,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: toolbar!,
|
||||
),
|
||||
if (toolbar != null) const Divider(height: 1),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: DataTable(
|
||||
headingRowColor: WidgetStateProperty.all(
|
||||
const Color(0xFFF0F4FF)),
|
||||
headingTextStyle: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.primaryDark,
|
||||
),
|
||||
dataTextStyle: const TextStyle(
|
||||
fontSize: 13, color: AppTheme.textPrimary),
|
||||
columnSpacing: 24,
|
||||
horizontalMargin: 16,
|
||||
dataRowMinHeight: 40,
|
||||
dataRowMaxHeight: 48,
|
||||
dividerThickness: 0.5,
|
||||
columns: columns,
|
||||
rows: rows,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Pagination
|
||||
if (totalCount != null)
|
||||
Container(
|
||||
height: 48,
|
||||
color: AppTheme.surface,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'共 $totalCount 条记录',
|
||||
style: const TextStyle(
|
||||
fontSize: 13, color: AppTheme.textSecondary),
|
||||
),
|
||||
const Spacer(),
|
||||
_PaginationBar(
|
||||
page: page,
|
||||
total: totalCount!,
|
||||
pageSize: pageSize,
|
||||
onPageChanged: onPageChanged,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PaginationBar extends StatelessWidget {
|
||||
final int page;
|
||||
final int total;
|
||||
final int pageSize;
|
||||
final ValueChanged<int>? onPageChanged;
|
||||
|
||||
const _PaginationBar({
|
||||
required this.page,
|
||||
required this.total,
|
||||
required this.pageSize,
|
||||
this.onPageChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final totalPages = (total / pageSize).ceil().clamp(1, 9999);
|
||||
return Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.first_page, size: 18),
|
||||
onPressed: page > 1 ? () => onPageChanged?.call(1) : null,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.chevron_left, size: 18),
|
||||
onPressed: page > 1 ? () => onPageChanged?.call(page - 1) : null,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
|
||||
),
|
||||
Text('$page / $totalPages',
|
||||
style: const TextStyle(fontSize: 13)),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.chevron_right, size: 18),
|
||||
onPressed:
|
||||
page < totalPages ? () => onPageChanged?.call(page + 1) : null,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.last_page, size: 18),
|
||||
onPressed: page < totalPages
|
||||
? () => onPageChanged?.call(totalPages)
|
||||
: null,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../core/theme/app_theme.dart';
|
||||
|
||||
/// Generic dialog wrapper for forms
|
||||
class FormDialog extends StatelessWidget {
|
||||
final String title;
|
||||
final Widget content;
|
||||
final String confirmLabel;
|
||||
final VoidCallback? onConfirm;
|
||||
final bool loading;
|
||||
final double width;
|
||||
|
||||
const FormDialog({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.content,
|
||||
this.confirmLabel = '保存',
|
||||
this.onConfirm,
|
||||
this.loading = false,
|
||||
this.width = 560,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
|
||||
child: SizedBox(
|
||||
width: width,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Title bar
|
||||
Container(
|
||||
height: 48,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppTheme.primary,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(4),
|
||||
topRight: Radius.circular(4),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.white, size: 18),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Content
|
||||
Flexible(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: content,
|
||||
),
|
||||
),
|
||||
// Action bar
|
||||
const Divider(height: 1),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
OutlinedButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ElevatedButton(
|
||||
onPressed: loading ? null : onConfirm,
|
||||
child: loading
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: Colors.white))
|
||||
: Text(confirmLabel),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Show a form dialog and return the result
|
||||
Future<T?> showFormDialog<T>({
|
||||
required BuildContext context,
|
||||
required String title,
|
||||
required Widget content,
|
||||
String confirmLabel = '保存',
|
||||
VoidCallback? onConfirm,
|
||||
double width = 560,
|
||||
}) {
|
||||
return showDialog<T>(
|
||||
context: context,
|
||||
builder: (_) => FormDialog(
|
||||
title: title,
|
||||
content: content,
|
||||
confirmLabel: confirmLabel,
|
||||
onConfirm: onConfirm,
|
||||
width: width,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../core/theme/app_theme.dart';
|
||||
|
||||
class PageScaffold extends StatelessWidget {
|
||||
final String title;
|
||||
final List<Widget> tabs;
|
||||
final List<Widget> tabViews;
|
||||
final int initialTab;
|
||||
|
||||
const PageScaffold({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.tabs,
|
||||
required this.tabViews,
|
||||
this.initialTab = 0,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DefaultTabController(
|
||||
length: tabs.length,
|
||||
initialIndex: initialTab,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
color: AppTheme.surface,
|
||||
child: TabBar(
|
||||
isScrollable: true,
|
||||
tabAlignment: TabAlignment.start,
|
||||
labelColor: AppTheme.primary,
|
||||
unselectedLabelColor: AppTheme.textSecondary,
|
||||
indicatorColor: AppTheme.primary,
|
||||
indicatorWeight: 2,
|
||||
labelStyle:
|
||||
const TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
|
||||
tabs: tabs,
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: TabBarView(children: tabViews),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../core/theme/app_theme.dart';
|
||||
|
||||
enum OrderStatus { draft, pending, approved, rejected }
|
||||
|
||||
extension OrderStatusLabel on OrderStatus {
|
||||
String get label {
|
||||
switch (this) {
|
||||
case OrderStatus.draft:
|
||||
return '草稿';
|
||||
case OrderStatus.pending:
|
||||
return '待审核';
|
||||
case OrderStatus.approved:
|
||||
return '已审核';
|
||||
case OrderStatus.rejected:
|
||||
return '已拒绝';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class StatusBadge extends StatelessWidget {
|
||||
final OrderStatus status;
|
||||
const StatusBadge(this.status, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final Color bg;
|
||||
final Color fg;
|
||||
switch (status) {
|
||||
case OrderStatus.draft:
|
||||
bg = const Color(0xFFF5F5F5);
|
||||
fg = AppTheme.textSecondary;
|
||||
break;
|
||||
case OrderStatus.pending:
|
||||
bg = const Color(0xFFFFF3E0);
|
||||
fg = AppTheme.accent;
|
||||
break;
|
||||
case OrderStatus.approved:
|
||||
bg = const Color(0xFFE8F5E9);
|
||||
fg = AppTheme.success;
|
||||
break;
|
||||
case OrderStatus.rejected:
|
||||
bg = const Color(0xFFFFEBEE);
|
||||
fg = AppTheme.danger;
|
||||
break;
|
||||
}
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
status.label,
|
||||
style: TextStyle(
|
||||
color: fg, fontSize: 12, fontWeight: FontWeight.w500),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user