diff --git a/backend/internal/handler/finance.go b/backend/internal/handler/finance.go new file mode 100644 index 0000000..34292aa --- /dev/null +++ b/backend/internal/handler/finance.go @@ -0,0 +1,63 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" + + "github.com/wangjia/jiu/backend/internal/middleware" + "github.com/wangjia/jiu/backend/internal/model" +) + +type FinanceHandler struct { + db *gorm.DB +} + +func NewFinanceHandler(db *gorm.DB) *FinanceHandler { + return &FinanceHandler{db: db} +} + +// ListRecords GET /api/v1/finance/records +func (h *FinanceHandler) ListRecords(c *gin.Context) { + shopID := middleware.GetShopID(c) + + var q struct { + Type string `form:"type"` + Month string `form:"month"` // e.g. "2026-04" + Page int `form:"page,default=1"` + PageSize int `form:"page_size,default=50"` + } + if err := c.ShouldBindQuery(&q); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if q.PageSize <= 0 { + q.PageSize = 50 + } + if q.Page <= 0 { + q.Page = 1 + } + + base := h.db.Model(&model.FinanceRecord{}).Where("shop_id = ?", shopID) + if q.Type != "" { + base = base.Where("type = ?", q.Type) + } + if q.Month != "" { + base = base.Where("DATE_FORMAT(record_date, '%Y-%m') = ?", q.Month) + } + + var total int64 + base.Count(&total) + + var records []model.FinanceRecord + offset := (q.Page - 1) * q.PageSize + base.Preload("Partner").Order("record_date DESC, id DESC").Offset(offset).Limit(q.PageSize).Find(&records) + + c.JSON(http.StatusOK, gin.H{ + "data": records, + "total": total, + "page": q.Page, + "page_size": q.PageSize, + }) +} diff --git a/backend/internal/handler/inventory.go b/backend/internal/handler/inventory.go index 3f4540f..589b620 100644 --- a/backend/internal/handler/inventory.go +++ b/backend/internal/handler/inventory.go @@ -64,11 +64,46 @@ func (h *InventoryHandler) Logs(c *gin.Context) { query.Count(&total) var logs []model.InventoryLog - query.Offset((page - 1) * pageSize).Limit(pageSize).Order("id DESC").Find(&logs) + query.Preload("Product").Preload("Warehouse").Offset((page-1)*pageSize).Limit(pageSize).Order("id DESC").Find(&logs) c.JSON(http.StatusOK, gin.H{"data": logs, "total": total, "page": page, "page_size": pageSize}) } +// Batches GET /api/v1/inventory/batches — 批次追踪:已审核入库单的明细行 +func (h *InventoryHandler) Batches(c *gin.Context) { + shopID := middleware.GetShopID(c) + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + + // 查询已审核的入库单明细,关联商品和仓库信息 + query := h.db.Model(&model.StockInItem{}). + Joins("JOIN stock_in_orders ON stock_in_orders.id = stock_in_items.order_id"). + Where("stock_in_orders.shop_id = ? AND stock_in_orders.status = 'approved'", shopID) + + if productID := c.Query("product_id"); productID != "" { + query = query.Where("stock_in_items.product_id = ?", productID) + } + if warehouseID := c.Query("warehouse_id"); warehouseID != "" { + query = query.Where("stock_in_orders.warehouse_id = ?", warehouseID) + } + + var total int64 + query.Count(&total) + + var items []model.StockInItem + query. + Preload("Product"). + Preload("Order", func(db *gorm.DB) *gorm.DB { + return db.Preload("Warehouse").Preload("Partner") + }). + Select("stock_in_items.*"). + Order("stock_in_orders.order_date DESC, stock_in_items.id DESC"). + Offset((page - 1) * pageSize).Limit(pageSize). + Find(&items) + + c.JSON(http.StatusOK, gin.H{"data": items, "total": total, "page": page, "page_size": pageSize}) +} + // CreateCheck POST /api/v1/inventory/checks func (h *InventoryHandler) CreateCheck(c *gin.Context) { shopID := middleware.GetShopID(c) diff --git a/backend/internal/handler/number_rule.go b/backend/internal/handler/number_rule.go new file mode 100644 index 0000000..f077900 --- /dev/null +++ b/backend/internal/handler/number_rule.go @@ -0,0 +1,66 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" + + "github.com/wangjia/jiu/backend/internal/middleware" + "github.com/wangjia/jiu/backend/internal/model" +) + +type NumberRuleHandler struct { + db *gorm.DB +} + +func NewNumberRuleHandler(db *gorm.DB) *NumberRuleHandler { + return &NumberRuleHandler{db: db} +} + +// List GET /api/v1/number-rules +func (h *NumberRuleHandler) List(c *gin.Context) { + shopID := middleware.GetShopID(c) + var rules []model.NumberRule + h.db.Where("shop_id = ?", shopID).Order("id").Find(&rules) + c.JSON(http.StatusOK, gin.H{"data": rules}) +} + +// Update PUT /api/v1/number-rules/:id +func (h *NumberRuleHandler) Update(c *gin.Context) { + shopID := middleware.GetShopID(c) + id := c.Param("id") + + var rule model.NumberRule + if err := h.db.Where("id = ? AND shop_id = ?", id, shopID).First(&rule).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + + var req struct { + Prefix string `json:"prefix"` + CurrentNo *int `json:"current_no"` + DateFormat string `json:"date_format"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + updates := map[string]any{} + if req.Prefix != "" { + updates["prefix"] = req.Prefix + } + if req.CurrentNo != nil { + updates["current_no"] = *req.CurrentNo + } + if req.DateFormat != "" { + updates["date_format"] = req.DateFormat + } + + if err := h.db.Model(&rule).Updates(updates).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"data": rule}) +} diff --git a/backend/internal/model/stock.go b/backend/internal/model/stock.go index 903fc0b..31c531e 100644 --- a/backend/internal/model/stock.go +++ b/backend/internal/model/stock.go @@ -37,7 +37,8 @@ type StockInItem struct { CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"` Remark string `gorm:"size:255" json:"remark"` - Product *Product `gorm:"foreignKey:ProductID" json:"product,omitempty"` + Product *Product `gorm:"foreignKey:ProductID" json:"product,omitempty"` + Order *StockInOrder `gorm:"foreignKey:OrderID" json:"order,omitempty"` } // -------- 出库单 -------- @@ -106,6 +107,9 @@ type InventoryLog struct { RefID uint64 `json:"ref_id"` OperatorID *uint64 `json:"operator_id"` CreatedAt time.Time `json:"created_at"` + + Product *Product `gorm:"foreignKey:ProductID" json:"product,omitempty"` + Warehouse *Warehouse `gorm:"foreignKey:WarehouseID" json:"warehouse,omitempty"` } // -------- 库存盘点 -------- diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 328b6df..7fb39c6 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -26,6 +26,8 @@ func Setup(r *gin.Engine, db *gorm.DB) { inventoryH := handler.NewInventoryHandler(db) importH := handler.NewImportHandler(db) userH := handler.NewUserHandler(db) + financeH := handler.NewFinanceHandler(db) + numberRuleH := handler.NewNumberRuleHandler(db) v1 := r.Group("/api/v1") @@ -102,6 +104,7 @@ func Setup(r *gin.Engine, db *gorm.DB) { { inventory.GET("", inventoryH.List) inventory.GET("/logs", inventoryH.Logs) + inventory.GET("/batches", inventoryH.Batches) inventory.POST("/checks", inventoryH.CreateCheck) inventory.GET("/checks/:id", inventoryH.GetCheck) } @@ -115,6 +118,19 @@ func Setup(r *gin.Engine, db *gorm.DB) { users.PUT("/:id/reset-password", userH.ResetPassword) } + // 财务 + finance := api.Group("/finance") + { + finance.GET("/records", financeH.ListRecords) + } + + // 编号规则 + numberRules := api.Group("/number-rules") + { + numberRules.GET("", numberRuleH.List) + numberRules.PUT("/:id", numberRuleH.Update) + } + // 导入 imp := api.Group("/import") { diff --git a/backend/internal/service/stock_test.go b/backend/internal/service/stock_test.go index b9e9bc2..29c0dc9 100644 --- a/backend/internal/service/stock_test.go +++ b/backend/internal/service/stock_test.go @@ -27,7 +27,7 @@ func TestStockService_ApproveStockIn_Success(t *testing.T) { WarehouseID: warehouse.ID, OperatorID: user.ID, Status: "pending", - OrderDate: time.Now(), + OrderDate: model.Date{Time: time.Now()}, Items: []model.StockInItem{ { ShopID: shop.ID, @@ -78,7 +78,7 @@ func TestStockService_ApproveStockIn_NotPending(t *testing.T) { WarehouseID: warehouse.ID, OperatorID: user.ID, Status: "draft", // 非 pending 状态 - OrderDate: time.Now(), + OrderDate: model.Date{Time: time.Now()}, } require.NoError(t, db.Create(&order).Error) @@ -111,7 +111,7 @@ func TestStockService_ApproveStockOut_Success(t *testing.T) { WarehouseID: warehouse.ID, OperatorID: user.ID, Status: "pending", - OrderDate: time.Now(), + OrderDate: model.Date{Time: time.Now()}, Items: []model.StockOutItem{ { ShopID: shop.ID, @@ -162,7 +162,7 @@ func TestStockService_ApproveStockOut_InsufficientStock(t *testing.T) { WarehouseID: warehouse.ID, OperatorID: user.ID, Status: "pending", - OrderDate: time.Now(), + OrderDate: model.Date{Time: time.Now()}, Items: []model.StockOutItem{ { ShopID: shop.ID, @@ -193,7 +193,7 @@ func TestStockService_ApproveStockOut_ProductNotInInventory(t *testing.T) { WarehouseID: warehouse.ID, OperatorID: user.ID, Status: "pending", - OrderDate: time.Now(), + OrderDate: model.Date{Time: time.Now()}, Items: []model.StockOutItem{ { ShopID: shop.ID, @@ -265,7 +265,7 @@ func TestStockService_MultipleStockIn_AccumulatesInventory(t *testing.T) { WarehouseID: warehouse.ID, OperatorID: user.ID, Status: "pending", - OrderDate: time.Now(), + OrderDate: model.Date{Time: time.Now()}, Items: []model.StockInItem{ {ShopID: shop.ID, ProductID: product.ID, Quantity: 10}, }, @@ -280,7 +280,7 @@ func TestStockService_MultipleStockIn_AccumulatesInventory(t *testing.T) { WarehouseID: warehouse.ID, OperatorID: user.ID, Status: "pending", - OrderDate: time.Now(), + OrderDate: model.Date{Time: time.Now()}, Items: []model.StockInItem{ {ShopID: shop.ID, ProductID: product.ID, Quantity: 5}, }, diff --git a/backend/seeds/S001.sql b/backend/seeds/S001.sql index 97158f6..9e5bdb5 100644 --- a/backend/seeds/S001.sql +++ b/backend/seeds/S001.sql @@ -242,12 +242,23 @@ INSERT INTO inventory_logs (id, shop_id, warehouse_id, product_id, direction, qu (12, 1, 2, 8, 'out', 12, 36, 24, 'stock_out', 2, 1, NOW()); -- ── 财务记录 ──────────────────────────────────────────────── --- 出库单 #1 对应应收款 + 已回款 +-- 应收账款:出库单 #1 (君悦大酒店) + #2 (外滩华尔道夫) +-- 收款:出库单 #1 已回款 +-- 应付账款:入库单 #1 (茅台 339000) + #2 (五粮液代理 51360) + #3 (郎酒 108480) +-- 付款:入库单 #1 已付款 INSERT INTO finance_records (id, shop_id, partner_id, type, amount, balance, ref_type, ref_id, operator_id, record_date, remark, created_at, updated_at) VALUES -(1, 1, 5, 'receivable', 48960, 48960, 'stock_out', 1, 1, - DATE_SUB(CURDATE(), INTERVAL 3 DAY), '君悦大酒店4月供货应收款', NOW(), NOW()), -(2, 1, 6, 'receivable', 39360, 39360, 'stock_out', 2, 1, - DATE_SUB(CURDATE(), INTERVAL 2 DAY), '外滩华尔道夫进口酒应收款', NOW(), NOW()), -(3, 1, 5, 'receipt', 48960, 0, 'stock_out', 1, 1, - DATE_SUB(CURDATE(), INTERVAL 1 DAY), '君悦大酒店回款,结清', NOW(), NOW()); +(1, 1, 5, 'receivable', 48960, 48960, 'stock_out', 1, 1, + DATE_SUB(CURDATE(), INTERVAL 6 DAY), '君悦大酒店4月供货应收款', NOW(), NOW()), +(2, 1, 6, 'receivable', 39360, 39360, 'stock_out', 2, 1, + DATE_SUB(CURDATE(), INTERVAL 5 DAY), '外滩华尔道夫进口酒应收款', NOW(), NOW()), +(3, 1, 5, 'receipt', 48960, 0, 'stock_out', 1, 1, + DATE_SUB(CURDATE(), INTERVAL 2 DAY), '君悦大酒店回款,结清', NOW(), NOW()), +(4, 1, 1, 'payable', 339000, 339000, 'stock_in', 1, 1, + DATE_SUB(CURDATE(), INTERVAL 6 DAY), '茅台系列首批入库应付款', NOW(), NOW()), +(5, 1, 2, 'payable', 51360, 51360, 'stock_in', 2, 1, + DATE_SUB(CURDATE(), INTERVAL 4 DAY), '浓香型白酒入库应付款', NOW(), NOW()), +(6, 1, 4, 'payable', 108480, 108480, 'stock_in', 3, 1, + DATE_SUB(CURDATE(), INTERVAL 3 DAY), '进口烈酒专库入库应付款', NOW(), NOW()), +(7, 1, 1, 'payment', 100000, 0, 'stock_in', 1, 1, + DATE_SUB(CURDATE(), INTERVAL 3 DAY), '预付茅台货款10万,银行转账', NOW(), NOW()); diff --git a/client/lib/core/router/app_router.dart b/client/lib/core/router/app_router.dart index 604330a..fbd4d09 100644 --- a/client/lib/core/router/app_router.dart +++ b/client/lib/core/router/app_router.dart @@ -6,8 +6,10 @@ 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/stock_out/stock_out_form_screen.dart'; import '../../screens/inventory/inventory_list_screen.dart'; import '../../screens/inventory/inventory_check_screen.dart'; +import '../../screens/inventory/batch_tracking_screen.dart'; import '../../screens/partners/partners_screen.dart'; import '../../screens/finance/finance_screen.dart'; import '../../screens/products/products_screen.dart'; @@ -83,6 +85,9 @@ final appRouterProvider = Provider((ref) { GoRoute( path: '/stock-out', pageBuilder: (_, __) => _noTransition(const StockOutListScreen())), + GoRoute( + path: '/stock-out/new', + pageBuilder: (_, __) => _noTransition(const StockOutFormScreen())), GoRoute( path: '/inventory', pageBuilder: (_, __) => @@ -91,6 +96,10 @@ final appRouterProvider = Provider((ref) { path: '/inventory/check', pageBuilder: (_, __) => _noTransition(const InventoryCheckScreen())), + GoRoute( + path: '/batches', + pageBuilder: (_, __) => + _noTransition(const BatchTrackingScreen())), GoRoute( path: '/partners', pageBuilder: (_, __) => _noTransition(const PartnersScreen())), diff --git a/client/lib/models/finance.dart b/client/lib/models/finance.dart new file mode 100644 index 0000000..2093a22 --- /dev/null +++ b/client/lib/models/finance.dart @@ -0,0 +1,52 @@ +class FinanceRecord { + final int id; + final String type; // receivable | payable | receipt | payment + final double amount; + final double balance; + final String? refType; + final int? refId; + final String? partnerName; + final String? recordDate; + final String? remark; + + const FinanceRecord({ + required this.id, + required this.type, + required this.amount, + required this.balance, + this.refType, + this.refId, + this.partnerName, + this.recordDate, + this.remark, + }); + + String get typeLabel { + switch (type) { + case 'receivable': + return '应收账款'; + case 'payable': + return '应付账款'; + case 'receipt': + return '收款'; + case 'payment': + return '付款'; + default: + return type; + } + } + + factory FinanceRecord.fromJson(Map json) => FinanceRecord( + id: (json['id'] as num).toInt(), + type: json['type'] as String, + amount: (json['amount'] as num).toDouble(), + balance: (json['balance'] as num).toDouble(), + refType: json['ref_type'] as String?, + refId: + json['ref_id'] != null ? (json['ref_id'] as num).toInt() : null, + partnerName: + (json['partner'] as Map?)?['name'] as String?, + recordDate: json['record_date'] as String?, + remark: json['remark'] as String?, + ); +} diff --git a/client/lib/models/inventory.dart b/client/lib/models/inventory.dart index c9a6407..578e8b9 100644 --- a/client/lib/models/inventory.dart +++ b/client/lib/models/inventory.dart @@ -43,6 +43,61 @@ class Inventory { } } +// 批次追踪:已审核入库单的明细行 +class BatchRecord { + final int id; + final int productId; + final String? productName; + final String? productCode; + final String? productSpec; + final String? productUnit; + final String? batchNo; + final double quantity; + final double unitPrice; + final String? orderNo; + final String? orderDate; + final String? warehouseName; + final String? supplierName; + + const BatchRecord({ + required this.id, + required this.productId, + this.productName, + this.productCode, + this.productSpec, + this.productUnit, + this.batchNo, + required this.quantity, + required this.unitPrice, + this.orderNo, + this.orderDate, + this.warehouseName, + this.supplierName, + }); + + factory BatchRecord.fromJson(Map json) { + final product = json['product'] as Map?; + final order = json['order'] as Map?; + final warehouse = order?['warehouse'] as Map?; + final partner = order?['partner'] as Map?; + return BatchRecord( + id: (json['id'] as num).toInt(), + productId: (json['product_id'] as num).toInt(), + productName: product?['name'] as String?, + productCode: product?['code'] as String?, + productSpec: product?['spec'] as String?, + productUnit: product?['unit'] as String?, + batchNo: json['batch_no'] as String?, + quantity: (json['quantity'] as num).toDouble(), + unitPrice: (json['unit_price'] as num).toDouble(), + orderNo: order?['order_no'] as String?, + orderDate: order?['order_date'] as String?, + warehouseName: warehouse?['name'] as String?, + supplierName: partner?['name'] as String?, + ); + } +} + class InventoryLog { final int warehouseId; final String? warehouseName; diff --git a/client/lib/models/number_rule.dart b/client/lib/models/number_rule.dart new file mode 100644 index 0000000..2c4a070 --- /dev/null +++ b/client/lib/models/number_rule.dart @@ -0,0 +1,44 @@ +class NumberRule { + final int id; + final String type; + final String prefix; + final int currentNo; + final String dateFormat; + + const NumberRule({ + required this.id, + required this.type, + required this.prefix, + required this.currentNo, + required this.dateFormat, + }); + + String get typeLabel { + switch (type) { + case 'stock_in': + return '入库单'; + case 'stock_out': + return '出库单'; + case 'inventory_check': + return '盘点单'; + case 'product': + return '商品编码'; + default: + return type; + } + } + + String get exampleNo { + final now = DateTime.now(); + final date = '${now.year}${now.month.toString().padLeft(2, '0')}${now.day.toString().padLeft(2, '0')}'; + return '$prefix$date${(currentNo + 1).toString().padLeft(3, '0')}'; + } + + factory NumberRule.fromJson(Map json) => NumberRule( + id: (json['id'] as num).toInt(), + type: json['type'] as String, + prefix: json['prefix'] as String? ?? '', + currentNo: (json['current_no'] as num).toInt(), + dateFormat: json['date_format'] as String? ?? 'YYYYMMDD', + ); +} diff --git a/client/lib/models/stock_in.dart b/client/lib/models/stock_in.dart index 12202a3..fa64ec2 100644 --- a/client/lib/models/stock_in.dart +++ b/client/lib/models/stock_in.dart @@ -33,10 +33,10 @@ class StockInItem { unitPrice: (json['unit_price'] as num).toDouble(), totalPrice: (json['total_price'] as num).toDouble(), batchNo: json['batch_no'] as String?, - productName: json['product_name'] as String?, - productCode: json['product_code'] as String?, - productSpec: json['product_spec'] as String?, - productUnit: json['product_unit'] as String?, + productName: (json['product'] as Map?)?['name'] as String?, + productCode: (json['product'] as Map?)?['code'] as String?, + productSpec: (json['product'] as Map?)?['spec'] as String?, + productUnit: (json['product'] as Map?)?['unit'] as String?, ); Map toJson() => { diff --git a/client/lib/models/stock_out.dart b/client/lib/models/stock_out.dart index 68c9f7a..6218b7f 100644 --- a/client/lib/models/stock_out.dart +++ b/client/lib/models/stock_out.dart @@ -29,10 +29,10 @@ class StockOutItem { quantity: (json['quantity'] as num).toDouble(), unitPrice: (json['unit_price'] as num).toDouble(), totalPrice: (json['total_price'] as num).toDouble(), - productName: json['product_name'] as String?, - productCode: json['product_code'] as String?, - productSpec: json['product_spec'] as String?, - productUnit: json['product_unit'] as String?, + productName: (json['product'] as Map?)?['name'] as String?, + productCode: (json['product'] as Map?)?['code'] as String?, + productSpec: (json['product'] as Map?)?['spec'] as String?, + productUnit: (json['product'] as Map?)?['unit'] as String?, ); } diff --git a/client/lib/providers/finance_provider.dart b/client/lib/providers/finance_provider.dart new file mode 100644 index 0000000..cbf824e --- /dev/null +++ b/client/lib/providers/finance_provider.dart @@ -0,0 +1,60 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../core/api/api_client.dart'; +import '../core/auth/auth_state.dart'; +import '../core/models/page_result.dart'; +import '../models/finance.dart'; +import '../repositories/finance_repository.dart'; + +final financeRepositoryProvider = Provider((ref) { + return FinanceRepository(ref.watch(apiClientProvider)); +}); + +final financeListProvider = + AsyncNotifierProvider>( + FinanceListNotifier.new, +); + +class FinanceListNotifier extends AsyncNotifier> { + int _page = 1; + String _type = ''; + String _month = ''; + + @override + Future> build() { + ref.watch(authStateProvider.select((s) => s.user?.shopId)); + return _fetch(); + } + + Future> _fetch() { + return ref.read(financeRepositoryProvider).listRecords( + type: _type.isEmpty ? null : _type, + month: _month.isEmpty ? null : _month, + page: _page, + ); + } + + void setType(String type) { + _type = type; + _page = 1; + reload(); + } + + void setMonth(String month) { + _month = month; + _page = 1; + reload(); + } + + void setPage(int page) { + _page = page; + reload(); + } + + void reload() { + state = const AsyncValue.loading(); + _fetch().then( + (result) => state = AsyncValue.data(result), + onError: (e, st) => state = AsyncValue.error(e, st), + ); + } +} diff --git a/client/lib/providers/number_rule_provider.dart b/client/lib/providers/number_rule_provider.dart new file mode 100644 index 0000000..3647e34 --- /dev/null +++ b/client/lib/providers/number_rule_provider.dart @@ -0,0 +1,33 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../core/api/api_client.dart'; +import '../core/auth/auth_state.dart'; +import '../models/number_rule.dart'; +import '../repositories/number_rule_repository.dart'; + +final numberRuleRepositoryProvider = Provider((ref) { + return NumberRuleRepository(ref.watch(apiClientProvider)); +}); + +final numberRuleListProvider = + AsyncNotifierProvider>( + NumberRuleListNotifier.new, +); + +class NumberRuleListNotifier extends AsyncNotifier> { + @override + Future> build() { + ref.watch(authStateProvider.select((s) => s.user?.shopId)); + return ref.read(numberRuleRepositoryProvider).list(); + } + + Future reload() async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard( + () => ref.read(numberRuleRepositoryProvider).list()); + } + + Future updateRule(int id, Map data) async { + await ref.read(numberRuleRepositoryProvider).update(id, data); + await reload(); + } +} diff --git a/client/lib/providers/stock_in_provider.dart b/client/lib/providers/stock_in_provider.dart index f05874d..3716273 100644 --- a/client/lib/providers/stock_in_provider.dart +++ b/client/lib/providers/stock_in_provider.dart @@ -4,6 +4,7 @@ import '../core/auth/auth_state.dart'; import '../core/models/page_result.dart'; import '../models/stock_in.dart'; import '../repositories/stock_in_repository.dart'; +import 'inventory_provider.dart'; final stockInRepositoryProvider = Provider((ref) { return StockInRepository(ref.watch(apiClientProvider)); @@ -74,6 +75,7 @@ class StockInListNotifier extends AsyncNotifier> { Future approveOrder(int id) async { await ref.read(stockInRepositoryProvider).approve(id); reload(); + ref.invalidate(inventoryListProvider); } Future rejectOrder(int id) async { diff --git a/client/lib/providers/stock_out_provider.dart b/client/lib/providers/stock_out_provider.dart index 11b17c0..2a0f5d1 100644 --- a/client/lib/providers/stock_out_provider.dart +++ b/client/lib/providers/stock_out_provider.dart @@ -4,6 +4,7 @@ import '../core/auth/auth_state.dart'; import '../core/models/page_result.dart'; import '../models/stock_out.dart'; import '../repositories/stock_out_repository.dart'; +import 'inventory_provider.dart'; final stockOutRepositoryProvider = Provider((ref) { return StockOutRepository(ref.watch(apiClientProvider)); @@ -74,6 +75,7 @@ class StockOutListNotifier extends AsyncNotifier> { Future approveOrder(int id) async { await ref.read(stockOutRepositoryProvider).approve(id); reload(); + ref.invalidate(inventoryListProvider); } Future rejectOrder(int id) async { diff --git a/client/lib/repositories/finance_repository.dart b/client/lib/repositories/finance_repository.dart new file mode 100644 index 0000000..2a425e9 --- /dev/null +++ b/client/lib/repositories/finance_repository.dart @@ -0,0 +1,36 @@ +import 'package:dio/dio.dart'; +import '../core/api/api_client.dart'; +import '../core/exceptions.dart'; +import '../core/models/page_result.dart'; +import '../models/finance.dart'; + +class FinanceRepository { + final ApiClient _client; + const FinanceRepository(this._client); + + Future> listRecords({ + String? type, + String? month, + int page = 1, + int pageSize = 50, + }) async { + try { + final params = { + 'page': page, + 'page_size': pageSize, + if (type != null && type.isNotEmpty) 'type': type, + if (month != null && month.isNotEmpty) 'month': month, + }; + final resp = await _client.get('/finance/records', params: params); + return PageResult.fromJson( + resp.data as Map, + FinanceRecord.fromJson, + ); + } on DioException catch (e) { + throw AppException( + e.response?.data?['error'] as String? ?? '获取财务记录失败', + statusCode: e.response?.statusCode, + ); + } + } +} diff --git a/client/lib/repositories/inventory_repository.dart b/client/lib/repositories/inventory_repository.dart index e5a3bd0..0c6252c 100644 --- a/client/lib/repositories/inventory_repository.dart +++ b/client/lib/repositories/inventory_repository.dart @@ -35,6 +35,44 @@ class InventoryRepository { } } + Future> createCheck(Map data) async { + try { + final resp = await _client.post('/inventory/checks', data: data); + return resp.data as Map; + } on DioException catch (e) { + throw AppException( + e.response?.data?['error'] as String? ?? '提交盘点失败', + statusCode: e.response?.statusCode, + ); + } + } + + Future> listBatches({ + int? productId, + int? warehouseId, + int page = 1, + int pageSize = 20, + }) async { + try { + final params = { + 'page': page, + 'page_size': pageSize, + if (productId != null) 'product_id': productId, + if (warehouseId != null) 'warehouse_id': warehouseId, + }; + final resp = await _client.get('/inventory/batches', params: params); + return PageResult.fromJson( + resp.data as Map, + BatchRecord.fromJson, + ); + } on DioException catch (e) { + throw AppException( + e.response?.data?['error'] as String? ?? '获取批次数据失败', + statusCode: e.response?.statusCode, + ); + } + } + Future> listLogs({ int? warehouseId, int? productId, diff --git a/client/lib/repositories/number_rule_repository.dart b/client/lib/repositories/number_rule_repository.dart new file mode 100644 index 0000000..feb9bd5 --- /dev/null +++ b/client/lib/repositories/number_rule_repository.dart @@ -0,0 +1,36 @@ +import 'package:dio/dio.dart'; +import '../core/api/api_client.dart'; +import '../core/exceptions.dart'; +import '../models/number_rule.dart'; + +class NumberRuleRepository { + final ApiClient _client; + const NumberRuleRepository(this._client); + + Future> list() async { + try { + final resp = await _client.get('/number-rules'); + final data = resp.data['data'] as List; + return data + .map((e) => NumberRule.fromJson(e as Map)) + .toList(); + } on DioException catch (e) { + throw AppException( + e.response?.data?['error'] as String? ?? '获取编号规则失败', + statusCode: e.response?.statusCode, + ); + } + } + + Future update(int id, Map data) async { + try { + final resp = await _client.put('/number-rules/$id', data: data); + return NumberRule.fromJson(resp.data['data'] as Map); + } on DioException catch (e) { + throw AppException( + e.response?.data?['error'] as String? ?? '更新编号规则失败', + statusCode: e.response?.statusCode, + ); + } + } +} diff --git a/client/lib/screens/finance/finance_screen.dart b/client/lib/screens/finance/finance_screen.dart index 1a1e7cb..6365e36 100644 --- a/client/lib/screens/finance/finance_screen.dart +++ b/client/lib/screens/finance/finance_screen.dart @@ -1,664 +1,280 @@ 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'; +import '../../models/finance.dart'; +import '../../providers/finance_provider.dart'; +import '../../widgets/data_table_card.dart'; +import '../../widgets/page_scaffold.dart'; -class FinanceScreen extends ConsumerStatefulWidget { +class FinanceScreen extends ConsumerWidget { const FinanceScreen({super.key}); @override - ConsumerState createState() => _FinanceScreenState(); -} - -class _FinanceScreenState extends ConsumerState { - int _page = 1; - String _monthFilter = '2026-04'; - String _typeFilter = '全部'; - - final List> _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) { + Widget build(BuildContext context, WidgetRef ref) { return PageScaffold( title: '财务管理', tabs: const [ Tab(text: '应付账款'), - Tab(text: '付款记录'), - Tab(text: '财务报表'), + Tab(text: '应收账款'), + Tab(text: '全部记录'), ], tabViews: [ - _buildPayableList(), - _buildPaymentHistory(), - _buildFinanceReport(), + _FinanceTab(typeFilter: 'payable'), + _FinanceTab(typeFilter: 'receivable'), + _FinanceTab(typeFilter: ''), ], ); } +} - Widget _buildPayableList() { - final filtered = _typeFilter == '全部' - ? _transactions - : _transactions.where((t) => t['type'] == _typeFilter).toList(); +// Each tab has its own independent state — avoids shared-provider conflicts +class _FinanceTab extends ConsumerStatefulWidget { + final String typeFilter; + const _FinanceTab({required this.typeFilter}); + + @override + ConsumerState<_FinanceTab> createState() => _FinanceTabState(); +} + +class _FinanceTabState extends ConsumerState<_FinanceTab> { + late String _month; + int _page = 1; + + // We drive fetches by maintaining a Future locally, bypassing the global provider + late Future> _future; + + @override + void initState() { + super.initState(); + final now = DateTime.now(); + _month = '${now.year}-${now.month.toString().padLeft(2, '0')}'; + _fetch(); + } + + void _fetch() { + _future = ref + .read(financeRepositoryProvider) + .listRecords( + type: widget.typeFilter.isEmpty ? null : widget.typeFilter, + month: _month, + page: _page, + pageSize: 50, + ) + .then((r) => r.data); + } + + void _refetch() { + setState(() => _fetch()); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder>( + future: _future, + builder: (context, snap) { + if (snap.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + if (snap.hasError) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('加载失败:${snap.error}', + style: const TextStyle(color: AppTheme.danger)), + const SizedBox(height: 12), + ElevatedButton( + onPressed: _refetch, child: const Text('重试')), + ], + ), + ); + } + return _buildContent(snap.data ?? []); + }, + ); + } + + Widget _buildContent(List records) { + final totalAmount = records.fold(0.0, (s, r) => s + r.amount); + final totalBalance = records.fold(0.0, (s, r) => s + r.balance); + final totalPaid = totalAmount - totalBalance; 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, - ), - ], + // Summary bar (only for type-filtered tabs) + if (widget.typeFilter.isNotEmpty && records.isNotEmpty) + Container( + color: AppTheme.background, + padding: const EdgeInsets.all(12), + child: Row( + children: [ + _SummaryCard( + title: '本期总额', + value: '¥${(totalAmount / 10000).toStringAsFixed(2)}万', + icon: Icons.account_balance_wallet, + color: AppTheme.primary, + ), + const SizedBox(width: 12), + _SummaryCard( + title: '已结清', + value: '¥${(totalPaid / 10000).toStringAsFixed(2)}万', + icon: Icons.check_circle, + color: AppTheme.success, + ), + const SizedBox(width: 12), + _SummaryCard( + title: '未结清', + value: '¥${(totalBalance / 10000).toStringAsFixed(2)}万', + icon: Icons.pending_actions, + color: AppTheme.danger, + ), + ], + ), ), - ), - const Divider(height: 1), + if (widget.typeFilter.isNotEmpty && records.isNotEmpty) + const Divider(height: 1), Expanded( child: DataTableCard( - totalCount: filtered.length, + totalCount: records.length, page: _page, - onPageChanged: (p) => setState(() => _page = p), + onPageChanged: (p) { + setState(() { + _page = p; + _fetch(); + }); + }, 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: '类型', + value: _month, + onChanged: (v) { + _month = v; + _page = 1; + _refetch(); + }, ), ], ), 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('金额'), numeric: true), DataColumn(label: Text('余额'), numeric: true), - DataColumn(label: Text('到期日')), - 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, + rows: records.isEmpty + ? [ + DataRow(cells: [ + const DataCell(SizedBox()), + const DataCell(Text('暂无记录', + style: TextStyle(color: AppTheme.textSecondary))), + const DataCell(SizedBox()), + const DataCell(SizedBox()), + const DataCell(SizedBox()), + const DataCell(SizedBox()), + const DataCell(SizedBox()), + ]) + ] + : records + .map((r) => DataRow(cells: [ + DataCell(Text( + r.recordDate?.substring(0, 10) ?? '-', + style: const TextStyle(fontSize: 12), + )), + DataCell(_TypeBadge(r.typeLabel)), + DataCell(SizedBox( + width: 160, + child: Text(r.partnerName ?? '-', + overflow: TextOverflow.ellipsis), + )), + DataCell(Text( + r.refType != null && r.refId != null + ? '${r.refType!.replaceAll('_', '-')}#${r.refId}' + : '-', 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(), + color: AppTheme.primary), + )), + DataCell(Text( + '¥${r.amount.toStringAsFixed(2)}', + style: const TextStyle(fontWeight: FontWeight.w500), + )), + DataCell(Text( + '¥${r.balance.toStringAsFixed(2)}', + style: TextStyle( + color: r.balance > 0 + ? AppTheme.danger + : AppTheme.textSecondary, + fontWeight: FontWeight.w600, + ), + )), + DataCell(SizedBox( + width: 160, + child: Text(r.remark ?? '-', + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 12, + color: AppTheme.textSecondary)), + )), + ])) + .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( - 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( - 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 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 { +class _TypeBadge 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, - }); + const _TypeBadge(this.label); @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, - ], + Color bg, fg; + switch (label) { + case '应收账款': + bg = const Color(0xFFE3F2FD); + fg = AppTheme.primary; + break; + case '应付账款': + bg = const Color(0xFFFFEBEE); + fg = AppTheme.danger; + break; + case '收款': + bg = const Color(0xFFE8F5E9); + fg = AppTheme.success; + break; + case '付款': + bg = const Color(0xFFFFF3E0); + fg = AppTheme.accent; + 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(label, + style: TextStyle( + color: fg, fontSize: 12, fontWeight: FontWeight.w500)), ); - return fullWidth ? SizedBox(width: double.infinity, child: content) : content; } } -class _FinanceSummaryCard extends StatelessWidget { +class _SummaryCard extends StatelessWidget { final String title; final String value; final IconData icon; final Color color; - const _FinanceSummaryCard({ + const _SummaryCard({ required this.title, required this.value, required this.icon, @@ -710,96 +326,6 @@ class _FinanceSummaryCard extends StatelessWidget { } } -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 onChanged; @@ -808,14 +334,13 @@ class _MonthSelector extends StatelessWidget { @override Widget build(BuildContext context) { - final months = [ - '2026-04', - '2026-03', - '2026-02', - '2026-01', - '2025-12', - '2025-11', - ]; + final now = DateTime.now(); + final months = List.generate(12, (i) { + final d = DateTime(now.year, now.month - i, 1); + return '${d.year}-${d.month.toString().padLeft(2, '0')}'; + }); + final safeValue = months.contains(value) ? value : months.first; + return Container( height: 36, padding: const EdgeInsets.symmetric(horizontal: 8), @@ -826,55 +351,15 @@ class _MonthSelector extends StatelessWidget { ), child: DropdownButtonHideUnderline( child: DropdownButton( - value: value, + value: safeValue, 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 items; - final ValueChanged 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( - 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), + style: + const TextStyle(fontSize: 13, color: AppTheme.textPrimary), ), ), ); diff --git a/client/lib/screens/inventory/batch_tracking_screen.dart b/client/lib/screens/inventory/batch_tracking_screen.dart new file mode 100644 index 0000000..67bb03b --- /dev/null +++ b/client/lib/screens/inventory/batch_tracking_screen.dart @@ -0,0 +1,181 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/theme/app_theme.dart'; +import '../../models/inventory.dart'; +import '../../providers/inventory_provider.dart'; +import '../../widgets/data_table_card.dart'; + +class BatchTrackingScreen extends ConsumerStatefulWidget { + const BatchTrackingScreen({super.key}); + + @override + ConsumerState createState() => + _BatchTrackingScreenState(); +} + +class _BatchTrackingScreenState extends ConsumerState { + int _page = 1; + late Future> _future; + + @override + void initState() { + super.initState(); + _fetch(); + } + + void _fetch() { + _future = ref + .read(inventoryRepositoryProvider) + .listBatches(page: _page, pageSize: 20) + .then((r) => r.data); + } + + void _refetch() { + setState(() => _fetch()); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder>( + future: _future, + builder: (context, snap) { + if (snap.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + if (snap.hasError) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('加载失败:${snap.error}', + style: const TextStyle(color: AppTheme.danger)), + const SizedBox(height: 12), + ElevatedButton( + onPressed: _refetch, child: const Text('重试')), + ], + ), + ); + } + return _buildTable(snap.data ?? []); + }, + ); + } + + Widget _buildTable(List records) { + return DataTableCard( + totalCount: records.length, + page: _page, + onPageChanged: (p) { + setState(() { + _page = p; + _fetch(); + }); + }, + toolbar: Row( + children: [ + const Text('已审核入库批次', + style: TextStyle( + fontSize: 13, color: AppTheme.textSecondary)), + const Spacer(), + IconButton( + icon: const Icon(Icons.refresh, size: 18), + onPressed: () { + setState(() { + _page = 1; + _fetch(); + }); + }, + tooltip: '刷新', + ), + ], + ), + columns: const [ + DataColumn(label: Text('商品')), + 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), + ], + rows: records.isEmpty + ? [ + const DataRow(cells: [ + DataCell(SizedBox()), + DataCell(Text('暂无批次记录', + style: TextStyle(color: AppTheme.textSecondary))), + DataCell(SizedBox()), + DataCell(SizedBox()), + DataCell(SizedBox()), + DataCell(SizedBox()), + DataCell(SizedBox()), + DataCell(SizedBox()), + DataCell(SizedBox()), + ]) + ] + : records.map((r) { + final hasBatch = + r.batchNo != null && r.batchNo!.isNotEmpty; + return DataRow(cells: [ + DataCell( + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(r.productName ?? '-', + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500)), + if (r.productCode != null) + Text(r.productCode!, + style: const TextStyle( + fontSize: 11, + color: AppTheme.textSecondary, + fontFamily: 'monospace')), + ], + ), + ), + DataCell(Text(r.productSpec ?? '-', + style: const TextStyle(fontSize: 12))), + DataCell(hasBatch + ? Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: AppTheme.primary.withOpacity(0.08), + borderRadius: BorderRadius.circular(3), + ), + child: Text(r.batchNo!, + style: const TextStyle( + fontSize: 12, + color: AppTheme.primary, + fontFamily: 'monospace')), + ) + : const Text('-', + style: TextStyle( + color: AppTheme.textSecondary))), + DataCell(Text(r.orderNo ?? '-', + style: const TextStyle( + fontSize: 12, + color: AppTheme.primary, + fontFamily: 'monospace'))), + DataCell(Text(r.supplierName ?? '-', + style: const TextStyle(fontSize: 12))), + DataCell(Text(r.warehouseName ?? '-', + style: const TextStyle(fontSize: 12))), + DataCell(Text( + r.orderDate?.substring(0, 10) ?? '-', + style: const TextStyle(fontSize: 12))), + DataCell(Text( + '${r.quantity.toStringAsFixed(0)} ${r.productUnit ?? ''}', + style: const TextStyle(fontWeight: FontWeight.w500))), + DataCell(Text( + '¥${r.unitPrice.toStringAsFixed(2)}', + style: const TextStyle(fontSize: 13))), + ]); + }).toList(), + ); + } +} diff --git a/client/lib/screens/inventory/inventory_check_screen.dart b/client/lib/screens/inventory/inventory_check_screen.dart index 7e5b198..9bb6040 100644 --- a/client/lib/screens/inventory/inventory_check_screen.dart +++ b/client/lib/screens/inventory/inventory_check_screen.dart @@ -3,6 +3,10 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../core/theme/app_theme.dart'; +import '../../models/inventory.dart'; +import '../../models/warehouse.dart'; +import '../../providers/inventory_provider.dart'; +import '../../providers/warehouse_provider.dart'; class InventoryCheckScreen extends ConsumerStatefulWidget { const InventoryCheckScreen({super.key}); @@ -12,100 +16,124 @@ class InventoryCheckScreen extends ConsumerStatefulWidget { _InventoryCheckScreenState(); } -class _InventoryCheckScreenState - extends ConsumerState { - final _checkNoCtrl = - TextEditingController(text: 'PD20260404001'); - String _warehouse = '主仓库'; +class _InventoryCheckScreenState extends ConsumerState { + Warehouse? _selectedWarehouse; String _checkType = '全盘'; bool _submitting = false; + bool _loadingItems = false; - // Mock inventory items for checking - final List> _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(), - }, - ]; + // Editable check items built from real inventory + final List<_CheckItem> _checkItems = []; + + String get _checkNo { + final now = DateTime.now(); + final date = + '${now.year}${now.month.toString().padLeft(2, '0')}${now.day.toString().padLeft(2, '0')}'; + return 'PD$date${_selectedWarehouse?.id.toString().padLeft(3, '0') ?? '001'}'; + } @override void dispose() { - _checkNoCtrl.dispose(); for (final item in _checkItems) { - (item['actualQtyCtrl'] as TextEditingController).dispose(); - (item['remark'] as TextEditingController).dispose(); + item.dispose(); } super.dispose(); } - int _getDiff(Map item) { - final actual = int.tryParse( - (item['actualQtyCtrl'] as TextEditingController).text) ?? - 0; - return actual - (item['systemQty'] as int); + Future _loadInventory(Warehouse wh) async { + setState(() { + _loadingItems = true; + for (final item in _checkItems) { + item.dispose(); + } + _checkItems.clear(); + }); + try { + final result = await ref + .read(inventoryRepositoryProvider) + .listInventory(warehouseId: wh.id, pageSize: 200); + final items = result.data + .map((inv) => _CheckItem(inventory: inv)) + .toList(); + if (mounted) { + setState(() { + _checkItems.addAll(items); + _loadingItems = false; + }); + } + } catch (e) { + if (mounted) { + setState(() => _loadingItems = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('加载库存失败:$e'), + backgroundColor: AppTheme.danger), + ); + } + } } Future _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 (_selectedWarehouse == null) { + ScaffoldMessenger.of(context) + .showSnackBar(const SnackBar(content: Text('请先选择仓库'))); + return; } - if (mounted) setState(() => _submitting = false); + setState(() => _submitting = true); + try { + final now = DateTime.now(); + final dateStr = + '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}'; + + final items = _checkItems.map((item) { + final actual = + double.tryParse(item.actualQtyCtrl.text) ?? item.inventory.quantity; + return { + 'product_id': item.inventory.productId, + 'actual_qty': actual, + 'remark': item.remarkCtrl.text.trim(), + }; + }).toList(); + + await ref.read(inventoryRepositoryProvider).createCheck({ + 'check_no': _checkNo, + 'warehouse_id': _selectedWarehouse!.id, + 'check_date': dateStr, + 'items': items, + }); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('盘点单已提交'), + backgroundColor: AppTheme.success, + ), + ); + context.go('/inventory'); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('提交失败:$e'), + backgroundColor: AppTheme.danger), + ); + } + } finally { + if (mounted) setState(() => _submitting = false); + } + } + + int _getDiff(_CheckItem item) { + final actual = + double.tryParse(item.actualQtyCtrl.text) ?? item.inventory.quantity; + return (actual - item.inventory.quantity).round(); } @override Widget build(BuildContext context) { + final asyncWarehouses = ref.watch(warehouseListProvider); + return Scaffold( backgroundColor: AppTheme.background, body: Column( @@ -127,12 +155,14 @@ class _InventoryCheckScreenState fontSize: 16, fontWeight: FontWeight.w600)), const Spacer(), OutlinedButton( - onPressed: () {}, - child: const Text('保存草稿'), + onPressed: () => context.go('/inventory'), + child: const Text('取消'), ), const SizedBox(width: 8), ElevatedButton.icon( - onPressed: _submitting ? null : _submit, + onPressed: (_submitting || _checkItems.isEmpty) + ? null + : _submit, icon: _submitting ? const SizedBox( width: 14, @@ -142,11 +172,6 @@ class _InventoryCheckScreenState : const Icon(Icons.check_circle_outline, size: 16), label: const Text('提交盘点'), ), - const SizedBox(width: 8), - OutlinedButton( - onPressed: () => context.go('/inventory'), - child: const Text('取消'), - ), ], ), ), @@ -175,28 +200,43 @@ class _InventoryCheckScreenState children: [ _InfoField( label: '盘点单号', - child: TextFormField( - controller: _checkNoCtrl, - readOnly: true, - style: const TextStyle( - fontFamily: 'monospace'), + child: InputDecorator( decoration: const InputDecoration(), + child: Text( + _checkNo, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 13), + ), ), ), _InfoField( label: '盘点仓库', - child: DropdownButtonFormField( - value: _warehouse, - items: ['主仓库', '副仓库', '全部仓库'] - .map((s) => DropdownMenuItem( - value: s, - child: Text(s, - style: const TextStyle( - fontSize: 13)))) - .toList(), - onChanged: (v) => - setState(() => _warehouse = v!), - decoration: const InputDecoration(), + child: asyncWarehouses.when( + loading: () => + const LinearProgressIndicator(), + error: (e, _) => Text('$e', + style: const TextStyle( + color: AppTheme.danger, + fontSize: 12)), + data: (warehouses) => + DropdownButtonFormField( + value: _selectedWarehouse, + hint: const Text('请选择仓库'), + items: warehouses + .map((w) => DropdownMenuItem( + value: w, + child: Text(w.name, + style: const TextStyle( + fontSize: 13)))) + .toList(), + onChanged: (w) { + setState( + () => _selectedWarehouse = w); + if (w != null) _loadInventory(w); + }, + decoration: const InputDecoration(), + ), ), ), _InfoField( @@ -219,10 +259,14 @@ class _InventoryCheckScreenState label: '盘点日期', child: InputDecorator( decoration: const InputDecoration(), - child: Text( - '2026-04-04', - style: const TextStyle(fontSize: 13), - ), + child: Builder(builder: (ctx) { + final now = DateTime.now(); + return Text( + '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}', + style: + const TextStyle(fontSize: 13), + ); + }), ), ), ], @@ -247,103 +291,133 @@ class _InventoryCheckScreenState 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), + if (_checkItems.isNotEmpty) + 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), + ), ), - 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(), + if (_selectedWarehouse == null) + const Center( + child: Padding( + padding: EdgeInsets.all(32), + child: Text('请先选择盘点仓库', + style: TextStyle( + color: AppTheme.textSecondary)), ), - ...List.generate( - _checkItems.length, - (i) => _buildCheckRow(i)), - ], - ), - const Divider(height: 1), - // Summary - Padding( - padding: const EdgeInsets.only(top: 12), - child: Row( + ) + else if (_loadingItems) + const Center( + child: Padding( + padding: EdgeInsets.all(32), + child: CircularProgressIndicator(), + ), + ) + else if (_checkItems.isEmpty) + const Center( + child: Padding( + padding: EdgeInsets.all(32), + child: Text('该仓库暂无库存记录', + style: TextStyle( + color: AppTheme.textSecondary)), + ), + ) + else + 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: [ - _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( + 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)), ], ), - ), + if (_checkItems.isNotEmpty) ...[ + const Divider(height: 1), + 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, + ), + ], + ), + ), + ], ], ), ), @@ -360,6 +434,7 @@ class _InventoryCheckScreenState TableRow _buildCheckRow(int index) { final item = _checkItems[index]; final diff = _getDiff(item); + final inv = item.inventory; return TableRow( decoration: BoxDecoration( @@ -371,39 +446,44 @@ class _InventoryCheckScreenState ), children: [ Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10), + 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, + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 10), + child: Text(inv.productCode ?? '-', 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, + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 10), + child: Text(inv.productName ?? '-', style: const TextStyle(fontSize: 13), overflow: TextOverflow.ellipsis), ), Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10), - child: Text(item['unit'] as String, + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 10), + child: Text(inv.productUnit ?? '-', style: const TextStyle(fontSize: 13)), ), Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10), - child: Text('${item['systemQty']}', + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 10), + child: Text(inv.quantity.toStringAsFixed(0), style: const TextStyle(fontSize: 13)), ), Padding( padding: const EdgeInsets.all(4), child: TextFormField( - controller: item['actualQtyCtrl'] as TextEditingController, + controller: item.actualQtyCtrl, decoration: const InputDecoration(), style: const TextStyle(fontSize: 13), keyboardType: TextInputType.number, @@ -412,7 +492,8 @@ class _InventoryCheckScreenState ), ), Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10), + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 10), child: Text( diff == 0 ? '0' : (diff > 0 ? '+$diff' : '$diff'), style: TextStyle( @@ -427,7 +508,7 @@ class _InventoryCheckScreenState Padding( padding: const EdgeInsets.all(4), child: TextFormField( - controller: item['remark'] as TextEditingController, + controller: item.remarkCtrl, decoration: const InputDecoration(hintText: '备注'), style: const TextStyle(fontSize: 13), ), @@ -437,6 +518,22 @@ class _InventoryCheckScreenState } } +class _CheckItem { + final Inventory inventory; + final TextEditingController actualQtyCtrl; + final TextEditingController remarkCtrl; + + _CheckItem({required this.inventory}) + : actualQtyCtrl = TextEditingController( + text: inventory.quantity.toStringAsFixed(0)), + remarkCtrl = TextEditingController(); + + void dispose() { + actualQtyCtrl.dispose(); + remarkCtrl.dispose(); + } +} + class _InfoField extends StatelessWidget { final String label; final Widget child; @@ -467,9 +564,7 @@ class _SummaryItem extends StatelessWidget { final Color color; const _SummaryItem( - {required this.label, - required this.value, - required this.color}); + {required this.label, required this.value, required this.color}); @override Widget build(BuildContext context) { diff --git a/client/lib/screens/settings/settings_screen.dart b/client/lib/screens/settings/settings_screen.dart index 17aaa4b..f4b8f38 100644 --- a/client/lib/screens/settings/settings_screen.dart +++ b/client/lib/screens/settings/settings_screen.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/theme/app_theme.dart'; +import '../../models/number_rule.dart'; import '../../models/user.dart'; import '../../models/warehouse.dart'; +import '../../providers/number_rule_provider.dart'; import '../../providers/user_provider.dart'; import '../../providers/warehouse_provider.dart'; @@ -311,67 +313,149 @@ class _SettingsScreenState extends ConsumerState { } 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(), + final asyncRules = ref.watch(numberRuleListProvider); + return asyncRules.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('加载失败:$e', + style: const TextStyle(color: AppTheme.danger)), + const SizedBox(height: 12), + ElevatedButton( + onPressed: () => + ref.read(numberRuleListProvider.notifier).reload(), + child: const Text('重试'), ), + ], + ), + ), + data: (rules) => 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.typeLabel, + 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, + style: const TextStyle( + color: AppTheme.primary, + fontFamily: 'monospace', + fontSize: 13, + fontWeight: FontWeight.w600)), + )), + DataCell(Text(r.dateFormat, + style: const TextStyle( + fontSize: 12, + color: AppTheme.textSecondary))), + DataCell(Text(r.exampleNo, + style: const TextStyle( + fontFamily: 'monospace', fontSize: 12))), + DataCell(Text('${r.currentNo}')), + DataCell(TextButton( + onPressed: () => + _showNumberRuleDialog(context, r), + child: const Text('编辑', + style: TextStyle(fontSize: 12)))), + ])) + .toList(), + ), + ), + ], + ), + ), + ); + } + + void _showNumberRuleDialog(BuildContext context, NumberRule rule) { + final prefixCtrl = TextEditingController(text: rule.prefix); + final currentNoCtrl = + TextEditingController(text: '${rule.currentNo}'); + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text('编辑编号规则 — ${rule.typeLabel}'), + content: SizedBox( + width: 360, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: prefixCtrl, + decoration: const InputDecoration(labelText: '前缀'), + ), + const SizedBox(height: 12), + TextField( + controller: currentNoCtrl, + decoration: const InputDecoration(labelText: '当前序号'), + keyboardType: TextInputType.number, + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('取消')), + ElevatedButton( + onPressed: () async { + Navigator.of(ctx).pop(); + try { + await ref + .read(numberRuleListProvider.notifier) + .updateRule(rule.id, { + 'prefix': prefixCtrl.text.trim(), + 'current_no': int.tryParse(currentNoCtrl.text) ?? rule.currentNo, + }); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('编号规则已更新'), + backgroundColor: AppTheme.success), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('更新失败:$e'), + backgroundColor: AppTheme.danger), + ); + } + } + }, + child: const Text('保存'), ), ], ), diff --git a/client/lib/screens/shell/app_shell.dart b/client/lib/screens/shell/app_shell.dart index 48e5146..5e08a47 100644 --- a/client/lib/screens/shell/app_shell.dart +++ b/client/lib/screens/shell/app_shell.dart @@ -23,6 +23,7 @@ class _AppShellState extends ConsumerState { _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.track_changes, label: '批次追踪', path: '/batches'), _NavItem( icon: Icons.account_balance_wallet, label: '财务管理', diff --git a/client/lib/screens/stock_in/stock_in_list_screen.dart b/client/lib/screens/stock_in/stock_in_list_screen.dart index 24f45f2..648c3b9 100644 --- a/client/lib/screens/stock_in/stock_in_list_screen.dart +++ b/client/lib/screens/stock_in/stock_in_list_screen.dart @@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart'; import '../../core/theme/app_theme.dart'; import '../../models/stock_in.dart'; import '../../providers/stock_in_provider.dart'; +import '../../repositories/stock_in_repository.dart'; import '../../widgets/data_table_card.dart'; import '../../widgets/page_scaffold.dart'; import '../../widgets/status_badge.dart'; @@ -45,17 +46,17 @@ class _StockInListScreenState extends ConsumerState { return PageScaffold( title: '入库管理', tabs: const [ - Tab(text: '入库单'), Tab(text: '入库审核'), + Tab(text: '入库单'), ], tabViews: [ - _buildListTab(filterStatus: null), - _buildListTab(filterStatus: 'pending'), + _buildListTab(filterStatus: 'pending', showNewButton: true), + _buildListTab(filterStatus: null, showNewButton: false), ], ); } - Widget _buildListTab({String? filterStatus}) { + Widget _buildListTab({String? filterStatus, required bool showNewButton}) { final asyncOrders = ref.watch(stockInListProvider); return asyncOrders.when( loading: () => const Center(child: CircularProgressIndicator()), @@ -75,7 +76,6 @@ class _StockInListScreenState extends ConsumerState { ), ), data: (result) { - // Client-side filter for review tab final orders = filterStatus != null ? result.data .where((o) => o.status == filterStatus) @@ -86,6 +86,7 @@ class _StockInListScreenState extends ConsumerState { totalCount: filterStatus != null ? orders.length : result.total, page: result.page, showStatusFilter: filterStatus == null, + showNewButton: showNewButton, ); }, ); @@ -96,6 +97,7 @@ class _StockInListScreenState extends ConsumerState { required int totalCount, required int page, required bool showStatusFilter, + required bool showNewButton, }) { return DataTableCard( totalCount: totalCount, @@ -104,11 +106,12 @@ class _StockInListScreenState extends ConsumerState { ref.read(stockInListProvider.notifier).setPage(p), toolbar: Row( children: [ - ElevatedButton.icon( - onPressed: () => context.go('/stock-in/new'), - icon: const Icon(Icons.add, size: 16), - label: const Text('新建入库单'), - ), + if (showNewButton) + ElevatedButton.icon( + onPressed: () => context.go('/stock-in/new'), + icon: const Icon(Icons.add, size: 16), + label: const Text('新建入库审核单'), + ), const Spacer(), if (showStatusFilter) ...[ _StatusFilterDropdown( @@ -187,6 +190,13 @@ class _StockInListScreenState extends ConsumerState { DataCell(Row( mainAxisSize: MainAxisSize.min, children: [ + TextButton( + onPressed: () => _showDetail(context, o.id), + child: const Text('详情', + style: TextStyle( + fontSize: 12, + color: AppTheme.primary)), + ), if (o.status == 'draft') TextButton( onPressed: () => @@ -224,6 +234,16 @@ class _StockInListScreenState extends ConsumerState { ); } + Future _showDetail(BuildContext context, int orderId) async { + showDialog( + context: context, + builder: (ctx) => _StockInDetailDialog( + orderId: orderId, + repository: ref.read(stockInRepositoryProvider), + ), + ); + } + Future _pickDateRange() async { final range = await showDateRangePicker( context: context, @@ -354,6 +374,218 @@ class _StockInListScreenState extends ConsumerState { } } +// Detail dialog — fetches full order with items +class _StockInDetailDialog extends StatefulWidget { + final int orderId; + final StockInRepository repository; + + const _StockInDetailDialog({ + required this.orderId, + required this.repository, + }); + + @override + State<_StockInDetailDialog> createState() => _StockInDetailDialogState(); +} + +class _StockInDetailDialogState extends State<_StockInDetailDialog> { + late Future _future; + + @override + void initState() { + super.initState(); + _future = widget.repository.get(widget.orderId); + } + + @override + Widget build(BuildContext context) { + return Dialog( + child: Container( + width: 720, + constraints: const BoxConstraints(maxHeight: 600), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + decoration: const BoxDecoration( + color: AppTheme.primary, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(12), + topRight: Radius.circular(12), + ), + ), + child: Row( + children: [ + const Text('入库单详情', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.white)), + const Spacer(), + IconButton( + icon: const Icon(Icons.close, color: Colors.white), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ), + Expanded( + child: FutureBuilder( + future: _future, + builder: (context, snap) { + if (snap.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + if (snap.hasError) { + return Center( + child: Text('加载失败:${snap.error}', + style: const TextStyle(color: AppTheme.danger))); + } + return _buildContent(snap.data!); + }, + ), + ), + ], + ), + ), + ); + } + + Widget _buildContent(StockInOrder o) { + return SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Basic info + Wrap( + spacing: 32, + runSpacing: 12, + children: [ + _InfoField('入库单号', o.orderNo), + _InfoField('状态', _statusLabel(o.status)), + _InfoField('供应商', o.partnerName ?? '-'), + _InfoField('仓库', o.warehouseName ?? '-'), + _InfoField('入库日期', o.orderDate?.substring(0, 10) ?? '-'), + _InfoField('合计金额', + o.totalAmount != null ? '¥${o.totalAmount!.toStringAsFixed(2)}' : '-'), + if (o.remark != null && o.remark!.isNotEmpty) + _InfoField('备注', o.remark!), + ], + ), + const SizedBox(height: 20), + const Text('商品明细', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppTheme.primaryDark)), + const SizedBox(height: 8), + if (o.items.isEmpty) + const Text('无商品明细', + style: TextStyle(color: AppTheme.textSecondary)) + else + Table( + border: TableBorder.all(color: AppTheme.border, width: 0.5), + columnWidths: const { + 0: FixedColumnWidth(36), + 1: FlexColumnWidth(3), + 2: FlexColumnWidth(1.5), + 3: FlexColumnWidth(1.5), + 4: FlexColumnWidth(1.5), + 5: FlexColumnWidth(1.5), + }, + children: [ + TableRow( + decoration: const BoxDecoration(color: Color(0xFFF0F4FF)), + children: ['序号', '商品', '规格', '数量', '单价', '金额'] + .map((h) => Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 8), + child: Text(h, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppTheme.primaryDark)), + )) + .toList(), + ), + ...o.items.asMap().entries.map((e) { + final i = e.key; + final item = e.value; + return TableRow( + decoration: BoxDecoration( + color: i.isEven ? Colors.white : const Color(0xFFFAFAFA)), + children: [ + _TableCell('${i + 1}'), + _TableCell(item.productName ?? '-'), + _TableCell(item.productSpec ?? '-'), + _TableCell(item.quantity.toStringAsFixed(3)), + _TableCell('¥${item.unitPrice.toStringAsFixed(2)}'), + _TableCell('¥${item.totalPrice.toStringAsFixed(2)}'), + ], + ); + }), + ], + ), + ], + ), + ); + } + + String _statusLabel(String status) { + switch (status) { + case 'draft': + return '草稿'; + case 'pending': + return '待审核'; + case 'approved': + return '已审核'; + case 'rejected': + return '已拒绝'; + default: + return status; + } + } +} + +class _InfoField extends StatelessWidget { + final String label; + final String value; + + const _InfoField(this.label, this.value); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, + style: const TextStyle( + fontSize: 12, color: AppTheme.textSecondary)), + const SizedBox(height: 2), + Text(value, + style: const TextStyle( + fontSize: 14, fontWeight: FontWeight.w500)), + ], + ); + } +} + +class _TableCell extends StatelessWidget { + final String text; + + const _TableCell(this.text); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + child: Text(text, style: const TextStyle(fontSize: 13)), + ); + } +} + class _StatusFilterDropdown extends StatelessWidget { final String value; final ValueChanged onChanged; diff --git a/client/lib/screens/stock_out/stock_out_form_screen.dart b/client/lib/screens/stock_out/stock_out_form_screen.dart new file mode 100644 index 0000000..f977d2b --- /dev/null +++ b/client/lib/screens/stock_out/stock_out_form_screen.dart @@ -0,0 +1,581 @@ +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'; +import '../../models/product.dart'; +import '../../providers/partner_provider.dart'; +import '../../providers/product_provider.dart'; +import '../../providers/stock_out_provider.dart'; +import '../../providers/warehouse_provider.dart'; + +class StockOutFormScreen extends ConsumerStatefulWidget { + const StockOutFormScreen({super.key}); + + @override + ConsumerState createState() => _StockOutFormScreenState(); +} + +class _StockOutFormScreenState extends ConsumerState { + final _formKey = GlobalKey(); + final _remarkCtrl = TextEditingController(); + int? _warehouseId; + int? _partnerId; + DateTime _orderDate = DateTime.now(); + bool _submitting = false; + + final List<_ItemRow> _items = []; + + @override + void initState() { + super.initState(); + _items.add(_ItemRow()); + } + + @override + void dispose() { + _remarkCtrl.dispose(); + for (final item in _items) { + item.dispose(); + } + super.dispose(); + } + + double get _totalAmount { + double total = 0; + for (final item in _items) { + final qty = double.tryParse(item.qtyCtrl.text) ?? 0; + final price = double.tryParse(item.priceCtrl.text) ?? 0; + total += qty * price; + } + return total; + } + + void _addItem() { + setState(() => _items.add(_ItemRow())); + } + + void _removeItem(int index) { + setState(() { + _items[index].dispose(); + _items.removeAt(index); + }); + } + + Future _submit(bool asDraft) async { + if (!asDraft && !_formKey.currentState!.validate()) return; + if (_warehouseId == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('请选择出库仓库'), backgroundColor: AppTheme.danger), + ); + return; + } + if (_items.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('请添加商品明细'), backgroundColor: AppTheme.danger), + ); + return; + } + + setState(() => _submitting = true); + + final itemsData = _items.map((item) { + final qty = double.tryParse(item.qtyCtrl.text) ?? 0; + final price = double.tryParse(item.priceCtrl.text) ?? 0; + return { + 'product_id': item.productId ?? 0, + 'quantity': qty, + 'unit_price': price, + 'total_price': qty * price, + }; + }).toList(); + + final data = { + 'warehouse_id': _warehouseId, + if (_partnerId != null) 'partner_id': _partnerId, + 'order_date': + '${_orderDate.year}-${_orderDate.month.toString().padLeft(2, '0')}-${_orderDate.day.toString().padLeft(2, '0')}', + if (_remarkCtrl.text.trim().isNotEmpty) 'remark': _remarkCtrl.text.trim(), + 'items': itemsData, + 'status': asDraft ? 'draft' : 'pending', + }; + + try { + await ref.read(stockOutListProvider.notifier).createOrder(data); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(asDraft ? '已保存为草稿' : '出库单已提交审核'), + backgroundColor: AppTheme.success, + ), + ); + context.go('/stock-out'); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('操作失败:$e'), backgroundColor: AppTheme.danger), + ); + } + } finally { + if (mounted) setState(() => _submitting = false); + } + } + + @override + Widget build(BuildContext context) { + final asyncWarehouses = ref.watch(warehouseListProvider); + final asyncCustomers = ref.watch(customerListProvider); + + return Scaffold( + backgroundColor: AppTheme.background, + body: Column( + children: [ + 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-out'), + 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-out'), + 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: [ + 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: [ + // Warehouse dropdown + _FormField( + label: '出库仓库', + required: true, + child: asyncWarehouses.when( + loading: () => + const LinearProgressIndicator(), + error: (_, __) => const Text('加载失败'), + data: (warehouses) => + DropdownButtonFormField( + value: _warehouseId, + hint: const Text('请选择仓库', + style: + TextStyle(fontSize: 13)), + items: warehouses + .map((w) => DropdownMenuItem( + value: w.id, + child: Text(w.name, + style: const TextStyle( + fontSize: 13)))) + .toList(), + onChanged: (v) => setState( + () => _warehouseId = v), + validator: (v) => + v == null ? '不能为空' : null, + decoration: const InputDecoration(), + ), + ), + ), + // Customer dropdown + _FormField( + label: '客户', + child: asyncCustomers.when( + loading: () => + const LinearProgressIndicator(), + error: (_, __) => const Text('加载失败'), + data: (result) => + DropdownButtonFormField( + value: _partnerId, + hint: const Text('请选择客户', + style: + TextStyle(fontSize: 13)), + items: result.data + .map((p) => DropdownMenuItem( + value: p.id, + child: Text(p.name, + style: const TextStyle( + fontSize: 13)))) + .toList(), + onChanged: (v) => setState( + () => _partnerId = v), + decoration: const InputDecoration(), + ), + ), + ), + // Date picker + _FormField( + label: '出库日期', + required: true, + child: InkWell( + onTap: _pickDate, + child: InputDecorator( + decoration: + const InputDecoration(), + 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), + 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), + Table( + columnWidths: const { + 0: FixedColumnWidth(36), + 1: FlexColumnWidth(3), + 2: FlexColumnWidth(1.5), + 3: FlexColumnWidth(1.5), + 4: FlexColumnWidth(1.5), + 5: 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), + 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 asyncProducts = ref.watch(productListProvider); + final qty = double.tryParse(item.qtyCtrl.text) ?? 0; + final price = double.tryParse(item.priceCtrl.text) ?? 0; + final amount = qty * price; + + return TableRow( + decoration: BoxDecoration( + color: index.isEven ? Colors.white : const Color(0xFFFAFAFA), + ), + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + child: Text('${index + 1}', + style: const TextStyle( + fontSize: 13, color: AppTheme.textSecondary)), + ), + Padding( + padding: const EdgeInsets.all(4), + child: asyncProducts.when( + loading: () => const LinearProgressIndicator(), + error: (_, __) => const Text('加载失败'), + data: (result) => DropdownButtonFormField( + value: item.productId, + hint: const Text('选择商品', + style: TextStyle(fontSize: 13)), + items: result.data + .map((p) => DropdownMenuItem( + value: p.id, + child: Text('${p.name}(${p.spec ?? p.unit})', + style: const TextStyle(fontSize: 13)))) + .toList(), + onChanged: (v) { + setState(() { + item.productId = v; + // Auto-fill sale price if available + if (v != null) { + final found = result.data.firstWhere( + (p) => p.id == v, + orElse: () => Product( + id: 0, code: '', name: '', unit: '')); + if (found.salePrice != null) { + item.priceCtrl.text = + found.salePrice!.toStringAsFixed(2); + } + } + }); + }, + validator: (v) => v == null ? '不能为空' : null, + decoration: const InputDecoration(), + ), + ), + ), + Padding( + padding: const EdgeInsets.all(4), + child: TextFormField( + controller: item.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; + }, + ), + ), + Padding( + padding: const EdgeInsets.all(4), + child: TextFormField( + controller: item.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; + }, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + child: Text( + '¥${amount.toStringAsFixed(2)}', + style: const TextStyle( + fontSize: 13, fontWeight: FontWeight.w500), + ), + ), + 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 _pickDate() async { + final date = await showDatePicker( + context: context, + initialDate: _orderDate, + firstDate: DateTime(2020), + lastDate: DateTime(2030), + ); + if (date != null) setState(() => _orderDate = date); + } +} + +class _ItemRow { + int? productId; + final TextEditingController qtyCtrl = TextEditingController(); + final TextEditingController priceCtrl = TextEditingController(); + + void dispose() { + qtyCtrl.dispose(); + priceCtrl.dispose(); + } +} + +class _FormField extends StatelessWidget { + final String label; + final Widget child; + 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, + ], + ), + ); + } +} diff --git a/client/lib/screens/stock_out/stock_out_list_screen.dart b/client/lib/screens/stock_out/stock_out_list_screen.dart index f5b56b4..a377dd3 100644 --- a/client/lib/screens/stock_out/stock_out_list_screen.dart +++ b/client/lib/screens/stock_out/stock_out_list_screen.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; import '../../core/theme/app_theme.dart'; import '../../models/stock_out.dart'; import '../../providers/stock_out_provider.dart'; +import '../../repositories/stock_out_repository.dart'; import '../../widgets/data_table_card.dart'; import '../../widgets/page_scaffold.dart'; import '../../widgets/status_badge.dart'; @@ -45,17 +47,17 @@ class _StockOutListScreenState extends ConsumerState { return PageScaffold( title: '出库管理', tabs: const [ - Tab(text: '出库单'), Tab(text: '出库审核'), + Tab(text: '出库单'), ], tabViews: [ - _buildListTab(filterStatus: null), - _buildListTab(filterStatus: 'pending'), + _buildListTab(filterStatus: 'pending', showNewButton: true), + _buildListTab(filterStatus: null, showNewButton: false), ], ); } - Widget _buildListTab({String? filterStatus}) { + Widget _buildListTab({String? filterStatus, required bool showNewButton}) { final asyncOrders = ref.watch(stockOutListProvider); return asyncOrders.when( loading: () => const Center(child: CircularProgressIndicator()), @@ -85,6 +87,7 @@ class _StockOutListScreenState extends ConsumerState { totalCount: filterStatus != null ? orders.length : result.total, page: result.page, showStatusFilter: filterStatus == null, + showNewButton: showNewButton, ); }, ); @@ -95,6 +98,7 @@ class _StockOutListScreenState extends ConsumerState { required int totalCount, required int page, required bool showStatusFilter, + required bool showNewButton, }) { return DataTableCard( totalCount: totalCount, @@ -103,11 +107,12 @@ class _StockOutListScreenState extends ConsumerState { ref.read(stockOutListProvider.notifier).setPage(p), toolbar: Row( children: [ - ElevatedButton.icon( - onPressed: () => _showCreateDialog(context), - icon: const Icon(Icons.add, size: 16), - label: const Text('新建出库单'), - ), + if (showNewButton) + ElevatedButton.icon( + onPressed: () => context.go('/stock-out/new'), + icon: const Icon(Icons.add, size: 16), + label: const Text('新建出库审核单'), + ), const Spacer(), if (showStatusFilter) ...[ _StatusFilterDropdown( @@ -186,6 +191,13 @@ class _StockOutListScreenState extends ConsumerState { DataCell(Row( mainAxisSize: MainAxisSize.min, children: [ + TextButton( + onPressed: () => _showDetail(context, o.id), + child: const Text('详情', + style: TextStyle( + fontSize: 12, + color: AppTheme.primary)), + ), if (o.status == 'draft') TextButton( onPressed: () => @@ -223,6 +235,16 @@ class _StockOutListScreenState extends ConsumerState { ); } + Future _showDetail(BuildContext context, int orderId) async { + showDialog( + context: context, + builder: (ctx) => _StockOutDetailDialog( + orderId: orderId, + repository: ref.read(stockOutRepositoryProvider), + ), + ); + } + Future _pickDateRange() async { final range = await showDateRangePicker( context: context, @@ -238,24 +260,6 @@ class _StockOutListScreenState extends ConsumerState { } } - 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('取消')), - ], - ), - ); - } - Future _confirmSubmit( BuildContext context, StockOutOrder o) async { final confirmed = await showDialog( @@ -324,7 +328,6 @@ class _StockOutListScreenState extends ConsumerState { content: Text('审核通过'), backgroundColor: AppTheme.success)); } } catch (e) { - // Show inventory-shortage error clearly if (mounted) { ScaffoldMessenger.of(context).showSnackBar(SnackBar( content: Text('审核失败:$e'), @@ -376,6 +379,217 @@ class _StockOutListScreenState extends ConsumerState { } } +// Detail dialog — fetches full order with items +class _StockOutDetailDialog extends StatefulWidget { + final int orderId; + final StockOutRepository repository; + + const _StockOutDetailDialog({ + required this.orderId, + required this.repository, + }); + + @override + State<_StockOutDetailDialog> createState() => _StockOutDetailDialogState(); +} + +class _StockOutDetailDialogState extends State<_StockOutDetailDialog> { + late Future _future; + + @override + void initState() { + super.initState(); + _future = widget.repository.get(widget.orderId); + } + + @override + Widget build(BuildContext context) { + return Dialog( + child: Container( + width: 720, + constraints: const BoxConstraints(maxHeight: 600), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + decoration: const BoxDecoration( + color: AppTheme.primary, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(12), + topRight: Radius.circular(12), + ), + ), + child: Row( + children: [ + const Text('出库单详情', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.white)), + const Spacer(), + IconButton( + icon: const Icon(Icons.close, color: Colors.white), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ), + Expanded( + child: FutureBuilder( + future: _future, + builder: (context, snap) { + if (snap.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + if (snap.hasError) { + return Center( + child: Text('加载失败:${snap.error}', + style: const TextStyle(color: AppTheme.danger))); + } + return _buildContent(snap.data!); + }, + ), + ), + ], + ), + ), + ); + } + + Widget _buildContent(StockOutOrder o) { + return SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 32, + runSpacing: 12, + children: [ + _InfoField('出库单号', o.orderNo), + _InfoField('状态', _statusLabel(o.status)), + _InfoField('客户/往来单位', o.partnerName ?? '-'), + _InfoField('仓库', o.warehouseName ?? '-'), + _InfoField('出库日期', o.orderDate?.substring(0, 10) ?? '-'), + _InfoField('合计金额', + o.totalAmount != null ? '¥${o.totalAmount!.toStringAsFixed(2)}' : '-'), + if (o.remark != null && o.remark!.isNotEmpty) + _InfoField('备注', o.remark!), + ], + ), + const SizedBox(height: 20), + const Text('商品明细', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppTheme.primaryDark)), + const SizedBox(height: 8), + if (o.items.isEmpty) + const Text('无商品明细', + style: TextStyle(color: AppTheme.textSecondary)) + else + Table( + border: TableBorder.all(color: AppTheme.border, width: 0.5), + columnWidths: const { + 0: FixedColumnWidth(36), + 1: FlexColumnWidth(3), + 2: FlexColumnWidth(1.5), + 3: FlexColumnWidth(1.5), + 4: FlexColumnWidth(1.5), + 5: FlexColumnWidth(1.5), + }, + children: [ + TableRow( + decoration: const BoxDecoration(color: Color(0xFFF0F4FF)), + children: ['序号', '商品', '规格', '数量', '单价', '金额'] + .map((h) => Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 8), + child: Text(h, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppTheme.primaryDark)), + )) + .toList(), + ), + ...o.items.asMap().entries.map((e) { + final i = e.key; + final item = e.value; + return TableRow( + decoration: BoxDecoration( + color: i.isEven ? Colors.white : const Color(0xFFFAFAFA)), + children: [ + _TableCell('${i + 1}'), + _TableCell(item.productName ?? '-'), + _TableCell(item.productSpec ?? '-'), + _TableCell(item.quantity.toStringAsFixed(3)), + _TableCell('¥${item.unitPrice.toStringAsFixed(2)}'), + _TableCell('¥${item.totalPrice.toStringAsFixed(2)}'), + ], + ); + }), + ], + ), + ], + ), + ); + } + + String _statusLabel(String status) { + switch (status) { + case 'draft': + return '草稿'; + case 'pending': + return '待审核'; + case 'approved': + return '已审核'; + case 'rejected': + return '已拒绝'; + default: + return status; + } + } +} + +class _InfoField extends StatelessWidget { + final String label; + final String value; + + const _InfoField(this.label, this.value); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, + style: const TextStyle( + fontSize: 12, color: AppTheme.textSecondary)), + const SizedBox(height: 2), + Text(value, + style: const TextStyle( + fontSize: 14, fontWeight: FontWeight.w500)), + ], + ); + } +} + +class _TableCell extends StatelessWidget { + final String text; + + const _TableCell(this.text); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + child: Text(text, style: const TextStyle(fontSize: 13)), + ); + } +} + class _StatusFilterDropdown extends StatelessWidget { final String value; final ValueChanged onChanged;