pharmacy-erp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/.env.example +34 -0
  2. package/LICENSE +21 -0
  3. package/README.md +479 -0
  4. package/backend/Dockerfile +44 -0
  5. package/backend/app/__init__.py +1 -0
  6. package/backend/app/core/__init__.py +1 -0
  7. package/backend/app/core/audit.py +33 -0
  8. package/backend/app/core/config.py +16 -0
  9. package/backend/app/core/database.py +26 -0
  10. package/backend/app/core/deps.py +49 -0
  11. package/backend/app/core/exceptions.py +43 -0
  12. package/backend/app/core/security.py +32 -0
  13. package/backend/app/main.py +54 -0
  14. package/backend/app/modules/__init__.py +0 -0
  15. package/backend/app/modules/admin/__init__.py +1 -0
  16. package/backend/app/modules/admin/models.py +15 -0
  17. package/backend/app/modules/admin/router.py +353 -0
  18. package/backend/app/modules/auth/__init__.py +0 -0
  19. package/backend/app/modules/auth/models.py +16 -0
  20. package/backend/app/modules/auth/router.py +73 -0
  21. package/backend/app/modules/auth/schemas.py +29 -0
  22. package/backend/app/modules/cash_sessions/__init__.py +0 -0
  23. package/backend/app/modules/cash_sessions/models.py +19 -0
  24. package/backend/app/modules/cash_sessions/router.py +85 -0
  25. package/backend/app/modules/cash_sessions/schemas.py +31 -0
  26. package/backend/app/modules/customers/__init__.py +0 -0
  27. package/backend/app/modules/customers/models.py +37 -0
  28. package/backend/app/modules/customers/router.py +296 -0
  29. package/backend/app/modules/customers/schemas.py +72 -0
  30. package/backend/app/modules/inventory/__init__.py +0 -0
  31. package/backend/app/modules/inventory/models.py +16 -0
  32. package/backend/app/modules/inventory/router.py +150 -0
  33. package/backend/app/modules/inventory/schemas.py +24 -0
  34. package/backend/app/modules/medicines/__init__.py +0 -0
  35. package/backend/app/modules/medicines/models.py +54 -0
  36. package/backend/app/modules/medicines/router.py +631 -0
  37. package/backend/app/modules/medicines/schemas.py +153 -0
  38. package/backend/app/modules/medicines/template_generator.py +118 -0
  39. package/backend/app/modules/notifications/__init__.py +1 -0
  40. package/backend/app/modules/notifications/models.py +15 -0
  41. package/backend/app/modules/notifications/router.py +89 -0
  42. package/backend/app/modules/notifications/schemas.py +22 -0
  43. package/backend/app/modules/organizations/__init__.py +0 -0
  44. package/backend/app/modules/organizations/models.py +16 -0
  45. package/backend/app/modules/organizations/router.py +49 -0
  46. package/backend/app/modules/organizations/schemas.py +31 -0
  47. package/backend/app/modules/reports/__init__.py +0 -0
  48. package/backend/app/modules/reports/router.py +409 -0
  49. package/backend/app/modules/roles/__init__.py +1 -0
  50. package/backend/app/modules/roles/models.py +43 -0
  51. package/backend/app/modules/roles/router.py +89 -0
  52. package/backend/app/modules/roles/schemas.py +55 -0
  53. package/backend/app/modules/sales/__init__.py +0 -0
  54. package/backend/app/modules/sales/models.py +44 -0
  55. package/backend/app/modules/sales/router.py +63 -0
  56. package/backend/app/modules/sales/schemas.py +42 -0
  57. package/backend/app/modules/sales/service.py +128 -0
  58. package/backend/app/modules/suppliers/__init__.py +0 -0
  59. package/backend/app/modules/suppliers/models.py +14 -0
  60. package/backend/app/modules/suppliers/router.py +56 -0
  61. package/backend/app/modules/suppliers/schemas.py +27 -0
  62. package/backend/app/modules/users/__init__.py +0 -0
  63. package/backend/app/modules/users/router.py +74 -0
  64. package/backend/app/seed.py +275 -0
  65. package/backend/data/.gitkeep +1 -0
  66. package/backend/requirements.txt +17 -0
  67. package/bin/cli.js +288 -0
  68. package/docker-compose.dev.yml +42 -0
  69. package/docker-compose.yml +59 -0
  70. package/frontend/Dockerfile +56 -0
  71. package/frontend/next.config.ts +19 -0
  72. package/frontend/package.json +60 -0
  73. package/frontend/pnpm-lock.yaml +5678 -0
  74. package/frontend/pnpm-workspace.yaml +6 -0
  75. package/frontend/postcss.config.mjs +6 -0
  76. package/frontend/public/logo.png +0 -0
  77. package/frontend/src/app/[locale]/(app)/admin/page.tsx +1426 -0
  78. package/frontend/src/app/[locale]/(app)/catalog/products/[id]/page.tsx +505 -0
  79. package/frontend/src/app/[locale]/(app)/catalog/products/page.tsx +753 -0
  80. package/frontend/src/app/[locale]/(app)/customers/[id]/page.tsx +500 -0
  81. package/frontend/src/app/[locale]/(app)/customers/page.tsx +538 -0
  82. package/frontend/src/app/[locale]/(app)/dashboard/page.tsx +175 -0
  83. package/frontend/src/app/[locale]/(app)/inventory/page.tsx +765 -0
  84. package/frontend/src/app/[locale]/(app)/layout.tsx +60 -0
  85. package/frontend/src/app/[locale]/(app)/purchasing/page.tsx +1 -0
  86. package/frontend/src/app/[locale]/(app)/reports/page.tsx +794 -0
  87. package/frontend/src/app/[locale]/(app)/sales/page.tsx +296 -0
  88. package/frontend/src/app/[locale]/(auth)/login/page.tsx +100 -0
  89. package/frontend/src/app/[locale]/(pos)/layout.tsx +33 -0
  90. package/frontend/src/app/[locale]/(pos)/pos/page.tsx +463 -0
  91. package/frontend/src/app/[locale]/(pos)/pos/pos-data.ts +89 -0
  92. package/frontend/src/app/[locale]/layout.tsx +30 -0
  93. package/frontend/src/app/[locale]/page.tsx +6 -0
  94. package/frontend/src/app/globals.css +77 -0
  95. package/frontend/src/app/layout.tsx +23 -0
  96. package/frontend/src/app/print.css +18 -0
  97. package/frontend/src/components/pos/CartPanel.tsx +525 -0
  98. package/frontend/src/components/pos/CashSessionGuard.tsx +255 -0
  99. package/frontend/src/components/pos/CloseSessionModal.tsx +160 -0
  100. package/frontend/src/components/pos/PaymentModal.tsx +266 -0
  101. package/frontend/src/components/pos/PaymentSettingsModal.tsx +472 -0
  102. package/frontend/src/components/pos/ProductCatalog.tsx +140 -0
  103. package/frontend/src/components/pos/SaleReceipt.tsx +133 -0
  104. package/frontend/src/components/ui/badge.tsx +28 -0
  105. package/frontend/src/components/ui/bulk-import-modal.tsx +400 -0
  106. package/frontend/src/components/ui/button.tsx +60 -0
  107. package/frontend/src/components/ui/card.tsx +13 -0
  108. package/frontend/src/components/ui/confirm-dialog.tsx +50 -0
  109. package/frontend/src/components/ui/input.tsx +32 -0
  110. package/frontend/src/components/ui/notification-center.tsx +228 -0
  111. package/frontend/src/components/ui/select.tsx +59 -0
  112. package/frontend/src/components/ui/sidebar.tsx +91 -0
  113. package/frontend/src/components/ui/slide-over.tsx +43 -0
  114. package/frontend/src/components/ui/stat-card.tsx +40 -0
  115. package/frontend/src/components/ui/switch.tsx +38 -0
  116. package/frontend/src/components/ui/topbar.tsx +97 -0
  117. package/frontend/src/i18n/request.ts +13 -0
  118. package/frontend/src/i18n/routing.ts +9 -0
  119. package/frontend/src/lib/api.ts +240 -0
  120. package/frontend/src/lib/constants.ts +30 -0
  121. package/frontend/src/lib/utils.ts +32 -0
  122. package/frontend/src/messages/ar.json +106 -0
  123. package/frontend/src/messages/en.json +106 -0
  124. package/frontend/src/middleware.ts +8 -0
  125. package/frontend/src/store/app-store.ts +37 -0
  126. package/frontend/src/store/auth-store.ts +52 -0
  127. package/frontend/src/store/payment-methods-store.ts +144 -0
  128. package/frontend/src/store/pos-store.ts +344 -0
  129. package/frontend/tailwind.config.ts +85 -0
  130. package/frontend/tsconfig.json +27 -0
  131. package/img/backgraund-logo.png +0 -0
  132. package/img/full-logo.png +0 -0
  133. package/img/logo.png +0 -0
  134. package/nginx/nginx.conf +67 -0
  135. package/package.json +88 -0
  136. package/start.bat +90 -0
  137. package/start.sh +64 -0
@@ -0,0 +1,153 @@
1
+ from pydantic import BaseModel
2
+ from typing import Optional, List
3
+ from datetime import datetime
4
+ import uuid
5
+ from decimal import Decimal
6
+
7
+ class CategoryCreate(BaseModel):
8
+ name_en: str
9
+ name_ar: Optional[str] = None
10
+ parent_id: Optional[uuid.UUID] = None
11
+ category_level: int = 1
12
+ icon: Optional[str] = None
13
+ sort_order: int = 0
14
+
15
+ class CategoryResponse(CategoryCreate):
16
+ id: uuid.UUID
17
+ category_level: int
18
+ icon: Optional[str] = None
19
+ sort_order: int
20
+ model_config = {"from_attributes": True}
21
+
22
+ class CategoryUpdate(BaseModel):
23
+ name_en: Optional[str] = None
24
+ name_ar: Optional[str] = None
25
+ parent_id: Optional[uuid.UUID] = None
26
+ category_level: Optional[int] = None
27
+ icon: Optional[str] = None
28
+ sort_order: Optional[int] = None
29
+
30
+ class CategoryTreeResponse(CategoryResponse):
31
+ children: List['CategoryTreeResponse'] = []
32
+ model_config = {"from_attributes": True}
33
+
34
+ class MedicineCreate(BaseModel):
35
+ sku: str
36
+ name_en: str
37
+ name_ar: Optional[str] = None
38
+ base_unit: str
39
+ selling_price: Decimal
40
+ barcode: Optional[str] = None
41
+ category_id: Optional[uuid.UUID] = None
42
+ generic_name: Optional[str] = None
43
+ brand_name: Optional[str] = None
44
+ strength: Optional[str] = None
45
+ dosage_form: Optional[str] = None
46
+ manufacturer: Optional[str] = None
47
+ description: Optional[str] = None
48
+ units_per_pack: int = 1
49
+ allow_loose_sale: bool = False
50
+ requires_prescription: bool = False
51
+ is_controlled: bool = False
52
+ is_cold_chain: bool = False
53
+ reorder_level: int = 0
54
+ max_stock: int = 0
55
+ image_url: Optional[str] = None
56
+
57
+ class MedicineUpdate(BaseModel):
58
+ sku: Optional[str] = None
59
+ name_en: Optional[str] = None
60
+ name_ar: Optional[str] = None
61
+ base_unit: Optional[str] = None
62
+ selling_price: Optional[Decimal] = None
63
+ barcode: Optional[str] = None
64
+ category_id: Optional[uuid.UUID] = None
65
+ generic_name: Optional[str] = None
66
+ brand_name: Optional[str] = None
67
+ strength: Optional[str] = None
68
+ dosage_form: Optional[str] = None
69
+ manufacturer: Optional[str] = None
70
+ description: Optional[str] = None
71
+ units_per_pack: Optional[int] = None
72
+ allow_loose_sale: Optional[bool] = None
73
+ requires_prescription: Optional[bool] = None
74
+ is_controlled: Optional[bool] = None
75
+ is_cold_chain: Optional[bool] = None
76
+ reorder_level: Optional[int] = None
77
+ max_stock: Optional[int] = None
78
+ image_url: Optional[str] = None
79
+ is_active: Optional[bool] = None
80
+
81
+ class MedicineResponse(MedicineCreate):
82
+ id: uuid.UUID
83
+ selling_price: Decimal
84
+ is_active: bool
85
+ model_config = {"from_attributes": True}
86
+
87
+ class InitialBatch(BaseModel):
88
+ batch_number: Optional[str] = None
89
+ quantity: Decimal
90
+ purchase_price: Decimal
91
+ production_date: Optional[datetime] = None
92
+ expiry_date: datetime
93
+ supplier_id: Optional[uuid.UUID] = None
94
+
95
+ class MedicineCreateWithBatch(MedicineCreate):
96
+ initial_batch: Optional[InitialBatch] = None
97
+
98
+ class BatchCreate(BaseModel):
99
+ batch_number: str
100
+ expiry_date: datetime
101
+ purchase_price: Decimal
102
+ quantity_received: Decimal
103
+ supplier_id: Optional[uuid.UUID] = None
104
+ production_date: Optional[datetime] = None
105
+ notes: Optional[str] = None
106
+
107
+ class BatchUpdate(BaseModel):
108
+ expiry_date: Optional[datetime] = None
109
+ purchase_price: Optional[Decimal] = None
110
+ production_date: Optional[datetime] = None
111
+ notes: Optional[str] = None
112
+ status: Optional[str] = None # ACTIVE, QUARANTINED, RECALLED, DAMAGED
113
+
114
+ class BatchResponse(BaseModel):
115
+ id: uuid.UUID
116
+ medicine_id: uuid.UUID
117
+ batch_number: str
118
+ expiry_date: datetime
119
+ purchase_price: Decimal
120
+ quantity_received: Decimal
121
+ quantity_remaining: Decimal
122
+ received_date: datetime
123
+ is_active: bool
124
+ production_date: Optional[datetime] = None
125
+ supplier_id: Optional[uuid.UUID] = None
126
+ status: Optional[str] = "ACTIVE"
127
+ notes: Optional[str] = None
128
+ model_config = {"from_attributes": True}
129
+
130
+ class BatchConflictResponse(BaseModel):
131
+ """Returned when a batch number already exists with a different expiry date"""
132
+ conflict: bool = True
133
+ message: str
134
+ existing_batch_number: str
135
+ existing_expiry_date: datetime
136
+ entered_expiry_date: datetime
137
+
138
+ class MedicineDetailResponse(MedicineResponse):
139
+ batches: List[BatchResponse] = []
140
+ category_name: Optional[str] = None
141
+
142
+ class BulkImportRowError(BaseModel):
143
+ row: int
144
+ field: Optional[str] = None
145
+ message: str
146
+
147
+ class BulkImportResponse(BaseModel):
148
+ total_rows: int
149
+ successful: int
150
+ failed: int
151
+ errors: List[BulkImportRowError] = []
152
+ created_medicines: List[MedicineResponse] = []
153
+
@@ -0,0 +1,118 @@
1
+ import io
2
+ import csv
3
+ from openpyxl import Workbook
4
+ from openpyxl.styles import PatternFill, Font, Alignment
5
+ from openpyxl.worksheet.datavalidation import DataValidation
6
+ from openpyxl.utils import get_column_letter
7
+
8
+ COLUMNS = [
9
+ {"header": "name_en *", "field": "name_en", "desc": "English name of the medicine (Required)"},
10
+ {"header": "name_ar", "field": "name_ar", "desc": "Arabic name"},
11
+ {"header": "sku", "field": "sku", "desc": "Stock Keeping Unit (Auto-generated if empty)"},
12
+ {"header": "barcode", "field": "barcode", "desc": "Barcode for scanning"},
13
+ {"header": "selling_price *", "field": "selling_price", "desc": "Selling price in base currency (Required)"},
14
+ {"header": "base_unit *", "field": "base_unit", "desc": "Base unit (Required)"},
15
+ {"header": "category", "field": "category", "desc": "Category name (Matched with existing)"},
16
+ {"header": "generic_name", "field": "generic_name", "desc": "Generic name / Scientific name"},
17
+ {"header": "brand_name", "field": "brand_name", "desc": "Brand name"},
18
+ {"header": "strength", "field": "strength", "desc": "Strength (e.g., 500mg)"},
19
+ {"header": "dosage_form", "field": "dosage_form", "desc": "Form of the medicine"},
20
+ {"header": "manufacturer", "field": "manufacturer", "desc": "Manufacturer name"},
21
+ {"header": "description", "field": "description", "desc": "Additional details"},
22
+ {"header": "units_per_pack", "field": "units_per_pack", "desc": "Number of units per pack (Default: 1)"},
23
+ {"header": "max_stock", "field": "max_stock", "desc": "Maximum stock allowed (Default: 0)"},
24
+ {"header": "initial_stock", "field": "initial_stock", "desc": "Initial stock quantity"},
25
+ {"header": "purchase_price", "field": "purchase_price", "desc": "Purchase price"},
26
+ {"header": "batch_number", "field": "batch_number", "desc": "Batch/Lot number"},
27
+ {"header": "expiry_date", "field": "expiry_date", "desc": "Expiry date YYYY-MM-DD"},
28
+ ]
29
+
30
+ EXAMPLE_ROW = [
31
+ "Amoxicillin 500mg",
32
+ "أموكسيسيلين 500 ملغ",
33
+ "AMX-500",
34
+ "1234567890123",
35
+ "15.50",
36
+ "Box",
37
+ "Antibiotics",
38
+ "Amoxicillin",
39
+ "Amoxil",
40
+ "500mg",
41
+ "capsule",
42
+ "GSK",
43
+ "Broad-spectrum antibiotic",
44
+ "20",
45
+ "100",
46
+ "100",
47
+ "10.50",
48
+ "B-AMX-500",
49
+ "2027-12-31"
50
+ ]
51
+
52
+ BASE_UNITS = ["Pack", "Tablet", "Bottle", "Box", "Tube", "Strip", "Vial", "Ampoule", "Piece"]
53
+ DOSAGE_FORMS = ["tablet", "capsule", "syrup", "suspension", "injection", "infusion", "cream", "ointment", "gel", "drops", "eye_drops", "ear_drops", "nasal_spray", "inhaler", "suppository", "powder"]
54
+
55
+ def generate_csv_template() -> io.BytesIO:
56
+ output = io.StringIO()
57
+ writer = csv.writer(output)
58
+
59
+ headers = [col["header"] for col in COLUMNS]
60
+ descriptions = [col["desc"] for col in COLUMNS]
61
+
62
+ writer.writerow(headers)
63
+ writer.writerow(EXAMPLE_ROW)
64
+ writer.writerow(descriptions)
65
+
66
+ bio = io.BytesIO(output.getvalue().encode('utf-8'))
67
+ return bio
68
+
69
+ def generate_xlsx_template() -> io.BytesIO:
70
+ wb = Workbook()
71
+ ws = wb.active
72
+ ws.title = "Medicines Import"
73
+
74
+ headers = [col["header"] for col in COLUMNS]
75
+ descriptions = [col["desc"] for col in COLUMNS]
76
+
77
+ ws.append(headers)
78
+ ws.append(EXAMPLE_ROW)
79
+ ws.append(descriptions)
80
+
81
+ # Styles
82
+ header_fill = PatternFill(start_color="000080", end_color="000080", fill_type="solid")
83
+ header_font = Font(color="FFFFFF", bold=True)
84
+ example_fill = PatternFill(start_color="E2EFDA", end_color="E2EFDA", fill_type="solid")
85
+ desc_fill = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid")
86
+
87
+ for col_idx, cell in enumerate(ws[1], 1):
88
+ cell.fill = header_fill
89
+ cell.font = header_font
90
+ cell.alignment = Alignment(horizontal="center")
91
+
92
+ for cell in ws[2]:
93
+ cell.fill = example_fill
94
+
95
+ for cell in ws[3]:
96
+ cell.fill = desc_fill
97
+
98
+ # Auto-fit columns
99
+ for col_idx, col in enumerate(COLUMNS, 1):
100
+ col_letter = get_column_letter(col_idx)
101
+ max_length = max(len(str(col["header"])), len(str(col["desc"])), len(str(EXAMPLE_ROW[col_idx-1])))
102
+ ws.column_dimensions[col_letter].width = max_length + 2
103
+
104
+ # Data Validation
105
+ # base_unit is column 6 (F)
106
+ dv_base_unit = DataValidation(type="list", formula1=f'"{",".join(BASE_UNITS)}"', allow_blank=False)
107
+ ws.add_data_validation(dv_base_unit)
108
+ dv_base_unit.add(f"F4:F1000")
109
+
110
+ # dosage_form is column 11 (K)
111
+ dv_dosage_form = DataValidation(type="list", formula1=f'"{",".join(DOSAGE_FORMS)}"', allow_blank=True)
112
+ ws.add_data_validation(dv_dosage_form)
113
+ dv_dosage_form.add(f"K4:K1000")
114
+
115
+ bio = io.BytesIO()
116
+ wb.save(bio)
117
+ bio.seek(0)
118
+ return bio
@@ -0,0 +1 @@
1
+ # Notifications module
@@ -0,0 +1,15 @@
1
+ from sqlalchemy.orm import Mapped, mapped_column
2
+ from sqlalchemy import String, Boolean, ForeignKey, Text
3
+ from app.core.database import Base
4
+ import uuid
5
+
6
+ class Notification(Base):
7
+ __tablename__ = "notifications"
8
+ user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=True)
9
+ type: Mapped[str] = mapped_column(String) # info, warning, danger, success
10
+ title: Mapped[str] = mapped_column(String)
11
+ title_ar: Mapped[str] = mapped_column(String, nullable=True)
12
+ message: Mapped[str] = mapped_column(Text)
13
+ message_ar: Mapped[str] = mapped_column(Text, nullable=True)
14
+ link: Mapped[str] = mapped_column(String, nullable=True)
15
+ is_read: Mapped[bool] = mapped_column(Boolean, default=False)
@@ -0,0 +1,89 @@
1
+ from fastapi import APIRouter, Depends
2
+ from sqlalchemy.ext.asyncio import AsyncSession
3
+ from sqlalchemy import select, update, delete, func, or_
4
+ from typing import List
5
+ from uuid import UUID
6
+
7
+ from app.core.database import get_db
8
+ from app.core.deps import get_current_user
9
+ from app.modules.auth.models import User
10
+ from app.modules.notifications.models import Notification
11
+ from app.modules.notifications.schemas import NotificationResponse, UnreadCountResponse
12
+
13
+ router = APIRouter(tags=["Notifications"])
14
+
15
+ @router.get("/", response_model=List[NotificationResponse])
16
+ async def get_notifications(
17
+ db: AsyncSession = Depends(get_db),
18
+ current_user: User = Depends(get_current_user)
19
+ ):
20
+ stmt = (
21
+ select(Notification)
22
+ .where(or_(Notification.user_id == current_user.id, Notification.user_id == None))
23
+ .order_by(Notification.created_at.desc())
24
+ .limit(50)
25
+ )
26
+ result = await db.execute(stmt)
27
+ return result.scalars().all()
28
+
29
+ @router.get("/unread-count", response_model=UnreadCountResponse)
30
+ async def get_unread_count(
31
+ db: AsyncSession = Depends(get_db),
32
+ current_user: User = Depends(get_current_user)
33
+ ):
34
+ stmt = (
35
+ select(func.count(Notification.id))
36
+ .where(
37
+ or_(Notification.user_id == current_user.id, Notification.user_id == None),
38
+ Notification.is_read == False
39
+ )
40
+ )
41
+ result = await db.execute(stmt)
42
+ count = result.scalar() or 0
43
+ return {"unread_count": count}
44
+
45
+ @router.patch("/{id}/read")
46
+ async def mark_as_read(
47
+ id: UUID,
48
+ db: AsyncSession = Depends(get_db),
49
+ current_user: User = Depends(get_current_user)
50
+ ):
51
+ stmt = (
52
+ update(Notification)
53
+ .where(Notification.id == id)
54
+ .where(or_(Notification.user_id == current_user.id, Notification.user_id == None))
55
+ .values(is_read=True)
56
+ )
57
+ await db.execute(stmt)
58
+ await db.commit()
59
+ return {"status": "success"}
60
+
61
+ @router.post("/read-all")
62
+ async def mark_all_as_read(
63
+ db: AsyncSession = Depends(get_db),
64
+ current_user: User = Depends(get_current_user)
65
+ ):
66
+ stmt = (
67
+ update(Notification)
68
+ .where(or_(Notification.user_id == current_user.id, Notification.user_id == None))
69
+ .where(Notification.is_read == False)
70
+ .values(is_read=True)
71
+ )
72
+ await db.execute(stmt)
73
+ await db.commit()
74
+ return {"status": "success"}
75
+
76
+ @router.delete("/{id}")
77
+ async def delete_notification(
78
+ id: UUID,
79
+ db: AsyncSession = Depends(get_db),
80
+ current_user: User = Depends(get_current_user)
81
+ ):
82
+ stmt = (
83
+ delete(Notification)
84
+ .where(Notification.id == id)
85
+ .where(or_(Notification.user_id == current_user.id, Notification.user_id == None))
86
+ )
87
+ await db.execute(stmt)
88
+ await db.commit()
89
+ return {"status": "success"}
@@ -0,0 +1,22 @@
1
+ from pydantic import BaseModel, ConfigDict
2
+ from typing import Optional
3
+ from datetime import datetime
4
+ from uuid import UUID
5
+
6
+ class NotificationResponse(BaseModel):
7
+ id: UUID
8
+ user_id: Optional[UUID] = None
9
+ type: str
10
+ title: str
11
+ title_ar: Optional[str] = None
12
+ message: str
13
+ message_ar: Optional[str] = None
14
+ link: Optional[str] = None
15
+ is_read: bool
16
+ created_at: datetime
17
+ updated_at: datetime
18
+
19
+ model_config = ConfigDict(from_attributes=True)
20
+
21
+ class UnreadCountResponse(BaseModel):
22
+ unread_count: int
File without changes
@@ -0,0 +1,16 @@
1
+ from sqlalchemy.orm import Mapped, mapped_column
2
+ from sqlalchemy import String, Boolean
3
+ from app.core.database import Base
4
+
5
+ class Branch(Base):
6
+ __tablename__ = "branches"
7
+ name: Mapped[str] = mapped_column(String)
8
+ name_ar: Mapped[str] = mapped_column(String, nullable=True)
9
+ code: Mapped[str] = mapped_column(String, unique=True)
10
+ address: Mapped[str] = mapped_column(String, nullable=True)
11
+ phone: Mapped[str] = mapped_column(String, nullable=True)
12
+ email: Mapped[str] = mapped_column(String, nullable=True)
13
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True)
14
+ currency: Mapped[str] = mapped_column(String, default="SAR")
15
+ timezone: Mapped[str] = mapped_column(String, default="Asia/Riyadh")
16
+ locale: Mapped[str] = mapped_column(String, default="ar_SA")
@@ -0,0 +1,49 @@
1
+ from fastapi import APIRouter, Depends, HTTPException, status
2
+ from sqlalchemy.ext.asyncio import AsyncSession
3
+ from sqlalchemy import select
4
+ from typing import List
5
+ import uuid
6
+
7
+ from app.core.database import get_db
8
+ from app.core.deps import get_current_user
9
+ from app.modules.auth.models import User
10
+ from app.modules.organizations.models import Branch
11
+ from app.modules.organizations.schemas import BranchCreate, BranchUpdate, BranchResponse
12
+
13
+ router = APIRouter()
14
+
15
+ @router.post("/", response_model=BranchResponse)
16
+ async def create_branch(branch: BranchCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
17
+ db_branch = Branch(**branch.model_dump())
18
+ db.add(db_branch)
19
+ await db.commit()
20
+ await db.refresh(db_branch)
21
+ return db_branch
22
+
23
+ @router.get("/", response_model=List[BranchResponse])
24
+ async def list_branches(db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
25
+ result = await db.execute(select(Branch))
26
+ return result.scalars().all()
27
+
28
+ @router.get("/{id}", response_model=BranchResponse)
29
+ async def get_branch(id: uuid.UUID, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
30
+ result = await db.execute(select(Branch).where(Branch.id == id))
31
+ branch = result.scalar_one_or_none()
32
+ if not branch:
33
+ raise HTTPException(status_code=404, detail="Branch not found")
34
+ return branch
35
+
36
+ @router.put("/{id}", response_model=BranchResponse)
37
+ async def update_branch(id: uuid.UUID, branch: BranchUpdate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
38
+ result = await db.execute(select(Branch).where(Branch.id == id))
39
+ db_branch = result.scalar_one_or_none()
40
+ if not db_branch:
41
+ raise HTTPException(status_code=404, detail="Branch not found")
42
+
43
+ update_data = branch.model_dump(exclude_unset=True)
44
+ for key, value in update_data.items():
45
+ setattr(db_branch, key, value)
46
+
47
+ await db.commit()
48
+ await db.refresh(db_branch)
49
+ return db_branch
@@ -0,0 +1,31 @@
1
+ from pydantic import BaseModel, EmailStr
2
+ from typing import Optional
3
+ import uuid
4
+
5
+ class BranchCreate(BaseModel):
6
+ name: str
7
+ code: str
8
+ name_ar: Optional[str] = None
9
+ address: Optional[str] = None
10
+ phone: Optional[str] = None
11
+ email: Optional[EmailStr] = None
12
+ currency: str = "SAR"
13
+ timezone: str = "Asia/Riyadh"
14
+ locale: str = "ar_SA"
15
+
16
+ class BranchUpdate(BaseModel):
17
+ name: Optional[str] = None
18
+ code: Optional[str] = None
19
+ name_ar: Optional[str] = None
20
+ address: Optional[str] = None
21
+ phone: Optional[str] = None
22
+ email: Optional[EmailStr] = None
23
+ currency: Optional[str] = None
24
+ timezone: Optional[str] = None
25
+ locale: Optional[str] = None
26
+ is_active: Optional[bool] = None
27
+
28
+ class BranchResponse(BranchCreate):
29
+ id: uuid.UUID
30
+ is_active: bool
31
+ model_config = {"from_attributes": True}
File without changes