From b7a860e3e523af80f498e1d6bf7533c4f85787e0 Mon Sep 17 00:00:00 2001 From: Babak Alizadeh Date: Sun, 2 Nov 2025 09:41:38 +0000 Subject: [PATCH] progress in cheques --- hesabixAPI/adapters/api/v1/warehouses.py | 32 ++++ hesabixAPI/adapters/db/models/check.py | 4 +- hesabixAPI/app/core/permissions.py | 8 +- hesabixAPI/app/services/check_service.py | 18 +- hesabixAPI/app/services/invoice_service.py | 91 ++++++++-- hesabixAPI/app/services/warehouse_service.py | 35 ++++ hesabixAPI/hesabix_api.egg-info/SOURCES.txt | 2 + .../scripts/migrate_checks_enum_uppercase.py | 47 ++++++ .../normalize_checks_type_uppercase.py | 20 +++ hesabixUI/hesabix_ui/lib/main.dart | 1 + .../lib/pages/business/accounts_page.dart | 157 ++++++++++++++++-- .../lib/pages/business/warehouses_page.dart | 140 ++++++++-------- 12 files changed, 447 insertions(+), 108 deletions(-) create mode 100644 hesabixAPI/scripts/migrate_checks_enum_uppercase.py create mode 100644 hesabixAPI/scripts/normalize_checks_type_uppercase.py diff --git a/hesabixAPI/adapters/api/v1/warehouses.py b/hesabixAPI/adapters/api/v1/warehouses.py index 9b80314..4eee425 100644 --- a/hesabixAPI/adapters/api/v1/warehouses.py +++ b/hesabixAPI/adapters/api/v1/warehouses.py @@ -8,6 +8,7 @@ from adapters.db.session import get_db from app.core.auth_dependency import get_current_user, AuthContext from app.core.permissions import require_business_access from app.core.responses import success_response, ApiError, format_datetime_fields +from adapters.api.v1.schemas import QueryInfo, PaginatedResponse from adapters.api.v1.schema_models.warehouse import ( WarehouseCreateRequest, WarehouseUpdateRequest, @@ -18,6 +19,7 @@ from app.services.warehouse_service import ( get_warehouse, update_warehouse, delete_warehouse, + query_warehouses, ) @@ -103,3 +105,33 @@ def delete_warehouse_endpoint( return success_response({"deleted": ok}, request) + +@router.post("/business/{business_id}/query") +def query_warehouses_endpoint( + request: Request, + business_id: int, + payload: QueryInfo, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +) -> Dict[str, Any]: + # دسترسی به کسب‌وکار و مجوز خواندن انبار + if not ctx.can_access_business(int(business_id)): + raise ApiError("FORBIDDEN", f"No access to business {business_id}", http_status=403) + if not ctx.can_read_section("inventory"): + raise ApiError("FORBIDDEN", "Missing business permission: inventory.read", http_status=403) + result = query_warehouses(db, business_id, payload) + # تطبیق خروجی با ساختار DataTableResponse (items + pagination) + data = { + "items": format_datetime_fields(result["items"], request), + "pagination": { + "total": result["total"], + "page": result["page"], + "per_page": result["limit"], + "total_pages": result["total_pages"], + "has_next": result["page"] < result["total_pages"], + "has_prev": result["page"] > 1, + }, + "query_info": payload.model_dump(), + } + return success_response(data=data, request=request) + diff --git a/hesabixAPI/adapters/db/models/check.py b/hesabixAPI/adapters/db/models/check.py index f428344..e27a165 100644 --- a/hesabixAPI/adapters/db/models/check.py +++ b/hesabixAPI/adapters/db/models/check.py @@ -19,8 +19,8 @@ from adapters.db.session import Base class CheckType(str, Enum): - RECEIVED = "received" - TRANSFERRED = "transferred" + RECEIVED = "RECEIVED" + TRANSFERRED = "TRANSFERRED" class Check(Base): diff --git a/hesabixAPI/app/core/permissions.py b/hesabixAPI/app/core/permissions.py index 4796cfb..fef0364 100644 --- a/hesabixAPI/app/core/permissions.py +++ b/hesabixAPI/app/core/permissions.py @@ -1,5 +1,5 @@ from functools import wraps -from typing import Callable, Any +from typing import Callable, Any, get_type_hints import inspect from fastapi import Depends @@ -131,6 +131,12 @@ def require_business_access(business_id_param: str = "business_id"): return result # Preserve original signature so FastAPI sees correct parameters (including Request) wrapper.__signature__ = inspect.signature(func) # type: ignore[attr-defined] + # Also preserve evaluated type annotations to avoid ForwardRef issues under __future__.annotations + try: + wrapper.__annotations__ = get_type_hints(func, globalns=getattr(func, "__globals__", None)) # type: ignore[attr-defined] + except Exception: + # Fallback to original annotations (may be string-based) if evaluation fails + wrapper.__annotations__ = getattr(func, "__annotations__", {}) return wrapper return decorator diff --git a/hesabixAPI/app/services/check_service.py b/hesabixAPI/app/services/check_service.py index 32db88a..4baa948 100644 --- a/hesabixAPI/app/services/check_service.py +++ b/hesabixAPI/app/services/check_service.py @@ -21,11 +21,11 @@ def _parse_iso(dt: str) -> datetime: def create_check(db: Session, business_id: int, data: Dict[str, Any]) -> Dict[str, Any]: ctype = str(data.get('type', '')).lower() - if ctype not in (CheckType.RECEIVED.value, CheckType.TRANSFERRED.value): + if ctype not in ("received", "transferred"): raise ApiError("INVALID_CHECK_TYPE", "Invalid check type", http_status=400) person_id = data.get('person_id') - if ctype == CheckType.RECEIVED.value and not person_id: + if ctype == "received" and not person_id: raise ApiError("PERSON_REQUIRED", "person_id is required for received checks", http_status=400) issue_date = _parse_iso(str(data.get('issue_date'))) @@ -63,7 +63,7 @@ def create_check(db: Session, business_id: int, data: Dict[str, Any]) -> Dict[st obj = Check( business_id=business_id, - type=CheckType(ctype), + type=CheckType[ctype.upper()], person_id=int(person_id) if person_id else None, issue_date=issue_date, due_date=due_date, @@ -93,9 +93,9 @@ def update_check(db: Session, check_id: int, data: Dict[str, Any]) -> Optional[D if 'type' in data and data['type'] is not None: ctype = str(data['type']).lower() - if ctype not in (CheckType.RECEIVED.value, CheckType.TRANSFERRED.value): + if ctype not in ("received", "transferred"): raise ApiError("INVALID_CHECK_TYPE", "Invalid check type", http_status=400) - obj.type = CheckType(ctype) + obj.type = CheckType[ctype.upper()] if 'person_id' in data: obj.person_id = int(data['person_id']) if data['person_id'] is not None else None @@ -192,7 +192,11 @@ def list_checks(db: Session, business_id: int, query: Dict[str, Any]) -> Dict[st if not prop or not op: continue if prop == 'type' and op == '=': - q = q.filter(Check.type == val) + try: + enum_val = CheckType[str(val).upper()] + q = q.filter(Check.type == enum_val) + except Exception: + pass elif prop == 'currency' and op == '=': try: q = q.filter(Check.currency_id == int(val)) @@ -267,7 +271,7 @@ def check_to_dict(db: Session, obj: Optional[Check]) -> Optional[Dict[str, Any]] return { "id": obj.id, "business_id": obj.business_id, - "type": obj.type.value, + "type": obj.type.name.lower(), "person_id": obj.person_id, "person_name": person_name, "issue_date": obj.issue_date.isoformat(), diff --git a/hesabixAPI/app/services/invoice_service.py b/hesabixAPI/app/services/invoice_service.py index bb3f26b..bcd51af 100644 --- a/hesabixAPI/app/services/invoice_service.py +++ b/hesabixAPI/app/services/invoice_service.py @@ -68,12 +68,24 @@ def _iter_product_movements( """ if not product_ids: return [] + # فقط کالاهای با کنترل موجودی را لحاظ کن + tracked_ids: List[int] = [ + int(pid) + for pid, tracked in db.query(Product.id, Product.track_inventory).filter( + Product.business_id == business_id, + Product.id.in_(list({int(pid) for pid in product_ids})), + ).all() + if bool(tracked) + ] + if not tracked_ids: + return [] + q = db.query(DocumentLine, Document).join(Document, Document.id == DocumentLine.document_id).filter( and_( Document.business_id == business_id, Document.is_proforma == False, # noqa: E712 Document.document_date <= up_to_date, - DocumentLine.product_id.in_(list({int(pid) for pid in product_ids})), + DocumentLine.product_id.in_(tracked_ids), ) ) if exclude_document_id is not None: @@ -353,6 +365,9 @@ def _extract_cogs_total(lines: List[Dict[str, Any]]) -> Decimal: total = Decimal(0) for line in lines: info = line.get("extra_info") or {} + # فقط برای کالاهای دارای کنترل موجودی + if not bool(info.get("inventory_tracked")): + continue qty = Decimal(str(line.get("quantity", 0) or 0)) if info.get("cogs_amount") is not None: total += Decimal(str(info.get("cogs_amount"))) @@ -486,20 +501,45 @@ def create_invoice( if mv == "out": outgoing_lines.append(ln) - # Ensure stock sufficiency for outgoing - if outgoing_lines: - _ensure_stock_sufficient(db, business_id, document_date, outgoing_lines) + # Resolve inventory tracking per product and annotate lines + all_product_ids = [int(ln.get("product_id")) for ln in lines_input if ln.get("product_id")] + track_map: Dict[int, bool] = {} + if all_product_ids: + for pid, tracked in db.query(Product.id, Product.track_inventory).filter( + Product.business_id == business_id, + Product.id.in_(all_product_ids), + ).all(): + track_map[int(pid)] = bool(tracked) - # Costing method + for ln in lines_input: + pid = ln.get("product_id") + if not pid: + continue + info = dict(ln.get("extra_info") or {}) + info["inventory_tracked"] = bool(track_map.get(int(pid), False)) + ln["extra_info"] = info + + # Filter outgoing lines to only inventory-tracked products for stock checks + tracked_outgoing_lines: List[Dict[str, Any]] = [] + for ln in outgoing_lines: + pid = ln.get("product_id") + if pid and track_map.get(int(pid)): + tracked_outgoing_lines.append(ln) + + # Ensure stock sufficiency for outgoing (only for tracked products) + if tracked_outgoing_lines: + _ensure_stock_sufficient(db, business_id, document_date, tracked_outgoing_lines) + + # Costing method (only for tracked products) costing_method = _get_costing_method(data) - if costing_method == "fifo" and outgoing_lines: - fifo_costs = _calculate_fifo_cogs_for_outgoing(db, business_id, document_date, outgoing_lines) - # annotate lines with cogs_amount in the same order as outgoing_lines + if costing_method == "fifo" and tracked_outgoing_lines: + fifo_costs = _calculate_fifo_cogs_for_outgoing(db, business_id, document_date, tracked_outgoing_lines) + # annotate lines with cogs_amount in the same order as tracked_outgoing_lines i = 0 for ln in lines_input: info = ln.get("extra_info") or {} mv = info.get("movement") or movement_hint - if mv == "out": + if mv == "out" and info.get("inventory_tracked"): amt = fifo_costs[i] i += 1 info = dict(info) @@ -901,18 +941,41 @@ def update_invoice( if mv == "out": outgoing_lines.append(ln) - if outgoing_lines: - _ensure_stock_sufficient(db, document.business_id, document.document_date, outgoing_lines, exclude_document_id=document.id) + # Resolve and annotate inventory tracking for all lines + all_product_ids = [int(ln.get("product_id")) for ln in lines_input if ln.get("product_id")] + track_map: Dict[int, bool] = {} + if all_product_ids: + for pid, tracked in db.query(Product.id, Product.track_inventory).filter( + Product.business_id == document.business_id, + Product.id.in_(all_product_ids), + ).all(): + track_map[int(pid)] = bool(tracked) + for ln in lines_input: + pid = ln.get("product_id") + if not pid: + continue + info = dict(ln.get("extra_info") or {}) + info["inventory_tracked"] = bool(track_map.get(int(pid), False)) + ln["extra_info"] = info + + tracked_outgoing_lines: List[Dict[str, Any]] = [] + for ln in outgoing_lines: + pid = ln.get("product_id") + if pid and track_map.get(int(pid)): + tracked_outgoing_lines.append(ln) + + if tracked_outgoing_lines: + _ensure_stock_sufficient(db, document.business_id, document.document_date, tracked_outgoing_lines, exclude_document_id=document.id) header_for_costing = data if data else {"extra_info": document.extra_info} costing_method = _get_costing_method(header_for_costing) - if costing_method == "fifo" and outgoing_lines: - fifo_costs = _calculate_fifo_cogs_for_outgoing(db, document.business_id, document.document_date, outgoing_lines, exclude_document_id=document.id) + if costing_method == "fifo" and tracked_outgoing_lines: + fifo_costs = _calculate_fifo_cogs_for_outgoing(db, document.business_id, document.document_date, tracked_outgoing_lines, exclude_document_id=document.id) i = 0 for ln in lines_input: info = ln.get("extra_info") or {} mv = info.get("movement") or movement_hint - if mv == "out": + if mv == "out" and info.get("inventory_tracked"): amt = fifo_costs[i] i += 1 info = dict(info) diff --git a/hesabixAPI/app/services/warehouse_service.py b/hesabixAPI/app/services/warehouse_service.py index 2e32030..dc2dbfb 100644 --- a/hesabixAPI/app/services/warehouse_service.py +++ b/hesabixAPI/app/services/warehouse_service.py @@ -8,6 +8,8 @@ from app.core.responses import ApiError from adapters.db.models.warehouse import Warehouse from adapters.db.repositories.warehouse_repository import WarehouseRepository from adapters.api.v1.schema_models.warehouse import WarehouseCreateRequest, WarehouseUpdateRequest +from adapters.api.v1.schemas import QueryInfo, FilterItem +from app.services.query_service import QueryService def _to_dict(obj: Warehouse) -> Dict[str, Any]: @@ -88,3 +90,36 @@ def delete_warehouse(db: Session, business_id: int, warehouse_id: int) -> bool: return repo.delete(warehouse_id) + +def query_warehouses(db: Session, business_id: int, query_info: QueryInfo) -> Dict[str, Any]: + """Query warehouses with filters/search/sorting/pagination scoped to business.""" + # Ensure business scoping via filters + base_filter = FilterItem(property="business_id", operator="=", value=business_id) + merged_filters = [base_filter] + if query_info.filters: + merged_filters.extend(query_info.filters) + + effective_query = QueryInfo( + sort_by=query_info.sort_by, + sort_desc=query_info.sort_desc, + take=query_info.take, + skip=query_info.skip, + search=query_info.search, + search_fields=query_info.search_fields, + filters=merged_filters, + ) + + results, total = QueryService.query_with_filters(Warehouse, db, effective_query) + items = [_to_dict(w) for w in results] + limit = max(1, effective_query.take) + page = (effective_query.skip // limit) + 1 + total_pages = (total + limit - 1) // limit + + return { + "items": items, + "total": total, + "page": page, + "limit": limit, + "total_pages": total_pages, + } + diff --git a/hesabixAPI/hesabix_api.egg-info/SOURCES.txt b/hesabixAPI/hesabix_api.egg-info/SOURCES.txt index a01d6a2..e940fb4 100644 --- a/hesabixAPI/hesabix_api.egg-info/SOURCES.txt +++ b/hesabixAPI/hesabix_api.egg-info/SOURCES.txt @@ -130,6 +130,7 @@ app/core/responses.py app/core/security.py app/core/settings.py app/core/smart_normalizer.py +app/services/account_service.py app/services/api_key_service.py app/services/auth_service.py app/services/bank_account_service.py @@ -144,6 +145,7 @@ app/services/document_service.py app/services/email_service.py app/services/expense_income_service.py app/services/file_storage_service.py +app/services/invoice_service.py app/services/person_service.py app/services/petty_cash_service.py app/services/price_list_service.py diff --git a/hesabixAPI/scripts/migrate_checks_enum_uppercase.py b/hesabixAPI/scripts/migrate_checks_enum_uppercase.py new file mode 100644 index 0000000..a90e8ac --- /dev/null +++ b/hesabixAPI/scripts/migrate_checks_enum_uppercase.py @@ -0,0 +1,47 @@ +from sqlalchemy import text +from adapters.db.session import SessionLocal + + +def run() -> None: + """Migrate MySQL ENUM values of checks.type to uppercase only. + + Steps: + 1) Allow both lowercase and uppercase temporarily + 2) Update existing rows to uppercase + 3) Restrict enum to uppercase values only + Safe to re-run. + """ + with SessionLocal() as db: + # Case-insensitive collations in MySQL prevent having both 'received' and 'RECEIVED' + # So we migrate via temporary placeholders. + + # 1) Add placeholders alongside existing lowercase values + db.execute(text( + "ALTER TABLE checks MODIFY COLUMN type ENUM('received','transferred','TMP_R','TMP_T') NOT NULL" + )) + + # 2) Move existing rows to placeholders + db.execute(text("UPDATE checks SET type='TMP_R' WHERE type='received'")) + db.execute(text("UPDATE checks SET type='TMP_T' WHERE type='transferred'")) + + # 3) Switch enum to uppercase + placeholders + db.execute(text( + "ALTER TABLE checks MODIFY COLUMN type ENUM('RECEIVED','TRANSFERRED','TMP_R','TMP_T') NOT NULL" + )) + + # 4) Move placeholders to uppercase values + db.execute(text("UPDATE checks SET type='RECEIVED' WHERE type='TMP_R'")) + db.execute(text("UPDATE checks SET type='TRANSFERRED' WHERE type='TMP_T'")) + + # 5) Drop placeholders + db.execute(text( + "ALTER TABLE checks MODIFY COLUMN type ENUM('RECEIVED','TRANSFERRED') NOT NULL" + )) + + db.commit() + + +if __name__ == "__main__": + run() + + diff --git a/hesabixAPI/scripts/normalize_checks_type_uppercase.py b/hesabixAPI/scripts/normalize_checks_type_uppercase.py new file mode 100644 index 0000000..a2eb965 --- /dev/null +++ b/hesabixAPI/scripts/normalize_checks_type_uppercase.py @@ -0,0 +1,20 @@ +from sqlalchemy import text +from adapters.db.session import SessionLocal + + +def run() -> None: + """Normalize existing checks.type values to uppercase to match Enum. + + Converts 'received' -> 'RECEIVED' and 'transferred' -> 'TRANSFERRED'. + Safe to run multiple times. + """ + with SessionLocal() as db: + db.execute(text("UPDATE checks SET type='RECEIVED' WHERE LOWER(type)='received'")) + db.execute(text("UPDATE checks SET type='TRANSFERRED' WHERE LOWER(type)='transferred'")) + db.commit() + + +if __name__ == "__main__": + run() + + diff --git a/hesabixUI/hesabix_ui/lib/main.dart b/hesabixUI/hesabix_ui/lib/main.dart index 5e45ee7..f1e2dd5 100644 --- a/hesabixUI/hesabix_ui/lib/main.dart +++ b/hesabixUI/hesabix_ui/lib/main.dart @@ -535,6 +535,7 @@ class _MyAppState extends State { pageBuilder: (context, state) => NoTransitionPage( child: AccountsPage( businessId: int.parse(state.pathParameters['business_id']!), + authStore: _authStore!, ), ), ), diff --git a/hesabixUI/hesabix_ui/lib/pages/business/accounts_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/accounts_page.dart index a180ad0..291dfdb 100644 --- a/hesabixUI/hesabix_ui/lib/pages/business/accounts_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/accounts_page.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:hesabix_ui/l10n/app_localizations.dart'; import 'package:hesabix_ui/core/api_client.dart'; +import 'package:hesabix_ui/core/auth_store.dart'; class AccountNode { final String id; @@ -42,8 +43,9 @@ class _VisibleNode { } class AccountsPage extends StatefulWidget { - final int businessId; - const AccountsPage({super.key, required this.businessId}); + final int businessId; + final AuthStore authStore; + const AccountsPage({super.key, required this.businessId, required this.authStore}); @override State createState() => _AccountsPageState(); @@ -156,16 +158,20 @@ class _AccountsPageState extends State { final pid = int.tryParse(selectedParentId!); if (pid != null) payload["parent_id"] = pid; } - try { - final api = ApiClient(); - await api.post( - '/api/v1/accounts/business/${widget.businessId}/create', - data: payload, - ); - if (context.mounted) Navigator.of(ctx).pop(true); - } catch (_) { - // نمایش خطا می‌تواند بعداً اضافه شود - } + try { + final api = ApiClient(); + await api.post( + '/api/v1/accounts/business/${widget.businessId}/create', + data: payload, + ); + if (context.mounted) Navigator.of(ctx).pop(true); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('خطا در ایجاد حساب: $e')), + ); + } + } }, child: Text(t.add), ), @@ -206,6 +212,105 @@ class _AccountsPageState extends State { }); } + Future _openEditDialog(AccountNode node) async { + final t = AppLocalizations.of(context); + final codeCtrl = TextEditingController(text: node.code); + final nameCtrl = TextEditingController(text: node.name); + final typeCtrl = TextEditingController(text: node.accountType ?? ''); + final parents = _flattenNodes(); + String? selectedParentId; + final result = await showDialog( + context: context, + builder: (ctx) { + return AlertDialog( + title: Text(t.edit), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField(controller: codeCtrl, decoration: InputDecoration(labelText: t.code)), + TextField(controller: nameCtrl, decoration: InputDecoration(labelText: t.title)), + TextField(controller: typeCtrl, decoration: InputDecoration(labelText: t.type)), + DropdownButtonFormField( + value: selectedParentId, + items: [ + DropdownMenuItem(value: null, child: Text('بدون والد')), + ...parents.map((p) => DropdownMenuItem(value: p["id"], child: Text(p["title"]!))).toList(), + ], + onChanged: (v) { selectedParentId = v; }, + decoration: const InputDecoration(labelText: 'حساب والد'), + ), + ], + ), + ), + actions: [ + TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: Text(t.cancel)), + FilledButton( + onPressed: () async { + final name = nameCtrl.text.trim(); + final code = codeCtrl.text.trim(); + final atype = typeCtrl.text.trim(); + if (name.isEmpty || code.isEmpty || atype.isEmpty) return; + final Map payload = {"name": name, "code": code, "account_type": atype}; + if (selectedParentId != null && selectedParentId!.isNotEmpty) { + final pid = int.tryParse(selectedParentId!); + if (pid != null) payload["parent_id"] = pid; + } + try { + final id = int.tryParse(node.id); + if (id == null) return; + final api = ApiClient(); + await api.put('/api/v1/accounts/account/$id', data: payload); + if (context.mounted) Navigator.of(ctx).pop(true); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('خطا در ویرایش حساب: $e')), + ); + } + } + }, + child: Text(t.save), + ), + ], + ); + }, + ); + if (result == true) { + await _fetch(); + } + } + + Future _confirmDelete(AccountNode node) async { + final t = AppLocalizations.of(context); + final id = int.tryParse(node.id); + if (id == null) return; + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(t.delete), + content: const Text('آیا مطمئن هستید؟'), + actions: [ + TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: Text(t.cancel)), + FilledButton(onPressed: () => Navigator.of(ctx).pop(true), child: Text(t.delete)), + ], + ), + ); + if (ok == true) { + try { + final api = ApiClient(); + await api.delete('/api/v1/accounts/account/$id'); + await _fetch(); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('خطا در حذف حساب: $e')), + ); + } + } + } + } + String _localizedAccountType(AppLocalizations t, String? value) { if (value == null || value.isEmpty) return '-'; final ln = t.localeName; @@ -273,7 +378,7 @@ class _AccountsPageState extends State { Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), color: Theme.of(context).colorScheme.surfaceContainerHighest, - child: Row( + child: Row( children: [ const SizedBox(width: 28), // expander space Expanded(flex: 2, child: Text(t.code, style: const TextStyle(fontWeight: FontWeight.w600))), @@ -314,7 +419,21 @@ class _AccountsPageState extends State { ), Expanded(flex: 2, child: Text(node.code, style: const TextStyle(fontFeatures: []))), Expanded(flex: 5, child: Text(node.name)), - Expanded(flex: 3, child: Text(_localizedAccountType(t, node.accountType))), + Expanded(flex: 3, child: Text(_localizedAccountType(t, node.accountType))), + SizedBox( + width: 40, + child: PopupMenuButton( + padding: EdgeInsets.zero, + onSelected: (v) { + if (v == 'edit') _openEditDialog(node); + if (v == 'delete') _confirmDelete(node); + }, + itemBuilder: (context) => [ + const PopupMenuItem(value: 'edit', child: Text('ویرایش')), + const PopupMenuItem(value: 'delete', child: Text('حذف')), + ], + ), + ), ], ), ), @@ -325,10 +444,12 @@ class _AccountsPageState extends State { ), ], ), - floatingActionButton: FloatingActionButton( - onPressed: _openCreateDialog, - child: const Icon(Icons.add), - ), + floatingActionButton: widget.authStore.canWriteSection('accounting') + ? FloatingActionButton( + onPressed: _openCreateDialog, + child: const Icon(Icons.add), + ) + : null, ); } } diff --git a/hesabixUI/hesabix_ui/lib/pages/business/warehouses_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/warehouses_page.dart index 3763da7..b1a7ae1 100644 --- a/hesabixUI/hesabix_ui/lib/pages/business/warehouses_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/warehouses_page.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import '../../services/warehouse_service.dart'; import '../../models/warehouse_model.dart'; +import '../../widgets/data_table/data_table_widget.dart'; +import '../../widgets/data_table/data_table_config.dart'; class WarehousesPage extends StatefulWidget { final int businessId; @@ -12,69 +14,81 @@ class WarehousesPage extends StatefulWidget { class _WarehousesPageState extends State { final WarehouseService _service = WarehouseService(); - bool _loading = true; - String? _error; - List _items = const []; + final GlobalKey _tableKey = GlobalKey(); - @override - void initState() { - super.initState(); - _load(); - } - - Future _load() async { + void _refreshTable() { try { - setState(() { - _loading = true; - _error = null; - }); - final items = await _service.listWarehouses(businessId: widget.businessId); - if (!mounted) return; - setState(() { - _items = items; - _loading = false; - }); - } catch (e) { - if (!mounted) return; - setState(() { - _error = e.toString(); - _loading = false; - }); - } + final current = _tableKey.currentState as dynamic; + current?.refresh(); + } catch (_) {} } @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: const Text('مدیریت انبارها')), - floatingActionButton: FloatingActionButton( - onPressed: _showCreateDialog, - child: const Icon(Icons.add), + body: DataTableWidget( + key: _tableKey, + fromJson: (m) => Warehouse.fromJson(m), + config: DataTableConfig( + endpoint: '/api/v1/warehouses/business/${widget.businessId}/query', + title: 'فهرست انبارها', + showBackButton: true, + onBack: () => Navigator.of(context).maybePop(), + showTableIcon: false, + showSearch: true, + showPagination: true, + showRowNumbers: true, + enableSorting: true, + searchFields: const ['code', 'name', 'description'], + customHeaderActions: [ + Tooltip( + message: 'افزودن انبار', + child: IconButton( + onPressed: _showCreateDialog, + icon: const Icon(Icons.add), + ), + ), + ], + columns: [ + ActionColumn('actions', 'عملیات', actions: [ + DataTableAction( + icon: Icons.edit_outlined, + label: 'ویرایش', + onTap: (item) { + if (item is Warehouse) _showEditDialog(item); + }, + ), + DataTableAction( + icon: Icons.delete_outline, + label: 'حذف', + onTap: (item) { + if (item is Warehouse) _delete(item); + }, + isDestructive: true, + ), + ]), + TextColumn('code', 'کد', + formatter: (item) => (item as Warehouse).code, + width: ColumnWidth.small), + TextColumn('name', 'نام', + formatter: (item) => (item as Warehouse).name, + width: ColumnWidth.medium), + TextColumn('description', 'توضیحات', + formatter: (item) => (item as Warehouse).description ?? '', + width: ColumnWidth.large, + searchable: true), + TextColumn('is_default', 'پیش‌فرض', + formatter: (item) => (item as Warehouse).isDefault ? 'بله' : 'خیر', + sortable: false, + searchable: false, + width: ColumnWidth.small), + DateColumn('created_at', 'ایجاد', + formatter: (item) => (item as Warehouse).createdAt?.toIso8601String() ?? '', + showTime: false, + width: ColumnWidth.small), + ], + ), ), - body: _loading - ? const Center(child: CircularProgressIndicator()) - : _error != null - ? Center(child: Text(_error!, style: TextStyle(color: Colors.red.shade700))) - : RefreshIndicator( - onRefresh: _load, - child: ListView.separated( - itemCount: _items.length, - separatorBuilder: (_, __) => const Divider(height: 1), - itemBuilder: (ctx, idx) { - final w = _items[idx]; - return ListTile( - leading: Icon(w.isDefault ? Icons.star : Icons.store, color: w.isDefault ? Colors.orange : null), - title: Text('${w.code} - ${w.name}'), - subtitle: Text(w.description ?? ''), - onTap: () => _showEditDialog(w), - trailing: IconButton( - icon: const Icon(Icons.delete_outline), - onPressed: () => _delete(w), - ), - ); - }, - ), - ), ); } @@ -109,7 +123,7 @@ class _WarehousesPageState extends State { ); if (ok != true) return; try { - final created = await _service.createWarehouse( + await _service.createWarehouse( businessId: widget.businessId, payload: { 'code': codeCtrl.text.trim(), @@ -118,9 +132,7 @@ class _WarehousesPageState extends State { }, ); if (!mounted) return; - setState(() { - _items = [created, ..._items]; - }); + _refreshTable(); } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا: $e'))); @@ -158,7 +170,7 @@ class _WarehousesPageState extends State { ); if (ok != true) return; try { - final updated = await _service.updateWarehouse( + await _service.updateWarehouse( businessId: widget.businessId, warehouseId: w.id!, payload: { @@ -168,9 +180,7 @@ class _WarehousesPageState extends State { }, ); if (!mounted) return; - setState(() { - _items = _items.map((e) => e.id == updated.id ? updated : e).toList(); - }); + _refreshTable(); } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا: $e'))); @@ -194,9 +204,7 @@ class _WarehousesPageState extends State { final deleted = await _service.deleteWarehouse(businessId: widget.businessId, warehouseId: w.id!); if (!mounted) return; if (deleted) { - setState(() { - _items = _items.where((e) => e.id != w.id).toList(); - }); + _refreshTable(); } } catch (e) { if (!mounted) return;