progress in cheques

This commit is contained in:
Hesabix 2025-11-02 09:41:38 +00:00
parent 9701fa31b2
commit b7a860e3e5
12 changed files with 447 additions and 108 deletions

View file

@ -8,6 +8,7 @@ from adapters.db.session import get_db
from app.core.auth_dependency import get_current_user, AuthContext from app.core.auth_dependency import get_current_user, AuthContext
from app.core.permissions import require_business_access from app.core.permissions import require_business_access
from app.core.responses import success_response, ApiError, format_datetime_fields from app.core.responses import success_response, ApiError, format_datetime_fields
from adapters.api.v1.schemas import QueryInfo, PaginatedResponse
from adapters.api.v1.schema_models.warehouse import ( from adapters.api.v1.schema_models.warehouse import (
WarehouseCreateRequest, WarehouseCreateRequest,
WarehouseUpdateRequest, WarehouseUpdateRequest,
@ -18,6 +19,7 @@ from app.services.warehouse_service import (
get_warehouse, get_warehouse,
update_warehouse, update_warehouse,
delete_warehouse, delete_warehouse,
query_warehouses,
) )
@ -103,3 +105,33 @@ def delete_warehouse_endpoint(
return success_response({"deleted": ok}, request) return success_response({"deleted": ok}, request)
@router.post("/business/{business_id}/query")
def query_warehouses_endpoint(
request: Request,
business_id: int,
payload: QueryInfo,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Dict[str, Any]:
# دسترسی به کسب‌وکار و مجوز خواندن انبار
if not ctx.can_access_business(int(business_id)):
raise ApiError("FORBIDDEN", f"No access to business {business_id}", http_status=403)
if not ctx.can_read_section("inventory"):
raise ApiError("FORBIDDEN", "Missing business permission: inventory.read", http_status=403)
result = query_warehouses(db, business_id, payload)
# تطبیق خروجی با ساختار DataTableResponse (items + pagination)
data = {
"items": format_datetime_fields(result["items"], request),
"pagination": {
"total": result["total"],
"page": result["page"],
"per_page": result["limit"],
"total_pages": result["total_pages"],
"has_next": result["page"] < result["total_pages"],
"has_prev": result["page"] > 1,
},
"query_info": payload.model_dump(),
}
return success_response(data=data, request=request)

View file

@ -19,8 +19,8 @@ from adapters.db.session import Base
class CheckType(str, Enum): class CheckType(str, Enum):
RECEIVED = "received" RECEIVED = "RECEIVED"
TRANSFERRED = "transferred" TRANSFERRED = "TRANSFERRED"
class Check(Base): class Check(Base):

View file

@ -1,5 +1,5 @@
from functools import wraps from functools import wraps
from typing import Callable, Any from typing import Callable, Any, get_type_hints
import inspect import inspect
from fastapi import Depends from fastapi import Depends
@ -131,6 +131,12 @@ def require_business_access(business_id_param: str = "business_id"):
return result return result
# Preserve original signature so FastAPI sees correct parameters (including Request) # Preserve original signature so FastAPI sees correct parameters (including Request)
wrapper.__signature__ = inspect.signature(func) # type: ignore[attr-defined] wrapper.__signature__ = inspect.signature(func) # type: ignore[attr-defined]
# Also preserve evaluated type annotations to avoid ForwardRef issues under __future__.annotations
try:
wrapper.__annotations__ = get_type_hints(func, globalns=getattr(func, "__globals__", None)) # type: ignore[attr-defined]
except Exception:
# Fallback to original annotations (may be string-based) if evaluation fails
wrapper.__annotations__ = getattr(func, "__annotations__", {})
return wrapper return wrapper
return decorator return decorator

View file

@ -21,11 +21,11 @@ def _parse_iso(dt: str) -> datetime:
def create_check(db: Session, business_id: int, data: Dict[str, Any]) -> Dict[str, Any]: def create_check(db: Session, business_id: int, data: Dict[str, Any]) -> Dict[str, Any]:
ctype = str(data.get('type', '')).lower() ctype = str(data.get('type', '')).lower()
if ctype not in (CheckType.RECEIVED.value, CheckType.TRANSFERRED.value): if ctype not in ("received", "transferred"):
raise ApiError("INVALID_CHECK_TYPE", "Invalid check type", http_status=400) raise ApiError("INVALID_CHECK_TYPE", "Invalid check type", http_status=400)
person_id = data.get('person_id') person_id = data.get('person_id')
if ctype == CheckType.RECEIVED.value and not person_id: if ctype == "received" and not person_id:
raise ApiError("PERSON_REQUIRED", "person_id is required for received checks", http_status=400) raise ApiError("PERSON_REQUIRED", "person_id is required for received checks", http_status=400)
issue_date = _parse_iso(str(data.get('issue_date'))) issue_date = _parse_iso(str(data.get('issue_date')))
@ -63,7 +63,7 @@ def create_check(db: Session, business_id: int, data: Dict[str, Any]) -> Dict[st
obj = Check( obj = Check(
business_id=business_id, business_id=business_id,
type=CheckType(ctype), type=CheckType[ctype.upper()],
person_id=int(person_id) if person_id else None, person_id=int(person_id) if person_id else None,
issue_date=issue_date, issue_date=issue_date,
due_date=due_date, due_date=due_date,
@ -93,9 +93,9 @@ def update_check(db: Session, check_id: int, data: Dict[str, Any]) -> Optional[D
if 'type' in data and data['type'] is not None: if 'type' in data and data['type'] is not None:
ctype = str(data['type']).lower() ctype = str(data['type']).lower()
if ctype not in (CheckType.RECEIVED.value, CheckType.TRANSFERRED.value): if ctype not in ("received", "transferred"):
raise ApiError("INVALID_CHECK_TYPE", "Invalid check type", http_status=400) raise ApiError("INVALID_CHECK_TYPE", "Invalid check type", http_status=400)
obj.type = CheckType(ctype) obj.type = CheckType[ctype.upper()]
if 'person_id' in data: if 'person_id' in data:
obj.person_id = int(data['person_id']) if data['person_id'] is not None else None obj.person_id = int(data['person_id']) if data['person_id'] is not None else None
@ -192,7 +192,11 @@ def list_checks(db: Session, business_id: int, query: Dict[str, Any]) -> Dict[st
if not prop or not op: if not prop or not op:
continue continue
if prop == 'type' and op == '=': if prop == 'type' and op == '=':
q = q.filter(Check.type == val) try:
enum_val = CheckType[str(val).upper()]
q = q.filter(Check.type == enum_val)
except Exception:
pass
elif prop == 'currency' and op == '=': elif prop == 'currency' and op == '=':
try: try:
q = q.filter(Check.currency_id == int(val)) q = q.filter(Check.currency_id == int(val))
@ -267,7 +271,7 @@ def check_to_dict(db: Session, obj: Optional[Check]) -> Optional[Dict[str, Any]]
return { return {
"id": obj.id, "id": obj.id,
"business_id": obj.business_id, "business_id": obj.business_id,
"type": obj.type.value, "type": obj.type.name.lower(),
"person_id": obj.person_id, "person_id": obj.person_id,
"person_name": person_name, "person_name": person_name,
"issue_date": obj.issue_date.isoformat(), "issue_date": obj.issue_date.isoformat(),

View file

@ -68,12 +68,24 @@ def _iter_product_movements(
""" """
if not product_ids: if not product_ids:
return [] return []
# فقط کالاهای با کنترل موجودی را لحاظ کن
tracked_ids: List[int] = [
int(pid)
for pid, tracked in db.query(Product.id, Product.track_inventory).filter(
Product.business_id == business_id,
Product.id.in_(list({int(pid) for pid in product_ids})),
).all()
if bool(tracked)
]
if not tracked_ids:
return []
q = db.query(DocumentLine, Document).join(Document, Document.id == DocumentLine.document_id).filter( q = db.query(DocumentLine, Document).join(Document, Document.id == DocumentLine.document_id).filter(
and_( and_(
Document.business_id == business_id, Document.business_id == business_id,
Document.is_proforma == False, # noqa: E712 Document.is_proforma == False, # noqa: E712
Document.document_date <= up_to_date, Document.document_date <= up_to_date,
DocumentLine.product_id.in_(list({int(pid) for pid in product_ids})), DocumentLine.product_id.in_(tracked_ids),
) )
) )
if exclude_document_id is not None: if exclude_document_id is not None:
@ -353,6 +365,9 @@ def _extract_cogs_total(lines: List[Dict[str, Any]]) -> Decimal:
total = Decimal(0) total = Decimal(0)
for line in lines: for line in lines:
info = line.get("extra_info") or {} info = line.get("extra_info") or {}
# فقط برای کالاهای دارای کنترل موجودی
if not bool(info.get("inventory_tracked")):
continue
qty = Decimal(str(line.get("quantity", 0) or 0)) qty = Decimal(str(line.get("quantity", 0) or 0))
if info.get("cogs_amount") is not None: if info.get("cogs_amount") is not None:
total += Decimal(str(info.get("cogs_amount"))) total += Decimal(str(info.get("cogs_amount")))
@ -486,20 +501,45 @@ def create_invoice(
if mv == "out": if mv == "out":
outgoing_lines.append(ln) outgoing_lines.append(ln)
# Ensure stock sufficiency for outgoing # Resolve inventory tracking per product and annotate lines
if outgoing_lines: all_product_ids = [int(ln.get("product_id")) for ln in lines_input if ln.get("product_id")]
_ensure_stock_sufficient(db, business_id, document_date, outgoing_lines) track_map: Dict[int, bool] = {}
if all_product_ids:
for pid, tracked in db.query(Product.id, Product.track_inventory).filter(
Product.business_id == business_id,
Product.id.in_(all_product_ids),
).all():
track_map[int(pid)] = bool(tracked)
# Costing method for ln in lines_input:
pid = ln.get("product_id")
if not pid:
continue
info = dict(ln.get("extra_info") or {})
info["inventory_tracked"] = bool(track_map.get(int(pid), False))
ln["extra_info"] = info
# Filter outgoing lines to only inventory-tracked products for stock checks
tracked_outgoing_lines: List[Dict[str, Any]] = []
for ln in outgoing_lines:
pid = ln.get("product_id")
if pid and track_map.get(int(pid)):
tracked_outgoing_lines.append(ln)
# Ensure stock sufficiency for outgoing (only for tracked products)
if tracked_outgoing_lines:
_ensure_stock_sufficient(db, business_id, document_date, tracked_outgoing_lines)
# Costing method (only for tracked products)
costing_method = _get_costing_method(data) costing_method = _get_costing_method(data)
if costing_method == "fifo" and outgoing_lines: if costing_method == "fifo" and tracked_outgoing_lines:
fifo_costs = _calculate_fifo_cogs_for_outgoing(db, business_id, document_date, outgoing_lines) fifo_costs = _calculate_fifo_cogs_for_outgoing(db, business_id, document_date, tracked_outgoing_lines)
# annotate lines with cogs_amount in the same order as outgoing_lines # annotate lines with cogs_amount in the same order as tracked_outgoing_lines
i = 0 i = 0
for ln in lines_input: for ln in lines_input:
info = ln.get("extra_info") or {} info = ln.get("extra_info") or {}
mv = info.get("movement") or movement_hint mv = info.get("movement") or movement_hint
if mv == "out": if mv == "out" and info.get("inventory_tracked"):
amt = fifo_costs[i] amt = fifo_costs[i]
i += 1 i += 1
info = dict(info) info = dict(info)
@ -901,18 +941,41 @@ def update_invoice(
if mv == "out": if mv == "out":
outgoing_lines.append(ln) outgoing_lines.append(ln)
if outgoing_lines: # Resolve and annotate inventory tracking for all lines
_ensure_stock_sufficient(db, document.business_id, document.document_date, outgoing_lines, exclude_document_id=document.id) all_product_ids = [int(ln.get("product_id")) for ln in lines_input if ln.get("product_id")]
track_map: Dict[int, bool] = {}
if all_product_ids:
for pid, tracked in db.query(Product.id, Product.track_inventory).filter(
Product.business_id == document.business_id,
Product.id.in_(all_product_ids),
).all():
track_map[int(pid)] = bool(tracked)
for ln in lines_input:
pid = ln.get("product_id")
if not pid:
continue
info = dict(ln.get("extra_info") or {})
info["inventory_tracked"] = bool(track_map.get(int(pid), False))
ln["extra_info"] = info
tracked_outgoing_lines: List[Dict[str, Any]] = []
for ln in outgoing_lines:
pid = ln.get("product_id")
if pid and track_map.get(int(pid)):
tracked_outgoing_lines.append(ln)
if tracked_outgoing_lines:
_ensure_stock_sufficient(db, document.business_id, document.document_date, tracked_outgoing_lines, exclude_document_id=document.id)
header_for_costing = data if data else {"extra_info": document.extra_info} header_for_costing = data if data else {"extra_info": document.extra_info}
costing_method = _get_costing_method(header_for_costing) costing_method = _get_costing_method(header_for_costing)
if costing_method == "fifo" and outgoing_lines: if costing_method == "fifo" and tracked_outgoing_lines:
fifo_costs = _calculate_fifo_cogs_for_outgoing(db, document.business_id, document.document_date, outgoing_lines, exclude_document_id=document.id) fifo_costs = _calculate_fifo_cogs_for_outgoing(db, document.business_id, document.document_date, tracked_outgoing_lines, exclude_document_id=document.id)
i = 0 i = 0
for ln in lines_input: for ln in lines_input:
info = ln.get("extra_info") or {} info = ln.get("extra_info") or {}
mv = info.get("movement") or movement_hint mv = info.get("movement") or movement_hint
if mv == "out": if mv == "out" and info.get("inventory_tracked"):
amt = fifo_costs[i] amt = fifo_costs[i]
i += 1 i += 1
info = dict(info) info = dict(info)

View file

@ -8,6 +8,8 @@ from app.core.responses import ApiError
from adapters.db.models.warehouse import Warehouse from adapters.db.models.warehouse import Warehouse
from adapters.db.repositories.warehouse_repository import WarehouseRepository from adapters.db.repositories.warehouse_repository import WarehouseRepository
from adapters.api.v1.schema_models.warehouse import WarehouseCreateRequest, WarehouseUpdateRequest from adapters.api.v1.schema_models.warehouse import WarehouseCreateRequest, WarehouseUpdateRequest
from adapters.api.v1.schemas import QueryInfo, FilterItem
from app.services.query_service import QueryService
def _to_dict(obj: Warehouse) -> Dict[str, Any]: def _to_dict(obj: Warehouse) -> Dict[str, Any]:
@ -88,3 +90,36 @@ def delete_warehouse(db: Session, business_id: int, warehouse_id: int) -> bool:
return repo.delete(warehouse_id) return repo.delete(warehouse_id)
def query_warehouses(db: Session, business_id: int, query_info: QueryInfo) -> Dict[str, Any]:
"""Query warehouses with filters/search/sorting/pagination scoped to business."""
# Ensure business scoping via filters
base_filter = FilterItem(property="business_id", operator="=", value=business_id)
merged_filters = [base_filter]
if query_info.filters:
merged_filters.extend(query_info.filters)
effective_query = QueryInfo(
sort_by=query_info.sort_by,
sort_desc=query_info.sort_desc,
take=query_info.take,
skip=query_info.skip,
search=query_info.search,
search_fields=query_info.search_fields,
filters=merged_filters,
)
results, total = QueryService.query_with_filters(Warehouse, db, effective_query)
items = [_to_dict(w) for w in results]
limit = max(1, effective_query.take)
page = (effective_query.skip // limit) + 1
total_pages = (total + limit - 1) // limit
return {
"items": items,
"total": total,
"page": page,
"limit": limit,
"total_pages": total_pages,
}

View file

@ -130,6 +130,7 @@ app/core/responses.py
app/core/security.py app/core/security.py
app/core/settings.py app/core/settings.py
app/core/smart_normalizer.py app/core/smart_normalizer.py
app/services/account_service.py
app/services/api_key_service.py app/services/api_key_service.py
app/services/auth_service.py app/services/auth_service.py
app/services/bank_account_service.py app/services/bank_account_service.py
@ -144,6 +145,7 @@ app/services/document_service.py
app/services/email_service.py app/services/email_service.py
app/services/expense_income_service.py app/services/expense_income_service.py
app/services/file_storage_service.py app/services/file_storage_service.py
app/services/invoice_service.py
app/services/person_service.py app/services/person_service.py
app/services/petty_cash_service.py app/services/petty_cash_service.py
app/services/price_list_service.py app/services/price_list_service.py

View file

@ -0,0 +1,47 @@
from sqlalchemy import text
from adapters.db.session import SessionLocal
def run() -> None:
"""Migrate MySQL ENUM values of checks.type to uppercase only.
Steps:
1) Allow both lowercase and uppercase temporarily
2) Update existing rows to uppercase
3) Restrict enum to uppercase values only
Safe to re-run.
"""
with SessionLocal() as db:
# Case-insensitive collations in MySQL prevent having both 'received' and 'RECEIVED'
# So we migrate via temporary placeholders.
# 1) Add placeholders alongside existing lowercase values
db.execute(text(
"ALTER TABLE checks MODIFY COLUMN type ENUM('received','transferred','TMP_R','TMP_T') NOT NULL"
))
# 2) Move existing rows to placeholders
db.execute(text("UPDATE checks SET type='TMP_R' WHERE type='received'"))
db.execute(text("UPDATE checks SET type='TMP_T' WHERE type='transferred'"))
# 3) Switch enum to uppercase + placeholders
db.execute(text(
"ALTER TABLE checks MODIFY COLUMN type ENUM('RECEIVED','TRANSFERRED','TMP_R','TMP_T') NOT NULL"
))
# 4) Move placeholders to uppercase values
db.execute(text("UPDATE checks SET type='RECEIVED' WHERE type='TMP_R'"))
db.execute(text("UPDATE checks SET type='TRANSFERRED' WHERE type='TMP_T'"))
# 5) Drop placeholders
db.execute(text(
"ALTER TABLE checks MODIFY COLUMN type ENUM('RECEIVED','TRANSFERRED') NOT NULL"
))
db.commit()
if __name__ == "__main__":
run()

View file

@ -0,0 +1,20 @@
from sqlalchemy import text
from adapters.db.session import SessionLocal
def run() -> None:
"""Normalize existing checks.type values to uppercase to match Enum.
Converts 'received' -> 'RECEIVED' and 'transferred' -> 'TRANSFERRED'.
Safe to run multiple times.
"""
with SessionLocal() as db:
db.execute(text("UPDATE checks SET type='RECEIVED' WHERE LOWER(type)='received'"))
db.execute(text("UPDATE checks SET type='TRANSFERRED' WHERE LOWER(type)='transferred'"))
db.commit()
if __name__ == "__main__":
run()

View file

@ -535,6 +535,7 @@ class _MyAppState extends State<MyApp> {
pageBuilder: (context, state) => NoTransitionPage( pageBuilder: (context, state) => NoTransitionPage(
child: AccountsPage( child: AccountsPage(
businessId: int.parse(state.pathParameters['business_id']!), businessId: int.parse(state.pathParameters['business_id']!),
authStore: _authStore!,
), ),
), ),
), ),

View file

@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart'; import 'package:hesabix_ui/l10n/app_localizations.dart';
import 'package:hesabix_ui/core/api_client.dart'; import 'package:hesabix_ui/core/api_client.dart';
import 'package:hesabix_ui/core/auth_store.dart';
class AccountNode { class AccountNode {
final String id; final String id;
@ -42,8 +43,9 @@ class _VisibleNode {
} }
class AccountsPage extends StatefulWidget { class AccountsPage extends StatefulWidget {
final int businessId; final int businessId;
const AccountsPage({super.key, required this.businessId}); final AuthStore authStore;
const AccountsPage({super.key, required this.businessId, required this.authStore});
@override @override
State<AccountsPage> createState() => _AccountsPageState(); State<AccountsPage> createState() => _AccountsPageState();
@ -156,16 +158,20 @@ class _AccountsPageState extends State<AccountsPage> {
final pid = int.tryParse(selectedParentId!); final pid = int.tryParse(selectedParentId!);
if (pid != null) payload["parent_id"] = pid; if (pid != null) payload["parent_id"] = pid;
} }
try { try {
final api = ApiClient(); final api = ApiClient();
await api.post( await api.post(
'/api/v1/accounts/business/${widget.businessId}/create', '/api/v1/accounts/business/${widget.businessId}/create',
data: payload, data: payload,
); );
if (context.mounted) Navigator.of(ctx).pop(true); if (context.mounted) Navigator.of(ctx).pop(true);
} catch (_) { } catch (e) {
// نمایش خطا میتواند بعداً اضافه شود if (context.mounted) {
} ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('خطا در ایجاد حساب: $e')),
);
}
}
}, },
child: Text(t.add), child: Text(t.add),
), ),
@ -206,6 +212,105 @@ class _AccountsPageState extends State<AccountsPage> {
}); });
} }
Future<void> _openEditDialog(AccountNode node) async {
final t = AppLocalizations.of(context);
final codeCtrl = TextEditingController(text: node.code);
final nameCtrl = TextEditingController(text: node.name);
final typeCtrl = TextEditingController(text: node.accountType ?? '');
final parents = _flattenNodes();
String? selectedParentId;
final result = await showDialog<bool>(
context: context,
builder: (ctx) {
return AlertDialog(
title: Text(t.edit),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(controller: codeCtrl, decoration: InputDecoration(labelText: t.code)),
TextField(controller: nameCtrl, decoration: InputDecoration(labelText: t.title)),
TextField(controller: typeCtrl, decoration: InputDecoration(labelText: t.type)),
DropdownButtonFormField<String>(
value: selectedParentId,
items: [
DropdownMenuItem<String>(value: null, child: Text('بدون والد')),
...parents.map((p) => DropdownMenuItem<String>(value: p["id"], child: Text(p["title"]!))).toList(),
],
onChanged: (v) { selectedParentId = v; },
decoration: const InputDecoration(labelText: 'حساب والد'),
),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: Text(t.cancel)),
FilledButton(
onPressed: () async {
final name = nameCtrl.text.trim();
final code = codeCtrl.text.trim();
final atype = typeCtrl.text.trim();
if (name.isEmpty || code.isEmpty || atype.isEmpty) return;
final Map<String, dynamic> payload = {"name": name, "code": code, "account_type": atype};
if (selectedParentId != null && selectedParentId!.isNotEmpty) {
final pid = int.tryParse(selectedParentId!);
if (pid != null) payload["parent_id"] = pid;
}
try {
final id = int.tryParse(node.id);
if (id == null) return;
final api = ApiClient();
await api.put('/api/v1/accounts/account/$id', data: payload);
if (context.mounted) Navigator.of(ctx).pop(true);
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('خطا در ویرایش حساب: $e')),
);
}
}
},
child: Text(t.save),
),
],
);
},
);
if (result == true) {
await _fetch();
}
}
Future<void> _confirmDelete(AccountNode node) async {
final t = AppLocalizations.of(context);
final id = int.tryParse(node.id);
if (id == null) return;
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(t.delete),
content: const Text('آیا مطمئن هستید؟'),
actions: [
TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: Text(t.cancel)),
FilledButton(onPressed: () => Navigator.of(ctx).pop(true), child: Text(t.delete)),
],
),
);
if (ok == true) {
try {
final api = ApiClient();
await api.delete('/api/v1/accounts/account/$id');
await _fetch();
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('خطا در حذف حساب: $e')),
);
}
}
}
}
String _localizedAccountType(AppLocalizations t, String? value) { String _localizedAccountType(AppLocalizations t, String? value) {
if (value == null || value.isEmpty) return '-'; if (value == null || value.isEmpty) return '-';
final ln = t.localeName; final ln = t.localeName;
@ -273,7 +378,7 @@ class _AccountsPageState extends State<AccountsPage> {
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
color: Theme.of(context).colorScheme.surfaceContainerHighest, color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: Row( child: Row(
children: [ children: [
const SizedBox(width: 28), // expander space const SizedBox(width: 28), // expander space
Expanded(flex: 2, child: Text(t.code, style: const TextStyle(fontWeight: FontWeight.w600))), Expanded(flex: 2, child: Text(t.code, style: const TextStyle(fontWeight: FontWeight.w600))),
@ -314,7 +419,21 @@ class _AccountsPageState extends State<AccountsPage> {
), ),
Expanded(flex: 2, child: Text(node.code, style: const TextStyle(fontFeatures: []))), Expanded(flex: 2, child: Text(node.code, style: const TextStyle(fontFeatures: []))),
Expanded(flex: 5, child: Text(node.name)), Expanded(flex: 5, child: Text(node.name)),
Expanded(flex: 3, child: Text(_localizedAccountType(t, node.accountType))), Expanded(flex: 3, child: Text(_localizedAccountType(t, node.accountType))),
SizedBox(
width: 40,
child: PopupMenuButton<String>(
padding: EdgeInsets.zero,
onSelected: (v) {
if (v == 'edit') _openEditDialog(node);
if (v == 'delete') _confirmDelete(node);
},
itemBuilder: (context) => [
const PopupMenuItem<String>(value: 'edit', child: Text('ویرایش')),
const PopupMenuItem<String>(value: 'delete', child: Text('حذف')),
],
),
),
], ],
), ),
), ),
@ -325,10 +444,12 @@ class _AccountsPageState extends State<AccountsPage> {
), ),
], ],
), ),
floatingActionButton: FloatingActionButton( floatingActionButton: widget.authStore.canWriteSection('accounting')
onPressed: _openCreateDialog, ? FloatingActionButton(
child: const Icon(Icons.add), onPressed: _openCreateDialog,
), child: const Icon(Icons.add),
)
: null,
); );
} }
} }

View file

@ -1,6 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../services/warehouse_service.dart'; import '../../services/warehouse_service.dart';
import '../../models/warehouse_model.dart'; import '../../models/warehouse_model.dart';
import '../../widgets/data_table/data_table_widget.dart';
import '../../widgets/data_table/data_table_config.dart';
class WarehousesPage extends StatefulWidget { class WarehousesPage extends StatefulWidget {
final int businessId; final int businessId;
@ -12,69 +14,81 @@ class WarehousesPage extends StatefulWidget {
class _WarehousesPageState extends State<WarehousesPage> { class _WarehousesPageState extends State<WarehousesPage> {
final WarehouseService _service = WarehouseService(); final WarehouseService _service = WarehouseService();
bool _loading = true; final GlobalKey _tableKey = GlobalKey();
String? _error;
List<Warehouse> _items = const <Warehouse>[];
@override void _refreshTable() {
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try { try {
setState(() { final current = _tableKey.currentState as dynamic;
_loading = true; current?.refresh();
_error = null; } catch (_) {}
});
final items = await _service.listWarehouses(businessId: widget.businessId);
if (!mounted) return;
setState(() {
_items = items;
_loading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = e.toString();
_loading = false;
});
}
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('مدیریت انبارها')), body: DataTableWidget<Warehouse>(
floatingActionButton: FloatingActionButton( key: _tableKey,
onPressed: _showCreateDialog, fromJson: (m) => Warehouse.fromJson(m),
child: const Icon(Icons.add), config: DataTableConfig<Warehouse>(
endpoint: '/api/v1/warehouses/business/${widget.businessId}/query',
title: 'فهرست انبارها',
showBackButton: true,
onBack: () => Navigator.of(context).maybePop(),
showTableIcon: false,
showSearch: true,
showPagination: true,
showRowNumbers: true,
enableSorting: true,
searchFields: const ['code', 'name', 'description'],
customHeaderActions: [
Tooltip(
message: 'افزودن انبار',
child: IconButton(
onPressed: _showCreateDialog,
icon: const Icon(Icons.add),
),
),
],
columns: [
ActionColumn('actions', 'عملیات', actions: [
DataTableAction(
icon: Icons.edit_outlined,
label: 'ویرایش',
onTap: (item) {
if (item is Warehouse) _showEditDialog(item);
},
),
DataTableAction(
icon: Icons.delete_outline,
label: 'حذف',
onTap: (item) {
if (item is Warehouse) _delete(item);
},
isDestructive: true,
),
]),
TextColumn('code', 'کد',
formatter: (item) => (item as Warehouse).code,
width: ColumnWidth.small),
TextColumn('name', 'نام',
formatter: (item) => (item as Warehouse).name,
width: ColumnWidth.medium),
TextColumn('description', 'توضیحات',
formatter: (item) => (item as Warehouse).description ?? '',
width: ColumnWidth.large,
searchable: true),
TextColumn('is_default', 'پیش‌فرض',
formatter: (item) => (item as Warehouse).isDefault ? 'بله' : 'خیر',
sortable: false,
searchable: false,
width: ColumnWidth.small),
DateColumn('created_at', 'ایجاد',
formatter: (item) => (item as Warehouse).createdAt?.toIso8601String() ?? '',
showTime: false,
width: ColumnWidth.small),
],
),
), ),
body: _loading
? const Center(child: CircularProgressIndicator())
: _error != null
? Center(child: Text(_error!, style: TextStyle(color: Colors.red.shade700)))
: RefreshIndicator(
onRefresh: _load,
child: ListView.separated(
itemCount: _items.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (ctx, idx) {
final w = _items[idx];
return ListTile(
leading: Icon(w.isDefault ? Icons.star : Icons.store, color: w.isDefault ? Colors.orange : null),
title: Text('${w.code} - ${w.name}'),
subtitle: Text(w.description ?? ''),
onTap: () => _showEditDialog(w),
trailing: IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: () => _delete(w),
),
);
},
),
),
); );
} }
@ -109,7 +123,7 @@ class _WarehousesPageState extends State<WarehousesPage> {
); );
if (ok != true) return; if (ok != true) return;
try { try {
final created = await _service.createWarehouse( await _service.createWarehouse(
businessId: widget.businessId, businessId: widget.businessId,
payload: { payload: {
'code': codeCtrl.text.trim(), 'code': codeCtrl.text.trim(),
@ -118,9 +132,7 @@ class _WarehousesPageState extends State<WarehousesPage> {
}, },
); );
if (!mounted) return; if (!mounted) return;
setState(() { _refreshTable();
_items = [created, ..._items];
});
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا: $e'))); ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا: $e')));
@ -158,7 +170,7 @@ class _WarehousesPageState extends State<WarehousesPage> {
); );
if (ok != true) return; if (ok != true) return;
try { try {
final updated = await _service.updateWarehouse( await _service.updateWarehouse(
businessId: widget.businessId, businessId: widget.businessId,
warehouseId: w.id!, warehouseId: w.id!,
payload: { payload: {
@ -168,9 +180,7 @@ class _WarehousesPageState extends State<WarehousesPage> {
}, },
); );
if (!mounted) return; if (!mounted) return;
setState(() { _refreshTable();
_items = _items.map((e) => e.id == updated.id ? updated : e).toList();
});
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا: $e'))); ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا: $e')));
@ -194,9 +204,7 @@ class _WarehousesPageState extends State<WarehousesPage> {
final deleted = await _service.deleteWarehouse(businessId: widget.businessId, warehouseId: w.id!); final deleted = await _service.deleteWarehouse(businessId: widget.businessId, warehouseId: w.id!);
if (!mounted) return; if (!mounted) return;
if (deleted) { if (deleted) {
setState(() { _refreshTable();
_items = _items.where((e) => e.id != w.id).toList();
});
} }
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;