From c88b1ccdd094c4260dda186aaeb7d8b06b727393 Mon Sep 17 00:00:00 2001 From: Babak Alizadeh Date: Tue, 21 Oct 2025 11:30:01 +0330 Subject: [PATCH] progress in c/i --- hesabixAPI/adapters/api/v1/expense_income.py | 88 ++ hesabixAPI/adapters/api/v1/transfers.py | 341 ++++++ hesabixAPI/app/main.py | 4 + .../app/services/expense_income_service.py | 398 +++++++ hesabixAPI/app/services/transfer_service.py | 680 ++++++++++++ .../hesabix_ui/android/settings.gradle.kts | 2 +- hesabixUI/hesabix_ui/lib/main.dart | 275 ++--- .../lib/models/transfer_document.dart | 48 + .../pages/business/expense_income_dialog.dart | 338 ++++++ .../business/expense_income_list_page.dart | 170 +++ .../pages/business/expense_income_page.dart | 431 ++++++++ .../lib/pages/business/transfers_page.dart | 380 +++++-- .../lib/services/expense_income_service.dart | 60 ++ .../lib/services/transfer_service.dart | 145 +++ .../lib/widgets/fiscal_year_switcher.dart | 2 - .../invoice/bank_account_combobox_widget.dart | 87 +- .../cash_register_combobox_widget.dart | 85 +- .../invoice/petty_cash_combobox_widget.dart | 85 +- .../widgets/person/person_form_dialog.dart | 10 +- .../transfer/transfer_details_dialog.dart | 77 ++ .../transfer/transfer_form_dialog.dart | 996 ++++++++++-------- .../lib/pages/business/check_form_page.dart | 24 - 22 files changed, 3973 insertions(+), 753 deletions(-) create mode 100644 hesabixAPI/adapters/api/v1/expense_income.py create mode 100644 hesabixAPI/adapters/api/v1/transfers.py create mode 100644 hesabixAPI/app/services/expense_income_service.py create mode 100644 hesabixAPI/app/services/transfer_service.py create mode 100644 hesabixUI/hesabix_ui/lib/models/transfer_document.dart create mode 100644 hesabixUI/hesabix_ui/lib/pages/business/expense_income_dialog.dart create mode 100644 hesabixUI/hesabix_ui/lib/pages/business/expense_income_list_page.dart create mode 100644 hesabixUI/hesabix_ui/lib/pages/business/expense_income_page.dart create mode 100644 hesabixUI/hesabix_ui/lib/services/expense_income_service.dart create mode 100644 hesabixUI/hesabix_ui/lib/services/transfer_service.dart create mode 100644 hesabixUI/hesabix_ui/lib/widgets/transfer/transfer_details_dialog.dart delete mode 100644 hesabix_ui/lib/pages/business/check_form_page.dart diff --git a/hesabixAPI/adapters/api/v1/expense_income.py b/hesabixAPI/adapters/api/v1/expense_income.py new file mode 100644 index 0000000..b9244d3 --- /dev/null +++ b/hesabixAPI/adapters/api/v1/expense_income.py @@ -0,0 +1,88 @@ +""" +API endpoints برای هزینه و درآمد (Expense & Income) +""" + +from typing import Any, Dict +from fastapi import APIRouter, Depends, Request, Body +from sqlalchemy.orm import Session + +from adapters.db.session import get_db +from app.core.auth_dependency import get_current_user, AuthContext +from app.core.permissions import require_business_management_dep, require_business_access +from app.core.responses import success_response, format_datetime_fields +from adapters.api.v1.schemas import QueryInfo +from app.services.expense_income_service import ( + create_expense_income, + list_expense_income, +) + + +router = APIRouter(tags=["expense-income"]) + + +@router.post( + "/businesses/{business_id}/expense-income/create", + summary="ایجاد سند هزینه یا درآمد", + description="ایجاد سند هزینه/درآمد با چند سطر حساب و چند طرف‌حساب", +) +@require_business_access("business_id") +async def create_expense_income_endpoint( + request: Request, + business_id: int, + body: Dict[str, Any] = Body(...), + db: Session = Depends(get_db), + ctx: AuthContext = Depends(get_current_user), + _: None = Depends(require_business_management_dep), +): + created = create_expense_income(db, business_id, ctx.get_user_id(), body) + return success_response( + data=format_datetime_fields(created, request), + request=request, + message="EXPENSE_INCOME_CREATED", + ) + + +@router.post( + "/businesses/{business_id}/expense-income", + summary="لیست اسناد هزینه/درآمد", + description="دریافت لیست اسناد هزینه/درآمد با جستجو و صفحه‌بندی", +) +@require_business_access("business_id") +async def list_expense_income_endpoint( + request: Request, + business_id: int, + query_info: QueryInfo = Body(...), + db: Session = Depends(get_db), + ctx: AuthContext = Depends(get_current_user), +): + query_dict: Dict[str, Any] = { + "take": query_info.take, + "skip": query_info.skip, + "sort_by": query_info.sort_by, + "sort_desc": query_info.sort_desc, + "search": query_info.search, + } + + # Read extra body filters + try: + body_json = await request.json() + if isinstance(body_json, dict): + for key in ["document_type", "from_date", "to_date"]: + if key in body_json: + query_dict[key] = body_json[key] + except Exception: + pass + + # Fiscal year from header + try: + fy_header = request.headers.get("X-Fiscal-Year-ID") + if fy_header: + query_dict["fiscal_year_id"] = int(fy_header) + except Exception: + pass + + result = list_expense_income(db, business_id, query_dict) + result["items"] = [format_datetime_fields(item, request) for item in result.get("items", [])] + return success_response(data=result, request=request, message="EXPENSE_INCOME_LIST_FETCHED") + + diff --git a/hesabixAPI/adapters/api/v1/transfers.py b/hesabixAPI/adapters/api/v1/transfers.py new file mode 100644 index 0000000..3909fd4 --- /dev/null +++ b/hesabixAPI/adapters/api/v1/transfers.py @@ -0,0 +1,341 @@ +""" +API endpoints برای انتقال وجه (Transfers) +""" + +from typing import Any, Dict +from fastapi import APIRouter, Depends, Request, Body +from sqlalchemy.orm import Session +from fastapi.responses import Response +import io, datetime, re + +from adapters.db.session import get_db +from app.core.auth_dependency import get_current_user, AuthContext +from app.core.responses import success_response, format_datetime_fields, ApiError +from app.core.permissions import require_business_management_dep, require_business_access +from adapters.api.v1.schemas import QueryInfo +from app.services.transfer_service import ( + create_transfer, + get_transfer, + list_transfers, + delete_transfer, + update_transfer, +) +from adapters.db.models.business import Business + + +router = APIRouter(tags=["transfers"]) + + +@router.post( + "/businesses/{business_id}/transfers", + summary="لیست اسناد انتقال", + description="دریافت لیست اسناد انتقال با فیلتر و جستجو", +) +@require_business_access("business_id") +async def list_transfers_endpoint( + request: Request, + business_id: int, + query_info: QueryInfo = Body(...), + db: Session = Depends(get_db), + ctx: AuthContext = Depends(get_current_user), +): + query_dict: Dict[str, Any] = { + "take": query_info.take, + "skip": query_info.skip, + "sort_by": query_info.sort_by, + "sort_desc": query_info.sort_desc, + "search": query_info.search, + } + try: + body_json = await request.json() + if isinstance(body_json, dict): + for key in ["from_date", "to_date"]: + if key in body_json: + query_dict[key] = body_json[key] + except Exception: + pass + + try: + fy_header = request.headers.get("X-Fiscal-Year-ID") + if fy_header: + query_dict["fiscal_year_id"] = int(fy_header) + except Exception: + pass + + result = list_transfers(db, business_id, query_dict) + result["items"] = [format_datetime_fields(item, request) for item in result.get("items", [])] + return success_response(data=result, request=request, message="TRANSFERS_LIST_FETCHED") + + +@router.post( + "/businesses/{business_id}/transfers/create", + summary="ایجاد سند انتقال", + description="ایجاد سند انتقال جدید", +) +@require_business_access("business_id") +async def create_transfer_endpoint( + request: Request, + business_id: int, + body: Dict[str, Any] = Body(...), + db: Session = Depends(get_db), + ctx: AuthContext = Depends(get_current_user), + _: None = Depends(require_business_management_dep), +): + created = create_transfer(db, business_id, ctx.get_user_id(), body) + return success_response(data=format_datetime_fields(created, request), request=request, message="TRANSFER_CREATED") + + +@router.get( + "/transfers/{document_id}", + summary="جزئیات سند انتقال", + description="دریافت جزئیات یک سند انتقال", +) +async def get_transfer_endpoint( + request: Request, + document_id: int, + db: Session = Depends(get_db), + ctx: AuthContext = Depends(get_current_user), +): + result = get_transfer(db, document_id) + if not result: + raise ApiError("DOCUMENT_NOT_FOUND", "Transfer document not found", http_status=404) + business_id = result.get("business_id") + if business_id and not ctx.can_access_business(business_id): + raise ApiError("FORBIDDEN", "Access denied", http_status=403) + return success_response(data=format_datetime_fields(result, request), request=request, message="TRANSFER_DETAILS") + + +@router.delete( + "/transfers/{document_id}", + summary="حذف سند انتقال", + description="حذف یک سند انتقال", +) +async def delete_transfer_endpoint( + request: Request, + document_id: int, + db: Session = Depends(get_db), + ctx: AuthContext = Depends(get_current_user), + _: None = Depends(require_business_management_dep), +): + result = get_transfer(db, document_id) + if result: + business_id = result.get("business_id") + if business_id and not ctx.can_access_business(business_id): + raise ApiError("FORBIDDEN", "Access denied", http_status=403) + ok = delete_transfer(db, document_id) + if not ok: + raise ApiError("DOCUMENT_NOT_FOUND", "Transfer document not found", http_status=404) + return success_response(data=None, request=request, message="TRANSFER_DELETED") + + +@router.put( + "/transfers/{document_id}", + summary="ویرایش سند انتقال", + description="به‌روزرسانی یک سند انتقال", +) +async def update_transfer_endpoint( + request: Request, + document_id: int, + body: Dict[str, Any] = Body(...), + db: Session = Depends(get_db), + ctx: AuthContext = Depends(get_current_user), + _: None = Depends(require_business_management_dep), +): + result = get_transfer(db, document_id) + if not result: + raise ApiError("DOCUMENT_NOT_FOUND", "Transfer document not found", http_status=404) + business_id = result.get("business_id") + if business_id and not ctx.can_access_business(business_id): + raise ApiError("FORBIDDEN", "Access denied", http_status=403) + updated = update_transfer(db, document_id, ctx.get_user_id(), body) + return success_response(data=format_datetime_fields(updated, request), request=request, message="TRANSFER_UPDATED") + + +@router.post( + "/businesses/{business_id}/transfers/export/excel", + summary="خروجی Excel لیست اسناد انتقال", + description="خروجی Excel لیست اسناد انتقال با فیلتر و جستجو", +) +@require_business_access("business_id") +async def export_transfers_excel( + business_id: int, + request: Request, + body: Dict[str, Any] = Body(...), + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +): + from openpyxl import Workbook + from openpyxl.styles import Font, PatternFill, Alignment, Border, Side + + max_export_records = 10000 + take_value = min(int(body.get("take", 1000)), max_export_records) + query_dict = { + "take": take_value, + "skip": int(body.get("skip", 0)), + "sort_by": body.get("sort_by"), + "sort_desc": bool(body.get("sort_desc", False)), + "search": body.get("search"), + "from_date": body.get("from_date"), + "to_date": body.get("to_date"), + } + + result = list_transfers(db, business_id, query_dict) + items = result.get('items', []) + items = [format_datetime_fields(item, request) for item in items] + + wb = Workbook() + ws = wb.active + ws.title = "Transfers" + + headers = ["کد", "تاریخ", "مبلغ کل", "ایجادکننده"] + keys = ["code", "document_date", "total_amount", "created_by_name"] + + header_font = Font(bold=True, color="FFFFFF") + header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid") + header_alignment = Alignment(horizontal="center", vertical="center") + border = Border(left=Side(style='thin'), right=Side(style='thin'), top=Side(style='thin'), bottom=Side(style='thin')) + + for col_idx, header in enumerate(headers, 1): + cell = ws.cell(row=1, column=col_idx, value=header) + cell.font = header_font + cell.fill = header_fill + cell.alignment = header_alignment + cell.border = border + + for row_idx, item in enumerate(items, 2): + for col_idx, key in enumerate(keys, 1): + val = item.get(key, "") + ws.cell(row=row_idx, column=col_idx, value=val).border = border + + # Auto width + for column in ws.columns: + max_length = 0 + column_letter = column[0].column_letter + for cell in column: + try: + if len(str(cell.value)) > max_length: + max_length = len(str(cell.value)) + except Exception: + pass + ws.column_dimensions[column_letter].width = min(max_length + 2, 50) + + buffer = io.BytesIO() + wb.save(buffer) + buffer.seek(0) + + biz_name = "" + try: + b = db.query(Business).filter(Business.id == business_id).first() + if b is not None: + biz_name = b.name or "" + except Exception: + biz_name = "" + + def slugify(text: str) -> str: + return re.sub(r"[^A-Za-z0-9_-]+", "_", text).strip("_") + + base = "transfers" + if biz_name: + base += f"_{slugify(biz_name)}" + filename = f"{base}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" + content = buffer.getvalue() + return Response( + content=content, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={ + "Content-Disposition": f"attachment; filename={filename}", + "Content-Length": str(len(content)), + "Access-Control-Expose-Headers": "Content-Disposition", + }, + ) + + +@router.post( + "/businesses/{business_id}/transfers/export/pdf", + summary="خروجی PDF لیست اسناد انتقال", + description="خروجی PDF لیست اسناد انتقال با فیلتر و جستجو", +) +@require_business_access("business_id") +async def export_transfers_pdf( + business_id: int, + request: Request, + body: Dict[str, Any] = Body(...), + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +): + from weasyprint import HTML + from weasyprint.text.fonts import FontConfiguration + from html import escape + + max_export_records = 10000 + take_value = min(int(body.get("take", 1000)), max_export_records) + query_dict = { + "take": take_value, + "skip": int(body.get("skip", 0)), + "sort_by": body.get("sort_by"), + "sort_desc": bool(body.get("sort_desc", False)), + "search": body.get("search"), + "from_date": body.get("from_date"), + "to_date": body.get("to_date"), + } + result = list_transfers(db, business_id, query_dict) + items = result.get('items', []) + items = [format_datetime_fields(item, request) for item in items] + + headers = ["کد", "تاریخ", "مبلغ کل", "ایجادکننده"] + keys = ["code", "document_date", "total_amount", "created_by_name"] + + header_html = ''.join(f'{escape(h)}' for h in headers) + rows_html = [] + for it in items: + row_cells = [] + for k in keys: + v = it.get(k, "") + row_cells.append(f'{escape(str(v))}') + rows_html.append(f'{"".join(row_cells)}') + + now = datetime.datetime.now().strftime('%Y/%m/%d %H:%M') + html = f""" + + + + + لیست انتقال‌ها + + + +
+
لیست انتقال‌ها
+
تاریخ تولید: {escape(now)}
+
+ + {header_html} + + {''.join(rows_html)} + +
+ + + """ + font_config = FontConfiguration() + pdf_bytes = HTML(string=html).write_pdf(font_config=font_config) + filename = f"transfers_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf" + return Response( + content=pdf_bytes, + media_type="application/pdf", + headers={ + "Content-Disposition": f"attachment; filename={filename}", + "Content-Length": str(len(pdf_bytes)), + "Access-Control-Expose-Headers": "Content-Disposition", + }, + ) + + diff --git a/hesabixAPI/app/main.py b/hesabixAPI/app/main.py index 8080bca..b70cb33 100644 --- a/hesabixAPI/app/main.py +++ b/hesabixAPI/app/main.py @@ -31,6 +31,7 @@ from adapters.api.v1.support.statuses import router as support_statuses_router from adapters.api.v1.admin.file_storage import router as admin_file_storage_router from adapters.api.v1.admin.email_config import router as admin_email_config_router from adapters.api.v1.receipts_payments import router as receipts_payments_router +from adapters.api.v1.transfers import router as transfers_router from adapters.api.v1.fiscal_years import router as fiscal_years_router from app.core.i18n import negotiate_locale, Translator from app.core.error_handlers import register_error_handlers @@ -308,6 +309,9 @@ def create_app() -> FastAPI: application.include_router(tax_units_router, prefix=settings.api_v1_prefix) application.include_router(tax_types_router, prefix=settings.api_v1_prefix) application.include_router(receipts_payments_router, prefix=settings.api_v1_prefix) + application.include_router(transfers_router, prefix=settings.api_v1_prefix) + from adapters.api.v1.expense_income import router as expense_income_router + application.include_router(expense_income_router, prefix=settings.api_v1_prefix) application.include_router(fiscal_years_router, prefix=settings.api_v1_prefix) # Support endpoints diff --git a/hesabixAPI/app/services/expense_income_service.py b/hesabixAPI/app/services/expense_income_service.py new file mode 100644 index 0000000..f61f2e3 --- /dev/null +++ b/hesabixAPI/app/services/expense_income_service.py @@ -0,0 +1,398 @@ +""" +سرویس هزینه و درآمد (Expense & Income) + +این سرویس ثبت اسناد «هزینه/درآمد» را با چند سطر حساب و چند سطر طرف‌حساب پشتیبانی می‌کند. +الگوی پیاده‌سازی بر اساس سرویس دریافت/پرداخت است. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional +from datetime import datetime, date +from decimal import Decimal +import logging + +from sqlalchemy.orm import Session +from sqlalchemy import and_, or_ + +from adapters.db.models.document import Document +from adapters.db.models.document_line import DocumentLine +from adapters.db.models.account import Account +from adapters.db.models.currency import Currency +from adapters.db.models.fiscal_year import FiscalYear +from adapters.db.models.user import User +from app.core.responses import ApiError + + +logger = logging.getLogger(__name__) + + +# نوع‌های سند +DOCUMENT_TYPE_EXPENSE = "expense" +DOCUMENT_TYPE_INCOME = "income" + + +def _parse_iso_date(dt: str | datetime | date) -> date: + if isinstance(dt, date) and not isinstance(dt, datetime): + return dt + if isinstance(dt, datetime): + return dt.date() + try: + return datetime.fromisoformat(str(dt)).date() + except Exception: + return datetime.utcnow().date() + + +def _get_fixed_account_by_code(db: Session, account_code: str) -> Account: + account = db.query(Account).filter(Account.code == str(account_code)).first() + if not account: + raise ApiError("ACCOUNT_NOT_FOUND", f"Account with code {account_code} not found", http_status=404) + return account + + +def _get_business_fiscal_year(db: Session, business_id: int) -> FiscalYear: + fy = db.query(FiscalYear).filter( + and_(FiscalYear.business_id == business_id, FiscalYear.is_closed == False) # noqa: E712 + ).order_by(FiscalYear.start_date.desc()).first() + if not fy: + raise ApiError("FISCAL_YEAR_NOT_FOUND", "Active fiscal year not found", http_status=404) + return fy + + +def create_expense_income( + db: Session, + business_id: int, + user_id: int, + data: Dict[str, Any], +) -> Dict[str, Any]: + """ + ایجاد سند هزینه/درآمد با چند سطر حساب و چند سطر طرف‌حساب + + data = { + "document_type": "expense" | "income", + "document_date": "2025-10-20", + "currency_id": 1, + "description": str?, + "item_lines": [ # سطرهای حساب‌های هزینه/درآمد + {"account_id": 123, "amount": 100000, "description": str?}, + ], + "counterparty_lines": [ # سطرهای طرف‌حساب (بانک/صندوق/شخص/چک ...) + { + "transaction_type": "bank" | "cash_register" | "petty_cash" | "check" | "person", + "amount": 100000, + "transaction_date": "2025-10-20T10:00:00", + "description": str?, + "commission": float?, # اختیاری + # فیلدهای اختیاری متناسب با نوع + "bank_id": int?, "bank_name": str?, + "cash_register_id": int?, "cash_register_name": str?, + "petty_cash_id": int?, "petty_cash_name": str?, + "check_id": int?, "check_number": str?, + "person_id": int?, "person_name": str?, + } + ] + } + """ + document_type = str(data.get("document_type", "")).lower() + if document_type not in (DOCUMENT_TYPE_EXPENSE, DOCUMENT_TYPE_INCOME): + raise ApiError("INVALID_DOCUMENT_TYPE", "document_type must be 'expense' or 'income'", http_status=400) + + is_income = document_type == DOCUMENT_TYPE_INCOME + + # تاریخ + document_date = _parse_iso_date(data.get("document_date", datetime.utcnow())) + + # ارز + currency_id = data.get("currency_id") + if not currency_id: + raise ApiError("CURRENCY_REQUIRED", "currency_id is required", http_status=400) + currency = db.query(Currency).filter(Currency.id == int(currency_id)).first() + if not currency: + raise ApiError("CURRENCY_NOT_FOUND", "Currency not found", http_status=404) + + # سال مالی فعال + fiscal_year = _get_business_fiscal_year(db, business_id) + + # اعتبارسنجی خطوط + item_lines: List[Dict[str, Any]] = list(data.get("item_lines") or []) + counterparty_lines: List[Dict[str, Any]] = list(data.get("counterparty_lines") or []) + if not item_lines: + raise ApiError("LINES_REQUIRED", "item_lines is required", http_status=400) + if not counterparty_lines: + raise ApiError("LINES_REQUIRED", "counterparty_lines is required", http_status=400) + + sum_items = Decimal(0) + for idx, line in enumerate(item_lines): + if not line.get("account_id"): + raise ApiError("ACCOUNT_REQUIRED", f"item_lines[{idx}].account_id is required", http_status=400) + amount = Decimal(str(line.get("amount", 0))) + if amount <= 0: + raise ApiError("AMOUNT_INVALID", f"item_lines[{idx}].amount must be > 0", http_status=400) + sum_items += amount + + sum_counterparties = Decimal(0) + for idx, line in enumerate(counterparty_lines): + amount = Decimal(str(line.get("amount", 0))) + if amount <= 0: + raise ApiError("AMOUNT_INVALID", f"counterparty_lines[{idx}].amount must be > 0", http_status=400) + sum_counterparties += amount + + if sum_items != sum_counterparties: + raise ApiError("LINES_NOT_BALANCED", "Sum of items and counterparties must be equal", http_status=400) + + # ایجاد سند + user = db.query(User).filter(User.id == int(user_id)).first() + if not user: + raise ApiError("USER_NOT_FOUND", "User not found", http_status=404) + + # کد سند ساده: EI-YYYYMMDD- + code = f"EI-{document_date.strftime('%Y%m%d')}-{int(datetime.utcnow().timestamp())%100000}" + + document = Document( + code=code, + business_id=business_id, + fiscal_year_id=fiscal_year.id, + currency_id=int(currency_id), + created_by_user_id=int(user_id), + document_date=document_date, + document_type=document_type, + is_proforma=False, + description=(data.get("description") or None), + extra_info=(data.get("extra_info") if isinstance(data.get("extra_info"), dict) else None), + ) + db.add(document) + db.flush() + + # سطرهای حساب‌های هزینه/درآمد + for line in item_lines: + account = db.query(Account).filter( + and_( + Account.id == int(line.get("account_id")), + or_(Account.business_id == business_id, Account.business_id == None), # noqa: E711 + ) + ).first() + if not account: + raise ApiError("ACCOUNT_NOT_FOUND", "Item account not found", http_status=404) + + amount = Decimal(str(line.get("amount", 0))) + description = (line.get("description") or "").strip() or None + + debit_amount = amount if not is_income else Decimal(0) + credit_amount = amount if is_income else Decimal(0) + + db.add(DocumentLine( + document_id=document.id, + account_id=account.id, + debit=debit_amount, + credit=credit_amount, + description=description, + )) + + # سطرهای طرف‌حساب (بانک/صندوق/شخص/چک/تنخواه) + for line in counterparty_lines: + amount = Decimal(str(line.get("amount", 0))) + description = (line.get("description") or "").strip() or None + transaction_type: Optional[str] = line.get("transaction_type") + + # انتخاب حساب طرف‌حساب + account: Optional[Account] = None + if transaction_type == "bank": + account = _get_fixed_account_by_code(db, "10203") + elif transaction_type == "cash_register": + account = _get_fixed_account_by_code(db, "10202") + elif transaction_type == "petty_cash": + account = _get_fixed_account_by_code(db, "10201") + elif transaction_type == "check": + # برای چک‌ها از کدهای اسناد دریافتنی/پرداختنی استفاده شود + account = _get_fixed_account_by_code(db, "10403" if is_income else "20202") + elif transaction_type == "person": + # پرداخت/دریافت با شخص عمومی پرداختنی + account = _get_fixed_account_by_code(db, "20201") + elif line.get("account_id"): + account = db.query(Account).filter( + and_( + Account.id == int(line.get("account_id")), + or_(Account.business_id == business_id, Account.business_id == None), # noqa: E711 + ) + ).first() + if not account: + raise ApiError("ACCOUNT_NOT_FOUND", "Account not found for counterparty line", http_status=404) + + extra_info: Dict[str, Any] = {} + if transaction_type: + extra_info["transaction_type"] = transaction_type + if line.get("transaction_date"): + extra_info["transaction_date"] = line.get("transaction_date") + if line.get("commission"): + extra_info["commission"] = float(line.get("commission")) + if transaction_type == "bank": + if line.get("bank_id"): + extra_info["bank_id"] = line.get("bank_id") + if line.get("bank_name"): + extra_info["bank_name"] = line.get("bank_name") + elif transaction_type == "cash_register": + if line.get("cash_register_id"): + extra_info["cash_register_id"] = line.get("cash_register_id") + if line.get("cash_register_name"): + extra_info["cash_register_name"] = line.get("cash_register_name") + elif transaction_type == "petty_cash": + if line.get("petty_cash_id"): + extra_info["petty_cash_id"] = line.get("petty_cash_id") + if line.get("petty_cash_name"): + extra_info["petty_cash_name"] = line.get("petty_cash_name") + elif transaction_type == "check": + if line.get("check_id"): + extra_info["check_id"] = line.get("check_id") + if line.get("check_number"): + extra_info["check_number"] = line.get("check_number") + elif transaction_type == "person": + if line.get("person_id"): + extra_info["person_id"] = line.get("person_id") + if line.get("person_name"): + extra_info["person_name"] = line.get("person_name") + + debit_amount = amount if is_income else Decimal(0) + credit_amount = amount if not is_income else Decimal(0) + + db.add(DocumentLine( + document_id=document.id, + account_id=account.id, + person_id=(int(line["person_id"]) if transaction_type == "person" and line.get("person_id") else None), + bank_account_id=(int(line["bank_id"]) if transaction_type == "bank" and line.get("bank_id") else None), + cash_register_id=line.get("cash_register_id"), + petty_cash_id=line.get("petty_cash_id"), + check_id=line.get("check_id"), + debit=debit_amount, + credit=credit_amount, + description=description, + extra_info=extra_info or None, + )) + + # توجه: خطوط کارمزد در این نسخه پیاده‌سازی نمی‌شود (می‌توان مشابه سرویس دریافت/پرداخت اضافه کرد) + + db.commit() + db.refresh(document) + return document_to_dict(db, document) + + +def document_to_dict(db: Session, document: Document) -> Dict[str, Any]: + lines = db.query(DocumentLine).filter(DocumentLine.document_id == document.id).all() + items: List[Dict[str, Any]] = [] + counterparties: List[Dict[str, Any]] = [] + for ln in lines: + account = db.query(Account).filter(Account.id == ln.account_id).first() + row = { + "id": ln.id, + "account_id": ln.account_id, + "account_name": account.name if account else None, + "debit": float(ln.debit or 0), + "credit": float(ln.credit or 0), + "description": ln.description, + "extra_info": ln.extra_info, + "person_id": ln.person_id, + "bank_account_id": ln.bank_account_id, + "cash_register_id": ln.cash_register_id, + "petty_cash_id": ln.petty_cash_id, + "check_id": ln.check_id, + } + # ساده: بر اساس وجود transaction_type در extra_info، به عنوان طرف‌حساب تلقی می‌شود + if ln.extra_info and ln.extra_info.get("transaction_type"): + counterparties.append(row) + else: + items.append(row) + + return { + "id": document.id, + "code": document.code, + "business_id": document.business_id, + "fiscal_year_id": document.fiscal_year_id, + "currency_id": document.currency_id, + "document_type": document.document_type, + "document_date": document.document_date.isoformat(), + "description": document.description, + "items": items, + "counterparties": counterparties, + } + + +def list_expense_income( + db: Session, + business_id: int, + query: Dict[str, Any], +) -> Dict[str, Any]: + """لیست اسناد هزینه و درآمد با فیلتر، جست‌وجو و صفحه‌بندی""" + q = db.query(Document).filter( + and_( + Document.business_id == business_id, + Document.document_type.in_([DOCUMENT_TYPE_EXPENSE, DOCUMENT_TYPE_INCOME]), + ) + ) + + # سال مالی + fiscal_year_id = query.get("fiscal_year_id") + try: + fiscal_year_id_int = int(fiscal_year_id) if fiscal_year_id is not None else None + except Exception: + fiscal_year_id_int = None + if fiscal_year_id_int is None: + try: + fy = _get_business_fiscal_year(db, business_id) + fiscal_year_id_int = fy.id + except Exception: + fiscal_year_id_int = None + if fiscal_year_id_int is not None: + q = q.filter(Document.fiscal_year_id == fiscal_year_id_int) + + # نوع سند + doc_type = query.get("document_type") + if doc_type in (DOCUMENT_TYPE_EXPENSE, DOCUMENT_TYPE_INCOME): + q = q.filter(Document.document_type == doc_type) + + # فیلتر تاریخ + from_date = query.get("from_date") + to_date = query.get("to_date") + if from_date: + try: + q = q.filter(Document.document_date >= _parse_iso_date(from_date)) + except Exception: + pass + if to_date: + try: + q = q.filter(Document.document_date <= _parse_iso_date(to_date)) + except Exception: + pass + + # جست‌وجو در کد سند + search = query.get("search") + if search: + q = q.filter(Document.code.ilike(f"%{search}%")) + + # مرتب‌سازی + sort_by = query.get("sort_by", "document_date") + sort_desc = bool(query.get("sort_desc", True)) + if isinstance(sort_by, str) and hasattr(Document, sort_by): + col = getattr(Document, sort_by) + q = q.order_by(col.desc() if sort_desc else col.asc()) + else: + q = q.order_by(Document.document_date.desc()) + + # صفحه‌بندی + skip = int(query.get("skip", 0)) + take = int(query.get("take", 20)) + total = q.count() + docs = q.offset(skip).limit(take).all() + + return { + "items": [document_to_dict(db, d) for d in docs], + "pagination": { + "total": total, + "page": (skip // take) + 1, + "per_page": take, + "total_pages": (total + take - 1) // take, + "has_next": skip + take < total, + "has_prev": skip > 0, + }, + "query_info": query, + } + + diff --git a/hesabixAPI/app/services/transfer_service.py b/hesabixAPI/app/services/transfer_service.py new file mode 100644 index 0000000..27b1314 --- /dev/null +++ b/hesabixAPI/app/services/transfer_service.py @@ -0,0 +1,680 @@ +from __future__ import annotations + +from typing import Any, Dict, Optional +from datetime import datetime, date +from decimal import Decimal +import logging + +from sqlalchemy.orm import Session +from sqlalchemy import and_, or_ + +from adapters.db.models.document import Document +from adapters.db.models.document_line import DocumentLine +from adapters.db.models.account import Account +from adapters.db.models.currency import Currency +from adapters.db.models.fiscal_year import FiscalYear +from adapters.db.models.user import User +from adapters.db.models.bank_account import BankAccount +from adapters.db.models.cash_register import CashRegister +from adapters.db.models.petty_cash import PettyCash +from app.core.responses import ApiError +import jdatetime + + +logger = logging.getLogger(__name__) + + +DOCUMENT_TYPE_TRANSFER = "transfer" + + +def _parse_iso_date(dt: str | datetime | date) -> date: + if isinstance(dt, date): + return dt + if isinstance(dt, datetime): + return dt.date() + + dt_str = str(dt).strip() + + try: + dt_str_clean = dt_str.replace('Z', '+00:00') + parsed = datetime.fromisoformat(dt_str_clean) + return parsed.date() + except Exception: + pass + + try: + if len(dt_str) == 10 and dt_str.count('-') == 2: + return datetime.strptime(dt_str, '%Y-%m-%d').date() + except Exception: + pass + + try: + if len(dt_str) == 10 and dt_str.count('/') == 2: + parts = dt_str.split('/') + if len(parts) == 3: + year, month, day = parts + try: + year_int = int(year) + month_int = int(month) + day_int = int(day) + if year_int > 1500: + jalali_date = jdatetime.date(year_int, month_int, day_int) + gregorian_date = jalali_date.togregorian() + return gregorian_date + else: + return datetime.strptime(dt_str, '%Y/%m/%d').date() + except (ValueError, jdatetime.JalaliDateError): + return datetime.strptime(dt_str, '%Y/%m/%d').date() + except Exception: + pass + + raise ApiError("INVALID_DATE", f"Invalid date format: {dt}", http_status=400) + + +def _get_current_fiscal_year(db: Session, business_id: int) -> FiscalYear: + fiscal_year = db.query(FiscalYear).filter( + and_( + FiscalYear.business_id == business_id, + FiscalYear.is_last == True, + ) + ).first() + if not fiscal_year: + raise ApiError("NO_FISCAL_YEAR", "No active fiscal year found for this business", http_status=400) + return fiscal_year + + +def _get_fixed_account_by_code(db: Session, account_code: str) -> Account: + account = db.query(Account).filter( + and_( + Account.business_id == None, + Account.code == account_code, + ) + ).first() + if not account: + raise ApiError("ACCOUNT_NOT_FOUND", f"Account with code {account_code} not found", http_status=500) + return account + + +def _account_code_for_type(account_type: str) -> str: + if account_type == "bank": + return "10203" + if account_type == "cash_register": + return "10202" + if account_type == "petty_cash": + return "10201" + raise ApiError("INVALID_ACCOUNT_TYPE", f"Invalid account type: {account_type}", http_status=400) + + +def _build_doc_code(prefix_base: str) -> str: + today = datetime.now().date() + prefix = f"{prefix_base}-{today.strftime('%Y%m%d')}" + return prefix + + +def create_transfer( + db: Session, + business_id: int, + user_id: int, + data: Dict[str, Any], +) -> Dict[str, Any]: + logger.info("=== شروع ایجاد سند انتقال ===") + + document_date = _parse_iso_date(data.get("document_date", datetime.now())) + currency_id = data.get("currency_id") + if not currency_id: + raise ApiError("CURRENCY_REQUIRED", "currency_id is required", http_status=400) + currency = db.query(Currency).filter(Currency.id == int(currency_id)).first() + if not currency: + raise ApiError("CURRENCY_NOT_FOUND", "Currency not found", http_status=404) + + fiscal_year = _get_current_fiscal_year(db, business_id) + + source = data.get("source") or {} + destination = data.get("destination") or {} + amount = Decimal(str(data.get("amount", 0))) + commission = Decimal(str(data.get("commission", 0))) if data.get("commission") is not None else Decimal(0) + + if amount <= 0: + raise ApiError("INVALID_AMOUNT", "amount must be greater than 0", http_status=400) + if commission < 0: + raise ApiError("INVALID_COMMISSION", "commission must be >= 0", http_status=400) + + src_type = str(source.get("type") or "").strip() + dst_type = str(destination.get("type") or "").strip() + src_id = source.get("id") + dst_id = destination.get("id") + + if src_type not in ("bank", "cash_register", "petty_cash"): + raise ApiError("INVALID_SOURCE", "source.type must be bank|cash_register|petty_cash", http_status=400) + if dst_type not in ("bank", "cash_register", "petty_cash"): + raise ApiError("INVALID_DESTINATION", "destination.type must be bank|cash_register|petty_cash", http_status=400) + if src_type == dst_type and src_id and dst_id and str(src_id) == str(dst_id): + raise ApiError("SAME_SOURCE_DESTINATION", "source and destination cannot be the same", http_status=400) + + # Resolve accounts by fixed codes + src_account = _get_fixed_account_by_code(db, _account_code_for_type(src_type)) + dst_account = _get_fixed_account_by_code(db, _account_code_for_type(dst_type)) + + # Generate document code TR-YYYYMMDD-NNNN + prefix = _build_doc_code("TR") + last_doc = db.query(Document).filter( + and_( + Document.business_id == business_id, + Document.code.like(f"{prefix}-%"), + ) + ).order_by(Document.code.desc()).first() + if last_doc: + try: + last_num = int(last_doc.code.split("-")[-1]) + next_num = last_num + 1 + except Exception: + next_num = 1 + else: + next_num = 1 + doc_code = f"{prefix}-{next_num:04d}" + + # Resolve names for auto description if needed + def _resolve_name(tp: str, _id: Any) -> str | None: + try: + if tp == "bank" and _id is not None: + ba = db.query(BankAccount).filter(BankAccount.id == int(_id)).first() + return ba.name if ba else None + if tp == "cash_register" and _id is not None: + cr = db.query(CashRegister).filter(CashRegister.id == int(_id)).first() + return cr.name if cr else None + if tp == "petty_cash" and _id is not None: + pc = db.query(PettyCash).filter(PettyCash.id == int(_id)).first() + return pc.name if pc else None + except Exception: + return None + return None + + auto_description = None + if not data.get("description"): + src_name = _resolve_name(src_type, src_id) or "مبدأ" + dst_name = _resolve_name(dst_type, dst_id) or "مقصد" + # human readable types + def _type_name(tp: str) -> str: + return "حساب بانکی" if tp == "bank" else ("صندوق" if tp == "cash_register" else "تنخواه") + auto_description = f"انتقال از {_type_name(src_type)} {src_name} به {_type_name(dst_type)} {dst_name}" + + document = Document( + business_id=business_id, + fiscal_year_id=fiscal_year.id, + code=doc_code, + document_type=DOCUMENT_TYPE_TRANSFER, + document_date=document_date, + currency_id=int(currency_id), + created_by_user_id=user_id, + registered_at=datetime.utcnow(), + is_proforma=False, + description=data.get("description") or auto_description, + extra_info=data.get("extra_info"), + ) + db.add(document) + db.flush() + + # Destination line (Debit) + dest_kwargs: Dict[str, Any] = {} + if dst_type == "bank" and dst_id is not None: + try: + dest_kwargs["bank_account_id"] = int(dst_id) + except Exception: + pass + elif dst_type == "cash_register" and dst_id is not None: + dest_kwargs["cash_register_id"] = dst_id + elif dst_type == "petty_cash" and dst_id is not None: + dest_kwargs["petty_cash_id"] = dst_id + + dest_line = DocumentLine( + document_id=document.id, + account_id=dst_account.id, + debit=amount, + credit=Decimal(0), + description=data.get("destination_description") or data.get("description"), + extra_info={ + "side": "destination", + "destination_type": dst_type, + "destination_id": dst_id, + }, + **dest_kwargs, + ) + db.add(dest_line) + + # Source line (Credit) + src_kwargs: Dict[str, Any] = {} + if src_type == "bank" and src_id is not None: + try: + src_kwargs["bank_account_id"] = int(src_id) + except Exception: + pass + elif src_type == "cash_register" and src_id is not None: + src_kwargs["cash_register_id"] = src_id + elif src_type == "petty_cash" and src_id is not None: + src_kwargs["petty_cash_id"] = src_id + + src_line = DocumentLine( + document_id=document.id, + account_id=src_account.id, + debit=Decimal(0), + credit=amount, + description=data.get("source_description") or data.get("description"), + extra_info={ + "side": "source", + "source_type": src_type, + "source_id": src_id, + }, + **src_kwargs, + ) + db.add(src_line) + + if commission > 0: + # Debit commission expense 70902 + commission_service_account = _get_fixed_account_by_code(db, "70902") + commission_expense_line = DocumentLine( + document_id=document.id, + account_id=commission_service_account.id, + debit=commission, + credit=Decimal(0), + description="کارمزد خدمات بانکی", + extra_info={ + "side": "commission", + "is_commission_line": True, + }, + **src_kwargs, + ) + db.add(commission_expense_line) + + # Credit commission to source account (increase credit of source) + commission_credit_line = DocumentLine( + document_id=document.id, + account_id=src_account.id, + debit=Decimal(0), + credit=commission, + description="کارمزد انتقال (ثبت در مبدأ)", + extra_info={ + "side": "commission", + "is_commission_line": True, + "source_type": src_type, + "source_id": src_id, + }, + **src_kwargs, + ) + db.add(commission_credit_line) + + db.commit() + db.refresh(document) + return transfer_document_to_dict(db, document) + + +def get_transfer(db: Session, document_id: int) -> Optional[Dict[str, Any]]: + document = db.query(Document).filter(Document.id == document_id).first() + if not document or document.document_type != DOCUMENT_TYPE_TRANSFER: + return None + return transfer_document_to_dict(db, document) + + +def list_transfers(db: Session, business_id: int, query: Dict[str, Any]) -> Dict[str, Any]: + q = db.query(Document).filter( + and_( + Document.business_id == business_id, + Document.document_type == DOCUMENT_TYPE_TRANSFER, + ) + ) + + fiscal_year_id = query.get("fiscal_year_id") + if fiscal_year_id is not None: + try: + fiscal_year_id = int(fiscal_year_id) + except (TypeError, ValueError): + fiscal_year_id = None + if fiscal_year_id is None: + try: + fiscal_year = _get_current_fiscal_year(db, business_id) + fiscal_year_id = fiscal_year.id + except Exception: + fiscal_year_id = None + if fiscal_year_id is not None: + q = q.filter(Document.fiscal_year_id == fiscal_year_id) + + from_date = query.get("from_date") + to_date = query.get("to_date") + if from_date: + try: + from_dt = _parse_iso_date(from_date) + q = q.filter(Document.document_date >= from_dt) + except Exception: + pass + if to_date: + try: + to_dt = _parse_iso_date(to_date) + q = q.filter(Document.document_date <= to_dt) + except Exception: + pass + + search = query.get("search") + if search: + q = q.filter(Document.code.ilike(f"%{search}%")) + + sort_by = query.get("sort_by", "document_date") + sort_desc = bool(query.get("sort_desc", True)) + if sort_by and isinstance(sort_by, str) and hasattr(Document, sort_by): + col = getattr(Document, sort_by) + q = q.order_by(col.desc() if sort_desc else col.asc()) + else: + q = q.order_by(Document.document_date.desc()) + + skip = int(query.get("skip", 0)) + take = int(query.get("take", 20)) + + total = q.count() + items = q.offset(skip).limit(take).all() + + return { + "items": [transfer_document_to_dict(db, doc) for doc in items], + "pagination": { + "total": total, + "page": (skip // take) + 1, + "per_page": take, + "total_pages": (total + take - 1) // take, + "has_next": skip + take < total, + "has_prev": skip > 0, + }, + "query_info": query, + } + + +def delete_transfer(db: Session, document_id: int) -> bool: + document = db.query(Document).filter(Document.id == document_id).first() + if not document or document.document_type != DOCUMENT_TYPE_TRANSFER: + return False + try: + fiscal_year = db.query(FiscalYear).filter(FiscalYear.id == document.fiscal_year_id).first() + if fiscal_year is not None and getattr(fiscal_year, "is_last", False) is not True: + raise ApiError("FISCAL_YEAR_LOCKED", "سند متعلق به سال مالی جاری نیست و قابل حذف نمی‌باشد", http_status=409) + except ApiError: + raise + except Exception: + pass + + db.delete(document) + db.commit() + return True + + +def update_transfer( + db: Session, + document_id: int, + user_id: int, + data: Dict[str, Any], +) -> Dict[str, Any]: + document = db.query(Document).filter(Document.id == document_id).first() + if document is None or document.document_type != DOCUMENT_TYPE_TRANSFER: + raise ApiError("DOCUMENT_NOT_FOUND", "Transfer document not found", http_status=404) + + try: + fiscal_year = db.query(FiscalYear).filter(FiscalYear.id == document.fiscal_year_id).first() + if fiscal_year is not None and getattr(fiscal_year, "is_last", False) is not True: + raise ApiError("FISCAL_YEAR_LOCKED", "سند متعلق به سال مالی جاری نیست و قابل ویرایش نمی‌باشد", http_status=409) + except ApiError: + raise + except Exception: + pass + + document_date = _parse_iso_date(data.get("document_date", document.document_date)) + currency_id = data.get("currency_id", document.currency_id) + if not currency_id: + raise ApiError("CURRENCY_REQUIRED", "currency_id is required", http_status=400) + currency = db.query(Currency).filter(Currency.id == int(currency_id)).first() + if not currency: + raise ApiError("CURRENCY_NOT_FOUND", "Currency not found", http_status=404) + + source = data.get("source") or {} + destination = data.get("destination") or {} + amount = Decimal(str(data.get("amount", 0))) + commission = Decimal(str(data.get("commission", 0))) if data.get("commission") is not None else Decimal(0) + + if amount <= 0: + raise ApiError("INVALID_AMOUNT", "amount must be greater than 0", http_status=400) + if commission < 0: + raise ApiError("INVALID_COMMISSION", "commission must be >= 0", http_status=400) + + src_type = str(source.get("type") or "").strip() + dst_type = str(destination.get("type") or "").strip() + src_id = source.get("id") + dst_id = destination.get("id") + + if src_type not in ("bank", "cash_register", "petty_cash"): + raise ApiError("INVALID_SOURCE", "source.type must be bank|cash_register|petty_cash", http_status=400) + if dst_type not in ("bank", "cash_register", "petty_cash"): + raise ApiError("INVALID_DESTINATION", "destination.type must be bank|cash_register|petty_cash", http_status=400) + if src_type == dst_type and src_id and dst_id and str(src_id) == str(dst_id): + raise ApiError("SAME_SOURCE_DESTINATION", "source and destination cannot be the same", http_status=400) + + # Update document fields + document.document_date = document_date + document.currency_id = int(currency_id) + if isinstance(data.get("extra_info"), dict) or data.get("extra_info") is None: + document.extra_info = data.get("extra_info") + if isinstance(data.get("description"), str) or data.get("description") is None: + if data.get("description"): + document.description = data.get("description") + else: + # regenerate auto description + def _resolve_name(tp: str, _id: Any) -> str | None: + try: + if tp == "bank" and _id is not None: + ba = db.query(BankAccount).filter(BankAccount.id == int(_id)).first() + return ba.name if ba else None + if tp == "cash_register" and _id is not None: + cr = db.query(CashRegister).filter(CashRegister.id == int(_id)).first() + return cr.name if cr else None + if tp == "petty_cash" and _id is not None: + pc = db.query(PettyCash).filter(PettyCash.id == int(_id)).first() + return pc.name if pc else None + except Exception: + return None + return None + def _type_name(tp: str) -> str: + return "حساب بانکی" if tp == "bank" else ("صندوق" if tp == "cash_register" else "تنخواه") + src_name = _resolve_name(src_type, src_id) or "مبدأ" + dst_name = _resolve_name(dst_type, dst_id) or "مقصد" + document.description = f"انتقال از {_type_name(src_type)} {src_name} به {_type_name(dst_type)} {dst_name}" + + # Remove old lines and recreate + db.query(DocumentLine).filter(DocumentLine.document_id == document.id).delete(synchronize_session=False) + + src_account = _get_fixed_account_by_code(db, _account_code_for_type(src_type)) + dst_account = _get_fixed_account_by_code(db, _account_code_for_type(dst_type)) + + dest_kwargs: Dict[str, Any] = {} + if dst_type == "bank" and dst_id is not None: + try: + dest_kwargs["bank_account_id"] = int(dst_id) + except Exception: + pass + elif dst_type == "cash_register" and dst_id is not None: + dest_kwargs["cash_register_id"] = dst_id + elif dst_type == "petty_cash" and dst_id is not None: + dest_kwargs["petty_cash_id"] = dst_id + + db.add(DocumentLine( + document_id=document.id, + account_id=dst_account.id, + debit=amount, + credit=Decimal(0), + description=data.get("destination_description") or data.get("description"), + extra_info={ + "side": "destination", + "destination_type": dst_type, + "destination_id": dst_id, + }, + **dest_kwargs, + )) + + src_kwargs: Dict[str, Any] = {} + if src_type == "bank" and src_id is not None: + try: + src_kwargs["bank_account_id"] = int(src_id) + except Exception: + pass + elif src_type == "cash_register" and src_id is not None: + src_kwargs["cash_register_id"] = src_id + elif src_type == "petty_cash" and src_id is not None: + src_kwargs["petty_cash_id"] = src_id + + db.add(DocumentLine( + document_id=document.id, + account_id=src_account.id, + debit=Decimal(0), + credit=amount, + description=data.get("source_description") or data.get("description"), + extra_info={ + "side": "source", + "source_type": src_type, + "source_id": src_id, + }, + **src_kwargs, + )) + + if commission > 0: + commission_service_account = _get_fixed_account_by_code(db, "70902") + db.add(DocumentLine( + document_id=document.id, + account_id=commission_service_account.id, + debit=commission, + credit=Decimal(0), + description="کارمزد خدمات بانکی", + extra_info={ + "side": "commission", + "is_commission_line": True, + }, + **src_kwargs, + )) + db.add(DocumentLine( + document_id=document.id, + account_id=src_account.id, + debit=Decimal(0), + credit=commission, + description="کارمزد انتقال (ثبت در مبدأ)", + extra_info={ + "side": "commission", + "is_commission_line": True, + "source_type": src_type, + "source_id": src_id, + }, + **src_kwargs, + )) + + db.commit() + db.refresh(document) + return transfer_document_to_dict(db, document) + + +def transfer_document_to_dict(db: Session, document: Document) -> Dict[str, Any]: + lines = db.query(DocumentLine).filter(DocumentLine.document_id == document.id).all() + + account_lines = [] + source_name = None + destination_name = None + source_type = None + destination_type = None + for line in lines: + account = db.query(Account).filter(Account.id == line.account_id).first() + if not account: + continue + + line_dict: Dict[str, Any] = { + "id": line.id, + "account_id": line.account_id, + "bank_account_id": line.bank_account_id, + "cash_register_id": line.cash_register_id, + "petty_cash_id": line.petty_cash_id, + "quantity": float(line.quantity) if line.quantity else None, + "account_name": account.name, + "account_code": account.code, + "account_type": account.account_type, + "debit": float(line.debit), + "credit": float(line.credit), + "amount": float(line.debit if line.debit > 0 else line.credit), + "description": line.description, + "extra_info": line.extra_info, + } + + if line.extra_info: + if "side" in line.extra_info: + line_dict["side"] = line.extra_info["side"] + if "source_type" in line.extra_info: + line_dict["source_type"] = line.extra_info["source_type"] + source_type = source_type or line.extra_info["source_type"] + if "destination_type" in line.extra_info: + line_dict["destination_type"] = line.extra_info["destination_type"] + destination_type = destination_type or line.extra_info["destination_type"] + if "is_commission_line" in line.extra_info: + line_dict["is_commission_line"] = line.extra_info["is_commission_line"] + + # capture source/destination names from linked entities + try: + if line_dict.get("side") == "source": + if line_dict.get("bank_account_id"): + ba = db.query(BankAccount).filter(BankAccount.id == int(line_dict["bank_account_id"])) .first() + source_name = ba.name if ba else source_name + elif line_dict.get("cash_register_id"): + cr = db.query(CashRegister).filter(CashRegister.id == int(line_dict["cash_register_id"])) .first() + source_name = cr.name if cr else source_name + elif line_dict.get("petty_cash_id"): + pc = db.query(PettyCash).filter(PettyCash.id == int(line_dict["petty_cash_id"])) .first() + source_name = pc.name if pc else source_name + elif line_dict.get("side") == "destination": + if line_dict.get("bank_account_id"): + ba = db.query(BankAccount).filter(BankAccount.id == int(line_dict["bank_account_id"])) .first() + destination_name = ba.name if ba else destination_name + elif line_dict.get("cash_register_id"): + cr = db.query(CashRegister).filter(CashRegister.id == int(line_dict["cash_register_id"])) .first() + destination_name = cr.name if cr else destination_name + elif line_dict.get("petty_cash_id"): + pc = db.query(PettyCash).filter(PettyCash.id == int(line_dict["petty_cash_id"])) .first() + destination_name = pc.name if pc else destination_name + except Exception: + pass + + account_lines.append(line_dict) + + # Compute total as sum of debits of non-commission lines (destination line amount) + total_amount = sum(l.get("debit", 0) for l in account_lines if not l.get("is_commission_line")) + + created_by = db.query(User).filter(User.id == document.created_by_user_id).first() + created_by_name = f"{created_by.first_name} {created_by.last_name}".strip() if created_by else None + + currency = db.query(Currency).filter(Currency.id == document.currency_id).first() + currency_code = currency.code if currency else None + + return { + "id": document.id, + "code": document.code, + "business_id": document.business_id, + "document_type": document.document_type, + "document_type_name": "انتقال", + "document_date": document.document_date.isoformat(), + "registered_at": document.registered_at.isoformat(), + "currency_id": document.currency_id, + "currency_code": currency_code, + "created_by_user_id": document.created_by_user_id, + "created_by_name": created_by_name, + "is_proforma": document.is_proforma, + "description": document.description, + "source_type": source_type, + "source_name": source_name, + "destination_type": destination_type, + "destination_name": destination_name, + "extra_info": document.extra_info, + "person_lines": [], + "account_lines": account_lines, + "total_amount": float(total_amount), + "person_lines_count": 0, + "account_lines_count": len(account_lines), + "created_at": document.created_at.isoformat(), + "updated_at": document.updated_at.isoformat(), + } + + diff --git a/hesabixUI/hesabix_ui/android/settings.gradle.kts b/hesabixUI/hesabix_ui/android/settings.gradle.kts index fb605bc..173d3ef 100644 --- a/hesabixUI/hesabix_ui/android/settings.gradle.kts +++ b/hesabixUI/hesabix_ui/android/settings.gradle.kts @@ -19,7 +19,7 @@ pluginManagement { plugins { id("dev.flutter.flutter-plugin-loader") version "1.0.0" - id("com.android.application") version "8.9.1" apply false + id("com.android.application") version "7.3.0" apply false id("org.jetbrains.kotlin.android") version "2.1.0" apply false } diff --git a/hesabixUI/hesabix_ui/lib/main.dart b/hesabixUI/hesabix_ui/lib/main.dart index c09279e..eea3d9e 100644 --- a/hesabixUI/hesabix_ui/lib/main.dart +++ b/hesabixUI/hesabix_ui/lib/main.dart @@ -39,6 +39,7 @@ import 'pages/business/petty_cash_page.dart'; import 'pages/business/checks_page.dart'; import 'pages/business/check_form_page.dart'; import 'pages/business/receipts_payments_list_page.dart'; +import 'pages/business/expense_income_list_page.dart'; import 'pages/business/transfers_page.dart'; import 'pages/error_404_page.dart'; import 'core/locale_controller.dart'; @@ -489,10 +490,8 @@ class _MyAppState extends State { ), ], ), - GoRoute( - path: '/business/:business_id', - name: 'business_shell', - builder: (context, state) { + ShellRoute( + builder: (context, state, child) { final businessId = int.parse(state.pathParameters['business_id']!); return BusinessShell( businessId: businessId, @@ -500,36 +499,25 @@ class _MyAppState extends State { localeController: controller, calendarController: _calendarController!, themeController: themeController, - child: const SizedBox.shrink(), // Will be replaced by child routes + child: child, ); }, routes: [ GoRoute( - path: 'dashboard', + path: '/business/:business_id/dashboard', name: 'business_dashboard', - builder: (context, state) { - final businessId = int.parse(state.pathParameters['business_id']!); - return BusinessShell( - businessId: businessId, - authStore: _authStore!, - localeController: controller, - calendarController: _calendarController!, - themeController: themeController, - child: BusinessDashboardPage(businessId: businessId), - ); - }, + pageBuilder: (context, state) => NoTransitionPage( + child: BusinessDashboardPage( + businessId: int.parse(state.pathParameters['business_id']!), + ), + ), ), GoRoute( - path: 'users-permissions', + path: '/business/:business_id/users-permissions', name: 'business_users_permissions', - builder: (context, state) { + pageBuilder: (context, state) { final businessId = int.parse(state.pathParameters['business_id']!); - return BusinessShell( - businessId: businessId, - authStore: _authStore!, - localeController: controller, - calendarController: _calendarController!, - themeController: themeController, + return NoTransitionPage( child: UsersPermissionsPage( businessId: businessId.toString(), authStore: _authStore!, @@ -539,31 +527,20 @@ class _MyAppState extends State { }, ), GoRoute( - path: 'chart-of-accounts', + path: '/business/:business_id/chart-of-accounts', name: 'business_chart_of_accounts', - builder: (context, state) { - final businessId = int.parse(state.pathParameters['business_id']!); - return BusinessShell( - businessId: businessId, - authStore: _authStore!, - localeController: controller, - calendarController: _calendarController!, - themeController: themeController, - child: AccountsPage(businessId: businessId), - ); - }, + pageBuilder: (context, state) => NoTransitionPage( + child: AccountsPage( + businessId: int.parse(state.pathParameters['business_id']!), + ), + ), ), GoRoute( - path: 'accounts', + path: '/business/:business_id/accounts', name: 'business_accounts', - builder: (context, state) { + pageBuilder: (context, state) { final businessId = int.parse(state.pathParameters['business_id']!); - return BusinessShell( - businessId: businessId, - authStore: _authStore!, - localeController: controller, - calendarController: _calendarController!, - themeController: themeController, + return NoTransitionPage( child: BankAccountsPage( businessId: businessId, authStore: _authStore!, @@ -572,16 +549,11 @@ class _MyAppState extends State { }, ), GoRoute( - path: 'petty-cash', + path: '/business/:business_id/petty-cash', name: 'business_petty_cash', - builder: (context, state) { + pageBuilder: (context, state) { final businessId = int.parse(state.pathParameters['business_id']!); - return BusinessShell( - businessId: businessId, - authStore: _authStore!, - localeController: controller, - calendarController: _calendarController!, - themeController: themeController, + return NoTransitionPage( child: PettyCashPage( businessId: businessId, authStore: _authStore!, @@ -590,16 +562,11 @@ class _MyAppState extends State { }, ), GoRoute( - path: 'cash-box', + path: '/business/:business_id/cash-box', name: 'business_cash_box', - builder: (context, state) { + pageBuilder: (context, state) { final businessId = int.parse(state.pathParameters['business_id']!); - return BusinessShell( - businessId: businessId, - authStore: _authStore!, - localeController: controller, - calendarController: _calendarController!, - themeController: themeController, + return NoTransitionPage( child: CashRegistersPage( businessId: businessId, authStore: _authStore!, @@ -608,16 +575,11 @@ class _MyAppState extends State { }, ), GoRoute( - path: 'wallet', + path: '/business/:business_id/wallet', name: 'business_wallet', - builder: (context, state) { + pageBuilder: (context, state) { final businessId = int.parse(state.pathParameters['business_id']!); - return BusinessShell( - businessId: businessId, - authStore: _authStore!, - localeController: controller, - calendarController: _calendarController!, - themeController: themeController, + return NoTransitionPage( child: WalletPage( businessId: businessId, authStore: _authStore!, @@ -626,16 +588,11 @@ class _MyAppState extends State { }, ), GoRoute( - path: 'invoice', + path: '/business/:business_id/invoice', name: 'business_invoice', - builder: (context, state) { + pageBuilder: (context, state) { final businessId = int.parse(state.pathParameters['business_id']!); - return BusinessShell( - businessId: businessId, - authStore: _authStore!, - localeController: controller, - calendarController: _calendarController!, - themeController: themeController, + return NoTransitionPage( child: InvoicePage( businessId: businessId, authStore: _authStore!, @@ -644,16 +601,11 @@ class _MyAppState extends State { }, ), GoRoute( - path: 'invoice/new', + path: '/business/:business_id/invoice/new', name: 'business_new_invoice', - builder: (context, state) { + pageBuilder: (context, state) { final businessId = int.parse(state.pathParameters['business_id']!); - return BusinessShell( - businessId: businessId, - authStore: _authStore!, - localeController: controller, - calendarController: _calendarController!, - themeController: themeController, + return NoTransitionPage( child: NewInvoicePage( businessId: businessId, authStore: _authStore!, @@ -663,16 +615,11 @@ class _MyAppState extends State { }, ), GoRoute( - path: 'reports', + path: '/business/:business_id/reports', name: 'business_reports', - builder: (context, state) { + pageBuilder: (context, state) { final businessId = int.parse(state.pathParameters['business_id']!); - return BusinessShell( - businessId: businessId, - authStore: _authStore!, - localeController: controller, - calendarController: _calendarController!, - themeController: themeController, + return NoTransitionPage( child: ReportsPage( businessId: businessId, authStore: _authStore!, @@ -681,20 +628,17 @@ class _MyAppState extends State { }, ), GoRoute( - path: 'settings', + path: '/business/:business_id/settings', name: 'business_settings', - builder: (context, state) { + pageBuilder: (context, state) { final businessId = int.parse(state.pathParameters['business_id']!); // گارد دسترسی: فقط کاربرانی که دسترسی join دارند if (!_authStore!.hasBusinessPermission('settings', 'join')) { - return PermissionGuard.buildAccessDeniedPage(); + return NoTransitionPage( + child: PermissionGuard.buildAccessDeniedPage(), + ); } - return BusinessShell( - businessId: businessId, - authStore: _authStore!, - localeController: controller, - calendarController: _calendarController!, - themeController: themeController, + return NoTransitionPage( child: SettingsPage( businessId: businessId, localeController: controller, @@ -705,16 +649,11 @@ class _MyAppState extends State { }, ), GoRoute( - path: 'product-attributes', + path: '/business/:business_id/product-attributes', name: 'business_product_attributes', - builder: (context, state) { + pageBuilder: (context, state) { final businessId = int.parse(state.pathParameters['business_id']!); - return BusinessShell( - businessId: businessId, - authStore: _authStore!, - localeController: controller, - calendarController: _calendarController!, - themeController: themeController, + return NoTransitionPage( child: ProductAttributesPage( businessId: businessId, authStore: _authStore!, @@ -723,16 +662,11 @@ class _MyAppState extends State { }, ), GoRoute( - path: 'products', + path: '/business/:business_id/products', name: 'business_products', - builder: (context, state) { + pageBuilder: (context, state) { final businessId = int.parse(state.pathParameters['business_id']!); - return BusinessShell( - businessId: businessId, - authStore: _authStore!, - localeController: controller, - calendarController: _calendarController!, - themeController: themeController, + return NoTransitionPage( child: ProductsPage( businessId: businessId, authStore: _authStore!, @@ -741,16 +675,11 @@ class _MyAppState extends State { }, ), GoRoute( - path: 'price-lists', + path: '/business/:business_id/price-lists', name: 'business_price_lists', - builder: (context, state) { + pageBuilder: (context, state) { final businessId = int.parse(state.pathParameters['business_id']!); - return BusinessShell( - businessId: businessId, - authStore: _authStore!, - localeController: controller, - calendarController: _calendarController!, - themeController: themeController, + return NoTransitionPage( child: PriceListsPage( businessId: businessId, authStore: _authStore!, @@ -759,17 +688,12 @@ class _MyAppState extends State { }, ), GoRoute( - path: 'price-lists/:price_list_id/items', + path: '/business/:business_id/price-lists/:price_list_id/items', name: 'business_price_list_items', - builder: (context, state) { + pageBuilder: (context, state) { final businessId = int.parse(state.pathParameters['business_id']!); final priceListId = int.parse(state.pathParameters['price_list_id']!); - return BusinessShell( - businessId: businessId, - authStore: _authStore!, - localeController: controller, - calendarController: _calendarController!, - themeController: themeController, + return NoTransitionPage( child: PriceListItemsPage( businessId: businessId, priceListId: priceListId, @@ -779,16 +703,11 @@ class _MyAppState extends State { }, ), GoRoute( - path: 'persons', + path: '/business/:business_id/persons', name: 'business_persons', - builder: (context, state) { + pageBuilder: (context, state) { final businessId = int.parse(state.pathParameters['business_id']!); - return BusinessShell( - businessId: businessId, - authStore: _authStore!, - localeController: controller, - calendarController: _calendarController!, - themeController: themeController, + return NoTransitionPage( child: PersonsPage( businessId: businessId, authStore: _authStore!, @@ -798,16 +717,11 @@ class _MyAppState extends State { ), // Receipts & Payments: list with data table GoRoute( - path: 'receipts-payments', + path: '/business/:business_id/receipts-payments', name: 'business_receipts_payments', - builder: (context, state) { + pageBuilder: (context, state) { final businessId = int.parse(state.pathParameters['business_id']!); - return BusinessShell( - businessId: businessId, - authStore: _authStore!, - localeController: controller, - calendarController: _calendarController!, - themeController: themeController, + return NoTransitionPage( child: ReceiptsPaymentsListPage( businessId: businessId, calendarController: _calendarController!, @@ -818,16 +732,26 @@ class _MyAppState extends State { }, ), GoRoute( - path: 'transfers', - name: 'business_transfers', - builder: (context, state) { + path: '/business/:business_id/expense-income', + name: 'business_expense_income', + pageBuilder: (context, state) { final businessId = int.parse(state.pathParameters['business_id']!); - return BusinessShell( - businessId: businessId, - authStore: _authStore!, - localeController: controller, - calendarController: _calendarController!, - themeController: themeController, + return NoTransitionPage( + child: ExpenseIncomeListPage( + businessId: businessId, + calendarController: _calendarController!, + authStore: _authStore!, + apiClient: ApiClient(), + ), + ); + }, + ), + GoRoute( + path: '/business/:business_id/transfers', + name: 'business_transfers', + pageBuilder: (context, state) { + final businessId = int.parse(state.pathParameters['business_id']!); + return NoTransitionPage( child: TransfersPage( businessId: businessId, calendarController: _calendarController!, @@ -838,16 +762,11 @@ class _MyAppState extends State { }, ), GoRoute( - path: 'checks', + path: '/business/:business_id/checks', name: 'business_checks', - builder: (context, state) { + pageBuilder: (context, state) { final businessId = int.parse(state.pathParameters['business_id']!); - return BusinessShell( - businessId: businessId, - authStore: _authStore!, - localeController: controller, - calendarController: _calendarController!, - themeController: themeController, + return NoTransitionPage( child: ChecksPage( businessId: businessId, authStore: _authStore!, @@ -856,16 +775,11 @@ class _MyAppState extends State { }, ), GoRoute( - path: 'checks/new', + path: '/business/:business_id/checks/new', name: 'business_new_check', - builder: (context, state) { + pageBuilder: (context, state) { final businessId = int.parse(state.pathParameters['business_id']!); - return BusinessShell( - businessId: businessId, - authStore: _authStore!, - localeController: controller, - calendarController: _calendarController!, - themeController: themeController, + return NoTransitionPage( child: CheckFormPage( businessId: businessId, authStore: _authStore!, @@ -875,17 +789,12 @@ class _MyAppState extends State { }, ), GoRoute( - path: 'checks/:check_id/edit', + path: '/business/:business_id/checks/:check_id/edit', name: 'business_edit_check', - builder: (context, state) { + pageBuilder: (context, state) { final businessId = int.parse(state.pathParameters['business_id']!); final checkId = int.tryParse(state.pathParameters['check_id'] ?? '0'); - return BusinessShell( - businessId: businessId, - authStore: _authStore!, - localeController: controller, - calendarController: _calendarController!, - themeController: themeController, + return NoTransitionPage( child: CheckFormPage( businessId: businessId, authStore: _authStore!, diff --git a/hesabixUI/hesabix_ui/lib/models/transfer_document.dart b/hesabixUI/hesabix_ui/lib/models/transfer_document.dart new file mode 100644 index 0000000..166e909 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/models/transfer_document.dart @@ -0,0 +1,48 @@ +/// مدل سند انتقال +class TransferDocument { + final int id; + final String code; + final DateTime documentDate; + final DateTime registeredAt; + final double totalAmount; + final String? createdByName; + final String? description; + final String? sourceType; + final String? sourceName; + final String? destinationType; + final String? destinationName; + + TransferDocument({ + required this.id, + required this.code, + required this.documentDate, + required this.registeredAt, + required this.totalAmount, + this.createdByName, + this.description, + this.sourceType, + this.sourceName, + this.destinationType, + this.destinationName, + }); + + factory TransferDocument.fromJson(Map json) { + return TransferDocument( + id: json['id'] as int, + code: (json['code'] ?? '').toString(), + documentDate: DateTime.tryParse((json['document_date'] ?? '').toString()) ?? DateTime.now(), + registeredAt: DateTime.tryParse((json['registered_at'] ?? '').toString()) ?? DateTime.now(), + totalAmount: (json['total_amount'] as num?)?.toDouble() ?? 0, + createdByName: (json['created_by_name'] ?? '') as String?, + description: (json['description'] ?? '') as String?, + sourceType: (json['source_type'] ?? '') as String?, + sourceName: (json['source_name'] ?? '') as String?, + destinationType: (json['destination_type'] ?? '') as String?, + destinationName: (json['destination_name'] ?? '') as String?, + ); + } + + String get documentTypeName => 'انتقال'; +} + + diff --git a/hesabixUI/hesabix_ui/lib/pages/business/expense_income_dialog.dart b/hesabixUI/hesabix_ui/lib/pages/business/expense_income_dialog.dart new file mode 100644 index 0000000..20b378f --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/pages/business/expense_income_dialog.dart @@ -0,0 +1,338 @@ +import 'package:flutter/material.dart'; +import '../../core/calendar_controller.dart'; +import '../../core/api_client.dart'; +import '../../core/auth_store.dart'; +import '../../widgets/date_input_field.dart'; +import '../../widgets/invoice/invoice_transactions_widget.dart'; +import '../../widgets/invoice/account_tree_combobox_widget.dart'; +import '../../models/invoice_type_model.dart'; +import '../../models/invoice_transaction.dart'; +import '../../models/account_tree_node.dart'; +import '../../services/expense_income_service.dart'; + +class ExpenseIncomeDialog extends StatefulWidget { + final int businessId; + final CalendarController calendarController; + final AuthStore authStore; + final ApiClient apiClient; + final Map? initial; // optional document for edit + const ExpenseIncomeDialog({super.key, required this.businessId, required this.calendarController, required this.authStore, required this.apiClient, this.initial}); + + @override + State createState() => _ExpenseIncomeDialogState(); +} + +class _ExpenseIncomeDialogState extends State { + final _formKey = GlobalKey(); + String _docType = 'expense'; + DateTime _docDate = DateTime.now(); + int? _currencyId; + final _descCtrl = TextEditingController(); + + final List<_ItemLine> _items = <_ItemLine>[]; + final List _transactions = []; + + @override + void initState() { + super.initState(); + _currencyId = widget.authStore.currentBusiness?.defaultCurrency?.id; + if (widget.initial != null) { + _docType = (widget.initial!['document_type'] as String?) ?? 'expense'; + final dd = widget.initial!['document_date'] as String?; + if (dd != null) _docDate = DateTime.tryParse(dd) ?? _docDate; + _descCtrl.text = (widget.initial!['description'] as String?) ?? ''; + // items and counterparties could be mapped if provided + } + } + + @override + void dispose() { + _descCtrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final sumItems = _items.fold(0, (p, e) => p + e.amount); + final sumTx = _transactions.fold(0, (p, e) => p + (e.amount.toDouble())); + final diff = (_docType == 'income') ? (sumTx - sumItems) : (sumItems - sumTx); + + return Dialog( + insetPadding: const EdgeInsets.all(16), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 1100, maxHeight: 720), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Row( + children: [ + const Expanded(child: Text('هزینه و درآمد', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600))), + SegmentedButton( + segments: const [ButtonSegment(value: 'expense', label: Text('هزینه')), ButtonSegment(value: 'income', label: Text('درآمد'))], + selected: {_docType}, + onSelectionChanged: (s) => setState(() => _docType = s.first), + ), + const SizedBox(width: 12), + SizedBox( + width: 200, + child: DateInputField( + value: _docDate, + calendarController: widget.calendarController, + onChanged: (d) => setState(() => _docDate = d ?? _docDate), + labelText: 'تاریخ سند', + hintText: 'انتخاب تاریخ', + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: TextField( + controller: _descCtrl, + decoration: const InputDecoration(labelText: 'توضیحات کلی سند', border: OutlineInputBorder()), + maxLines: 2, + ), + ), + const Divider(height: 1), + Expanded( + child: Row( + children: [ + Expanded(child: _ItemsPanel(businessId: widget.businessId, lines: _items, onChanged: (ls) => setState(() { _items..clear()..addAll(ls);}))), + const VerticalDivider(width: 1), + Expanded( + child: Padding( + padding: const EdgeInsets.all(12), + child: InvoiceTransactionsWidget( + transactions: _transactions, + onChanged: (txs) => setState(() { _transactions..clear()..addAll(txs); }), + businessId: widget.businessId, + calendarController: widget.calendarController, + invoiceType: _docType == 'income' ? InvoiceType.sales : InvoiceType.purchase, + ), + ), + ), + ], + ), + ), + const Divider(height: 1), + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: Row( + children: [ + Expanded( + child: Wrap(spacing: 16, runSpacing: 8, children: [ + _chip('جمع اقلام', sumItems), + _chip('جمع طرف‌حساب', sumTx), + _chip('اختلاف', diff, isError: diff != 0), + ]), + ), + TextButton(onPressed: () => Navigator.pop(context), child: const Text('انصراف')), + const SizedBox(width: 8), + FilledButton.icon(onPressed: _canSave ? _save : null, icon: const Icon(Icons.save), label: const Text('ثبت')), + ], + ), + ), + ], + ), + ), + ), + ); + } + + bool get _canSave { + if (_currencyId == null) return false; + if (_items.isEmpty || _transactions.isEmpty) return false; + final sumItems = _items.fold(0, (p, e) => p + e.amount); + final sumTx = _transactions.fold(0, (p, e) => p + (e.amount.toDouble())); + return sumItems == sumTx; + } + + Future _save() async { + showDialog(context: context, barrierDismissible: false, builder: (_) => const Center(child: CircularProgressIndicator())); + try { + final service = ExpenseIncomeService(widget.apiClient); + final itemsData = _items.map((e) => {'account_id': e.account?.id, 'amount': e.amount, if (e.description?.isNotEmpty == true) 'description': e.description}).toList(); + final txData = _transactions.map((tx) => { + 'transaction_type': tx.type.value, + 'transaction_date': tx.transactionDate.toIso8601String(), + 'amount': tx.amount.toDouble(), + if (tx.commission != null) 'commission': tx.commission?.toDouble(), + if (tx.description != null && tx.description!.isNotEmpty) 'description': tx.description, + 'bank_id': tx.bankId, + 'bank_name': tx.bankName, + 'cash_register_id': tx.cashRegisterId, + 'cash_register_name': tx.cashRegisterName, + 'petty_cash_id': tx.pettyCashId, + 'petty_cash_name': tx.pettyCashName, + 'check_id': tx.checkId, + 'check_number': tx.checkNumber, + 'person_id': tx.personId, + 'person_name': tx.personName, + 'account_id': tx.accountId, + 'account_name': tx.accountName, + }..removeWhere((k, v) => v == null)).toList(); + await service.create( + businessId: widget.businessId, + documentType: _docType, + documentDate: _docDate, + currencyId: _currencyId!, + description: _descCtrl.text.trim(), + itemLines: itemsData, + counterpartyLines: txData, + ); + if (!mounted) return; + Navigator.pop(context); // loading + Navigator.pop(context, true); // dialog success + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('سند با موفقیت ثبت شد'), backgroundColor: Colors.green)); + } catch (e) { + if (!mounted) return; + Navigator.pop(context); // loading + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا: $e'), backgroundColor: Colors.red)); + } + } +} + +class _ItemsPanel extends StatelessWidget { + final int businessId; + final List<_ItemLine> lines; + final ValueChanged> onChanged; + const _ItemsPanel({required this.businessId, required this.lines, required this.onChanged}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + const Expanded(child: Text('اقلام')), + IconButton(onPressed: () { final nl = List<_ItemLine>.from(lines); nl.add(_ItemLine.empty()); onChanged(nl); }, icon: const Icon(Icons.add)), + ], + ), + const SizedBox(height: 8), + Expanded( + child: lines.isEmpty + ? const Center(child: Text('موردی ثبت نشده')) + : ListView.separated( + itemCount: lines.length, + separatorBuilder: (_, __) => const SizedBox(height: 8), + itemBuilder: (ctx, i) => _ItemTile( + businessId: businessId, + line: lines[i], + onChanged: (l) { final nl = List<_ItemLine>.from(lines); nl[i] = l; onChanged(nl); }, + onDelete: () { final nl = List<_ItemLine>.from(lines); nl.removeAt(i); onChanged(nl); }, + ), + ), + ), + ], + ), + ); + } +} + +class _ItemTile extends StatefulWidget { + final int businessId; + final _ItemLine line; + final ValueChanged<_ItemLine> onChanged; + final VoidCallback onDelete; + const _ItemTile({required this.businessId, required this.line, required this.onChanged, required this.onDelete}); + + @override + State<_ItemTile> createState() => _ItemTileState(); +} + +class _ItemTileState extends State<_ItemTile> { + final _amountCtrl = TextEditingController(); + final _descCtrl = TextEditingController(); + + @override + void initState() { + super.initState(); + _amountCtrl.text = widget.line.amount == 0 ? '' : widget.line.amount.toStringAsFixed(0); + _descCtrl.text = widget.line.description ?? ''; + } + + @override + void dispose() { + _amountCtrl.dispose(); + _descCtrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + children: [ + Row( + children: [ + Expanded( + child: AccountTreeComboboxWidget( + businessId: widget.businessId, + selectedAccount: widget.line.account, + onChanged: (acc) => widget.onChanged(widget.line.copyWith(account: acc)), + label: 'حساب *', + hintText: 'انتخاب حساب', + isRequired: true, + ), + ), + const SizedBox(width: 8), + SizedBox( + width: 180, + child: TextFormField( + controller: _amountCtrl, + decoration: const InputDecoration(labelText: 'مبلغ', hintText: '1,000,000'), + keyboardType: TextInputType.number, + onChanged: (v) { + final val = double.tryParse(v.replaceAll(',', '')) ?? 0; + widget.onChanged(widget.line.copyWith(amount: val)); + }, + ), + ), + const SizedBox(width: 8), + IconButton(onPressed: widget.onDelete, icon: const Icon(Icons.delete_outline)), + ], + ), + const SizedBox(height: 8), + TextFormField( + controller: _descCtrl, + decoration: const InputDecoration(labelText: 'توضیحات'), + onChanged: (v) => widget.onChanged(widget.line.copyWith(description: v.trim().isEmpty ? null : v.trim())), + ), + ], + ), + ), + ); + } +} + +class _ItemLine { + final AccountTreeNode? account; + final double amount; + final String? description; + const _ItemLine({this.account, required this.amount, this.description}); + factory _ItemLine.empty() => const _ItemLine(amount: 0); + _ItemLine copyWith({AccountTreeNode? account, double? amount, String? description}) => _ItemLine( + account: account ?? this.account, + amount: amount ?? this.amount, + description: description ?? this.description, + ); +} + +Widget _chip(String label, double value, {bool isError = false}) { + return Chip( + label: Text('$label: ${value.toStringAsFixed(0)}'), + backgroundColor: isError ? Colors.red.shade100 : Colors.grey.shade200, + ); +} + + diff --git a/hesabixUI/hesabix_ui/lib/pages/business/expense_income_list_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/expense_income_list_page.dart new file mode 100644 index 0000000..e53a19d --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/pages/business/expense_income_list_page.dart @@ -0,0 +1,170 @@ +import 'package:flutter/material.dart'; +import '../../core/api_client.dart'; +import '../../core/auth_store.dart'; +import '../../core/calendar_controller.dart'; +import '../../core/date_utils.dart' show HesabixDateUtils; +import '../../utils/number_formatters.dart' show formatWithThousands; +import '../../services/expense_income_service.dart'; +import 'expense_income_dialog.dart'; + +class ExpenseIncomeListPage extends StatefulWidget { + final int businessId; + final CalendarController calendarController; + final AuthStore authStore; + final ApiClient apiClient; + const ExpenseIncomeListPage({super.key, required this.businessId, required this.calendarController, required this.authStore, required this.apiClient}); + + @override + State createState() => _ExpenseIncomeListPageState(); +} + +class _ExpenseIncomeListPageState extends State { + int _tabIndex = 0; // 0 expense, 1 income + final List> _items = >[]; + int _skip = 0; + int _take = 20; + int _total = 0; + bool _loading = false; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + setState(() => _loading = true); + try { + final svc = ExpenseIncomeService(widget.apiClient); + final res = await svc.list( + businessId: widget.businessId, + documentType: _tabIndex == 0 ? 'expense' : 'income', + skip: _skip, + take: _take, + ); + final data = (res['items'] as List? ?? const []).cast>(); + setState(() { + _items + ..clear() + ..addAll(data); + _total = (res['pagination']?['total'] as int?) ?? data.length; + }); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا: $e'), backgroundColor: Colors.red)); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Theme.of(context).colorScheme.surface, + body: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Row( + children: [ + const Expanded(child: Text('هزینه و درآمد', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w600))), + SegmentedButton( + segments: const [ButtonSegment(value: 0, label: Text('هزینه')), ButtonSegment(value: 1, label: Text('درآمد'))], + selected: {_tabIndex}, + onSelectionChanged: (s) async { + setState(() { _tabIndex = s.first; _skip = 0; }); + await _load(); + }, + ), + const SizedBox(width: 12), + FilledButton.icon( + onPressed: () async { + final ok = await showDialog( + context: context, + builder: (_) => ExpenseIncomeDialog( + businessId: widget.businessId, + calendarController: widget.calendarController, + authStore: widget.authStore, + apiClient: widget.apiClient, + ), + ); + if (ok == true) _load(); + }, + icon: const Icon(Icons.add), + label: const Text('افزودن'), + ), + ], + ), + ), + Expanded( + child: Card( + margin: const EdgeInsets.all(8), + child: _loading + ? const Center(child: CircularProgressIndicator()) + : _items.isEmpty + ? const Center(child: Text('داده‌ای یافت نشد')) + : ListView.separated( + itemCount: _items.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (ctx, i) { + final it = _items[i]; + final code = (it['code'] ?? '').toString(); + final type = (it['document_type'] ?? '').toString(); + final dateStr = (it['document_date'] ?? '').toString(); + final date = dateStr.isNotEmpty ? DateTime.tryParse(dateStr) : null; + final sumItems = _sum(it['items'] as List?); + final sumCps = _sum(it['counterparties'] as List?); + return ListTile( + title: Text(code), + subtitle: Text('${type == 'income' ? 'درآمد' : 'هزینه'} • ${date != null ? HesabixDateUtils.formatForDisplay(date, true) : '-'}'), + trailing: Text('${formatWithThousands(sumItems)} | ${formatWithThousands(sumCps)}'), + onTap: () async { + final ok = await showDialog( + context: context, + builder: (_) => ExpenseIncomeDialog( + businessId: widget.businessId, + calendarController: widget.calendarController, + authStore: widget.authStore, + apiClient: widget.apiClient, + initial: it, + ), + ); + if (ok == true) _load(); + }, + ); + }, + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: Row( + children: [ + Text('$_skip - ${_skip + _items.length} از $_total'), + const Spacer(), + IconButton(onPressed: _skip <= 0 ? null : () { setState(() { _skip = (_skip - _take).clamp(0, _total); }); _load(); }, icon: const Icon(Icons.chevron_right)), + IconButton(onPressed: (_skip + _take) >= _total ? null : () { setState(() { _skip = _skip + _take; }); _load(); }, icon: const Icon(Icons.chevron_left)), + ], + ), + ) + ], + ), + ), + ); + } + + double _sum(List? lines) { + if (lines == null) return 0; + double s = 0; + for (final l in lines) { + final m = (l as Map); + s += ((m['debit'] ?? 0) as num).toDouble(); + s += ((m['credit'] ?? 0) as num).toDouble(); + } + return s; + } +} + + diff --git a/hesabixUI/hesabix_ui/lib/pages/business/expense_income_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/expense_income_page.dart new file mode 100644 index 0000000..0e5ec6d --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/pages/business/expense_income_page.dart @@ -0,0 +1,431 @@ +import 'package:flutter/material.dart'; +import 'package:uuid/uuid.dart'; +import '../../core/auth_store.dart'; +import '../../core/calendar_controller.dart'; +import '../../widgets/date_input_field.dart'; +import '../../widgets/invoice/invoice_transactions_widget.dart'; +import '../../widgets/invoice/account_tree_combobox_widget.dart'; +import '../../models/invoice_type_model.dart'; +import '../../models/invoice_transaction.dart'; +import '../../models/account_tree_node.dart'; +import '../../utils/number_formatters.dart'; +import '../../services/expense_income_service.dart'; +import '../../core/api_client.dart'; + +class ExpenseIncomePage extends StatefulWidget { + final int businessId; + final AuthStore authStore; + final CalendarController calendarController; + final ApiClient apiClient; + const ExpenseIncomePage({super.key, required this.businessId, required this.authStore, required this.calendarController, required this.apiClient}); + + @override + State createState() => _ExpenseIncomePageState(); +} + +class _ExpenseIncomePageState extends State { + // uuid reserved for future draft IDs if needed + DateTime _docDate = DateTime.now(); + String _docType = 'expense'; + int? _currencyId; + final _descriptionController = TextEditingController(); + + final List<_ItemLine> _itemLines = <_ItemLine>[]; + final List<_TxLine> _txLines = <_TxLine>[]; + + @override + void initState() { + super.initState(); + _currencyId = widget.authStore.currentBusiness?.defaultCurrency?.id; + } + + @override + void dispose() { + _descriptionController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Row( + children: [ + const Expanded( + child: Text('هزینه و درآمد', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w600)), + ), + SegmentedButton( + segments: const [ + ButtonSegment(value: 'expense', label: Text('هزینه')), + ButtonSegment(value: 'income', label: Text('درآمد')), + ], + selected: {_docType}, + onSelectionChanged: (s) => setState(() => _docType = s.first), + ), + const SizedBox(width: 12), + SizedBox( + width: 220, + child: DateInputField( + value: _docDate, + calendarController: widget.calendarController, + onChanged: (d) => setState(() => _docDate = d ?? DateTime.now()), + labelText: 'تاریخ سند', + hintText: 'انتخاب تاریخ', + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: TextField( + controller: _descriptionController, + decoration: const InputDecoration( + labelText: 'توضیحات کلی سند', + hintText: 'توضیحات اختیاری...', + border: OutlineInputBorder(), + ), + maxLines: 2, + ), + ), + const Divider(height: 1), + Expanded( + child: Row( + children: [ + // پنل سطرهای حساب (هزینه/درآمد) + Expanded( + child: _ItemsPanel( + businessId: widget.businessId, + lines: _itemLines, + onChanged: (ls) => setState(() { + _itemLines + ..clear() + ..addAll(ls); + }), + ), + ), + const VerticalDivider(width: 1), + // پنل سطرهای طرف‌حساب (بازاستفاده از ویجت تراکنش‌ها) + Expanded( + child: Padding( + padding: const EdgeInsets.all(12), + child: InvoiceTransactionsWidget( + transactions: const [], + onChanged: (txs) => setState(() { + _txLines + ..clear() + ..addAll(txs.map(_TxLine.fromInvoiceTransaction)); + }), + businessId: widget.businessId, + calendarController: widget.calendarController, + invoiceType: _docType == 'income' ? InvoiceType.sales : InvoiceType.purchase, + ), + ), + ), + ], + ), + ), + const Divider(height: 1), + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: Row( + children: [ + Expanded( + child: Wrap( + spacing: 16, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + _chip('جمع اقلام', _sumItems()), + _chip('جمع طرف‌حساب', _sumTxs()), + _chip('اختلاف', (_docType == 'income' ? _sumTxs() - _sumItems() : _sumItems() - _sumTxs()), isError: _sumItems() != _sumTxs()), + ], + ), + ), + TextButton(onPressed: () => Navigator.pop(context), child: const Text('انصراف')), + const SizedBox(width: 8), + FilledButton.icon(onPressed: _canSave ? _save : null, icon: const Icon(Icons.save), label: const Text('ثبت')), + ], + ), + ), + ], + ), + ), + ); + } + + double _sumItems() => _itemLines.fold(0, (p, e) => p + e.amount); + double _sumTxs() => _txLines.fold(0, (p, e) => p + e.amount); + bool get _canSave => _currencyId != null && _itemLines.isNotEmpty && _txLines.isNotEmpty && _sumItems() == _sumTxs(); + + Future _save() async { + showDialog(context: context, barrierDismissible: false, builder: (_) => const Center(child: CircularProgressIndicator())); + try { + final service = ExpenseIncomeService(widget.apiClient); + final itemLinesData = _itemLines.map((e) => { + 'account_id': e.account?.id, + 'amount': e.amount, + if (e.description?.isNotEmpty == true) 'description': e.description, + }).toList(); + final counterpartyLinesData = _txLines.map((e) => e.toApiMap()).toList(); + await service.create( + businessId: widget.businessId, + documentType: _docType, + documentDate: _docDate, + currencyId: _currencyId!, + description: _descriptionController.text.trim(), + itemLines: itemLinesData, + counterpartyLines: counterpartyLinesData, + ); + if (!mounted) return; + Navigator.pop(context); // loading + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('سند با موفقیت ثبت شد'), backgroundColor: Colors.green)); + } catch (e) { + if (!mounted) return; + Navigator.pop(context); // loading + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا: $e'), backgroundColor: Colors.red)); + } + } +} + +class _ItemsPanel extends StatelessWidget { + final int businessId; + final List<_ItemLine> lines; + final ValueChanged> onChanged; + const _ItemsPanel({required this.businessId, required this.lines, required this.onChanged}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + const Expanded(child: Text('اقلام هزینه/درآمد')), + IconButton( + onPressed: () { + final newLines = List<_ItemLine>.from(lines); + newLines.add(_ItemLine.empty()); + onChanged(newLines); + }, + icon: const Icon(Icons.add), + ), + ], + ), + const SizedBox(height: 8), + Expanded( + child: lines.isEmpty + ? const Center(child: Text('موردی ثبت نشده')) + : ListView.separated( + itemCount: lines.length, + separatorBuilder: (_, __) => const SizedBox(height: 8), + itemBuilder: (ctx, i) => _ItemTile( + businessId: businessId, + line: lines[i], + onChanged: (l) { + final newLines = List<_ItemLine>.from(lines); + newLines[i] = l; + onChanged(newLines); + }, + onDelete: () { + final newLines = List<_ItemLine>.from(lines); + newLines.removeAt(i); + onChanged(newLines); + }, + ), + ), + ), + ], + ), + ); + } +} + +class _ItemTile extends StatefulWidget { + final int businessId; + final _ItemLine line; + final ValueChanged<_ItemLine> onChanged; + final VoidCallback onDelete; + const _ItemTile({required this.businessId, required this.line, required this.onChanged, required this.onDelete}); + + @override + State<_ItemTile> createState() => _ItemTileState(); +} + +class _ItemTileState extends State<_ItemTile> { + final _amountController = TextEditingController(); + final _descController = TextEditingController(); + + @override + void initState() { + super.initState(); + _amountController.text = widget.line.amount == 0 ? '' : widget.line.amount.toStringAsFixed(0); + _descController.text = widget.line.description ?? ''; + } + + @override + void dispose() { + _amountController.dispose(); + _descController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + children: [ + Row( + children: [ + Expanded( + child: AccountTreeComboboxWidget( + businessId: widget.businessId, + selectedAccount: widget.line.account, + onChanged: (acc) => widget.onChanged(widget.line.copyWith(account: acc)), + label: 'حساب *', + hintText: 'انتخاب حساب هزینه/درآمد', + isRequired: true, + ), + ), + const SizedBox(width: 8), + SizedBox( + width: 180, + child: TextFormField( + controller: _amountController, + decoration: const InputDecoration(labelText: 'مبلغ', hintText: '1,000,000'), + keyboardType: TextInputType.number, + onChanged: (v) { + final val = double.tryParse(v.replaceAll(',', '')) ?? 0; + widget.onChanged(widget.line.copyWith(amount: val)); + }, + ), + ), + const SizedBox(width: 8), + IconButton(onPressed: widget.onDelete, icon: const Icon(Icons.delete_outline)), + ], + ), + const SizedBox(height: 8), + TextFormField( + controller: _descController, + decoration: const InputDecoration(labelText: 'توضیحات'), + onChanged: (v) => widget.onChanged(widget.line.copyWith(description: v.trim().isEmpty ? null : v.trim())), + ), + ], + ), + ), + ); + } +} + +class _ItemLine { + final AccountTreeNode? account; + final double amount; + final String? description; + const _ItemLine({this.account, required this.amount, this.description}); + factory _ItemLine.empty() => const _ItemLine(amount: 0); + _ItemLine copyWith({AccountTreeNode? account, double? amount, String? description}) => _ItemLine( + account: account ?? this.account, + amount: amount ?? this.amount, + description: description ?? this.description, + ); +} + +class _TxLine { + final String id; + final DateTime date; + final String type; // bank|cash_register|petty_cash|check|person|account + final double amount; + final double? commission; + final String? description; + final String? bankId; + final String? bankName; + final String? cashRegisterId; + final String? cashRegisterName; + final String? pettyCashId; + final String? pettyCashName; + final String? checkId; + final String? checkNumber; + final String? personId; + final String? personName; + final AccountTreeNode? account; + + _TxLine({ + required this.id, + required this.date, + required this.type, + required this.amount, + this.commission, + this.description, + this.bankId, + this.bankName, + this.cashRegisterId, + this.cashRegisterName, + this.pettyCashId, + this.pettyCashName, + this.checkId, + this.checkNumber, + this.personId, + this.personName, + this.account, + }); + + Map toApiMap() => { + 'transaction_type': type, + 'transaction_date': date.toIso8601String(), + 'amount': amount, + if (commission != null) 'commission': commission, + if (description?.isNotEmpty == true) 'description': description, + 'bank_id': bankId, + 'bank_name': bankName, + 'cash_register_id': cashRegisterId, + 'cash_register_name': cashRegisterName, + 'petty_cash_id': pettyCashId, + 'petty_cash_name': pettyCashName, + 'check_id': checkId, + 'check_number': checkNumber, + 'person_id': personId, + 'person_name': personName, + if (account != null) 'account_id': account!.id, + }..removeWhere((k, v) => v == null); + + // اتصال به ویجت InvoiceTransactionsWidget + factory _TxLine.fromInvoiceTransaction(dynamic tx) => _TxLine( + id: tx.id ?? const Uuid().v4(), + date: tx.transactionDate, + type: tx.type.value, + amount: tx.amount.toDouble(), + commission: tx.commission?.toDouble(), + description: tx.description, + bankId: tx.bankId, + bankName: tx.bankName, + cashRegisterId: tx.cashRegisterId, + cashRegisterName: tx.cashRegisterName, + pettyCashId: tx.pettyCashId, + pettyCashName: tx.pettyCashName, + checkId: tx.checkId, + checkNumber: tx.checkNumber, + personId: tx.personId, + personName: tx.personName, + account: null, + ); + + // Not needed: we only consume transactions from widget +} + +Widget _chip(String label, double value, {bool isError = false}) { + return Chip( + label: Text('$label: ${formatWithThousands(value)}'), + backgroundColor: isError ? Colors.red.shade100 : Colors.grey.shade200, + ); +} + + diff --git a/hesabixUI/hesabix_ui/lib/pages/business/transfers_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/transfers_page.dart index 0196ca8..1fc06d8 100644 --- a/hesabixUI/hesabix_ui/lib/pages/business/transfers_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/transfers_page.dart @@ -1,9 +1,17 @@ import 'package:flutter/material.dart'; +import 'package:hesabix_ui/l10n/app_localizations.dart'; import '../../core/auth_store.dart'; import '../../core/calendar_controller.dart'; import '../../core/api_client.dart'; +import '../../models/transfer_document.dart'; +import '../../services/transfer_service.dart'; +import '../../widgets/data_table/data_table_widget.dart'; +import '../../widgets/data_table/data_table_config.dart'; +import '../../core/date_utils.dart' show HesabixDateUtils; +import '../../utils/number_formatters.dart' show formatWithThousands; +import '../../widgets/date_input_field.dart'; import '../../widgets/transfer/transfer_form_dialog.dart'; -import 'package:hesabix_ui/l10n/app_localizations.dart'; +import '../../widgets/transfer/transfer_details_dialog.dart'; class TransfersPage extends StatefulWidget { final int businessId; @@ -24,79 +32,44 @@ class TransfersPage extends StatefulWidget { } class _TransfersPageState extends State { - Future _showAddTransferDialog() async { - final result = await showDialog( - context: context, - builder: (context) => TransferFormDialog( - businessId: widget.businessId, - calendarController: widget.calendarController, - onSuccess: () { - // TODO: بروزرسانی لیست انتقالات - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('انتقال با موفقیت ثبت شد'), - backgroundColor: Colors.green, - ), - ); - }, - ), - ); - - if (result == true) { - // بروزرسانی صفحه در صورت نیاز - setState(() {}); + // کنترل جدول برای دسترسی به refresh + final GlobalKey _tableKey = GlobalKey(); + DateTime? _fromDate; + DateTime? _toDate; + + void _refreshData() { + final state = _tableKey.currentState; + if (state != null) { + try { + // ignore: avoid_dynamic_calls + (state as dynamic).refresh(); + return; + } catch (_) {} } + if (mounted) setState(() {}); } @override Widget build(BuildContext context) { final t = AppLocalizations.of(context); - + return Scaffold( - appBar: AppBar( - title: Text(t.transfers), - backgroundColor: Theme.of(context).colorScheme.surface, - foregroundColor: Theme.of(context).colorScheme.onSurface, - elevation: 0, - actions: [ - IconButton( - icon: const Icon(Icons.add), - onPressed: () => _showAddTransferDialog(), - tooltip: 'اضافه کردن انتقال جدید', - ), - ], - ), - body: Center( + backgroundColor: Theme.of(context).colorScheme.surface, + body: SafeArea( child: Column( - mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Icon( - Icons.swap_horiz, - size: 80, - color: Theme.of(context).colorScheme.primary.withOpacity(0.5), - ), - const SizedBox(height: 24), - Text( - 'صفحه لیست انتقال', - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - color: Theme.of(context).colorScheme.onSurface, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 16), - Text( - 'این صفحه به زودی آماده خواهد شد', - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7), - ), - ), - const SizedBox(height: 32), - ElevatedButton.icon( - onPressed: () => _showAddTransferDialog(), - icon: const Icon(Icons.add), - label: const Text('اضافه کردن انتقال جدید'), - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + _buildHeader(t), + _buildFilters(t), + Expanded( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: DataTableWidget( + key: _tableKey, + config: _buildTableConfig(t), + fromJson: (json) => TransferDocument.fromJson(json), + calendarController: widget.calendarController, + ), ), ), ], @@ -104,4 +77,281 @@ class _TransfersPageState extends State { ), ); } + + Widget _buildHeader(AppLocalizations t) { + return Container( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + t.transfers, + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 4), + Text( + 'مدیریت اسناد انتقال وجه', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + Tooltip( + message: 'افزودن انتقال جدید', + child: FilledButton.icon( + onPressed: _onAddNew, + icon: const Icon(Icons.add), + label: Text(t.add), + ), + ), + ], + ), + ); + } + + Widget _buildFilters(AppLocalizations t) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Expanded( + child: Row( + children: [ + Expanded( + child: DateInputField( + value: _fromDate, + calendarController: widget.calendarController, + onChanged: (date) { + setState(() => _fromDate = date); + _refreshData(); + }, + labelText: 'از تاریخ', + hintText: 'انتخاب تاریخ شروع', + ), + ), + const SizedBox(width: 8), + Expanded( + child: DateInputField( + value: _toDate, + calendarController: widget.calendarController, + onChanged: (date) { + setState(() => _toDate = date); + _refreshData(); + }, + labelText: 'تا تاریخ', + hintText: 'انتخاب تاریخ پایان', + ), + ), + IconButton( + onPressed: () { + setState(() { + _fromDate = null; + _toDate = null; + }); + _refreshData(); + }, + icon: const Icon(Icons.clear), + tooltip: 'پاک کردن فیلتر تاریخ', + ), + ], + ), + ), + ], + ), + ); + } + + DataTableConfig _buildTableConfig(AppLocalizations t) { + return DataTableConfig( + endpoint: '/businesses/${widget.businessId}/transfers', + title: t.transfers, + excelEndpoint: '/businesses/${widget.businessId}/transfers/export/excel', + pdfEndpoint: '/businesses/${widget.businessId}/transfers/export/pdf', + getExportParams: () => { + if (_fromDate != null) 'from_date': _fromDate!.toUtc().toIso8601String(), + if (_toDate != null) 'to_date': _toDate!.toUtc().toIso8601String(), + }, + columns: [ + TextColumn( + 'code', + 'کد سند', + width: ColumnWidth.medium, + formatter: (it) => it.code, + ), + TextColumn( + 'description', + 'توضیحات', + width: ColumnWidth.large, + formatter: (it) => (it.description ?? '').isNotEmpty ? it.description! : _composeDesc(it), + ), + TextColumn( + 'route', + 'مبدا → مقصد', + width: ColumnWidth.large, + formatter: (it) => _composeRoute(it), + ), + DateColumn( + 'document_date', + 'تاریخ سند', + width: ColumnWidth.medium, + formatter: (it) => HesabixDateUtils.formatForDisplay(it.documentDate, widget.calendarController.isJalali), + ), + NumberColumn( + 'total_amount', + 'مبلغ کل', + width: ColumnWidth.large, + formatter: (it) => formatWithThousands(it.totalAmount), + suffix: ' ریال', + ), + TextColumn( + 'created_by_name', + 'ایجادکننده', + width: ColumnWidth.medium, + formatter: (it) => it.createdByName ?? 'نامشخص', + ), + DateColumn( + 'registered_at', + 'تاریخ ثبت', + width: ColumnWidth.medium, + formatter: (it) => HesabixDateUtils.formatForDisplay(it.registeredAt, widget.calendarController.isJalali), + ), + ActionColumn( + 'actions', + 'عملیات', + width: ColumnWidth.medium, + actions: [ + DataTableAction(icon: Icons.visibility, label: 'مشاهده', onTap: (it) => _onView(it as TransferDocument)), + DataTableAction(icon: Icons.edit, label: 'ویرایش', onTap: (it) => _onEdit(it as TransferDocument)), + DataTableAction(icon: Icons.delete, label: 'حذف', onTap: (it) => _onDelete(it as TransferDocument), isDestructive: true), + ], + ), + ], + searchFields: ['code', 'created_by_name'], + dateRangeField: 'document_date', + showSearch: true, + showFilters: true, + showPagination: true, + showColumnSearch: true, + showRefreshButton: true, + showClearFiltersButton: true, + enableRowSelection: true, + enableMultiRowSelection: true, + showExportButtons: true, + showExcelExport: true, + showPdfExport: true, + defaultPageSize: 20, + pageSizeOptions: [10, 20, 50, 100], + // انتخاب سطرها در این صفحه استفاده خاصی ندارد + additionalParams: { + if (_fromDate != null) 'from_date': _fromDate!.toUtc().toIso8601String(), + if (_toDate != null) 'to_date': _toDate!.toUtc().toIso8601String(), + }, + onRowTap: (item) => _onView(item as TransferDocument), + onRowDoubleTap: (item) => _onEdit(item as TransferDocument), + emptyStateMessage: 'هیچ سند انتقالی یافت نشد', + loadingMessage: 'در حال بارگذاری اسناد انتقال...', + errorMessage: 'خطا در بارگذاری اسناد انتقال', + ); + } + + String _typeFa(String? t) { + switch (t) { + case 'bank': + return 'بانک'; + case 'cash_register': + return 'صندوق'; + case 'petty_cash': + return 'تنخواه'; + default: + return t ?? ''; + } + } + + String _composeRoute(TransferDocument it) { + final src = '${_typeFa(it.sourceType)} ${it.sourceName ?? ''}'.trim(); + final dst = '${_typeFa(it.destinationType)} ${it.destinationName ?? ''}'.trim(); + if (src.isEmpty && dst.isEmpty) return ''; + return '$src → $dst'; + } + + String _composeDesc(TransferDocument it) { + final r = _composeRoute(it); + if (r.isEmpty) return ''; + return 'انتقال $r'; + } + + void _onAddNew() async { + final result = await showDialog( + context: context, + builder: (context) => TransferFormDialog( + businessId: widget.businessId, + calendarController: widget.calendarController, + authStore: widget.authStore, + apiClient: widget.apiClient, + onSuccess: () {}, + ), + ); + if (result == true) _refreshData(); + } + + void _onView(TransferDocument item) async { + final svc = TransferService(widget.apiClient); + final full = await svc.getById(item.id); + if (!mounted) return; + showDialog( + context: context, + builder: (_) => TransferDetailsDialog(document: full), + ); + } + + void _onEdit(TransferDocument item) async { + final svc = TransferService(widget.apiClient); + final full = await svc.getById(item.id); + if (!mounted) return; + final result = await showDialog( + context: context, + builder: (_) => TransferFormDialog( + businessId: widget.businessId, + calendarController: widget.calendarController, + authStore: widget.authStore, + apiClient: widget.apiClient, + initial: full, + onSuccess: () {}, + ), + ); + if (result == true) _refreshData(); + } + + void _onDelete(TransferDocument item) async { + final confirm = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('حذف انتقال'), + content: Text('آیا از حذف سند ${item.code} مطمئن هستید؟'), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('انصراف')), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('حذف')), + ], + ), + ); + if (confirm == true) { + try { + final svc = TransferService(widget.apiClient); + await svc.deleteById(item.id); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('حذف شد'), backgroundColor: Colors.green)); + } + _refreshData(); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا: $e'), backgroundColor: Colors.red)); + } + } + } + } } diff --git a/hesabixUI/hesabix_ui/lib/services/expense_income_service.dart b/hesabixUI/hesabix_ui/lib/services/expense_income_service.dart new file mode 100644 index 0000000..e105534 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/services/expense_income_service.dart @@ -0,0 +1,60 @@ +import 'package:hesabix_ui/core/api_client.dart'; + +class ExpenseIncomeService { + final ApiClient api; + ExpenseIncomeService(this.api); + + Future> create({ + required int businessId, + required String documentType, // 'expense' | 'income' + required DateTime documentDate, + required int currencyId, + String? description, + List> itemLines = const [], + List> counterpartyLines = const [], + }) async { + final body = { + 'document_type': documentType, + 'document_date': documentDate.toIso8601String(), + 'currency_id': currencyId, + if (description != null && description.isNotEmpty) 'description': description, + 'item_lines': itemLines, + 'counterparty_lines': counterpartyLines, + }; + final res = await api.post>( + '/api/v1/businesses/$businessId/expense-income/create', + data: body, + ); + return res.data ?? {}; + } + + Future> list({ + required int businessId, + String? documentType, // 'expense' | 'income' + DateTime? fromDate, + DateTime? toDate, + int skip = 0, + int take = 20, + String? search, + String? sortBy, + bool sortDesc = true, + }) async { + final body = { + 'skip': skip, + 'take': take, + 'sort_desc': sortDesc, + if (sortBy != null) 'sort_by': sortBy, + if (search != null && search.isNotEmpty) 'search': search, + if (documentType != null) 'document_type': documentType, + if (fromDate != null) 'from_date': fromDate.toUtc().toIso8601String(), + if (toDate != null) 'to_date': toDate.toUtc().toIso8601String(), + }; + final res = await api.post>( + '/api/v1/businesses/$businessId/expense-income', + data: body, + ); + return res.data ?? {}; + } +} + + diff --git a/hesabixUI/hesabix_ui/lib/services/transfer_service.dart b/hesabixUI/hesabix_ui/lib/services/transfer_service.dart new file mode 100644 index 0000000..e307cb9 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/services/transfer_service.dart @@ -0,0 +1,145 @@ +import '../core/api_client.dart'; +import 'package:dio/dio.dart'; + +class TransferService { + final ApiClient _apiClient; + TransferService(this._apiClient); + + Future> create({ + required int businessId, + required DateTime documentDate, + required int currencyId, + required Map source, + required Map destination, + required double amount, + double? commission, + String? description, + Map? extraInfo, + }) async { + final body = { + 'document_date': documentDate.toIso8601String(), + 'currency_id': currencyId, + 'source': source, + 'destination': destination, + 'amount': amount, + if (commission != null) 'commission': commission, + if (description != null && description.isNotEmpty) 'description': description, + if (extraInfo != null) 'extra_info': extraInfo, + }; + final res = await _apiClient.post('/businesses/$businessId/transfers/create', data: body); + return (res.data as Map)['data'] as Map; + } + + Future> list({ + required int businessId, + int skip = 0, + int take = 20, + String? search, + DateTime? fromDate, + DateTime? toDate, + String? sortBy, + bool sortDesc = true, + }) async { + final body = { + 'skip': skip, + 'take': take, + 'sort_desc': sortDesc, + if (sortBy != null) 'sort_by': sortBy, + if (search != null && search.isNotEmpty) 'search': search, + if (fromDate != null) 'from_date': fromDate.toUtc().toIso8601String(), + if (toDate != null) 'to_date': toDate.toUtc().toIso8601String(), + }; + final res = await _apiClient.post('/businesses/$businessId/transfers', data: body); + return (res.data as Map)['data'] as Map; + } + + Future> exportExcel({ + required int businessId, + int skip = 0, + int take = 1000, + String? search, + DateTime? fromDate, + DateTime? toDate, + String? sortBy, + bool sortDesc = true, + }) async { + final body = { + 'skip': skip, + 'take': take, + 'sort_desc': sortDesc, + if (sortBy != null) 'sort_by': sortBy, + if (search != null && search.isNotEmpty) 'search': search, + if (fromDate != null) 'from_date': fromDate.toUtc().toIso8601String(), + if (toDate != null) 'to_date': toDate.toUtc().toIso8601String(), + }; + final res = await _apiClient.post>( + '/businesses/$businessId/transfers/export/excel', + data: body, + responseType: ResponseType.bytes, + ); + return res.data ?? []; + } + + Future> exportPdf({ + required int businessId, + int skip = 0, + int take = 1000, + String? search, + DateTime? fromDate, + DateTime? toDate, + String? sortBy, + bool sortDesc = true, + }) async { + final body = { + 'skip': skip, + 'take': take, + 'sort_desc': sortDesc, + if (sortBy != null) 'sort_by': sortBy, + if (search != null && search.isNotEmpty) 'search': search, + if (fromDate != null) 'from_date': fromDate.toUtc().toIso8601String(), + if (toDate != null) 'to_date': toDate.toUtc().toIso8601String(), + }; + final res = await _apiClient.post>( + '/businesses/$businessId/transfers/export/pdf', + data: body, + responseType: ResponseType.bytes, + ); + return res.data ?? []; + } + + Future> getById(int documentId) async { + final res = await _apiClient.get('/transfers/$documentId'); + return (res.data as Map)['data'] as Map; + } + + Future> update({ + required int documentId, + required DateTime documentDate, + required int currencyId, + required Map source, + required Map destination, + required double amount, + double? commission, + String? description, + Map? extraInfo, + }) async { + final body = { + 'document_date': documentDate.toIso8601String(), + 'currency_id': currencyId, + 'source': source, + 'destination': destination, + 'amount': amount, + if (commission != null) 'commission': commission, + if (description != null && description.isNotEmpty) 'description': description, + if (extraInfo != null) 'extra_info': extraInfo, + }; + final res = await _apiClient.put('/transfers/$documentId', data: body); + return (res.data as Map)['data'] as Map; + } + + Future deleteById(int documentId) async { + await _apiClient.delete('/transfers/$documentId'); + } +} + + diff --git a/hesabixUI/hesabix_ui/lib/widgets/fiscal_year_switcher.dart b/hesabixUI/hesabix_ui/lib/widgets/fiscal_year_switcher.dart index cfcf4b2..72a710a 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/fiscal_year_switcher.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/fiscal_year_switcher.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import '../core/fiscal_year_controller.dart'; -import 'package:hesabix_ui/l10n/app_localizations.dart'; class FiscalYearSwitcher extends StatelessWidget { final FiscalYearController controller; @@ -11,7 +10,6 @@ class FiscalYearSwitcher extends StatelessWidget { @override Widget build(BuildContext context) { - final t = AppLocalizations.of(context); final int? selectedId = controller.fiscalYearId ?? _currentDefaultId(); return DropdownButtonHideUnderline( diff --git a/hesabixUI/hesabix_ui/lib/widgets/invoice/bank_account_combobox_widget.dart b/hesabixUI/hesabix_ui/lib/widgets/invoice/bank_account_combobox_widget.dart index b053b83..f0d95fe 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/invoice/bank_account_combobox_widget.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/invoice/bank_account_combobox_widget.dart @@ -2,11 +2,14 @@ import 'dart:async'; import 'dart:developer'; import 'package:flutter/material.dart'; import '../../services/bank_account_service.dart'; +import '../../core/api_client.dart'; +import '../../services/currency_service.dart'; class BankAccountOption { final String id; final String name; - const BankAccountOption(this.id, this.name); + final int? currencyId; + const BankAccountOption(this.id, this.name, {this.currencyId}); } class BankAccountComboboxWidget extends StatefulWidget { @@ -16,6 +19,7 @@ class BankAccountComboboxWidget extends StatefulWidget { final String label; final String hintText; final bool isRequired; + final int? filterCurrencyId; const BankAccountComboboxWidget({ super.key, @@ -25,6 +29,7 @@ class BankAccountComboboxWidget extends StatefulWidget { this.label = 'بانک', this.hintText = 'جست‌وجو و انتخاب بانک', this.isRequired = false, + this.filterCurrencyId, }); @override @@ -38,6 +43,8 @@ class _BankAccountComboboxWidgetState extends State { int _seq = 0; String _latestQuery = ''; void Function(void Function())? _setModalState; + final CurrencyService _currencyService = CurrencyService(ApiClient()); + Map> _currencyById = >{}; List _items = []; bool _isLoading = false; @@ -47,9 +54,19 @@ class _BankAccountComboboxWidgetState extends State { @override void initState() { super.initState(); + _loadCurrencies(); _load(); } + @override + void didUpdateWidget(covariant BankAccountComboboxWidget oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.filterCurrencyId != widget.filterCurrencyId) { + // بازخوانی با فیلتر جدید ارز + _performSearch(_latestQuery); + } + } + @override void dispose() { _debounceTimer?.cancel(); @@ -61,6 +78,35 @@ class _BankAccountComboboxWidgetState extends State { await _performSearch(''); } + Future _loadCurrencies() async { + try { + final list = await _currencyService.listBusinessCurrencies(businessId: widget.businessId); + final map = >{}; + for (final m in list) { + final id = m['id']; + if (id is int) { + map[id] = m; + } + } + if (!mounted) return; + setState(() { + _currencyById = map; + }); + } catch (_) { + // ignore errors, currency labels will be omitted + } + } + + String _formatCurrencyLabel(int? currencyId) { + if (currencyId == null) return ''; + final m = _currencyById[currencyId]; + if (m == null) return ''; + final code = (m['code'] ?? '').toString(); + final title = (m['title'] ?? '').toString(); + if (code.isNotEmpty && title.isNotEmpty) return '$code'; + return code.isNotEmpty ? code : title; + } + void _onSearchChanged(String q) { _debounceTimer?.cancel(); _debounceTimer = Timer(const Duration(milliseconds: 400), () => _performSearch(q.trim())); @@ -98,13 +144,18 @@ class _BankAccountComboboxWidgetState extends State { final dynamic itemsRaw = (res['data'] != null && res['data'] is Map && (res['data'] as Map)['items'] != null) ? (res['data'] as Map)['items'] : res['items']; - final items = ((itemsRaw as List? ?? const [])).map((e) { + var items = ((itemsRaw as List? ?? const [])).map((e) { final m = Map.from(e as Map); final id = m['id']?.toString(); final name = m['name']?.toString() ?? 'نامشخص'; - log('Bank account item: id=$id, name=$name'); - return BankAccountOption(id ?? '', name); + final currencyId = (m['currency_id'] ?? m['currencyId']); + log('Bank account item: id=$id, name=$name, currencyId=$currencyId'); + return BankAccountOption(id ?? '', name, currencyId: currencyId is int ? currencyId : int.tryParse('${currencyId ?? ''}')); }).toList(); + // Filter by currency if requested + if (widget.filterCurrencyId != null) { + items = items.where((it) => it.currencyId == widget.filterCurrencyId).toList(); + } if (!mounted) return; setState(() { _items = items; @@ -149,6 +200,7 @@ class _BankAccountComboboxWidgetState extends State { isSearching: _isSearching, hasSearched: _hasSearched, onSearchChanged: _onSearchChanged, + currencyLabelBuilder: _formatCurrencyLabel, onSelected: (opt) { widget.onChanged(opt); Navigator.pop(context); @@ -166,8 +218,11 @@ class _BankAccountComboboxWidgetState extends State { (e) => e.id == widget.selectedAccountId, orElse: () => const BankAccountOption('', ''), ); + final currencyText = _formatCurrencyLabel(selected.currencyId); final text = (widget.selectedAccountId != null && widget.selectedAccountId!.isNotEmpty) - ? (selected.name.isNotEmpty ? selected.name : widget.hintText) + ? (selected.name.isNotEmpty + ? (currencyText.isNotEmpty ? '${selected.name} - $currencyText' : selected.name) + : widget.hintText) : widget.hintText; return InkWell( @@ -209,6 +264,7 @@ class _BankPickerBottomSheet extends StatelessWidget { final bool hasSearched; final ValueChanged onSearchChanged; final ValueChanged onSelected; + final String Function(int?)? currencyLabelBuilder; const _BankPickerBottomSheet({ required this.label, @@ -219,6 +275,7 @@ class _BankPickerBottomSheet extends StatelessWidget { required this.isSearching, required this.hasSearched, required this.onSearchChanged, + required this.currencyLabelBuilder, required this.onSelected, }); @@ -272,16 +329,34 @@ class _BankPickerBottomSheet extends StatelessWidget { ], ), ) - : ListView.builder( + : ListView.builder( itemCount: items.length, itemBuilder: (context, index) { final it = items[index]; + final currencyText = (it.currencyId != null) ? (currencyLabelBuilder?.call(it.currencyId) ?? '') : ''; return ListTile( leading: CircleAvatar( backgroundColor: colorScheme.primaryContainer, child: Icon(Icons.account_balance, color: colorScheme.onPrimaryContainer), ), title: Text(it.name), + trailing: currencyText.isNotEmpty + ? Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: colorScheme.primary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + currencyText, + style: TextStyle( + color: colorScheme.primary, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ) + : null, onTap: () => onSelected(it), ); }, diff --git a/hesabixUI/hesabix_ui/lib/widgets/invoice/cash_register_combobox_widget.dart b/hesabixUI/hesabix_ui/lib/widgets/invoice/cash_register_combobox_widget.dart index c24ad29..3e0ed71 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/invoice/cash_register_combobox_widget.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/invoice/cash_register_combobox_widget.dart @@ -1,11 +1,14 @@ import 'dart:async'; import 'package:flutter/material.dart'; import '../../services/cash_register_service.dart'; +import '../../core/api_client.dart'; +import '../../services/currency_service.dart'; class CashRegisterOption { final String id; final String name; - const CashRegisterOption(this.id, this.name); + final int? currencyId; + const CashRegisterOption(this.id, this.name, {this.currencyId}); } class CashRegisterComboboxWidget extends StatefulWidget { @@ -15,6 +18,7 @@ class CashRegisterComboboxWidget extends StatefulWidget { final String label; final String hintText; final bool isRequired; + final int? filterCurrencyId; const CashRegisterComboboxWidget({ super.key, @@ -24,6 +28,7 @@ class CashRegisterComboboxWidget extends StatefulWidget { this.label = 'صندوق', this.hintText = 'جست‌وجو و انتخاب صندوق', this.isRequired = false, + this.filterCurrencyId, }); @override @@ -42,13 +47,24 @@ class _CashRegisterComboboxWidgetState extends State bool _isLoading = false; bool _isSearching = false; bool _hasSearched = false; + final CurrencyService _currencyService = CurrencyService(ApiClient()); + Map> _currencyById = >{}; @override void initState() { super.initState(); + _loadCurrencies(); _load(); } + @override + void didUpdateWidget(covariant CashRegisterComboboxWidget oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.filterCurrencyId != widget.filterCurrencyId) { + _performSearch(_latestQuery); + } + } + @override void dispose() { _debounceTimer?.cancel(); @@ -60,6 +76,35 @@ class _CashRegisterComboboxWidgetState extends State await _performSearch(''); } + Future _loadCurrencies() async { + try { + final list = await _currencyService.listBusinessCurrencies(businessId: widget.businessId); + final map = >{}; + for (final m in list) { + final id = m['id']; + if (id is int) { + map[id] = m; + } + } + if (!mounted) return; + setState(() { + _currencyById = map; + }); + } catch (_) { + // ignore errors + } + } + + String _formatCurrencyLabel(int? currencyId) { + if (currencyId == null) return ''; + final m = _currencyById[currencyId]; + if (m == null) return ''; + final code = (m['code'] ?? '').toString(); + final title = (m['title'] ?? '').toString(); + if (code.isNotEmpty && title.isNotEmpty) return '$code'; + return code.isNotEmpty ? code : title; + } + void _onSearchChanged(String q) { _debounceTimer?.cancel(); _debounceTimer = Timer(const Duration(milliseconds: 400), () => _performSearch(q.trim())); @@ -95,10 +140,18 @@ class _CashRegisterComboboxWidgetState extends State final dynamic itemsRaw = (res['data'] != null && res['data'] is Map && (res['data'] as Map)['items'] != null) ? (res['data'] as Map)['items'] : res['items']; - final items = ((itemsRaw as List? ?? const [])).map((e) { + var items = ((itemsRaw as List? ?? const [])).map((e) { final m = Map.from(e as Map); - return CashRegisterOption('${m['id']}', (m['name']?.toString() ?? 'نامشخص')); + final currencyId = (m['currency_id'] ?? m['currencyId']); + return CashRegisterOption( + '${m['id']}', + (m['name']?.toString() ?? 'نامشخص'), + currencyId: currencyId is int ? currencyId : int.tryParse('${currencyId ?? ''}'), + ); }).toList(); + if (widget.filterCurrencyId != null) { + items = items.where((it) => it.currencyId == widget.filterCurrencyId).toList(); + } if (!mounted) return; setState(() { _items = items; @@ -143,6 +196,7 @@ class _CashRegisterComboboxWidgetState extends State isSearching: _isSearching, hasSearched: _hasSearched, onSearchChanged: _onSearchChanged, + currencyLabelBuilder: _formatCurrencyLabel, onSelected: (opt) { widget.onChanged(opt); Navigator.pop(context); @@ -160,8 +214,11 @@ class _CashRegisterComboboxWidgetState extends State (e) => e.id == widget.selectedRegisterId, orElse: () => const CashRegisterOption('', ''), ); + final currencyText = _formatCurrencyLabel(selected.currencyId); final text = (widget.selectedRegisterId != null && widget.selectedRegisterId!.isNotEmpty) - ? (selected.name.isNotEmpty ? selected.name : widget.hintText) + ? (selected.name.isNotEmpty + ? (currencyText.isNotEmpty ? '${selected.name} - $currencyText' : selected.name) + : widget.hintText) : widget.hintText; return InkWell( @@ -203,6 +260,7 @@ class _CashRegisterPickerBottomSheet extends StatelessWidget { final bool hasSearched; final ValueChanged onSearchChanged; final ValueChanged onSelected; + final String Function(int?)? currencyLabelBuilder; const _CashRegisterPickerBottomSheet({ required this.label, @@ -213,6 +271,7 @@ class _CashRegisterPickerBottomSheet extends StatelessWidget { required this.isSearching, required this.hasSearched, required this.onSearchChanged, + required this.currencyLabelBuilder, required this.onSelected, }); @@ -270,12 +329,30 @@ class _CashRegisterPickerBottomSheet extends StatelessWidget { itemCount: items.length, itemBuilder: (context, index) { final it = items[index]; + final currencyText = (it.currencyId != null) ? (currencyLabelBuilder?.call(it.currencyId) ?? '') : ''; return ListTile( leading: CircleAvatar( backgroundColor: colorScheme.primaryContainer, child: Icon(Icons.point_of_sale, color: colorScheme.onPrimaryContainer), ), title: Text(it.name), + trailing: currencyText.isNotEmpty + ? Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: colorScheme.primary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + currencyText, + style: TextStyle( + color: colorScheme.primary, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ) + : null, onTap: () => onSelected(it), ); }, diff --git a/hesabixUI/hesabix_ui/lib/widgets/invoice/petty_cash_combobox_widget.dart b/hesabixUI/hesabix_ui/lib/widgets/invoice/petty_cash_combobox_widget.dart index f8840ac..43b846e 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/invoice/petty_cash_combobox_widget.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/invoice/petty_cash_combobox_widget.dart @@ -1,11 +1,14 @@ import 'dart:async'; import 'package:flutter/material.dart'; import '../../services/petty_cash_service.dart'; +import '../../core/api_client.dart'; +import '../../services/currency_service.dart'; class PettyCashOption { final String id; final String name; - const PettyCashOption(this.id, this.name); + final int? currencyId; + const PettyCashOption(this.id, this.name, {this.currencyId}); } class PettyCashComboboxWidget extends StatefulWidget { @@ -15,6 +18,7 @@ class PettyCashComboboxWidget extends StatefulWidget { final String label; final String hintText; final bool isRequired; + final int? filterCurrencyId; const PettyCashComboboxWidget({ super.key, @@ -24,6 +28,7 @@ class PettyCashComboboxWidget extends StatefulWidget { this.label = 'تنخواه‌گردان', this.hintText = 'جست‌وجو و انتخاب تنخواه‌گردان', this.isRequired = false, + this.filterCurrencyId, }); @override @@ -42,13 +47,24 @@ class _PettyCashComboboxWidgetState extends State { bool _isLoading = false; bool _isSearching = false; bool _hasSearched = false; + final CurrencyService _currencyService = CurrencyService(ApiClient()); + Map> _currencyById = >{}; @override void initState() { super.initState(); + _loadCurrencies(); _load(); } + @override + void didUpdateWidget(covariant PettyCashComboboxWidget oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.filterCurrencyId != widget.filterCurrencyId) { + _performSearch(_latestQuery); + } + } + @override void dispose() { _debounceTimer?.cancel(); @@ -60,6 +76,35 @@ class _PettyCashComboboxWidgetState extends State { await _performSearch(''); } + Future _loadCurrencies() async { + try { + final list = await _currencyService.listBusinessCurrencies(businessId: widget.businessId); + final map = >{}; + for (final m in list) { + final id = m['id']; + if (id is int) { + map[id] = m; + } + } + if (!mounted) return; + setState(() { + _currencyById = map; + }); + } catch (_) { + // ignore errors + } + } + + String _formatCurrencyLabel(int? currencyId) { + if (currencyId == null) return ''; + final m = _currencyById[currencyId]; + if (m == null) return ''; + final code = (m['code'] ?? '').toString(); + final title = (m['title'] ?? '').toString(); + if (code.isNotEmpty && title.isNotEmpty) return '$code'; + return code.isNotEmpty ? code : title; + } + void _onSearchChanged(String q) { _debounceTimer?.cancel(); _debounceTimer = Timer(const Duration(milliseconds: 400), () => _performSearch(q.trim())); @@ -95,10 +140,18 @@ class _PettyCashComboboxWidgetState extends State { final dynamic itemsRaw = (res['data'] != null && res['data'] is Map && (res['data'] as Map)['items'] != null) ? (res['data'] as Map)['items'] : res['items']; - final items = ((itemsRaw as List? ?? const [])).map((e) { + var items = ((itemsRaw as List? ?? const [])).map((e) { final m = Map.from(e as Map); - return PettyCashOption('${m['id']}', (m['name']?.toString() ?? 'نامشخص')); + final currencyId = (m['currency_id'] ?? m['currencyId']); + return PettyCashOption( + '${m['id']}', + (m['name']?.toString() ?? 'نامشخص'), + currencyId: currencyId is int ? currencyId : int.tryParse('${currencyId ?? ''}'), + ); }).toList(); + if (widget.filterCurrencyId != null) { + items = items.where((it) => it.currencyId == widget.filterCurrencyId).toList(); + } if (!mounted) return; setState(() { _items = items; @@ -143,6 +196,7 @@ class _PettyCashComboboxWidgetState extends State { isSearching: _isSearching, hasSearched: _hasSearched, onSearchChanged: _onSearchChanged, + currencyLabelBuilder: _formatCurrencyLabel, onSelected: (opt) { widget.onChanged(opt); Navigator.pop(context); @@ -160,8 +214,11 @@ class _PettyCashComboboxWidgetState extends State { (e) => e.id == widget.selectedPettyCashId, orElse: () => const PettyCashOption('', ''), ); + final currencyText = _formatCurrencyLabel(selected.currencyId); final text = (widget.selectedPettyCashId != null && widget.selectedPettyCashId!.isNotEmpty) - ? (selected.name.isNotEmpty ? selected.name : widget.hintText) + ? (selected.name.isNotEmpty + ? (currencyText.isNotEmpty ? '${selected.name} - $currencyText' : selected.name) + : widget.hintText) : widget.hintText; return InkWell( @@ -203,6 +260,7 @@ class _PettyCashPickerBottomSheet extends StatelessWidget { final bool hasSearched; final ValueChanged onSearchChanged; final ValueChanged onSelected; + final String Function(int?)? currencyLabelBuilder; const _PettyCashPickerBottomSheet({ required this.label, @@ -213,6 +271,7 @@ class _PettyCashPickerBottomSheet extends StatelessWidget { required this.isSearching, required this.hasSearched, required this.onSearchChanged, + required this.currencyLabelBuilder, required this.onSelected, }); @@ -270,12 +329,30 @@ class _PettyCashPickerBottomSheet extends StatelessWidget { itemCount: items.length, itemBuilder: (context, index) { final it = items[index]; + final currencyText = (it.currencyId != null) ? (currencyLabelBuilder?.call(it.currencyId) ?? '') : ''; return ListTile( leading: CircleAvatar( backgroundColor: colorScheme.primaryContainer, child: Icon(Icons.wallet, color: colorScheme.onPrimaryContainer), ), title: Text(it.name), + trailing: currencyText.isNotEmpty + ? Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: colorScheme.primary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + currencyText, + style: TextStyle( + color: colorScheme.primary, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ) + : null, onTap: () => onSelected(it), ); }, diff --git a/hesabixUI/hesabix_ui/lib/widgets/person/person_form_dialog.dart b/hesabixUI/hesabix_ui/lib/widgets/person/person_form_dialog.dart index 5835e8b..445eeb2 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/person/person_form_dialog.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/person/person_form_dialog.dart @@ -62,6 +62,7 @@ class _PersonFormDialogState extends State { bool _commissionExcludeAdditionsDeductions = false; bool _commissionPostInInvoiceDocument = false; + // ignore: unused_field PersonType _selectedPersonType = PersonType.customer; // legacy single select (for compatibility) final Set _selectedPersonTypes = {}; bool _isActive = true; @@ -588,15 +589,6 @@ class _PersonFormDialogState extends State { ); } - Widget _buildSectionHeader(String title) { - return Text( - title, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - color: Theme.of(context).primaryColor, - ), - ); - } Widget _buildBasicInfoFields(AppLocalizations t) { return Column( diff --git a/hesabixUI/hesabix_ui/lib/widgets/transfer/transfer_details_dialog.dart b/hesabixUI/hesabix_ui/lib/widgets/transfer/transfer_details_dialog.dart new file mode 100644 index 0000000..de098e7 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/transfer/transfer_details_dialog.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; + +class TransferDetailsDialog extends StatelessWidget { + final Map document; + const TransferDetailsDialog({super.key, required this.document}); + + @override + Widget build(BuildContext context) { + final lines = List>.from(document['account_lines'] as List? ?? const []); + final code = document['code'] as String? ?? ''; + final date = document['document_date'] as String? ?? ''; + final total = (document['total_amount'] as num?)?.toStringAsFixed(0) ?? '0'; + return Dialog( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 800, maxHeight: 600), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Row( + children: [ + const Icon(Icons.swap_horiz), + const SizedBox(width: 8), + Expanded(child: Text('سند انتقال $code')), + IconButton(onPressed: () => Navigator.pop(context), icon: const Icon(Icons.close)), + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + Chip(label: Text('تاریخ: $date')), + const SizedBox(width: 8), + Chip(label: Text('مبلغ کل: $total')), + ], + ), + ), + const Divider(height: 1), + Expanded( + child: ListView.separated( + itemCount: lines.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, i) { + final l = lines[i]; + final name = (l['account_name'] as String?) ?? ''; + final code = (l['account_code'] as String?) ?? ''; + final side = (l['side'] as String?) ?? ''; + final isCommission = (l['is_commission_line'] as bool?) ?? false; + final amount = (l['amount'] as num?)?.toStringAsFixed(0) ?? ''; + return ListTile( + leading: Icon(isCommission ? Icons.receipt_long : Icons.account_balance_wallet), + title: Text(name), + subtitle: Text('کد: $code • سمت: ${isCommission ? 'کارمزد' : side}'), + trailing: Text(amount), + ); + }, + ), + ), + const Divider(height: 1), + Padding( + padding: const EdgeInsets.all(12), + child: Align( + alignment: Alignment.centerRight, + child: FilledButton( + onPressed: () => Navigator.pop(context), + child: const Text('بستن'), + ), + ), + ) + ], + ), + ), + ); + } +} diff --git a/hesabixUI/hesabix_ui/lib/widgets/transfer/transfer_form_dialog.dart b/hesabixUI/hesabix_ui/lib/widgets/transfer/transfer_form_dialog.dart index f693935..013a263 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/transfer/transfer_form_dialog.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/transfer/transfer_form_dialog.dart @@ -1,20 +1,30 @@ import 'package:flutter/material.dart'; import '../../core/calendar_controller.dart'; -import '../../services/bank_account_service.dart'; -import '../../services/cash_register_service.dart'; -import '../../services/petty_cash_service.dart'; import '../date_input_field.dart'; +import '../invoice/bank_account_combobox_widget.dart'; +import '../invoice/cash_register_combobox_widget.dart'; +import '../invoice/petty_cash_combobox_widget.dart'; +import '../../core/auth_store.dart'; +import '../../core/api_client.dart'; +import '../../services/transfer_service.dart'; +import '../../widgets/banking/currency_picker_widget.dart'; class TransferFormDialog extends StatefulWidget { final int businessId; final CalendarController calendarController; final VoidCallback? onSuccess; + final AuthStore? authStore; + final ApiClient? apiClient; + final Map? initial; // اگر موجود باشد، حالت ویرایش const TransferFormDialog({ super.key, required this.businessId, required this.calendarController, this.onSuccess, + this.authStore, + this.apiClient, + this.initial, }); @override @@ -29,29 +39,68 @@ class _TransferFormDialogState extends State { bool _isLoading = false; DateTime _transferDate = DateTime.now(); - - // سرویس‌ها - final BankAccountService _bankService = BankAccountService(); - final CashRegisterService _cashRegisterService = CashRegisterService(); - final PettyCashService _pettyCashService = PettyCashService(); + int? _currencyId; // انتخاب مبدا و مقصد String? _fromType = 'bank'; // پیش‌فرض بانک String? _toType = 'bank'; // پیش‌فرض بانک - int? _fromId; - int? _toId; - - // لیست‌های داده - List> _banks = []; - List> _cashRegisters = []; - List> _pettyCashList = []; - - bool _isDataLoaded = false; + String? _fromId; + String? _toId; @override void initState() { super.initState(); - _loadData(); + // Prefill when editing + final init = widget.initial; + if (init != null) { + try { + final docDate = init['document_date'] as String?; + if (docDate != null) { + _transferDate = DateTime.tryParse(docDate) ?? _transferDate; + } + _descriptionController.text = (init['description'] as String?) ?? ''; + final amount = (init['total_amount'] as num?)?.toDouble(); + if (amount != null && amount > 0) { + _amountController.text = amount.toStringAsFixed(0); + } + _currencyId = (init['currency_id'] as int?); + // Infer commission from lines + final lines = List>.from(init['account_lines'] as List? ?? const []); + final commissionLine = lines.firstWhere( + (l) => (l['is_commission_line'] as bool?) == true && (l['account_code'] == '70902'), + orElse: () => {}, + ); + final commissionVal = (commissionLine['amount'] as num?)?.toDouble(); + if (commissionVal != null && commissionVal > 0) { + _commissionController.text = commissionVal.toStringAsFixed(0); + } + // Detect source/destination lines + final src = lines.firstWhere( + (l) => (l['side'] as String?) == 'source' && (l['is_commission_line'] as bool?) != true, + orElse: () => {}, + ); + final dst = lines.firstWhere( + (l) => (l['side'] as String?) == 'destination' && (l['is_commission_line'] as bool?) != true, + orElse: () => {}, + ); + if (src.isNotEmpty) { + _fromType = (src['source_type'] as String?) ?? _fromType; + final bid = src['bank_account_id']; + final cid = src['cash_register_id']; + final pid = src['petty_cash_id']; + _fromId = (bid ?? cid ?? pid)?.toString(); + } + if (dst.isNotEmpty) { + _toType = (dst['destination_type'] as String?) ?? _toType; + final bid = dst['bank_account_id']; + final cid = dst['cash_register_id']; + final pid = dst['petty_cash_id']; + _toId = (bid ?? cid ?? pid)?.toString(); + } + } catch (_) {} + } + // If still not set (create), use business default currency + _currencyId ??= widget.authStore?.currentBusiness?.defaultCurrency?.id; } @override @@ -62,59 +111,6 @@ class _TransferFormDialogState extends State { super.dispose(); } - Future _loadData() async { - if (_isDataLoaded) return; - - setState(() { - _isLoading = true; - }); - - try { - // بارگذاری لیست بانک‌ها - final bankResponse = await _bankService.list( - businessId: widget.businessId, - queryInfo: {'take': 100, 'skip': 0}, - ); - _banks = (bankResponse['items'] as List?) - ?.map((item) => item as Map) - .toList() ?? []; - - // بارگذاری لیست صندوق‌ها - final cashRegisterResponse = await _cashRegisterService.list( - businessId: widget.businessId, - queryInfo: {'take': 100, 'skip': 0}, - ); - _cashRegisters = (cashRegisterResponse['items'] as List?) - ?.map((item) => item as Map) - .toList() ?? []; - - // بارگذاری لیست تنخواه گردان‌ها - final pettyCashResponse = await _pettyCashService.list( - businessId: widget.businessId, - queryInfo: {'take': 100, 'skip': 0}, - ); - _pettyCashList = (pettyCashResponse['items'] as List?) - ?.map((item) => item as Map) - .toList() ?? []; - - setState(() { - _isDataLoaded = true; - }); - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('خطا در بارگذاری داده‌ها: $e'), - backgroundColor: Colors.red, - ), - ); - } - } finally { - setState(() { - _isLoading = false; - }); - } - } Future _save() async { if (!_formKey.currentState!.validate()) return; @@ -154,20 +150,59 @@ class _TransferFormDialogState extends State { }); try { - // TODO: ایجاد سرویس انتقال و ارسال درخواست به API - // فعلاً فقط پیام موفقیت نمایش می‌دهیم - - await Future.delayed(const Duration(seconds: 1)); // شبیه‌سازی درخواست API - + final api = widget.apiClient ?? ApiClient(); + final service = TransferService(api); + final currencyId = _currencyId; + if (currencyId == null) throw Exception('ارز انتخاب نشده است'); + + final double amount = double.parse(_amountController.text.replaceAll(',', '')); + final double? commission = _commissionController.text.trim().isEmpty + ? null + : double.parse(_commissionController.text.replaceAll(',', '')); + + final src = { + 'type': _fromType, + 'id': _fromId != null ? int.tryParse(_fromId!) ?? _fromId : null, + }..removeWhere((k, v) => v == null); + + final dst = { + 'type': _toType, + 'id': _toId != null ? int.tryParse(_toId!) ?? _toId : null, + }..removeWhere((k, v) => v == null); + + final isEdit = widget.initial != null && widget.initial!['id'] != null; + if (isEdit) { + await service.update( + documentId: widget.initial!['id'] as int, + documentDate: _transferDate, + currencyId: currencyId, + source: src, + destination: dst, + amount: amount, + commission: commission, + description: _descriptionController.text.trim(), + ); + } else { + await service.create( + businessId: widget.businessId, + documentDate: _transferDate, + currencyId: currencyId, + source: src, + destination: dst, + amount: amount, + commission: commission, + description: _descriptionController.text.trim(), + ); + } + if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('انتقال با موفقیت ثبت شد'), + SnackBar( + content: Text(isEdit ? 'ویرایش با موفقیت انجام شد' : 'انتقال با موفقیت ثبت شد'), backgroundColor: Colors.green, ), ); - - Navigator.of(context).pop(); + Navigator.of(context).pop(true); widget.onSuccess?.call(); } } catch (e) { @@ -189,250 +224,382 @@ class _TransferFormDialogState extends State { Widget _buildAccountSelector({ required String label, required String? selectedType, - required int? selectedId, + required String? selectedId, required ValueChanged onTypeChanged, - required ValueChanged onIdChanged, + required ValueChanged onIdChanged, }) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: Theme.of(context).colorScheme.outline.withOpacity(0.2), + width: 1, ), - const SizedBox(height: 12), - - // انتخاب نوع حساب با SegmentedButton - Card( - elevation: 4, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + color: Theme.of(context).colorScheme.surface, + boxShadow: [ + BoxShadow( + color: Theme.of(context).colorScheme.shadow.withOpacity(0.05), + blurRadius: 8, + offset: const Offset(0, 2), ), - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.3), - Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.1), - ], - ), - ), - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - children: [ - // SegmentedButton برای انتخاب نوع - SegmentedButton( - segments: const [ - ButtonSegment( - value: 'bank', - label: Text('بانک'), - icon: Icon(Icons.account_balance, size: 16), - ), - ButtonSegment( - value: 'cash_register', - label: Text('صندوق'), - icon: Icon(Icons.point_of_sale, size: 16), - ), - ButtonSegment( - value: 'petty_cash', - label: Text('تنخواه'), - icon: Icon(Icons.money, size: 16), - ), - ], - selected: selectedType != null ? {selectedType} : {}, - onSelectionChanged: (Set selection) { - if (selection.isNotEmpty) { - onTypeChanged(selection.first); - onIdChanged(null); // ریست کردن انتخاب قبلی - } - }, - style: ButtonStyle( - backgroundColor: MaterialStateProperty.resolveWith((states) { - if (states.contains(MaterialState.selected)) { - return Theme.of(context).primaryColor; - } - return Theme.of(context).colorScheme.surface; - }), - foregroundColor: MaterialStateProperty.resolveWith((states) { - if (states.contains(MaterialState.selected)) { - return Colors.white; - } - return Theme.of(context).colorScheme.onSurface; - }), - minimumSize: MaterialStateProperty.all(const Size(0, 40)), - padding: MaterialStateProperty.all(const EdgeInsets.symmetric(horizontal: 8)), - ), - ), - ], - ), - ), - ), - ), - - const SizedBox(height: 12), - - // انتخاب حساب خاص - if (selectedType != null) - Card( - elevation: 2, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - child: Padding( - padding: const EdgeInsets.all(16), - child: DropdownButtonFormField( - value: selectedId, - decoration: InputDecoration( - labelText: _getAccountTypeLabel(selectedType), - border: OutlineInputBorder( + ], + ), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header with icon and label + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Theme.of(context).primaryColor.withOpacity(0.1), borderRadius: BorderRadius.circular(8), ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide( - color: Theme.of(context).colorScheme.outline.withOpacity(0.5), - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide( - color: Theme.of(context).primaryColor, - width: 2, - ), - ), - prefixIcon: Icon( - _getAccountTypeIcon(selectedType), + child: Icon( + Icons.account_balance_wallet, color: Theme.of(context).primaryColor, + size: 20, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + label, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ), + ], + ), + + const SizedBox(height: 20), + + // Account type selection with improved styling + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.3), + border: Border.all( + color: Theme.of(context).colorScheme.outline.withOpacity(0.1), + ), + ), + child: Padding( + padding: const EdgeInsets.all(4), + child: SegmentedButton( + segments: const [ + ButtonSegment( + value: 'bank', + label: Text('بانک'), + icon: Icon(Icons.account_balance, size: 18), + ), + ButtonSegment( + value: 'cash_register', + label: Text('صندوق'), + icon: Icon(Icons.point_of_sale, size: 18), + ), + ButtonSegment( + value: 'petty_cash', + label: Text('تنخواه'), + icon: Icon(Icons.money, size: 18), + ), + ], + selected: selectedType != null ? {selectedType} : {}, + onSelectionChanged: (Set selection) { + if (selection.isNotEmpty) { + final selectedType = selection.first; + onTypeChanged(selectedType); + onIdChanged(null); + } + }, + style: ButtonStyle( + backgroundColor: MaterialStateProperty.resolveWith((states) { + if (states.contains(MaterialState.selected)) { + return Theme.of(context).primaryColor; + } + return Colors.transparent; + }), + foregroundColor: MaterialStateProperty.resolveWith((states) { + if (states.contains(MaterialState.selected)) { + return Colors.white; + } + return Theme.of(context).colorScheme.onSurface; + }), + minimumSize: MaterialStateProperty.all(const Size(0, 44)), + padding: MaterialStateProperty.all(const EdgeInsets.symmetric(horizontal: 12)), + shape: MaterialStateProperty.all( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), ), - filled: true, - fillColor: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.3), ), - items: _getAccountItems(selectedType), - onChanged: onIdChanged, - validator: (value) { - if (value == null) return 'لطفاً حساب را انتخاب کنید'; - return null; - }, ), ), - ), - ], + + const SizedBox(height: 16), + + // Account selection combobox + if (selectedType != null) + AnimatedContainer( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + child: _buildAccountCombobox(selectedType, selectedId, onIdChanged), + ), + ], + ), + ), ); } - String _getAccountTypeLabel(String type) { - switch (type) { + Widget _buildAccountCombobox(String accountType, String? selectedId, ValueChanged onIdChanged) { + switch (accountType) { case 'bank': - return 'انتخاب بانک'; + return BankAccountComboboxWidget( + businessId: widget.businessId, + selectedAccountId: selectedId, + onChanged: (option) { + onIdChanged(option?.id); + }, + label: 'انتخاب بانک', + hintText: 'جست‌وجو و انتخاب بانک', + isRequired: true, + filterCurrencyId: _currencyId, + ); case 'cash_register': - return 'انتخاب صندوق'; + return CashRegisterComboboxWidget( + businessId: widget.businessId, + selectedRegisterId: selectedId, + onChanged: (option) { + onIdChanged(option?.id); + }, + label: 'انتخاب صندوق', + hintText: 'جست‌وجو و انتخاب صندوق', + isRequired: true, + filterCurrencyId: _currencyId, + ); case 'petty_cash': - return 'انتخاب تنخواه گردان'; + return PettyCashComboboxWidget( + businessId: widget.businessId, + selectedPettyCashId: selectedId, + onChanged: (option) { + onIdChanged(option?.id); + }, + label: 'انتخاب تنخواه گردان', + hintText: 'جست‌وجو و انتخاب تنخواه گردان', + isRequired: true, + filterCurrencyId: _currencyId, + ); default: - return 'انتخاب حساب'; - } - } - - IconData _getAccountTypeIcon(String type) { - switch (type) { - case 'bank': - return Icons.account_balance; - case 'cash_register': - return Icons.point_of_sale; - case 'petty_cash': - return Icons.money; - default: - return Icons.account_balance_wallet; + return Container(); } } - List> _getAccountItems(String type) { - List> items = []; - - switch (type) { - case 'bank': - items = _banks; - break; - case 'cash_register': - items = _cashRegisters; - break; - case 'petty_cash': - items = _pettyCashList; - break; - } - - return items.map((item) { - return DropdownMenuItem( - value: item['id'] as int, - child: Text(item['name'] as String? ?? 'نامشخص'), - ); - }).toList(); + Widget _buildInputField({ + required TextEditingController controller, + required String labelText, + required IconData icon, + String? suffixText, + String? helperText, + TextInputType? keyboardType, + int maxLines = 1, + String? Function(String?)? validator, + }) { + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: Theme.of(context).colorScheme.outline.withOpacity(0.2), + width: 1, + ), + color: Theme.of(context).colorScheme.surface, + boxShadow: [ + BoxShadow( + color: Theme.of(context).colorScheme.shadow.withOpacity(0.05), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.all(4), + child: TextFormField( + controller: controller, + keyboardType: keyboardType, + maxLines: maxLines, + validator: validator, + decoration: InputDecoration( + labelText: labelText, + labelStyle: TextStyle( + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7), + fontSize: 14, + ), + suffixText: suffixText, + helperText: helperText, + helperStyle: TextStyle( + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6), + fontSize: 12, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Theme.of(context).primaryColor, + width: 2, + ), + ), + prefixIcon: Container( + margin: const EdgeInsets.all(8), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Theme.of(context).primaryColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(6), + ), + child: Icon( + icon, + color: Theme.of(context).primaryColor, + size: 18, + ), + ), + filled: true, + fillColor: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.2), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ), + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, + fontSize: 16, + ), + ), + ), + ); + } + + Widget _buildDateField() { + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: Theme.of(context).colorScheme.outline.withOpacity(0.2), + width: 1, + ), + color: Theme.of(context).colorScheme.surface, + boxShadow: [ + BoxShadow( + color: Theme.of(context).colorScheme.shadow.withOpacity(0.05), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.all(4), + child: DateInputField( + value: _transferDate, + onChanged: (date) { + if (date != null) { + setState(() { + _transferDate = date; + }); + } + }, + labelText: 'تاریخ انتقال', + calendarController: widget.calendarController, + firstDate: DateTime(2020), + lastDate: DateTime.now().add(const Duration(days: 365)), + ), + ), + ); } @override Widget build(BuildContext context) { return Dialog( shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), + borderRadius: BorderRadius.circular(20), ), - elevation: 8, + elevation: 12, child: Container( - width: MediaQuery.of(context).size.width * 0.9, + width: MediaQuery.of(context).size.width * 0.95, constraints: BoxConstraints( - maxHeight: MediaQuery.of(context).size.height * 0.9, - maxWidth: 1000, // حداکثر عرض برای دسکتاپ + maxHeight: MediaQuery.of(context).size.height * 0.95, + maxWidth: 1200, // حداکثر عرض برای دسکتاپ ), decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), + borderRadius: BorderRadius.circular(20), gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [ Theme.of(context).colorScheme.surface, - Theme.of(context).colorScheme.surface.withOpacity(0.95), + Theme.of(context).colorScheme.surface.withOpacity(0.98), ], ), + boxShadow: [ + BoxShadow( + color: Theme.of(context).colorScheme.shadow.withOpacity(0.1), + blurRadius: 20, + offset: const Offset(0, 8), + ), + ], ), child: Column( mainAxisSize: MainAxisSize.min, children: [ // هدر دیالوگ با طراحی بهبود یافته Container( - padding: const EdgeInsets.all(24), + padding: const EdgeInsets.all(28), decoration: BoxDecoration( gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, colors: [ Theme.of(context).primaryColor, - Theme.of(context).primaryColor.withOpacity(0.8), + Theme.of(context).primaryColor.withOpacity(0.85), ], ), borderRadius: const BorderRadius.only( - topLeft: Radius.circular(16), - topRight: Radius.circular(16), + topLeft: Radius.circular(20), + topRight: Radius.circular(20), ), + boxShadow: [ + BoxShadow( + color: Theme.of(context).primaryColor.withOpacity(0.3), + blurRadius: 8, + offset: const Offset(0, 4), + ), + ], ), child: Row( children: [ Container( - padding: const EdgeInsets.all(12), + padding: const EdgeInsets.all(14), decoration: BoxDecoration( - color: Colors.white.withOpacity(0.2), - borderRadius: BorderRadius.circular(12), + color: Colors.white.withOpacity(0.25), + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: Colors.white.withOpacity(0.3), + width: 1, + ), ), child: const Icon( Icons.swap_horiz, color: Colors.white, - size: 24, + size: 26, ), ), - const SizedBox(width: 16), + const SizedBox(width: 20), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -441,13 +608,16 @@ class _TransferFormDialogState extends State { 'ثبت انتقال', style: Theme.of(context).textTheme.headlineSmall?.copyWith( color: Colors.white, - fontWeight: FontWeight.bold, + fontWeight: FontWeight.w700, + fontSize: 24, ), ), + const SizedBox(height: 4), Text( 'انتقال بین حساب‌های مختلف', style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Colors.white.withOpacity(0.9), + fontSize: 16, ), ), ], @@ -455,12 +625,13 @@ class _TransferFormDialogState extends State { ), IconButton( onPressed: () => Navigator.of(context).pop(), - icon: const Icon(Icons.close, color: Colors.white), + icon: const Icon(Icons.close, color: Colors.white, size: 24), style: IconButton.styleFrom( backgroundColor: Colors.white.withOpacity(0.2), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(12), ), + padding: const EdgeInsets.all(12), ), ), ], @@ -476,12 +647,30 @@ class _TransferFormDialogState extends State { final isDesktop = constraints.maxWidth > 800; if (isDesktop) { - // طراحی دو ستونه برای دسکتاپ + // طراحی دو ستونه برای دسکتاپ با بهبود تراز return SingleChildScrollView( child: Padding( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(24), child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + // ردیف 0: انتخاب ارز + Row( + children: [ + SizedBox( + width: 260, + child: CurrencyPickerWidget( + businessId: widget.businessId, + selectedCurrencyId: _currencyId, + onChanged: (id) => setState(() => _currencyId = id), + label: 'ارز', + hintText: 'انتخاب ارز', + ), + ), + const Spacer(), + ], + ), + const SizedBox(height: 16), // ردیف اول: انتخاب مبدا و مقصد Row( crossAxisAlignment: CrossAxisAlignment.start, @@ -504,7 +693,7 @@ class _TransferFormDialogState extends State { }, ), ), - const SizedBox(width: 24), + const SizedBox(width: 32), Expanded( child: _buildAccountSelector( label: 'به (مقصد)', @@ -526,40 +715,18 @@ class _TransferFormDialogState extends State { ], ), - const SizedBox(height: 24), + const SizedBox(height: 32), - // ردیف دوم: مبلغ و کارمزد + // ردیف دوم: مبلغ و کارمزد با طراحی بهبود یافته Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( - child: TextFormField( + child: _buildInputField( controller: _amountController, - decoration: InputDecoration( - labelText: 'مبلغ انتقال', - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide( - color: Theme.of(context).colorScheme.outline.withOpacity(0.5), - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide( - color: Theme.of(context).primaryColor, - width: 2, - ), - ), - suffixText: 'ریال', - prefixIcon: Icon( - Icons.attach_money, - color: Theme.of(context).primaryColor, - ), - filled: true, - fillColor: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.3), - ), + labelText: 'مبلغ انتقال', + icon: Icons.attach_money, + suffixText: 'ریال', keyboardType: TextInputType.number, validator: (value) { if (value == null || value.isEmpty) { @@ -575,37 +742,14 @@ class _TransferFormDialogState extends State { }, ), ), - const SizedBox(width: 24), + const SizedBox(width: 32), Expanded( - child: TextFormField( + child: _buildInputField( controller: _commissionController, - decoration: InputDecoration( - labelText: 'کارمزد', - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide( - color: Theme.of(context).colorScheme.outline.withOpacity(0.5), - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide( - color: Theme.of(context).primaryColor, - width: 2, - ), - ), - suffixText: 'ریال', - helperText: 'اختیاری', - prefixIcon: Icon( - Icons.percent, - color: Theme.of(context).primaryColor, - ), - filled: true, - fillColor: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.3), - ), + labelText: 'کارمزد', + icon: Icons.percent, + suffixText: 'ریال', + helperText: 'اختیاری', keyboardType: TextInputType.number, validator: (value) { if (value != null && value.isNotEmpty) { @@ -623,81 +767,49 @@ class _TransferFormDialogState extends State { ], ), - const SizedBox(height: 24), + const SizedBox(height: 32), - // ردیف سوم: تاریخ + // ردیف سوم: تاریخ و توضیحات Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( - child: DateInputField( - value: _transferDate, - onChanged: (date) { - if (date != null) { - setState(() { - _transferDate = date; - }); - } - }, - labelText: 'تاریخ انتقال', - calendarController: widget.calendarController, - firstDate: DateTime(2020), - lastDate: DateTime.now().add(const Duration(days: 365)), + child: _buildDateField(), + ), + const SizedBox(width: 32), + Expanded( + child: _buildInputField( + controller: _descriptionController, + labelText: 'توضیحات', + icon: Icons.description, + maxLines: 3, + // اختیاری: بدون اعتبارسنجی اجباری + validator: (value) => null, ), ), - const SizedBox(width: 24), - const Expanded(child: SizedBox()), // فضای خالی برای تراز کردن ], ), - - const SizedBox(height: 24), - - // ردیف چهارم: توضیحات (تمام عرض) - TextFormField( - controller: _descriptionController, - decoration: InputDecoration( - labelText: 'توضیحات', - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide( - color: Theme.of(context).colorScheme.outline.withOpacity(0.5), - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide( - color: Theme.of(context).primaryColor, - width: 2, - ), - ), - prefixIcon: Icon( - Icons.description, - color: Theme.of(context).primaryColor, - ), - filled: true, - fillColor: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.3), - ), - maxLines: 3, - validator: (value) { - if (value == null || value.isEmpty) { - return 'لطفاً توضیحات را وارد کنید'; - } - return null; - }, - ), ], ), ), ); } else { - // طراحی تک ستونه برای موبایل + // طراحی تک ستونه برای موبایل با بهبود تراز return SingleChildScrollView( child: Padding( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(20), child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + // ارز + CurrencyPickerWidget( + businessId: widget.businessId, + selectedCurrencyId: _currencyId, + onChanged: (id) => setState(() => _currencyId = id), + label: 'ارز', + hintText: 'انتخاب ارز', + ), + const SizedBox(height: 16), // انتخاب مبدا _buildAccountSelector( label: 'از (مبدا)', @@ -739,13 +851,11 @@ class _TransferFormDialogState extends State { const SizedBox(height: 24), // مبلغ - TextFormField( + _buildInputField( controller: _amountController, - decoration: const InputDecoration( - labelText: 'مبلغ انتقال', - border: OutlineInputBorder(), - suffixText: 'ریال', - ), + labelText: 'مبلغ انتقال', + icon: Icons.attach_money, + suffixText: 'ریال', keyboardType: TextInputType.number, validator: (value) { if (value == null || value.isEmpty) { @@ -764,14 +874,12 @@ class _TransferFormDialogState extends State { const SizedBox(height: 24), // کارمزد - TextFormField( + _buildInputField( controller: _commissionController, - decoration: const InputDecoration( - labelText: 'کارمزد', - border: OutlineInputBorder(), - suffixText: 'ریال', - helperText: 'اختیاری', - ), + labelText: 'کارمزد', + icon: Icons.percent, + suffixText: 'ریال', + helperText: 'اختیاری', keyboardType: TextInputType.number, validator: (value) { if (value != null && value.isNotEmpty) { @@ -789,58 +897,17 @@ class _TransferFormDialogState extends State { const SizedBox(height: 24), // تاریخ - DateInputField( - value: _transferDate, - onChanged: (date) { - if (date != null) { - setState(() { - _transferDate = date; - }); - } - }, - labelText: 'تاریخ انتقال', - calendarController: widget.calendarController, - firstDate: DateTime(2020), - lastDate: DateTime.now().add(const Duration(days: 365)), - ), + _buildDateField(), const SizedBox(height: 24), // توضیحات - TextFormField( + _buildInputField( controller: _descriptionController, - decoration: InputDecoration( - labelText: 'توضیحات', - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide( - color: Theme.of(context).colorScheme.outline.withOpacity(0.5), - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide( - color: Theme.of(context).primaryColor, - width: 2, - ), - ), - prefixIcon: Icon( - Icons.description, - color: Theme.of(context).primaryColor, - ), - filled: true, - fillColor: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.3), - ), + labelText: 'توضیحات', + icon: Icons.description, maxLines: 3, - validator: (value) { - if (value == null || value.isEmpty) { - return 'لطفاً توضیحات را وارد کنید'; - } - return null; - }, + validator: (value) => null, ), ], ), @@ -854,12 +921,18 @@ class _TransferFormDialogState extends State { // دکمه‌های عملیات با طراحی بهبود یافته Container( - padding: const EdgeInsets.all(24), + padding: const EdgeInsets.all(28), decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.3), + color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.2), borderRadius: const BorderRadius.only( - bottomLeft: Radius.circular(16), - bottomRight: Radius.circular(16), + bottomLeft: Radius.circular(20), + bottomRight: Radius.circular(20), + ), + border: Border( + top: BorderSide( + color: Theme.of(context).colorScheme.outline.withOpacity(0.1), + width: 1, + ), ), ), child: Row( @@ -867,36 +940,49 @@ class _TransferFormDialogState extends State { children: [ OutlinedButton.icon( onPressed: _isLoading ? null : () => Navigator.of(context).pop(), - icon: const Icon(Icons.close), + icon: const Icon(Icons.close, size: 20), label: const Text('انصراف'), style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 14), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(12), ), + side: BorderSide( + color: Theme.of(context).colorScheme.outline.withOpacity(0.5), + width: 1.5, + ), + foregroundColor: Theme.of(context).colorScheme.onSurface, ), ), - const SizedBox(width: 16), + const SizedBox(width: 20), ElevatedButton.icon( onPressed: _isLoading ? null : _save, icon: _isLoading ? const SizedBox( - width: 16, - height: 16, + width: 18, + height: 18, child: CircularProgressIndicator( - strokeWidth: 2, + strokeWidth: 2.5, color: Colors.white, ), ) - : const Icon(Icons.save), - label: Text(_isLoading ? 'در حال ثبت...' : 'ثبت انتقال'), + : const Icon(Icons.save, size: 20), + label: Text( + _isLoading ? 'در حال ثبت...' : 'ثبت انتقال', + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 16, + ), + ), style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 14), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(12), ), backgroundColor: Theme.of(context).primaryColor, foregroundColor: Colors.white, + elevation: 4, + shadowColor: Theme.of(context).primaryColor.withOpacity(0.3), ), ), ], diff --git a/hesabix_ui/lib/pages/business/check_form_page.dart b/hesabix_ui/lib/pages/business/check_form_page.dart deleted file mode 100644 index bfb7d10..0000000 --- a/hesabix_ui/lib/pages/business/check_form_page.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'package:flutter/material.dart'; -import '../../core/auth_store.dart'; - -class CheckFormPage extends StatelessWidget { - final int businessId; - final AuthStore authStore; - final int? checkId; // null => new, not null => edit - - const CheckFormPage({ - super.key, - required this.businessId, - required this.authStore, - this.checkId, - }); - - @override - Widget build(BuildContext context) { - return const Scaffold( - body: SizedBox.expand(), - ); - } -} - -