progress in bank accounts

This commit is contained in:
Hesabix 2025-10-03 02:25:35 +03:30
parent c1f3b8287c
commit dd3a17fbd8
20 changed files with 2549 additions and 6 deletions

View file

@ -0,0 +1,514 @@
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, Request, Body, HTTPException
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.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 adapters.api.v1.schema_models.bank_account import (
BankAccountCreateRequest,
BankAccountUpdateRequest,
)
from app.services.bank_account_service import (
create_bank_account,
update_bank_account,
delete_bank_account,
get_bank_account_by_id,
list_bank_accounts,
bulk_delete_bank_accounts,
)
router = APIRouter(prefix="/bank-accounts", tags=["bank-accounts"])
@router.post(
"/businesses/{business_id}/bank-accounts",
summary="لیست حساب‌های بانکی کسب‌وکار",
description="دریافت لیست حساب‌های بانکی یک کسب و کار با امکان جستجو و فیلتر",
)
@require_business_access("business_id")
async def list_bank_accounts_endpoint(
request: Request,
business_id: int,
query_info: QueryInfo,
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,
"search_fields": query_info.search_fields,
"filters": query_info.filters,
}
result = list_bank_accounts(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="BANK_ACCOUNTS_LIST_FETCHED")
@router.post(
"/businesses/{business_id}/bank-accounts/create",
summary="ایجاد حساب بانکی جدید",
description="ایجاد حساب بانکی برای یک کسب‌وکار مشخص",
)
@require_business_access("business_id")
async def create_bank_account_endpoint(
request: Request,
business_id: int,
body: BankAccountCreateRequest = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_management_dep),
):
payload: Dict[str, Any] = body.model_dump(exclude_unset=True)
created = create_bank_account(db, business_id, payload)
return success_response(data=format_datetime_fields(created, request), request=request, message="BANK_ACCOUNT_CREATED")
@router.get(
"/bank-accounts/{account_id}",
summary="جزئیات حساب بانکی",
description="دریافت جزئیات حساب بانکی بر اساس شناسه",
)
async def get_bank_account_endpoint(
request: Request,
account_id: int,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_management_dep),
):
result = get_bank_account_by_id(db, account_id)
if not result:
raise ApiError("BANK_ACCOUNT_NOT_FOUND", "Bank account not found", http_status=404)
# بررسی دسترسی به کسب وکار مرتبط
try:
biz_id = int(result.get("business_id"))
except Exception:
biz_id = None
if biz_id is not None and not ctx.can_access_business(biz_id):
raise ApiError("FORBIDDEN", "Access denied", http_status=403)
return success_response(data=format_datetime_fields(result, request), request=request, message="BANK_ACCOUNT_DETAILS")
@router.put(
"/bank-accounts/{account_id}",
summary="ویرایش حساب بانکی",
description="ویرایش اطلاعات حساب بانکی",
)
async def update_bank_account_endpoint(
request: Request,
account_id: int,
body: BankAccountUpdateRequest = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_management_dep),
):
payload: Dict[str, Any] = body.model_dump(exclude_unset=True)
result = update_bank_account(db, account_id, payload)
if result is None:
raise ApiError("BANK_ACCOUNT_NOT_FOUND", "Bank account not found", http_status=404)
# بررسی دسترسی به کسب وکار مرتبط
try:
biz_id = int(result.get("business_id"))
except Exception:
biz_id = None
if biz_id is not None and not ctx.can_access_business(biz_id):
raise ApiError("FORBIDDEN", "Access denied", http_status=403)
return success_response(data=format_datetime_fields(result, request), request=request, message="BANK_ACCOUNT_UPDATED")
@router.delete(
"/bank-accounts/{account_id}",
summary="حذف حساب بانکی",
description="حذف یک حساب بانکی",
)
async def delete_bank_account_endpoint(
request: Request,
account_id: int,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_management_dep),
):
# ابتدا بررسی دسترسی بر اساس business مربوط به حساب
result = get_bank_account_by_id(db, account_id)
if result:
try:
biz_id = int(result.get("business_id"))
except Exception:
biz_id = None
if biz_id is not None and not ctx.can_access_business(biz_id):
raise ApiError("FORBIDDEN", "Access denied", http_status=403)
ok = delete_bank_account(db, account_id)
if not ok:
raise ApiError("BANK_ACCOUNT_NOT_FOUND", "Bank account not found", http_status=404)
return success_response(data=None, request=request, message="BANK_ACCOUNT_DELETED")
@router.post(
"/businesses/{business_id}/bank-accounts/bulk-delete",
summary="حذف گروهی حساب‌های بانکی",
description="حذف چندین حساب بانکی بر اساس شناسه‌ها",
)
@require_business_access("business_id")
async def bulk_delete_bank_accounts_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),
):
ids = body.get("ids")
if not isinstance(ids, list):
ids = []
try:
ids = [int(x) for x in ids if isinstance(x, (int, str)) and str(x).isdigit()]
except Exception:
ids = []
if not ids:
return success_response({"deleted": 0, "skipped": 0, "total_requested": 0}, request, message="NO_VALID_IDS_FOR_DELETE")
# فراخوانی تابع حذف گروهی از سرویس
result = bulk_delete_bank_accounts(db, business_id, ids)
return success_response(result, request, message="BANK_ACCOUNTS_BULK_DELETE_DONE")
@router.post(
"/businesses/{business_id}/bank-accounts/export/excel",
summary="خروجی Excel لیست حساب‌های بانکی",
description="خروجی Excel لیست حساب‌های بانکی با قابلیت فیلتر، انتخاب سطرها و رعایت ترتیب/نمایش ستون‌ها",
)
@require_business_access("business_id")
async def export_bank_accounts_excel(
business_id: int,
request: Request,
body: Dict[str, Any] = Body(...),
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
):
import io
from fastapi.responses import Response
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
from app.core.i18n import negotiate_locale
# دریافت داده‌ها از سرویس
query_dict = {
"take": int(body.get("take", 1000)), # برای export همه داده‌ها
"skip": int(body.get("skip", 0)),
"sort_by": body.get("sort_by"),
"sort_desc": bool(body.get("sort_desc", False)),
"search": body.get("search"),
"search_fields": body.get("search_fields"),
"filters": body.get("filters"),
}
result = list_bank_accounts(db, business_id, query_dict)
items: List[Dict[str, Any]] = result.get("items", [])
items = [format_datetime_fields(item, request) for item in items]
headers: List[str] = [
"code", "name", "branch", "account_number", "sheba_number", "card_number", "owner_name", "pos_number", "is_active", "is_default"
]
wb = Workbook()
ws = wb.active
ws.title = "BankAccounts"
# RTL/LTR
locale = negotiate_locale(request.headers.get("Accept-Language"))
if locale == 'fa':
try:
ws.sheet_view.rightToLeft = True
except Exception:
pass
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'))
# Header
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
# Rows
for row_idx, item in enumerate(items, 2):
for col_idx, key in enumerate(headers, 1):
value = item.get(key, "")
if isinstance(value, list):
value = ", ".join(str(v) for v in value)
cell = ws.cell(row=row_idx, column=col_idx, value=value)
cell.border = border
if locale == 'fa':
cell.alignment = Alignment(horizontal="right")
# 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)
content = buffer.getvalue()
return Response(
content=content,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={
"Content-Disposition": "attachment; filename=bank_accounts.xlsx",
"Content-Length": str(len(content)),
"Access-Control-Expose-Headers": "Content-Disposition",
},
)
@router.post(
"/businesses/{business_id}/bank-accounts/export/pdf",
summary="خروجی PDF لیست حساب‌های بانکی",
description="خروجی PDF لیست حساب‌های بانکی با قابلیت فیلتر، انتخاب سطرها و رعایت ترتیب/نمایش ستون‌ها",
)
@require_business_access("business_id")
async def export_bank_accounts_pdf(
business_id: int,
request: Request,
body: Dict[str, Any] = Body(...),
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
):
from fastapi.responses import Response
from weasyprint import HTML, CSS
from weasyprint.text.fonts import FontConfiguration
from app.core.i18n import negotiate_locale
# Build query dict similar to persons export
query_dict = {
"take": int(body.get("take", 1000)), # برای export همه داده‌ها
"skip": int(body.get("skip", 0)),
"sort_by": body.get("sort_by"),
"sort_desc": bool(body.get("sort_desc", False)),
"search": body.get("search"),
"search_fields": body.get("search_fields"),
"filters": body.get("filters"),
}
# دریافت داده‌ها از سرویس
result = list_bank_accounts(db, business_id, query_dict)
items: List[Dict[str, Any]] = result.get("items", [])
items = [format_datetime_fields(item, request) for item in items]
# Selection handling
selected_only = bool(body.get('selected_only', False))
selected_indices = body.get('selected_indices')
if selected_only and selected_indices is not None:
indices = None
if isinstance(selected_indices, str):
import json
try:
indices = json.loads(selected_indices)
except (json.JSONDecodeError, TypeError):
indices = None
elif isinstance(selected_indices, list):
indices = selected_indices
if isinstance(indices, list):
items = [items[i] for i in indices if isinstance(i, int) and 0 <= i < len(items)]
# Prepare headers/keys from export_columns (order + visibility)
headers: List[str] = []
keys: List[str] = []
export_columns = body.get('export_columns')
if export_columns:
for col in export_columns:
key = col.get('key')
label = col.get('label', key)
if key:
keys.append(str(key))
headers.append(str(label))
else:
if items:
keys = list(items[0].keys())
headers = keys
else:
keys = [
"code", "name", "branch", "account_number", "sheba_number",
"card_number", "owner_name", "pos_number", "is_active", "is_default",
]
headers = keys
# Load business info
business_name = ""
try:
from adapters.db.models.business import Business
biz = db.query(Business).filter(Business.id == business_id).first()
if biz is not None:
business_name = biz.name or ""
except Exception:
business_name = ""
# Locale and calendar-aware date
locale = negotiate_locale(request.headers.get("Accept-Language"))
is_fa = (locale == 'fa')
html_lang = 'fa' if is_fa else 'en'
html_dir = 'rtl' if is_fa else 'ltr'
try:
from app.core.calendar import CalendarConverter
calendar_header = request.headers.get("X-Calendar-Type", "jalali").lower()
formatted_now = CalendarConverter.format_datetime(
__import__("datetime").datetime.now(),
"jalali" if calendar_header in ["jalali", "persian", "shamsi"] else "gregorian",
)
now_str = formatted_now.get('formatted', formatted_now.get('date_time', ''))
except Exception:
from datetime import datetime
now_str = datetime.now().strftime('%Y/%m/%d %H:%M')
# Labels
title_text = "گزارش لیست حساب‌های بانکی" if is_fa else "Bank Accounts List Report"
label_biz = "نام کسب‌وکار" if is_fa else "Business Name"
label_date = "تاریخ گزارش" if is_fa else "Report Date"
footer_text = "تولید شده توسط Hesabix" if is_fa else "Generated by Hesabix"
page_label_left = "صفحه " if is_fa else "Page "
page_label_of = " از " if is_fa else " of "
def escape_val(v: Any) -> str:
try:
return str(v).replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
except Exception:
return str(v)
rows_html: List[str] = []
for item in items:
tds = []
for key in keys:
value = item.get(key)
if value is None:
value = ""
elif isinstance(value, list):
value = ", ".join(str(v) for v in value)
tds.append(f"<td>{escape_val(value)}</td>")
rows_html.append(f"<tr>{''.join(tds)}</tr>")
headers_html = ''.join(f"<th>{escape_val(h)}</th>" for h in headers)
table_html = f"""
<html lang=\"{html_lang}\" dir=\"{html_dir}\">
<head>
<meta charset='utf-8'>
<style>
@page {{
size: A4 landscape;
margin: 12mm;
@bottom-{ 'left' if is_fa else 'right' } {{
content: "{page_label_left}" counter(page) "{page_label_of}" counter(pages);
font-size: 10px;
color: #666;
}}
}}
body {{
font-family: sans-serif;
font-size: 11px;
color: #222;
}}
.header {{
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
border-bottom: 2px solid #444;
padding-bottom: 6px;
}}
.title {{
font-size: 16px;
font-weight: 700;
}}
.meta {{
font-size: 11px;
color: #555;
}}
.table-wrapper {{
width: 100%;
}}
table.report-table {{
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}}
thead th {{
background: #f0f3f7;
border: 1px solid #c7cdd6;
padding: 6px 4px;
text-align: center;
font-weight: 700;
white-space: nowrap;
}}
tbody td {{
border: 1px solid #d7dde6;
padding: 5px 4px;
vertical-align: top;
overflow-wrap: anywhere;
word-break: break-word;
white-space: normal;
}}
.footer {{
position: running(footer);
font-size: 10px;
color: #666;
margin-top: 8px;
text-align: {'left' if is_fa else 'right'};
}}
</style>
</head>
<body>
<div class=\"header\">
<div>
<div class=\"title\">{title_text}</div>
<div class=\"meta\">{label_biz}: {escape_val(business_name)}</div>
</div>
<div class=\"meta\">{label_date}: {escape_val(now_str)}</div>
</div>
<div class=\"table-wrapper\">
<table class=\"report-table\">
<thead>
<tr>{headers_html}</tr>
</thead>
<tbody>
{''.join(rows_html)}
</tbody>
</table>
</div>
<div class=\"footer\">{footer_text}</div>
</body>
</html>
"""
font_config = FontConfiguration()
pdf_bytes = HTML(string=table_html).write_pdf(font_config=font_config)
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={
"Content-Disposition": "attachment; filename=bank_accounts.pdf",
"Content-Length": str(len(pdf_bytes)),
"Access-Control-Expose-Headers": "Content-Disposition",
},
)

View file

@ -23,6 +23,84 @@ from adapters.db.models.business import Business
router = APIRouter(prefix="/persons", tags=["persons"])
@router.post("/businesses/{business_id}/persons/bulk-delete",
summary="حذف گروهی اشخاص",
description="حذف چندین شخص بر اساس شناسه‌ها یا کدها",
)
async def bulk_delete_persons_endpoint(
request: Request,
business_id: int,
body: Dict[str, Any] = Body(...),
db: Session = Depends(get_db),
auth_context: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_management_dep),
):
"""حذف گروهی اشخاص برای یک کسب‌وکار مشخص
ورودی:
- ids: لیست شناسههای اشخاص
- codes: لیست کدهای اشخاص در همان کسبوکار
"""
from sqlalchemy import and_ as _and
from adapters.db.models.person import Person
ids = body.get("ids")
codes = body.get("codes")
deleted = 0
skipped = 0
if not ids and not codes:
return success_response({"deleted": 0, "skipped": 0}, request)
# Normalize inputs
if isinstance(ids, list):
try:
ids = [int(x) for x in ids if isinstance(x, (int, str)) and str(x).isdigit()]
except Exception:
ids = []
else:
ids = []
if isinstance(codes, list):
try:
codes = [int(str(x).strip()) for x in codes if str(x).strip().isdigit()]
except Exception:
codes = []
else:
codes = []
# Delete by IDs first
if ids:
for pid in ids:
try:
person = db.query(Person).filter(_and(Person.id == pid, Person.business_id == business_id)).first()
if person is None:
skipped += 1
continue
db.delete(person)
deleted += 1
except Exception:
skipped += 1
db.commit()
# Delete by codes
if codes:
try:
items = db.query(Person).filter(_and(Person.business_id == business_id, Person.code.in_(codes))).all()
for obj in items:
try:
db.delete(obj)
deleted += 1
except Exception:
skipped += 1
db.commit()
except Exception:
# In case of query issues, treat all as skipped
skipped += len(codes)
return success_response({"deleted": deleted, "skipped": skipped}, request)
@router.post("/businesses/{business_id}/persons/create",
summary="ایجاد شخص جدید",
description="ایجاد شخص جدید برای کسب و کار مشخص",

View file

@ -0,0 +1,93 @@
from __future__ import annotations
from typing import Optional
from pydantic import BaseModel, Field
class BankAccountCreateRequest(BaseModel):
code: Optional[str] = Field(default=None, max_length=50)
name: str = Field(..., min_length=1, max_length=255)
branch: Optional[str] = Field(default=None, max_length=255)
account_number: Optional[str] = Field(default=None, max_length=50)
sheba_number: Optional[str] = Field(default=None, max_length=30)
card_number: Optional[str] = Field(default=None, max_length=20)
owner_name: Optional[str] = Field(default=None, max_length=255)
pos_number: Optional[str] = Field(default=None, max_length=50)
payment_id: Optional[str] = Field(default=None, max_length=100)
description: Optional[str] = Field(default=None, max_length=500)
currency_id: int = Field(..., ge=1)
is_active: bool = Field(default=True)
is_default: bool = Field(default=False)
@classmethod
def __get_validators__(cls):
yield from super().__get_validators__()
@classmethod
def validate(cls, value): # type: ignore[override]
obj = super().validate(value)
if getattr(obj, 'code', None) is not None:
code_val = str(getattr(obj, 'code'))
if code_val.strip() != '':
if not code_val.isdigit():
raise ValueError("کد حساب باید فقط عددی باشد")
if len(code_val) < 3:
raise ValueError("کد حساب باید حداقل ۳ رقم باشد")
return obj
class BankAccountUpdateRequest(BaseModel):
code: Optional[str] = Field(default=None, max_length=50)
name: Optional[str] = Field(default=None, min_length=1, max_length=255)
branch: Optional[str] = Field(default=None, max_length=255)
account_number: Optional[str] = Field(default=None, max_length=50)
sheba_number: Optional[str] = Field(default=None, max_length=30)
card_number: Optional[str] = Field(default=None, max_length=20)
owner_name: Optional[str] = Field(default=None, max_length=255)
pos_number: Optional[str] = Field(default=None, max_length=50)
payment_id: Optional[str] = Field(default=None, max_length=100)
description: Optional[str] = Field(default=None, max_length=500)
currency_id: Optional[int] = Field(default=None, ge=1)
is_active: Optional[bool] = Field(default=None)
is_default: Optional[bool] = Field(default=None)
@classmethod
def __get_validators__(cls):
yield from super().__get_validators__()
@classmethod
def validate(cls, value): # type: ignore[override]
obj = super().validate(value)
if getattr(obj, 'code', None) is not None:
code_val = str(getattr(obj, 'code'))
if code_val.strip() != '':
if not code_val.isdigit():
raise ValueError("کد حساب باید فقط عددی باشد")
if len(code_val) < 3:
raise ValueError("کد حساب باید حداقل ۳ رقم باشد")
return obj
class BankAccountResponse(BaseModel):
id: int
business_id: int
code: Optional[str]
name: str
branch: Optional[str]
account_number: Optional[str]
sheba_number: Optional[str]
card_number: Optional[str]
owner_name: Optional[str]
pos_number: Optional[str]
payment_id: Optional[str]
description: Optional[str]
currency_id: int
is_active: bool
is_default: bool
created_at: str
updated_at: str
class Config:
from_attributes = True

View file

@ -36,3 +36,4 @@ from .product import Product # noqa: F401
from .price_list import PriceList, PriceItem # noqa: F401
from .product_attribute_link import ProductAttributeLink # noqa: F401
from .tax_unit import TaxUnit # noqa: F401
from .bank_account import BankAccount # noqa: F401

View file

@ -0,0 +1,47 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import String, Integer, DateTime, ForeignKey, UniqueConstraint, Boolean
from sqlalchemy.orm import Mapped, mapped_column, relationship
from adapters.db.session import Base
class BankAccount(Base):
__tablename__ = "bank_accounts"
__table_args__ = (
UniqueConstraint('business_id', 'code', name='uq_bank_accounts_business_code'),
)
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
business_id: Mapped[int] = mapped_column(Integer, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False, index=True)
# اطلاعات اصلی/نمایشی
code: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True, comment="کد یکتا در هر کسب‌وکار (اختیاری)")
name: Mapped[str] = mapped_column(String(255), nullable=False, index=True, comment="نام حساب")
description: Mapped[str | None] = mapped_column(String(500), nullable=True)
# اطلاعات بانکی
branch: Mapped[str | None] = mapped_column(String(255), nullable=True)
account_number: Mapped[str | None] = mapped_column(String(50), nullable=True)
sheba_number: Mapped[str | None] = mapped_column(String(30), nullable=True)
card_number: Mapped[str | None] = mapped_column(String(20), nullable=True)
owner_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
pos_number: Mapped[str | None] = mapped_column(String(50), nullable=True)
payment_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
# تنظیمات
currency_id: Mapped[int] = mapped_column(Integer, ForeignKey("currencies.id", ondelete="RESTRICT"), nullable=False, index=True)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="1")
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="0")
# زمان‌بندی
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# روابط
business = relationship("Business", backref="bank_accounts")
currency = relationship("Currency", backref="bank_accounts")

View file

@ -16,6 +16,7 @@ from adapters.api.v1.product_attributes import router as product_attributes_rout
from adapters.api.v1.products import router as products_router
from adapters.api.v1.price_lists import router as price_lists_router
from adapters.api.v1.persons import router as persons_router
from adapters.api.v1.bank_accounts import router as bank_accounts_router
from adapters.api.v1.tax_units import router as tax_units_router
from adapters.api.v1.tax_units import alias_router as units_alias_router
from adapters.api.v1.tax_types import router as tax_types_router
@ -292,6 +293,7 @@ def create_app() -> FastAPI:
application.include_router(products_router, prefix=settings.api_v1_prefix)
application.include_router(price_lists_router, prefix=settings.api_v1_prefix)
application.include_router(persons_router, prefix=settings.api_v1_prefix)
application.include_router(bank_accounts_router, prefix=settings.api_v1_prefix)
application.include_router(tax_units_router, prefix=settings.api_v1_prefix)
application.include_router(units_alias_router, prefix=settings.api_v1_prefix)
application.include_router(tax_types_router, prefix=settings.api_v1_prefix)
@ -334,8 +336,9 @@ def create_app() -> FastAPI:
# اضافه کردن security schemes
openapi_schema["components"]["securitySchemes"] = {
"ApiKeyAuth": {
"type": "http",
"scheme": "ApiKey",
"type": "apiKey",
"in": "header",
"name": "Authorization",
"description": "کلید API برای احراز هویت. فرمت: ApiKey sk_your_api_key_here"
}
}
@ -344,8 +347,8 @@ def create_app() -> FastAPI:
for path, methods in openapi_schema["paths"].items():
for method, details in methods.items():
if method in ["get", "post", "put", "delete", "patch"]:
# تمام endpoint های auth، users و support نیاز به احراز هویت دارند
if "/auth/" in path or "/users" in path or "/support" in path:
# تمام endpoint های auth، users، support و bank-accounts نیاز به احراز هویت دارند
if "/auth/" in path or "/users" in path or "/support" in path or "/bank-accounts" in path:
details["security"] = [{"ApiKeyAuth": []}]
application.openapi_schema = openapi_schema

View file

@ -0,0 +1,255 @@
from __future__ import annotations
from typing import Any, Dict, List, Optional
from sqlalchemy.orm import Session
from sqlalchemy import and_, func
from adapters.db.models.bank_account import BankAccount
from app.core.responses import ApiError
def create_bank_account(
db: Session,
business_id: int,
data: Dict[str, Any],
) -> Dict[str, Any]:
# مدیریت کد یکتا در هر کسب‌وکار (در صورت ارسال)
code = data.get("code")
if code is not None and str(code).strip() != "":
# اعتبارسنجی عددی بودن کد
if not str(code).isdigit():
raise ApiError("INVALID_BANK_ACCOUNT_CODE", "Bank account code must be numeric", http_status=400)
# اعتبارسنجی حداقل طول کد
if len(str(code)) < 3:
raise ApiError("INVALID_BANK_ACCOUNT_CODE", "Bank account code must be at least 3 digits", http_status=400)
exists = db.query(BankAccount).filter(and_(BankAccount.business_id == business_id, BankAccount.code == str(code))).first()
if exists:
raise ApiError("DUPLICATE_BANK_ACCOUNT_CODE", "Duplicate bank account code", http_status=400)
else:
# تولید خودکار کد: max + 1 به صورت رشته (حداقل ۳ رقم)
max_code = db.query(func.max(BankAccount.code)).filter(BankAccount.business_id == business_id).scalar()
try:
if max_code is not None and str(max_code).isdigit():
next_code_int = int(max_code) + 1
else:
next_code_int = 100 # شروع از ۱۰۰ برای حداقل ۳ رقم
# اگر کد کمتر از ۳ رقم است، آن را به ۳ رقم تبدیل کن
if next_code_int < 100:
next_code_int = 100
code = str(next_code_int)
except Exception:
code = "100" # در صورت خطا، حداقل کد ۳ رقمی
obj = BankAccount(
business_id=business_id,
code=code,
name=data.get("name"),
branch=data.get("branch"),
account_number=data.get("account_number"),
sheba_number=data.get("sheba_number"),
card_number=data.get("card_number"),
owner_name=data.get("owner_name"),
pos_number=data.get("pos_number"),
payment_id=data.get("payment_id"),
description=data.get("description"),
currency_id=int(data.get("currency_id")),
is_active=bool(data.get("is_active", True)),
is_default=bool(data.get("is_default", False)),
)
# اگر پیش فرض شد، بقیه را غیر پیش فرض کن
if obj.is_default:
db.query(BankAccount).filter(BankAccount.business_id == business_id, BankAccount.id != obj.id).update({BankAccount.is_default: False})
db.add(obj)
db.commit()
db.refresh(obj)
return bank_account_to_dict(obj)
def get_bank_account_by_id(db: Session, account_id: int) -> Optional[Dict[str, Any]]:
obj = db.query(BankAccount).filter(BankAccount.id == account_id).first()
return bank_account_to_dict(obj) if obj else None
def update_bank_account(db: Session, account_id: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
obj = db.query(BankAccount).filter(BankAccount.id == account_id).first()
if obj is None:
return None
if "code" in data and data["code"] is not None and str(data["code"]).strip() != "":
if not str(data["code"]).isdigit():
raise ApiError("INVALID_BANK_ACCOUNT_CODE", "Bank account code must be numeric", http_status=400)
if len(str(data["code"])) < 3:
raise ApiError("INVALID_BANK_ACCOUNT_CODE", "Bank account code must be at least 3 digits", http_status=400)
exists = db.query(BankAccount).filter(and_(BankAccount.business_id == obj.business_id, BankAccount.code == str(data["code"]), BankAccount.id != obj.id)).first()
if exists:
raise ApiError("DUPLICATE_BANK_ACCOUNT_CODE", "Duplicate bank account code", http_status=400)
obj.code = str(data["code"])
for field in [
"name","branch","account_number","sheba_number","card_number",
"owner_name","pos_number","payment_id","description",
]:
if field in data:
setattr(obj, field, data.get(field))
if "currency_id" in data and data["currency_id"] is not None:
obj.currency_id = int(data["currency_id"]) # TODO: اعتبارسنجی وجود ارز
if "is_active" in data and data["is_active"] is not None:
obj.is_active = bool(data["is_active"])
if "is_default" in data and data["is_default"] is not None:
obj.is_default = bool(data["is_default"])
if obj.is_default:
# تنها یک حساب پیش‌فرض در هر بیزنس
db.query(BankAccount).filter(BankAccount.business_id == obj.business_id, BankAccount.id != obj.id).update({BankAccount.is_default: False})
db.commit()
db.refresh(obj)
return bank_account_to_dict(obj)
def delete_bank_account(db: Session, account_id: int) -> bool:
obj = db.query(BankAccount).filter(BankAccount.id == account_id).first()
if obj is None:
return False
db.delete(obj)
db.commit()
return True
def list_bank_accounts(
db: Session,
business_id: int,
query: Dict[str, Any],
) -> Dict[str, Any]:
q = db.query(BankAccount).filter(BankAccount.business_id == business_id)
# جستجو
if query.get("search") and query.get("search_fields"):
term = f"%{query['search']}%"
from sqlalchemy import or_
conditions = []
for f in query["search_fields"]:
if f == "code":
conditions.append(BankAccount.code.ilike(term))
elif f == "name":
conditions.append(BankAccount.name.ilike(term))
elif f == "branch":
conditions.append(BankAccount.branch.ilike(term))
elif f == "account_number":
conditions.append(BankAccount.account_number.ilike(term))
elif f == "sheba_number":
conditions.append(BankAccount.sheba_number.ilike(term))
elif f == "card_number":
conditions.append(BankAccount.card_number.ilike(term))
elif f == "owner_name":
conditions.append(BankAccount.owner_name.ilike(term))
elif f == "pos_number":
conditions.append(BankAccount.pos_number.ilike(term))
elif f == "payment_id":
conditions.append(BankAccount.payment_id.ilike(term))
if conditions:
q = q.filter(or_(*conditions))
# فیلترها
if query.get("filters"):
for flt in query["filters"]:
prop = getattr(flt, 'property', None) if not isinstance(flt, dict) else flt.get('property')
op = getattr(flt, 'operator', None) if not isinstance(flt, dict) else flt.get('operator')
val = getattr(flt, 'value', None) if not isinstance(flt, dict) else flt.get('value')
if not prop or not op:
continue
if prop in {"is_active", "is_default"} and op == "=":
q = q.filter(getattr(BankAccount, prop) == val)
elif prop == "currency_id" and op == "=":
q = q.filter(BankAccount.currency_id == val)
# مرتب سازی
sort_by = query.get("sort_by") or "created_at"
sort_desc = bool(query.get("sort_desc", True))
col = getattr(BankAccount, sort_by, BankAccount.created_at)
q = q.order_by(col.desc() if sort_desc else col.asc())
# صفحه‌بندی
skip = int(query.get("skip", 0))
take = int(query.get("take", 20))
total = q.count()
items = q.offset(skip).limit(take).all()
return {
"items": [bank_account_to_dict(i) for i 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 bulk_delete_bank_accounts(db: Session, business_id: int, account_ids: List[int]) -> Dict[str, Any]:
"""
حذف گروهی حسابهای بانکی
"""
if not account_ids:
return {"deleted": 0, "skipped": 0}
# بررسی وجود حساب‌ها و دسترسی به کسب‌وکار
accounts = db.query(BankAccount).filter(
BankAccount.id.in_(account_ids),
BankAccount.business_id == business_id
).all()
deleted_count = 0
skipped_count = 0
for account in accounts:
try:
db.delete(account)
deleted_count += 1
except Exception:
skipped_count += 1
# commit تغییرات
try:
db.commit()
except Exception:
db.rollback()
raise ApiError("BULK_DELETE_FAILED", "Bulk delete failed for bank accounts", http_status=500)
return {
"deleted": deleted_count,
"skipped": skipped_count,
"total_requested": len(account_ids)
}
def bank_account_to_dict(obj: BankAccount) -> Dict[str, Any]:
return {
"id": obj.id,
"business_id": obj.business_id,
"code": obj.code,
"name": obj.name,
"branch": obj.branch,
"account_number": obj.account_number,
"sheba_number": obj.sheba_number,
"card_number": obj.card_number,
"owner_name": obj.owner_name,
"pos_number": obj.pos_number,
"payment_id": obj.payment_id,
"description": obj.description,
"currency_id": obj.currency_id,
"is_active": bool(obj.is_active),
"is_default": bool(obj.is_default),
"created_at": obj.created_at.isoformat(),
"updated_at": obj.updated_at.isoformat(),
}

View file

@ -5,6 +5,7 @@ adapters/api/__init__.py
adapters/api/v1/__init__.py
adapters/api/v1/accounts.py
adapters/api/v1/auth.py
adapters/api/v1/bank_accounts.py
adapters/api/v1/business_dashboard.py
adapters/api/v1/business_users.py
adapters/api/v1/businesses.py
@ -23,6 +24,7 @@ adapters/api/v1/admin/email_config.py
adapters/api/v1/admin/file_storage.py
adapters/api/v1/schema_models/__init__.py
adapters/api/v1/schema_models/account.py
adapters/api/v1/schema_models/bank_account.py
adapters/api/v1/schema_models/email.py
adapters/api/v1/schema_models/file_storage.py
adapters/api/v1/schema_models/person.py
@ -41,6 +43,7 @@ adapters/db/session.py
adapters/db/models/__init__.py
adapters/db/models/account.py
adapters/db/models/api_key.py
adapters/db/models/bank_account.py
adapters/db/models/business.py
adapters/db/models/business_permission.py
adapters/db/models/captcha.py
@ -101,6 +104,8 @@ app/core/settings.py
app/core/smart_normalizer.py
app/services/api_key_service.py
app/services/auth_service.py
app/services/bank_account_service.py
app/services/bulk_price_update_service.py
app/services/business_dashboard_service.py
app/services/business_service.py
app/services/captcha_service.py
@ -122,6 +127,7 @@ hesabix_api.egg-info/dependency_links.txt
hesabix_api.egg-info/requires.txt
hesabix_api.egg-info/top_level.txt
migrations/env.py
migrations/versions/20250102_000001_seed_support_data.py
migrations/versions/20250117_000003_add_business_table.py
migrations/versions/20250117_000004_add_business_contact_fields.py
migrations/versions/20250117_000005_add_business_geographic_fields.py
@ -155,6 +161,7 @@ migrations/versions/20250929_000501_add_products_and_pricing.py
migrations/versions/20251001_000601_update_price_items_currency_unique_not_null.py
migrations/versions/20251001_001101_drop_price_list_currency_default_unit.py
migrations/versions/20251001_001201_merge_heads_drop_currency_tax_units.py
migrations/versions/20251002_000101_add_bank_accounts_table.py
migrations/versions/4b2ea782bcb3_merge_heads.py
migrations/versions/5553f8745c6e_add_support_tables.py
migrations/versions/9f9786ae7191_create_tax_units_table.py

View file

@ -20,6 +20,39 @@ msgstr "Request failed"
msgid "VALIDATION_ERROR"
msgstr "Validation error"
# Banking / Bank Accounts
msgid "BANK_ACCOUNTS_LIST_FETCHED"
msgstr "Bank accounts list retrieved successfully"
msgid "BANK_ACCOUNT_CREATED"
msgstr "Bank account created successfully"
msgid "BANK_ACCOUNT_DETAILS"
msgstr "Bank account details"
msgid "BANK_ACCOUNT_UPDATED"
msgstr "Bank account updated successfully"
msgid "BANK_ACCOUNT_DELETED"
msgstr "Bank account deleted successfully"
msgid "BANK_ACCOUNT_NOT_FOUND"
msgstr "Bank account not found"
msgid "NO_VALID_IDS_FOR_DELETE"
msgstr "No valid IDs submitted for deletion"
msgid "BANK_ACCOUNTS_BULK_DELETE_DONE"
msgstr "Bulk delete completed for bank accounts"
msgid "INVALID_BANK_ACCOUNT_CODE"
msgstr "Invalid bank account code"
msgid "DUPLICATE_BANK_ACCOUNT_CODE"
msgstr "Duplicate bank account code"
msgid "BULK_DELETE_FAILED"
msgstr "Bulk delete failed for bank accounts"
msgid "STRING_TOO_SHORT"
msgstr "String is too short"

View file

@ -41,6 +41,39 @@ msgstr "درخواست ناموفق بود"
msgid "VALIDATION_ERROR"
msgstr "خطای اعتبارسنجی"
# Banking / Bank Accounts
msgid "BANK_ACCOUNTS_LIST_FETCHED"
msgstr "لیست حساب‌های بانکی با موفقیت دریافت شد"
msgid "BANK_ACCOUNT_CREATED"
msgstr "حساب بانکی با موفقیت ایجاد شد"
msgid "BANK_ACCOUNT_DETAILS"
msgstr "جزئیات حساب بانکی"
msgid "BANK_ACCOUNT_UPDATED"
msgstr "حساب بانکی با موفقیت به‌روزرسانی شد"
msgid "BANK_ACCOUNT_DELETED"
msgstr "حساب بانکی با موفقیت حذف شد"
msgid "BANK_ACCOUNT_NOT_FOUND"
msgstr "حساب بانکی یافت نشد"
msgid "NO_VALID_IDS_FOR_DELETE"
msgstr "هیچ شناسه معتبری برای حذف ارسال نشده"
msgid "BANK_ACCOUNTS_BULK_DELETE_DONE"
msgstr "حذف گروهی حساب‌های بانکی انجام شد"
msgid "INVALID_BANK_ACCOUNT_CODE"
msgstr "کد حساب بانکی نامعتبر است"
msgid "DUPLICATE_BANK_ACCOUNT_CODE"
msgstr "کد حساب بانکی تکراری است"
msgid "BULK_DELETE_FAILED"
msgstr "خطا در حذف گروهی حساب‌های بانکی"
msgid "STRING_TOO_SHORT"
msgstr "رشته خیلی کوتاه است"

View file

@ -0,0 +1,179 @@
"""seed_support_data
Revision ID: 20250102_000001
Revises: 5553f8745c6e
Create Date: 2025-01-02 00:00:01.000000
"""
from alembic import op
import sqlalchemy as sa
from datetime import datetime
# revision identifiers, used by Alembic.
revision = '20250102_000001'
down_revision = '5553f8745c6e'
branch_labels = None
depends_on = None
def upgrade() -> None:
# اضافه کردن دسته‌بندی‌های اولیه
categories_table = sa.table('support_categories',
sa.column('id', sa.Integer),
sa.column('name', sa.String),
sa.column('description', sa.Text),
sa.column('is_active', sa.Boolean),
sa.column('created_at', sa.DateTime),
sa.column('updated_at', sa.DateTime)
)
categories_data = [
{
'name': 'مشکل فنی',
'description': 'مشکلات فنی و باگ‌ها',
'is_active': True,
'created_at': datetime.utcnow(),
'updated_at': datetime.utcnow()
},
{
'name': 'درخواست ویژگی',
'description': 'درخواست ویژگی‌های جدید',
'is_active': True,
'created_at': datetime.utcnow(),
'updated_at': datetime.utcnow()
},
{
'name': 'سوال',
'description': 'سوالات عمومی',
'is_active': True,
'created_at': datetime.utcnow(),
'updated_at': datetime.utcnow()
},
{
'name': 'شکایت',
'description': 'شکایات و انتقادات',
'is_active': True,
'created_at': datetime.utcnow(),
'updated_at': datetime.utcnow()
},
{
'name': 'سایر',
'description': 'سایر موارد',
'is_active': True,
'created_at': datetime.utcnow(),
'updated_at': datetime.utcnow()
}
]
op.bulk_insert(categories_table, categories_data)
# اضافه کردن اولویت‌های اولیه
priorities_table = sa.table('support_priorities',
sa.column('id', sa.Integer),
sa.column('name', sa.String),
sa.column('description', sa.Text),
sa.column('color', sa.String),
sa.column('order', sa.Integer),
sa.column('created_at', sa.DateTime),
sa.column('updated_at', sa.DateTime)
)
priorities_data = [
{
'name': 'کم',
'description': 'اولویت کم',
'color': '#28a745',
'order': 1,
'created_at': datetime.utcnow(),
'updated_at': datetime.utcnow()
},
{
'name': 'متوسط',
'description': 'اولویت متوسط',
'color': '#ffc107',
'order': 2,
'created_at': datetime.utcnow(),
'updated_at': datetime.utcnow()
},
{
'name': 'بالا',
'description': 'اولویت بالا',
'color': '#fd7e14',
'order': 3,
'created_at': datetime.utcnow(),
'updated_at': datetime.utcnow()
},
{
'name': 'فوری',
'description': 'اولویت فوری',
'color': '#dc3545',
'order': 4,
'created_at': datetime.utcnow(),
'updated_at': datetime.utcnow()
}
]
op.bulk_insert(priorities_table, priorities_data)
# اضافه کردن وضعیت‌های اولیه
statuses_table = sa.table('support_statuses',
sa.column('id', sa.Integer),
sa.column('name', sa.String),
sa.column('description', sa.Text),
sa.column('color', sa.String),
sa.column('is_final', sa.Boolean),
sa.column('created_at', sa.DateTime),
sa.column('updated_at', sa.DateTime)
)
statuses_data = [
{
'name': 'باز',
'description': 'تیکت باز و در انتظار پاسخ',
'color': '#007bff',
'is_final': False,
'created_at': datetime.utcnow(),
'updated_at': datetime.utcnow()
},
{
'name': 'در حال پیگیری',
'description': 'تیکت در حال بررسی',
'color': '#6f42c1',
'is_final': False,
'created_at': datetime.utcnow(),
'updated_at': datetime.utcnow()
},
{
'name': 'در انتظار کاربر',
'description': 'در انتظار پاسخ کاربر',
'color': '#17a2b8',
'is_final': False,
'created_at': datetime.utcnow(),
'updated_at': datetime.utcnow()
},
{
'name': 'بسته',
'description': 'تیکت بسته شده',
'color': '#6c757d',
'is_final': True,
'created_at': datetime.utcnow(),
'updated_at': datetime.utcnow()
},
{
'name': 'حل شده',
'description': 'مشکل حل شده',
'color': '#28a745',
'is_final': True,
'created_at': datetime.utcnow(),
'updated_at': datetime.utcnow()
}
]
op.bulk_insert(statuses_table, statuses_data)
def downgrade() -> None:
# حذف داده‌های اضافه شده
op.execute("DELETE FROM support_statuses")
op.execute("DELETE FROM support_priorities")
op.execute("DELETE FROM support_categories")

View file

@ -0,0 +1,51 @@
"""add bank_accounts table
Revision ID: 20251002_000101_add_bank_accounts_table
Revises: 20251001_001201_merge_heads_drop_currency_tax_units
Create Date: 2025-10-02 00:01:01.000001
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '20251002_000101_add_bank_accounts_table'
down_revision = '20251001_001201_merge_heads_drop_currency_tax_units'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
'bank_accounts',
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('business_id', sa.Integer(), sa.ForeignKey('businesses.id', ondelete='CASCADE'), nullable=False),
sa.Column('code', sa.String(length=50), nullable=True),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('description', sa.String(length=500), nullable=True),
sa.Column('branch', sa.String(length=255), nullable=True),
sa.Column('account_number', sa.String(length=50), nullable=True),
sa.Column('sheba_number', sa.String(length=30), nullable=True),
sa.Column('card_number', sa.String(length=20), nullable=True),
sa.Column('owner_name', sa.String(length=255), nullable=True),
sa.Column('pos_number', sa.String(length=50), nullable=True),
sa.Column('payment_id', sa.String(length=100), nullable=True),
sa.Column('currency_id', sa.Integer(), sa.ForeignKey('currencies.id', ondelete='RESTRICT'), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('1')),
sa.Column('is_default', sa.Boolean(), nullable=False, server_default=sa.text('0')),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.UniqueConstraint('business_id', 'code', name='uq_bank_accounts_business_code'),
)
op.create_index('ix_bank_accounts_business_id', 'bank_accounts', ['business_id'])
op.create_index('ix_bank_accounts_currency_id', 'bank_accounts', ['currency_id'])
def downgrade() -> None:
op.drop_index('ix_bank_accounts_currency_id', table_name='bank_accounts')
op.drop_index('ix_bank_accounts_business_id', table_name='bank_accounts')
op.drop_table('bank_accounts')

View file

@ -23,6 +23,7 @@ import 'pages/business/business_shell.dart';
import 'pages/business/dashboard/business_dashboard_page.dart';
import 'pages/business/users_permissions_page.dart';
import 'pages/business/accounts_page.dart';
import 'pages/business/bank_accounts_page.dart';
import 'pages/business/settings_page.dart';
import 'pages/business/persons_page.dart';
import 'pages/business/product_attributes_page.dart';
@ -553,7 +554,10 @@ class _MyAppState extends State<MyApp> {
localeController: controller,
calendarController: _calendarController!,
themeController: themeController,
child: AccountsPage(businessId: businessId),
child: BankAccountsPage(
businessId: businessId,
authStore: _authStore!,
),
);
},
),

View file

@ -0,0 +1,85 @@
class BankAccount {
final int? id;
final int businessId;
final String? code;
final String name;
final String? branch;
final String? accountNumber;
final String? shebaNumber;
final String? cardNumber;
final String? ownerName;
final String? posNumber;
final int currencyId;
final String? paymentId;
final String? description;
final bool isActive;
final bool isDefault;
final DateTime? createdAt;
final DateTime? updatedAt;
const BankAccount({
this.id,
required this.businessId,
this.code,
required this.name,
this.branch,
this.accountNumber,
this.shebaNumber,
this.cardNumber,
this.ownerName,
this.posNumber,
required this.currencyId,
this.paymentId,
this.description,
this.isActive = true,
this.isDefault = false,
this.createdAt,
this.updatedAt,
});
factory BankAccount.fromJson(Map<String, dynamic> json) {
return BankAccount(
id: json['id'] as int?,
businessId: (json['business_id'] ?? json['businessId']) as int,
code: json['code'] as String?,
name: (json['name'] ?? '') as String,
branch: json['branch'] as String?,
accountNumber: json['account_number'] as String?,
shebaNumber: json['sheba_number'] as String?,
cardNumber: json['card_number'] as String?,
ownerName: json['owner_name'] as String?,
posNumber: json['pos_number'] as String?,
currencyId: (json['currency_id'] ?? json['currencyId']) as int,
paymentId: json['payment_id'] as String?,
description: json['description'] as String?,
isActive: (json['is_active'] ?? true) as bool,
isDefault: (json['is_default'] ?? false) as bool,
createdAt: json['created_at'] != null ? DateTime.tryParse(json['created_at'].toString()) : null,
updatedAt: json['updated_at'] != null ? DateTime.tryParse(json['updated_at'].toString()) : null,
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'id': id,
'business_id': businessId,
'code': code,
'name': name,
'branch': branch,
'account_number': accountNumber,
'sheba_number': shebaNumber,
'card_number': cardNumber,
'owner_name': ownerName,
'pos_number': posNumber,
'currency_id': currencyId,
'payment_id': paymentId,
'description': description,
'is_active': isActive,
'is_default': isDefault,
'created_at': createdAt?.toIso8601String(),
'updated_at': updatedAt?.toIso8601String(),
};
}
}

View file

@ -0,0 +1,337 @@
import 'package:flutter/material.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart';
import 'package:hesabix_ui/core/api_client.dart';
import '../../widgets/data_table/data_table_widget.dart';
import '../../widgets/data_table/data_table_config.dart';
import '../../widgets/permission/permission_widgets.dart';
import '../../core/auth_store.dart';
import '../../models/bank_account_model.dart';
import '../../widgets/banking/bank_account_form_dialog.dart';
import '../../services/bank_account_service.dart';
import '../../services/currency_service.dart';
class BankAccountsPage extends StatefulWidget {
final int businessId;
final AuthStore authStore;
const BankAccountsPage({super.key, required this.businessId, required this.authStore});
@override
State<BankAccountsPage> createState() => _BankAccountsPageState();
}
class _BankAccountsPageState extends State<BankAccountsPage> {
final _bankAccountService = BankAccountService();
final _currencyService = CurrencyService(ApiClient());
final GlobalKey _bankAccountsTableKey = GlobalKey();
Map<int, String> _currencyNames = {};
@override
void initState() {
super.initState();
_loadCurrencies();
}
Future<void> _loadCurrencies() async {
try {
final currencies = await _currencyService.listBusinessCurrencies(
businessId: widget.businessId,
);
final currencyMap = <int, String>{};
for (final currency in currencies) {
currencyMap[currency['id'] as int] = '${currency['title']} (${currency['code']})';
}
setState(() {
_currencyNames = currencyMap;
});
} catch (e) {
// Handle error silently for now
}
}
@override
Widget build(BuildContext context) {
final t = AppLocalizations.of(context);
if (!widget.authStore.canReadSection('bank_accounts')) {
return AccessDeniedPage(message: t.accessDenied);
}
return Scaffold(
body: DataTableWidget<BankAccount>(
key: _bankAccountsTableKey,
config: _buildDataTableConfig(t),
fromJson: BankAccount.fromJson,
),
);
}
DataTableConfig<BankAccount> _buildDataTableConfig(AppLocalizations t) {
return DataTableConfig<BankAccount>(
endpoint: '/api/v1/bank-accounts/businesses/${widget.businessId}/bank-accounts',
title: t.accounts,
excelEndpoint: '/api/v1/bank-accounts/businesses/${widget.businessId}/bank-accounts/export/excel',
pdfEndpoint: '/api/v1/bank-accounts/businesses/${widget.businessId}/bank-accounts/export/pdf',
getExportParams: () => {'business_id': widget.businessId},
showBackButton: true,
onBack: () => Navigator.of(context).maybePop(),
showTableIcon: false,
showRowNumbers: true,
enableRowSelection: true,
enableMultiRowSelection: true,
columns: [
TextColumn(
'code',
t.code,
width: ColumnWidth.small,
formatter: (account) => (account.code?.toString() ?? '-'),
textAlign: TextAlign.center,
),
TextColumn(
'name',
t.title,
width: ColumnWidth.large,
formatter: (account) => account.name,
),
TextColumn(
'branch',
(t.localeName == 'fa') ? 'شعبه' : 'Branch',
width: ColumnWidth.medium,
formatter: (account) => account.branch ?? '-',
),
TextColumn(
'account_number',
t.accountNumber,
width: ColumnWidth.large,
formatter: (account) => account.accountNumber ?? '-',
),
TextColumn(
'sheba_number',
t.shebaNumber,
width: ColumnWidth.large,
formatter: (account) => account.shebaNumber ?? '-',
),
TextColumn(
'card_number',
t.cardNumber,
width: ColumnWidth.medium,
formatter: (account) => account.cardNumber ?? '-',
),
TextColumn(
'owner_name',
t.owner,
width: ColumnWidth.medium,
formatter: (account) => account.ownerName ?? '-',
),
TextColumn(
'pos_number',
(t.localeName == 'fa') ? 'شماره پوز' : 'POS Number',
width: ColumnWidth.medium,
formatter: (account) => account.posNumber ?? '-',
),
TextColumn(
'currency_id',
t.currency,
width: ColumnWidth.medium,
formatter: (account) => _currencyNames[account.currencyId] ?? ((t.localeName == 'fa') ? 'نامشخص' : 'Unknown'),
),
TextColumn(
'is_active',
t.active,
width: ColumnWidth.small,
formatter: (account) => account.isActive ? t.active : t.inactive,
),
TextColumn(
'is_default',
t.isDefault,
width: ColumnWidth.small,
formatter: (account) => account.isDefault ? t.yes : t.no,
),
ActionColumn(
'actions',
t.actions,
actions: [
DataTableAction(
icon: Icons.edit,
label: t.edit,
onTap: (account) => _editBankAccount(account),
),
DataTableAction(
icon: Icons.delete,
label: t.delete,
color: Colors.red,
onTap: (account) => _deleteBankAccount(account),
),
],
),
],
searchFields: ['code', 'name', 'branch', 'account_number', 'sheba_number', 'card_number', 'owner_name', 'pos_number', 'payment_id'],
filterFields: ['is_active', 'is_default', 'currency_id'],
defaultPageSize: 20,
customHeaderActions: [
PermissionButton(
section: 'bank_accounts',
action: 'add',
authStore: widget.authStore,
child: Tooltip(
message: t.addBankAccount,
child: IconButton(
onPressed: _addBankAccount,
icon: const Icon(Icons.add),
),
),
),
if (widget.authStore.canDeleteSection('bank_accounts'))
Tooltip(
message: t.deleteBankAccounts,
child: IconButton(
onPressed: () async {
final t = AppLocalizations.of(context);
try {
final state = _bankAccountsTableKey.currentState as dynamic;
final selectedIndices = (state?.getSelectedRowIndices() as List<int>?) ?? const <int>[];
final items = (state?.getSelectedItems() as List<dynamic>?) ?? const <dynamic>[];
if (selectedIndices.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t.noRowsSelectedError)));
return;
}
final ids = <int>[];
for (final i in selectedIndices) {
if (i >= 0 && i < items.length) {
final row = items[i];
if (row is BankAccount && row.id != null) {
ids.add(row.id!);
} else if (row is Map<String, dynamic>) {
final id = row['id'];
if (id is int) ids.add(id);
}
}
}
if (ids.isEmpty) return;
final confirm = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(t.deleteBankAccounts),
content: Text(t.deleteBankAccounts),
actions: [
TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: Text(t.cancel)),
FilledButton.tonal(onPressed: () => Navigator.of(ctx).pop(true), child: Text(t.delete)),
],
),
);
if (confirm != true) return;
final client = ApiClient();
await client.post<Map<String, dynamic>>(
'/api/v1/bank-accounts/businesses/${widget.businessId}/bank-accounts/bulk-delete',
data: { 'ids': ids },
);
try { ( _bankAccountsTableKey.currentState as dynamic)?.refresh(); } catch (_) {}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t.bankAccountDeletedSuccessfully)));
}
} catch (e) {
if (mounted) {
final t = AppLocalizations.of(context);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('${t.error}: $e')));
}
}
},
icon: const Icon(Icons.delete_sweep_outlined),
),
),
],
);
}
void _addBankAccount() {
showDialog(
context: context,
builder: (context) => BankAccountFormDialog(
businessId: widget.businessId,
onSuccess: () {
final state = _bankAccountsTableKey.currentState;
try {
// Call public refresh() via dynamic to avoid private state typing
// ignore: avoid_dynamic_calls
(state as dynamic)?.refresh();
} catch (_) {}
},
),
);
}
void _editBankAccount(BankAccount account) {
showDialog(
context: context,
builder: (context) => BankAccountFormDialog(
businessId: widget.businessId,
account: account,
onSuccess: () {
final state = _bankAccountsTableKey.currentState;
try {
// ignore: avoid_dynamic_calls
(state as dynamic)?.refresh();
} catch (_) {}
},
),
);
}
void _deleteBankAccount(BankAccount account) {
final t = AppLocalizations.of(context);
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(t.deleteBankAccount),
content: Text(t.deleteConfirm(account.name)),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(t.cancel),
),
TextButton(
onPressed: () async {
Navigator.of(context).pop();
await _performDelete(account);
},
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: Text(t.delete),
),
],
),
);
}
Future<void> _performDelete(BankAccount account) async {
final t = AppLocalizations.of(context);
try {
await _bankAccountService.delete(account.id!);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(t.bankAccountDeletedSuccessfully),
backgroundColor: Colors.green,
),
);
// Refresh the table after successful deletion
try {
(_bankAccountsTableKey.currentState as dynamic)?.refresh();
} catch (_) {}
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${t.error}: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
}

View file

@ -6,6 +6,7 @@ import '../../core/calendar_controller.dart';
import '../../theme/theme_controller.dart';
import '../../widgets/combined_user_menu_button.dart';
import '../../widgets/person/person_form_dialog.dart';
import '../../widgets/banking/bank_account_form_dialog.dart';
import '../../widgets/product/product_form_dialog.dart';
import '../../widgets/category/category_tree_dialog.dart';
import '../../services/business_dashboard_service.dart';
@ -705,7 +706,13 @@ class _BusinessShellState extends State<BusinessShell> {
} else if (child.label == t.productAttributes) {
// Navigate to add product attribute
} else if (child.label == t.accounts) {
// Navigate to add account
// Open add bank account dialog
showDialog(
context: context,
builder: (ctx) => BankAccountFormDialog(
businessId: widget.businessId,
),
);
} else if (child.label == t.pettyCash) {
// Navigate to add petty cash
} else if (child.label == t.cashBox) {
@ -851,6 +858,13 @@ class _BusinessShellState extends State<BusinessShell> {
onTap: () {
if (item.label == t.people) {
showAddPersonDialog();
} else if (item.label == t.accounts) {
showDialog(
context: context,
builder: (ctx) => BankAccountFormDialog(
businessId: widget.businessId,
),
);
}
// سایر مسیرهای افزودن در آینده متصل میشوند
},

View file

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart';
import 'package:hesabix_ui/core/api_client.dart';
import '../../widgets/data_table/data_table_widget.dart';
import '../../widgets/data_table/data_table_config.dart';
import '../../widgets/person/person_form_dialog.dart';
@ -278,6 +279,67 @@ class _PersonsPageState extends State<PersonsPage> {
),
),
),
if (widget.authStore.canDeleteSection('people'))
Tooltip(
message: AppLocalizations.of(context).deletePerson,
child: IconButton(
onPressed: () async {
final t = AppLocalizations.of(context);
try {
final state = _personsTableKey.currentState as dynamic;
final selectedIndices = (state?.getSelectedRowIndices() as List<int>?) ?? const <int>[];
final items = (state?.getSelectedItems() as List<dynamic>?) ?? const <dynamic>[];
if (selectedIndices.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t.noRowsSelectedError)));
return;
}
final ids = <int>[];
for (final i in selectedIndices) {
if (i >= 0 && i < items.length) {
final row = items[i];
if (row is Person && row.id != null) {
ids.add(row.id!);
} else if (row is Map<String, dynamic>) {
final id = row['id'];
if (id is int) ids.add(id);
}
}
}
if (ids.isEmpty) return;
final confirm = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(t.deletePerson),
content: Text(t.deleteConfirm('${ids.length}')),
actions: [
TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: Text(t.cancel)),
FilledButton.tonal(onPressed: () => Navigator.of(ctx).pop(true), child: Text(t.delete)),
],
),
);
if (confirm != true) return;
final client = ApiClient();
await client.post<Map<String, dynamic>>(
'/api/v1/persons/businesses/${widget.businessId}/persons/bulk-delete',
data: { 'ids': ids },
);
try { ( _personsTableKey.currentState as dynamic)?.refresh(); } catch (_) {}
if (mounted) {
// Reuse generic success text available in l10n
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t.productsDeletedSuccessfully)));
}
} catch (e) {
if (mounted) {
final t = AppLocalizations.of(context);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('${t.error}: $e')));
}
}
},
icon: const Icon(Icons.delete_sweep_outlined),
),
),
Builder(builder: (context) {
final theme = Theme.of(context);
return Tooltip(

View file

@ -0,0 +1,59 @@
import 'package:dio/dio.dart';
import '../core/api_client.dart';
import '../models/bank_account_model.dart';
class BankAccountService {
final ApiClient _client;
BankAccountService({ApiClient? client}) : _client = client ?? ApiClient();
Future<Map<String, dynamic>> list({required int businessId, required Map<String, dynamic> queryInfo}) async {
final res = await _client.post<Map<String, dynamic>>(
'/api/v1/bank-accounts/businesses/$businessId/bank-accounts',
data: queryInfo,
);
return (res.data ?? <String, dynamic>{});
}
Future<BankAccount> create({required int businessId, required Map<String, dynamic> payload}) async {
final res = await _client.post<Map<String, dynamic>>(
'/api/v1/bank-accounts/businesses/$businessId/bank-accounts/create',
data: payload,
);
final data = (res.data?['data'] as Map<String, dynamic>? ?? <String, dynamic>{});
return BankAccount.fromJson(data);
}
Future<BankAccount> getById(int id) async {
final res = await _client.get<Map<String, dynamic>>('/api/v1/bank-accounts/bank-accounts/$id');
final data = (res.data?['data'] as Map<String, dynamic>? ?? <String, dynamic>{});
return BankAccount.fromJson(data);
}
Future<BankAccount> update({required int id, required Map<String, dynamic> payload}) async {
final res = await _client.put<Map<String, dynamic>>('/api/v1/bank-accounts/bank-accounts/$id', data: payload);
final data = (res.data?['data'] as Map<String, dynamic>? ?? <String, dynamic>{});
return BankAccount.fromJson(data);
}
Future<void> delete(int id) async {
await _client.delete<Map<String, dynamic>>('/api/v1/bank-accounts/bank-accounts/$id');
}
Future<Response<List<int>>> exportExcel({required int businessId, required Map<String, dynamic> body}) async {
return _client.post<List<int>>(
'/api/v1/bank-accounts/businesses/$businessId/bank-accounts/export/excel',
data: body,
responseType: ResponseType.bytes,
);
}
Future<Response<List<int>>> exportPdf({required int businessId, required Map<String, dynamic> body}) async {
return _client.post<List<int>>(
'/api/v1/bank-accounts/businesses/$businessId/bank-accounts/export/pdf',
data: body,
responseType: ResponseType.bytes,
);
}
}

View file

@ -0,0 +1,524 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart';
import '../../models/bank_account_model.dart';
import '../../services/bank_account_service.dart';
import 'currency_picker_widget.dart';
class BankAccountFormDialog extends StatefulWidget {
final int businessId;
final BankAccount? account; // null برای افزودن، مقدار برای ویرایش
final VoidCallback? onSuccess;
const BankAccountFormDialog({
super.key,
required this.businessId,
this.account,
this.onSuccess,
});
@override
State<BankAccountFormDialog> createState() => _BankAccountFormDialogState();
}
class _BankAccountFormDialogState extends State<BankAccountFormDialog> {
final _formKey = GlobalKey<FormState>();
final _bankAccountService = BankAccountService();
bool _isLoading = false;
// Code (unique) controls
final _codeController = TextEditingController();
bool _autoGenerateCode = true;
// Controllers for basic info
final _nameController = TextEditingController();
final _branchController = TextEditingController();
final _accountNumberController = TextEditingController();
final _shebaNumberController = TextEditingController();
final _cardNumberController = TextEditingController();
final _ownerNameController = TextEditingController();
final _posNumberController = TextEditingController();
final _paymentIdController = TextEditingController();
final _descriptionController = TextEditingController();
bool _isActive = true;
bool _isDefault = false;
int? _currencyId; // TODO: wired later to currency picker
@override
void initState() {
super.initState();
_initializeForm();
}
void _initializeForm() {
if (widget.account != null) {
final account = widget.account!;
if (account.code != null) {
_codeController.text = account.code!;
_autoGenerateCode = false;
}
_nameController.text = account.name;
_branchController.text = account.branch ?? '';
_accountNumberController.text = account.accountNumber ?? '';
_shebaNumberController.text = account.shebaNumber ?? '';
_cardNumberController.text = account.cardNumber ?? '';
_ownerNameController.text = account.ownerName ?? '';
_posNumberController.text = account.posNumber ?? '';
_paymentIdController.text = account.paymentId ?? '';
_descriptionController.text = account.description ?? '';
_isActive = account.isActive;
_isDefault = account.isDefault;
_currencyId = account.currencyId;
}
}
@override
void dispose() {
_codeController.dispose();
_nameController.dispose();
_branchController.dispose();
_accountNumberController.dispose();
_shebaNumberController.dispose();
_cardNumberController.dispose();
_ownerNameController.dispose();
_posNumberController.dispose();
_paymentIdController.dispose();
_descriptionController.dispose();
super.dispose();
}
Future<void> _saveBankAccount() async {
if (!_formKey.currentState!.validate()) return;
if (_currencyId == null) {
final t = AppLocalizations.of(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(t.currency),
backgroundColor: Colors.red,
),
);
return;
}
setState(() {
_isLoading = true;
});
try {
final accountData = {
'code': _autoGenerateCode ? null : _codeController.text.trim().isEmpty ? null : _codeController.text.trim(),
'name': _nameController.text.trim(),
'branch': _branchController.text.trim().isEmpty ? null : _branchController.text.trim(),
'account_number': _accountNumberController.text.trim().isEmpty ? null : _accountNumberController.text.trim(),
'sheba_number': _shebaNumberController.text.trim().isEmpty ? null : _shebaNumberController.text.trim(),
'card_number': _cardNumberController.text.trim().isEmpty ? null : _cardNumberController.text.trim(),
'owner_name': _ownerNameController.text.trim().isEmpty ? null : _ownerNameController.text.trim(),
'pos_number': _posNumberController.text.trim().isEmpty ? null : _posNumberController.text.trim(),
'payment_id': _paymentIdController.text.trim().isEmpty ? null : _paymentIdController.text.trim(),
'description': _descriptionController.text.trim().isEmpty ? null : _descriptionController.text.trim(),
'is_active': _isActive,
'is_default': _isDefault,
'currency_id': _currencyId,
};
if (widget.account == null) {
// Create new bank account
await _bankAccountService.create(
businessId: widget.businessId,
payload: accountData,
);
} else {
// Update existing bank account
await _bankAccountService.update(
id: widget.account!.id!,
payload: accountData,
);
}
if (mounted) {
Navigator.of(context).pop();
widget.onSuccess?.call();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(widget.account == null
? 'حساب بانکی با موفقیت ایجاد شد'
: 'حساب بانکی با موفقیت به‌روزرسانی شد'),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('خطا: $e'),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
final t = AppLocalizations.of(context);
final isEditing = widget.account != null;
return Dialog(
child: Container(
width: MediaQuery.of(context).size.width * 0.9,
height: MediaQuery.of(context).size.height * 0.9,
padding: const EdgeInsets.all(24),
child: Column(
children: [
// Header
Row(
children: [
Icon(
isEditing ? Icons.edit : Icons.add,
color: Theme.of(context).primaryColor,
),
const SizedBox(width: 8),
Text(
isEditing ? t.editBankAccount : t.addBankAccount,
style: Theme.of(context).textTheme.headlineSmall,
),
const Spacer(),
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close),
),
],
),
const Divider(),
const SizedBox(height: 16),
// Form with tabs
Expanded(
child: Form(
key: _formKey,
child: DefaultTabController(
length: 3,
child: Column(
children: [
TabBar(
isScrollable: true,
tabs: [
Tab(text: t.title),
Tab(text: t.personBankInfo),
Tab(text: t.settings),
],
),
const SizedBox(height: 12),
Expanded(
child: TabBarView(
children: [
SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
child: _buildBasicInfoFields(t),
),
),
SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
child: _buildBankingInfoFields(t),
),
),
SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
child: _buildSettingsFields(t),
),
),
],
),
),
],
),
),
),
),
// Actions
const Divider(),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: _isLoading ? null : () => Navigator.of(context).pop(),
child: Text(t.cancel),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: _isLoading ? null : _saveBankAccount,
child: _isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(isEditing ? t.update : t.add),
),
],
),
],
),
),
);
}
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(
children: [
_buildSectionHeader(t.title),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextFormField(
controller: _codeController,
readOnly: _autoGenerateCode,
decoration: InputDecoration(
labelText: t.code,
hintText: t.uniqueCodeNumeric,
suffixIcon: Container(
margin: const EdgeInsets.symmetric(vertical: 6, horizontal: 6),
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Theme.of(context).colorScheme.surfaceContainerHighest,
),
child: ToggleButtons(
isSelected: [_autoGenerateCode, !_autoGenerateCode],
borderRadius: BorderRadius.circular(6),
constraints: const BoxConstraints(minHeight: 32, minWidth: 64),
onPressed: (index) {
setState(() {
_autoGenerateCode = (index == 0);
});
},
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Text(t.automatic),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Text(t.manual),
),
],
),
),
),
keyboardType: TextInputType.text,
validator: (value) {
if (!_autoGenerateCode) {
if (value == null || value.trim().isEmpty) {
return t.personCodeRequired;
}
if (value.trim().length < 3) {
return t.passwordMinLength; // fallback generic
}
if (!RegExp(r'^\d+$').hasMatch(value.trim())) {
return t.codeMustBeNumeric;
}
}
return null;
},
),
),
],
),
const SizedBox(height: 16),
// Currency picker
CurrencyPickerWidget(
businessId: widget.businessId,
selectedCurrencyId: _currencyId,
onChanged: (value) {
setState(() {
_currencyId = value;
});
},
label: t.currency,
hintText: t.currency,
),
const SizedBox(height: 16),
TextFormField(
controller: _nameController,
decoration: InputDecoration(
labelText: t.title,
hintText: t.title,
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'نام حساب الزامی است';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _descriptionController,
decoration: InputDecoration(
labelText: t.description,
hintText: t.description,
),
maxLines: 3,
),
],
);
}
Widget _buildBankingInfoFields(AppLocalizations t) {
return Column(
children: [
_buildSectionHeader(t.personBankInfo),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextFormField(
controller: _branchController,
decoration: InputDecoration(
labelText: (t.localeName == 'fa') ? 'شعبه' : 'Branch',
hintText: (t.localeName == 'fa') ? 'شعبه' : 'Branch',
),
),
),
const SizedBox(width: 16),
Expanded(
child: TextFormField(
controller: _ownerNameController,
decoration: InputDecoration(
labelText: t.owner,
hintText: t.owner,
),
),
),
],
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextFormField(
controller: _accountNumberController,
decoration: InputDecoration(
labelText: t.accountNumber,
hintText: t.accountNumber,
),
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
),
),
const SizedBox(width: 16),
Expanded(
child: TextFormField(
controller: _cardNumberController,
decoration: InputDecoration(
labelText: t.cardNumber,
hintText: t.cardNumber,
),
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
),
),
],
),
const SizedBox(height: 16),
TextFormField(
controller: _shebaNumberController,
decoration: InputDecoration(
labelText: t.shebaNumber,
hintText: t.shebaNumber,
),
keyboardType: TextInputType.text,
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'[0-9A-Za-z]')),
LengthLimitingTextInputFormatter(24),
],
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextFormField(
controller: _posNumberController,
decoration: InputDecoration(
labelText: (t.localeName == 'fa') ? 'شماره پوز' : 'POS Number',
hintText: (t.localeName == 'fa') ? 'شماره پوز' : 'POS Number',
),
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
),
),
const SizedBox(width: 16),
Expanded(
child: TextFormField(
controller: _paymentIdController,
decoration: InputDecoration(
labelText: t.personPaymentId,
hintText: t.personPaymentId,
),
),
),
],
),
],
);
}
Widget _buildSettingsFields(AppLocalizations t) {
return Column(
children: [
_buildSectionHeader(t.settings),
const SizedBox(height: 16),
SwitchListTile(
title: Text(t.active),
subtitle: Text(t.active),
value: _isActive,
onChanged: (value) {
setState(() {
_isActive = value;
});
},
),
const SizedBox(height: 8),
SwitchListTile(
title: Text(t.isDefault),
subtitle: Text(t.defaultConfiguration),
value: _isDefault,
onChanged: (value) {
setState(() {
_isDefault = value;
});
},
),
],
);
}
}

View file

@ -0,0 +1,164 @@
import 'package:flutter/material.dart';
import '../../core/api_client.dart';
import '../../services/currency_service.dart';
class CurrencyPickerWidget extends StatefulWidget {
final int? selectedCurrencyId;
final int businessId;
final ValueChanged<int?> onChanged;
final String? label;
final String? hintText;
final bool enabled;
const CurrencyPickerWidget({
super.key,
this.selectedCurrencyId,
required this.businessId,
required this.onChanged,
this.label,
this.hintText,
this.enabled = true,
});
@override
State<CurrencyPickerWidget> createState() => _CurrencyPickerWidgetState();
}
class _CurrencyPickerWidgetState extends State<CurrencyPickerWidget> {
final CurrencyService _currencyService = CurrencyService(ApiClient());
List<Map<String, dynamic>> _currencies = [];
bool _isLoading = false;
String? _error;
@override
void initState() {
super.initState();
_loadCurrencies();
}
Future<void> _loadCurrencies() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final currencies = await _currencyService.listBusinessCurrencies(
businessId: widget.businessId,
);
setState(() {
_currencies = currencies;
_isLoading = false;
});
} catch (e) {
setState(() {
_error = e.toString();
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return const SizedBox(
height: 56,
child: Center(
child: CircularProgressIndicator(),
),
);
}
if (_error != null) {
return Container(
height: 56,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(color: Colors.red),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
const Icon(Icons.error_outline, color: Colors.red),
const SizedBox(width: 8),
Expanded(
child: Text(
'خطا در بارگذاری ارزها: $_error',
style: const TextStyle(color: Colors.red),
),
),
TextButton(
onPressed: _loadCurrencies,
child: const Text('تلاش مجدد'),
),
],
),
);
}
if (_currencies.isEmpty) {
return Container(
height: 56,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey),
borderRadius: BorderRadius.circular(8),
),
child: const Center(
child: Text('هیچ ارزی یافت نشد'),
),
);
}
return DropdownButtonFormField<int>(
value: widget.selectedCurrencyId,
onChanged: widget.enabled ? widget.onChanged : null,
decoration: InputDecoration(
labelText: widget.label ?? 'ارز',
hintText: widget.hintText ?? 'انتخاب ارز',
border: const OutlineInputBorder(),
enabled: widget.enabled,
),
items: _currencies.map((currency) {
final isDefault = currency['is_default'] == true;
return DropdownMenuItem<int>(
value: currency['id'] as int,
child: Row(
children: [
Expanded(
child: Text(
'${currency['title']} (${currency['code']})',
style: TextStyle(
fontWeight: isDefault ? FontWeight.bold : FontWeight.normal,
),
),
),
if (isDefault)
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Theme.of(context).primaryColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
),
child: Text(
'پیش‌فرض',
style: TextStyle(
fontSize: 10,
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.bold,
),
),
),
],
),
);
}).toList(),
validator: (value) {
if (value == null) {
return 'انتخاب ارز الزامی است';
}
return null;
},
);
}
}