diff --git a/.claude/launch.json b/.claude/launch.json
new file mode 100644
index 0000000..98a68c8
--- /dev/null
+++ b/.claude/launch.json
@@ -0,0 +1,23 @@
+{
+ "version": "0.0.1",
+ "configurations": [
+ {
+ "name": "dev",
+ "runtimeExecutable": "npm",
+ "runtimeArgs": [
+ "run",
+ "dev"
+ ],
+ "port": 5173
+ },
+ {
+ "name": "serve",
+ "runtimeExecutable": "php",
+ "runtimeArgs": [
+ "artisan",
+ "serve"
+ ],
+ "port": 8000
+ }
+ ]
+}
diff --git a/app/Enums/DocumentPriorityEnum.php b/app/Enums/DocumentPriorityEnum.php
index e0a7eee..02f4316 100644
--- a/app/Enums/DocumentPriorityEnum.php
+++ b/app/Enums/DocumentPriorityEnum.php
@@ -8,5 +8,13 @@ enum DocumentPriorityEnum: string
case Medium = 'عادی';
case High = 'اورژانسی';
+
+ public function processingHours(): int
+ {
+ return match ($this) {
+ self::Medium => 24,
+ self::High => 3,
+ };
+ }
}
diff --git a/app/Enums/InvoicePaymentMethodEnum.php b/app/Enums/InvoicePaymentMethodEnum.php
new file mode 100644
index 0000000..2137fb1
--- /dev/null
+++ b/app/Enums/InvoicePaymentMethodEnum.php
@@ -0,0 +1,11 @@
+withdrawRequestService = $withdrawRequestService;
+ $this->walletService = $walletService;
+ }
+
+ public function index(Request $request)
+ {
+ $lists = WithdrawRequest::with(['user', 'bankCard', 'reviewedBy'])
+ ->when(isset($request->search), function ($query) use ($request) {
+ $query->whereHas('user', function ($userQuery) use ($request) {
+ $userQuery->where('fullname', 'LIKE', "%{$request->search}%")
+ ->orWhere('phone', 'LIKE', "%{$request->search}%")
+ ->orWhere('email', 'LIKE', "%{$request->search}%");
+ });
+ })
+ ->when(isset($request->status) && $request->status != 'همه وضعیت ها', function ($query) use ($request) {
+ $query->where('status', $request->status);
+ })
+ ->latest()
+ ->paginate(20)
+ ->withQueryString()
+ ->onEachSide(1);
+
+ $pending = WithdrawRequest::where('status', WithdrawRequestStatusEnum::Pending->value);
+
+ return Inertia::admin('WithdrawRequests/index', [
+ 'withdrawRequests' => WithdrawRequestResource::collection($lists),
+ 'stats' => [
+ 'pending_count' => (clone $pending)->count(),
+ 'pending_amount' => (int) (clone $pending)->sum('amount'),
+ 'system_wallet_balance' => $this->walletService->system()->balance,
+ ],
+ 'search' => $request->search,
+ 'status' => $request->status,
+ ]);
+ }
+
+ public function approve(WithdrawRequest $withdrawRequest)
+ {
+ $this->withdrawRequestService->approve($withdrawRequest, Auth::user());
+
+ session()->flash('success', 'درخواست برداشت تایید شد و مبلغ به ولت سایت منتقل شد.');
+
+ return response(null, 200);
+ }
+
+ public function reject(WithdrawRejectRequest $request, WithdrawRequest $withdrawRequest)
+ {
+ $this->withdrawRequestService->reject($withdrawRequest, Auth::user(), $request->validated('note'));
+
+ session()->flash('success', 'درخواست برداشت رد شد.');
+
+ return response(null, 200);
+ }
+}
diff --git a/app/Http/Controllers/Client/Doctor/CaseController.php b/app/Http/Controllers/Client/Doctor/CaseController.php
index 5cfeb99..ccbaa8c 100644
--- a/app/Http/Controllers/Client/Doctor/CaseController.php
+++ b/app/Http/Controllers/Client/Doctor/CaseController.php
@@ -3,7 +3,10 @@
namespace App\Http\Controllers\Client\Doctor;
use App\Http\Controllers\Controller;
+use App\Http\Requests\Client\CaseRejectRequest;
+use App\Http\Resources\Client\CaseDetailResource;
use App\Http\Resources\Client\CaseResource;
+use App\Models\Document;
use App\Services\Document\CaseService;
use Illuminate\Support\Facades\Auth;
use Inertia\Inertia;
@@ -28,4 +31,25 @@ class CaseController extends Controller
'cases' => CaseResource::collection($cases),
]);
}
+
+ public function show(Document $case)
+ {
+ abort_unless($this->caseService->canAccess($case, Auth::user()), 403);
+
+ $case->load(['pet', 'requester', 'media', 'reports']);
+
+ return Inertia::client('Dashboard/Cases/show', [
+ 'case' => (new CaseDetailResource($case))->resolve(),
+ 'canReport' => $this->caseService->canReport($case, Auth::user()),
+ ]);
+ }
+
+ public function reject(CaseRejectRequest $request, Document $case)
+ {
+ $this->caseService->reject($case, Auth::user(), $request->validated('reason'));
+
+ session()->flash('success', 'درخواست رد شد و مبلغ فاکتور به کیف پول درخواستکننده بازگردانده شد.');
+
+ return redirect()->route('dashboard.cases.index');
+ }
}
diff --git a/app/Http/Controllers/Client/Doctor/DashboardController.php b/app/Http/Controllers/Client/Doctor/DashboardController.php
index b7f6a78..fce1ae9 100644
--- a/app/Http/Controllers/Client/Doctor/DashboardController.php
+++ b/app/Http/Controllers/Client/Doctor/DashboardController.php
@@ -23,6 +23,7 @@ class DashboardController extends Controller
return Inertia::client('Dashboard/Dashboard', [
'cases' => CaseResource::collection($cases),
+ 'stats' => $this->caseService->statsForUser(Auth::user()),
]);
}
}
diff --git a/app/Http/Controllers/Client/Doctor/DocumentController.php b/app/Http/Controllers/Client/Doctor/DocumentController.php
index c00831c..3b90ef7 100644
--- a/app/Http/Controllers/Client/Doctor/DocumentController.php
+++ b/app/Http/Controllers/Client/Doctor/DocumentController.php
@@ -32,8 +32,6 @@ class DocumentController extends Controller
public function show(Document $document)
{
- return Inertia::client('Dashboard/Documents/show', [
- 'document' => $document,
- ]);
+ return redirect()->route('dashboard.cases.show', $document);
}
}
diff --git a/app/Http/Controllers/Client/Doctor/ReportController.php b/app/Http/Controllers/Client/Doctor/ReportController.php
index 243d9cf..4a44fdb 100644
--- a/app/Http/Controllers/Client/Doctor/ReportController.php
+++ b/app/Http/Controllers/Client/Doctor/ReportController.php
@@ -3,13 +3,53 @@
namespace App\Http\Controllers\Client\Doctor;
use App\Http\Controllers\Controller;
-use Illuminate\Http\Request;
+use App\Http\Requests\Client\ReportSubmitRequest;
+use App\Http\Resources\Client\ReportResource;
+use App\Models\Document;
+use App\Models\Report;
+use App\Services\Document\CaseService;
+use Illuminate\Support\Facades\Auth;
use Inertia\Inertia;
class ReportController extends Controller
{
+ protected CaseService $caseService;
+
+ public function __construct(CaseService $caseService)
+ {
+ $this->caseService = $caseService;
+ }
+
public function index()
{
- return Inertia::client('Dashboard/Reports/Index');
+ $reports = Report::with(['document', 'doctor'])
+ ->where(function ($query) {
+ $query->where('doctor_id', Auth::id())
+ ->orWhereHas('document', fn ($documentQuery) => $documentQuery->where('requester_id', Auth::id()));
+ })
+ ->latest('submitted_at')
+ ->paginate(10)
+ ->withQueryString()
+ ->onEachSide(1);
+
+ return Inertia::client('Dashboard/Reports/Index', [
+ 'reports' => ReportResource::collection($reports),
+ ]);
+ }
+
+ public function store(ReportSubmitRequest $request)
+ {
+ $document = Document::findOrFail($request->validated('document_id'));
+
+ $this->caseService->submitReport(
+ $document,
+ Auth::user(),
+ $request->validated(),
+ $request->file('files', [])
+ );
+
+ session()->flash('success', 'گزارش با موفقیت ثبت شد.');
+
+ return redirect()->route('dashboard.cases.index');
}
}
diff --git a/app/Http/Controllers/Client/FastRequestController.php b/app/Http/Controllers/Client/FastRequestController.php
index e95faba..3c46dc7 100644
--- a/app/Http/Controllers/Client/FastRequestController.php
+++ b/app/Http/Controllers/Client/FastRequestController.php
@@ -45,7 +45,7 @@ class FastRequestController extends Controller
);
return response()->json([
- 'document' => new FastRequestResource($document->refresh()->load('pet')),
+ 'document' => new FastRequestResource($document->refresh()->load(['pet', 'invoice'])),
]);
}
}
diff --git a/app/Http/Controllers/Client/InvoiceController.php b/app/Http/Controllers/Client/InvoiceController.php
new file mode 100644
index 0000000..9c4cef0
--- /dev/null
+++ b/app/Http/Controllers/Client/InvoiceController.php
@@ -0,0 +1,45 @@
+walletService = $walletService;
+ }
+
+ public function index()
+ {
+ $invoices = Auth::user()->invoices()
+ ->with('document')
+ ->latest()
+ ->paginate(10)
+ ->withQueryString()
+ ->onEachSide(1);
+
+ return Inertia::client('Dashboard/Invoices/Index', [
+ 'invoices' => InvoiceResource::collection($invoices),
+ ]);
+ }
+
+ public function payWithWallet(Invoice $invoice)
+ {
+ abort_unless($invoice->user_id === Auth::id(), 403);
+
+ $invoice = $this->walletService->payInvoice(Auth::user(), $invoice);
+
+ return response()->json([
+ 'invoice' => new InvoiceResource($invoice),
+ ]);
+ }
+}
diff --git a/app/Http/Controllers/Client/PaymentController.php b/app/Http/Controllers/Client/PaymentController.php
new file mode 100644
index 0000000..e6ac324
--- /dev/null
+++ b/app/Http/Controllers/Client/PaymentController.php
@@ -0,0 +1,97 @@
+zibalPaymentService = $zibalPaymentService;
+ $this->walletService = $walletService;
+ $this->invoiceService = $invoiceService;
+ }
+
+ public function store(PaymentRequest $request)
+ {
+ $invoice = Invoice::where('id', $request->invoice_id)
+ ->where('user_id', Auth::id())
+ ->where('status', InvoiceStatusEnum::Pending->value)
+ ->firstOrFail();
+
+ $payment = $this->zibalPaymentService->request(Auth::user(), $invoice->amount, $invoice->number, [
+ 'purpose' => PaymentPurposeEnum::Invoice->value,
+ 'invoice_id' => $invoice->id,
+ ]);
+
+ return response()->json([
+ 'payment' => new PaymentResource($payment),
+ 'url' => $this->zibalPaymentService->paymentUrl($payment),
+ ]);
+ }
+
+ public function callback(Request $request)
+ {
+ $payment = Payment::where('track_id', $request->trackId)->first();
+
+ if (!$payment) {
+ return redirect()->route('dashboard.index');
+ }
+
+ $wasPending = $payment->status === PaymentStatusEnum::Pending;
+
+ if ($request->success == 1) {
+ $payment = $this->zibalPaymentService->verify($payment);
+ } else {
+ $payment = $this->zibalPaymentService->markFailed($payment);
+ }
+
+ if ($wasPending && $payment->status === PaymentStatusEnum::Paid) {
+ $this->settle($payment);
+ }
+
+ session()->flash('payment_result', [
+ 'status' => $payment->status === PaymentStatusEnum::Paid ? 'paid' : 'failed',
+ 'track_id' => $payment->track_id,
+ 'purpose' => $payment->purpose->name,
+ ]);
+
+ return redirect()->route('dashboard.index');
+ }
+
+ protected function settle(Payment $payment): void
+ {
+ if ($payment->purpose === PaymentPurposeEnum::WalletTopUp) {
+ $this->walletService->deposit($payment->payer, $payment->amount, WalletTransactionReasonEnum::TopUp, $payment);
+ return;
+ }
+
+ $invoice = $payment->invoice;
+
+ if ($invoice && $invoice->status === InvoiceStatusEnum::Pending) {
+ $this->invoiceService->markPaid($invoice, InvoicePaymentMethodEnum::Gateway);
+ $this->walletService->recordGatewayInvoicePayment($invoice, $payment);
+ }
+ }
+}
diff --git a/app/Http/Controllers/Client/ProfileController.php b/app/Http/Controllers/Client/ProfileController.php
new file mode 100644
index 0000000..ebaf2e7
--- /dev/null
+++ b/app/Http/Controllers/Client/ProfileController.php
@@ -0,0 +1,42 @@
+fileStorageService = $fileStorageService;
+ }
+
+ public function update(ProfileUpdateRequest $request)
+ {
+ $user = Auth::user();
+ $data = $request->safe()->except('avatar');
+
+ if ($request->hasFile('avatar')) {
+ if (!empty($user->profile_url)) {
+ if ($medium = Medium::where('url', $user->profile_url)->first()) {
+ $this->fileStorageService->deleteMedium($medium);
+ }
+ }
+
+ $medium = $this->fileStorageService->storeFile($request->file('avatar'), 'public', 'profile');
+ $data['profile_url'] = $medium->url;
+ }
+
+ $user->update($data);
+
+ session()->flash('success', 'حساب کاربری شما با موفقیت ویرایش شد.');
+
+ return redirect()->back();
+ }
+}
diff --git a/app/Http/Controllers/Client/WalletController.php b/app/Http/Controllers/Client/WalletController.php
new file mode 100644
index 0000000..eadd46d
--- /dev/null
+++ b/app/Http/Controllers/Client/WalletController.php
@@ -0,0 +1,103 @@
+walletService = $walletService;
+ $this->zibalPaymentService = $zibalPaymentService;
+ $this->withdrawRequestService = $withdrawRequestService;
+ }
+
+ public function index()
+ {
+ $wallet = $this->walletService->forUser(Auth::user());
+
+ $transactions = $wallet->transactions()
+ ->with('invoice')
+ ->latest()
+ ->paginate(10)
+ ->withQueryString()
+ ->onEachSide(1);
+
+ $withdrawRequests = Auth::user()->withdrawRequests()
+ ->with('bankCard')
+ ->latest()
+ ->take(10)
+ ->get();
+
+ return Inertia::client('Dashboard/Wallet/Index', [
+ 'transactions' => WalletTransactionResource::collection($transactions),
+ 'withdrawRequests' => WithdrawRequestResource::collection($withdrawRequests)->resolve(),
+ 'bankCards' => BankCardResource::collection(Auth::user()->bankCards()->latest()->get())->resolve(),
+ ]);
+ }
+
+ public function deposit(WalletDepositRequest $request)
+ {
+ $payment = $this->zibalPaymentService->request(
+ Auth::user(),
+ $request->validated('amount'),
+ 'wallet-' . Auth::id() . '-' . now()->timestamp,
+ ['purpose' => PaymentPurposeEnum::WalletTopUp->value]
+ );
+
+ return response()->json([
+ 'payment' => new PaymentResource($payment),
+ 'url' => $this->zibalPaymentService->paymentUrl($payment),
+ ]);
+ }
+
+ public function storeWithdrawRequest(WithdrawRequestStoreRequest $request)
+ {
+ $bankCard = BankCard::findOrFail($request->validated('bank_card_id'));
+
+ $this->withdrawRequestService->request(Auth::user(), $request->validated('amount'), $bankCard);
+
+ session()->flash('success', 'درخواست برداشت شما ثبت شد و در انتظار بررسی است.');
+
+ return redirect()->route('dashboard.wallet.index');
+ }
+
+ public function storeBankCard(BankCardStoreRequest $request)
+ {
+ Auth::user()->bankCards()->create($request->validated());
+
+ session()->flash('success', 'کارت بانکی با موفقیت ثبت شد.');
+
+ return redirect()->route('dashboard.wallet.index');
+ }
+
+ public function destroyBankCard(BankCard $bankCard)
+ {
+ abort_unless($bankCard->user_id === Auth::id(), 403);
+
+ $bankCard->delete();
+
+ return redirect()->route('dashboard.wallet.index');
+ }
+}
diff --git a/app/Http/Requests/Admin/WithdrawRejectRequest.php b/app/Http/Requests/Admin/WithdrawRejectRequest.php
new file mode 100644
index 0000000..7edb8d0
--- /dev/null
+++ b/app/Http/Requests/Admin/WithdrawRejectRequest.php
@@ -0,0 +1,20 @@
+ ['nullable', 'string', 'max:1000'],
+ ];
+ }
+}
diff --git a/app/Http/Requests/Client/BankCardStoreRequest.php b/app/Http/Requests/Client/BankCardStoreRequest.php
new file mode 100644
index 0000000..db5a1d7
--- /dev/null
+++ b/app/Http/Requests/Client/BankCardStoreRequest.php
@@ -0,0 +1,21 @@
+ ['required', 'digits:16'],
+ 'holder_name' => ['required', 'string', 'max:255'],
+ ];
+ }
+}
diff --git a/app/Http/Requests/Client/CaseRejectRequest.php b/app/Http/Requests/Client/CaseRejectRequest.php
new file mode 100644
index 0000000..dad6246
--- /dev/null
+++ b/app/Http/Requests/Client/CaseRejectRequest.php
@@ -0,0 +1,20 @@
+ ['required', 'string', 'max:1000'],
+ ];
+ }
+}
diff --git a/app/Http/Requests/Client/PaymentRequest.php b/app/Http/Requests/Client/PaymentRequest.php
new file mode 100644
index 0000000..a105c6e
--- /dev/null
+++ b/app/Http/Requests/Client/PaymentRequest.php
@@ -0,0 +1,20 @@
+ ['required', 'integer', 'exists:invoices,id'],
+ ];
+ }
+}
diff --git a/app/Http/Requests/Client/ProfileUpdateRequest.php b/app/Http/Requests/Client/ProfileUpdateRequest.php
new file mode 100644
index 0000000..e7b8c15
--- /dev/null
+++ b/app/Http/Requests/Client/ProfileUpdateRequest.php
@@ -0,0 +1,27 @@
+ ['required', 'string', 'max:255'],
+ 'birthdate' => ['nullable', 'date'],
+ 'national_code' => ['required', 'string', 'max:20'],
+ 'email' => ['nullable', 'email', 'max:255'],
+ 'province' => ['nullable', 'string', 'max:255'],
+ 'city' => ['nullable', 'string', 'max:255'],
+ 'address' => ['nullable', 'string', 'max:500'],
+ 'avatar' => ['nullable', 'image', 'mimes:png,jpg,jpeg', 'max:10240'],
+ ];
+ }
+}
diff --git a/app/Http/Requests/Client/ReportSubmitRequest.php b/app/Http/Requests/Client/ReportSubmitRequest.php
new file mode 100644
index 0000000..29cee07
--- /dev/null
+++ b/app/Http/Requests/Client/ReportSubmitRequest.php
@@ -0,0 +1,26 @@
+ ['required', 'exists:documents,id'],
+ 'technique' => ['nullable', 'string', 'max:10000'],
+ 'finding' => ['required', 'string', 'max:10000'],
+ 'diagnose' => ['nullable', 'string', 'max:10000'],
+ 'recommendation' => ['nullable', 'string', 'max:10000'],
+ 'files' => ['nullable', 'array'],
+ 'files.*' => ['file', 'image', 'max:10240'],
+ ];
+ }
+}
diff --git a/app/Http/Requests/Client/WalletDepositRequest.php b/app/Http/Requests/Client/WalletDepositRequest.php
new file mode 100644
index 0000000..bbc0ff0
--- /dev/null
+++ b/app/Http/Requests/Client/WalletDepositRequest.php
@@ -0,0 +1,20 @@
+ ['required', 'integer', 'min:10000', 'max:500000000'],
+ ];
+ }
+}
diff --git a/app/Http/Requests/Client/WithdrawRequestStoreRequest.php b/app/Http/Requests/Client/WithdrawRequestStoreRequest.php
new file mode 100644
index 0000000..0a8dccc
--- /dev/null
+++ b/app/Http/Requests/Client/WithdrawRequestStoreRequest.php
@@ -0,0 +1,21 @@
+ ['required', 'integer', 'min:10000', 'max:500000000'],
+ 'bank_card_id' => ['required', 'exists:bank_cards,id'],
+ ];
+ }
+}
diff --git a/app/Http/Resources/Admin/WithdrawRequestResource.php b/app/Http/Resources/Admin/WithdrawRequestResource.php
new file mode 100644
index 0000000..fd95f67
--- /dev/null
+++ b/app/Http/Resources/Admin/WithdrawRequestResource.php
@@ -0,0 +1,31 @@
+
+ */
+ public function toArray(Request $request): array
+ {
+ return [
+ 'id' => $this->id,
+ 'amount' => $this->amount,
+ 'status' => $this->status,
+ 'admin_note' => $this->admin_note,
+ 'user' => $this->relationLoaded('user') ? $this->user->only(['id', 'fullname', 'level', 'phone']) : null,
+ 'bank_card' => $this->relationLoaded('bankCard') && $this->bankCard
+ ? $this->bankCard->only(['id', 'card_number', 'holder_name'])
+ : null,
+ 'reviewed_by' => $this->relationLoaded('reviewedBy') && $this->reviewedBy ? $this->reviewedBy->only(['id', 'fullname']) : null,
+ 'created_at' => verta($this->created_at)->format('%Y/%m/%d - H:i'),
+ 'reviewed_at' => $this->reviewed_at ? verta($this->reviewed_at)->format('%Y/%m/%d - H:i') : null,
+ ];
+ }
+}
diff --git a/app/Http/Resources/Client/BankCardResource.php b/app/Http/Resources/Client/BankCardResource.php
new file mode 100644
index 0000000..2e9164e
--- /dev/null
+++ b/app/Http/Resources/Client/BankCardResource.php
@@ -0,0 +1,18 @@
+ $this->id,
+ 'card_number' => $this->card_number,
+ 'holder_name' => $this->holder_name,
+ ];
+ }
+}
diff --git a/app/Http/Resources/Client/CaseDetailResource.php b/app/Http/Resources/Client/CaseDetailResource.php
new file mode 100644
index 0000000..88d953b
--- /dev/null
+++ b/app/Http/Resources/Client/CaseDetailResource.php
@@ -0,0 +1,76 @@
+reports->sortByDesc('submitted_at')->first();
+
+ return [
+ 'id' => $this->id,
+ 'code' => $this->code,
+ 'status' => $this->status,
+ 'modality' => $this->modality,
+ 'priority' => $this->priority,
+ 'note' => $this->note,
+ 'rejection_reason' => $this->rejection_reason,
+ 'submitted_at' => $this->submitted_at ? verta($this->submitted_at)->format('%Y/%m/%d - H:i') : null,
+ 'processing_deadline' => $this->processing_deadline?->toIso8601String(),
+ 'pet' => $this->whenLoaded('pet', fn () => [
+ 'name' => $this->pet->name,
+ 'type' => $this->pet->type,
+ 'gender' => $this->pet->gender,
+ 'breed' => $this->pet->breed,
+ 'age' => $this->pet->birthday ? (int) $this->pet->birthday->diffInYears(now()) : null,
+ 'weight' => $this->pet->weight,
+ 'sterilized' => $this->pet->sterilized,
+ ]),
+ 'requester' => $this->whenLoaded('requester', fn () => [
+ 'level' => $this->requester->level,
+ 'fullname' => $this->requester->fullname,
+ 'phone' => $this->requester->phone,
+ 'specialty' => $this->requester->specialty,
+ 'clinic_name' => $this->requester->clinic_name,
+ 'clinic_phone' => $this->requester->clinic_phone,
+ 'province' => $this->requester->province,
+ 'city' => $this->requester->city,
+ ]),
+ 'primary_files' => $this->mediaFiles('primary'),
+ 'history_files' => $this->mediaFiles('history'),
+ 'report' => $report ? [
+ 'id' => $report->id,
+ 'status' => $report->status,
+ 'technique' => $report->technique,
+ 'finding' => $report->finding,
+ 'diagnose' => $report->diagnose,
+ 'recommendation' => $report->recommendation,
+ 'submitted_at' => $report->submitted_at ? verta($report->submitted_at)->format('%Y/%m/%d - H:i') : null,
+ 'attachments' => $report->attachments->map(fn ($medium) => $this->mediumPayload($medium))->values(),
+ ] : null,
+ ];
+ }
+
+ protected function mediaFiles(string $collection): array
+ {
+ return $this->media
+ ->filter(fn ($medium) => $medium->pivot->collection === $collection)
+ ->map(fn ($medium) => $this->mediumPayload($medium))
+ ->values()
+ ->all();
+ }
+
+ protected function mediumPayload($medium): array
+ {
+ return [
+ 'id' => $medium->id,
+ 'filename' => $medium->filename,
+ 'url' => $medium->url,
+ 'mime_type' => $medium->mime_type,
+ ];
+ }
+}
diff --git a/app/Http/Resources/Client/FastRequestResource.php b/app/Http/Resources/Client/FastRequestResource.php
index c1de4ea..d2eea82 100644
--- a/app/Http/Resources/Client/FastRequestResource.php
+++ b/app/Http/Resources/Client/FastRequestResource.php
@@ -2,7 +2,6 @@
namespace App\Http\Resources\Client;
-use App\Enums\DocumentModalityEnum;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
@@ -17,10 +16,7 @@ class FastRequestResource extends JsonResource
'status' => $this->status,
'priority' => $this->priority,
'pet' => new PetArchiveResource($this->whenLoaded('pet')),
- 'invoice' => [
- 'number' => str_pad((string) $this->id, 5, '0', STR_PAD_LEFT),
- 'amount' => DocumentModalityEnum::from($this->modality)->price(),
- ],
+ 'invoice' => new InvoiceResource($this->whenLoaded('invoice')),
];
}
}
diff --git a/app/Http/Resources/Client/InvoiceResource.php b/app/Http/Resources/Client/InvoiceResource.php
new file mode 100644
index 0000000..a30ac67
--- /dev/null
+++ b/app/Http/Resources/Client/InvoiceResource.php
@@ -0,0 +1,27 @@
+ $this->id,
+ 'number' => $this->number,
+ 'amount' => $this->amount,
+ 'status' => $this->status,
+ 'payment_method' => $this->payment_method,
+ 'paid_at' => $this->paid_at,
+ 'created_at' => $this->created_at,
+ 'document' => $this->whenLoaded('document', fn () => [
+ 'id' => $this->document->id,
+ 'code' => $this->document->code,
+ 'modality' => $this->document->modality,
+ ]),
+ ];
+ }
+}
diff --git a/app/Http/Resources/Client/PaymentResource.php b/app/Http/Resources/Client/PaymentResource.php
new file mode 100644
index 0000000..1f6e960
--- /dev/null
+++ b/app/Http/Resources/Client/PaymentResource.php
@@ -0,0 +1,20 @@
+ $this->id,
+ 'track_id' => $this->track_id,
+ 'amount' => $this->amount,
+ 'status' => $this->status,
+ 'ref_number' => $this->ref_number,
+ ];
+ }
+}
diff --git a/app/Http/Resources/Client/ReportResource.php b/app/Http/Resources/Client/ReportResource.php
new file mode 100644
index 0000000..9cee25b
--- /dev/null
+++ b/app/Http/Resources/Client/ReportResource.php
@@ -0,0 +1,25 @@
+ $this->id,
+ 'status' => $this->status,
+ 'submitted_at' => $this->submitted_at ? verta($this->submitted_at)->format('%Y/%m/%d - H:i') : null,
+ 'document' => $this->whenLoaded('document', fn () => [
+ 'id' => $this->document->id,
+ 'code' => $this->document->code,
+ 'modality' => $this->document->modality,
+ 'status' => $this->document->status,
+ ]),
+ 'doctor' => $this->whenLoaded('doctor', fn () => optional($this->doctor)->fullname),
+ ];
+ }
+}
diff --git a/app/Http/Resources/Client/WalletTransactionResource.php b/app/Http/Resources/Client/WalletTransactionResource.php
new file mode 100644
index 0000000..8e1618f
--- /dev/null
+++ b/app/Http/Resources/Client/WalletTransactionResource.php
@@ -0,0 +1,24 @@
+ $this->id,
+ 'type' => $this->type,
+ 'reason' => $this->reason,
+ 'amount' => $this->amount,
+ 'balance_after' => $this->balance_after,
+ 'description' => $this->description,
+ 'via_gateway' => $this->payment_id !== null,
+ 'invoice_number' => $this->whenLoaded('invoice', fn () => $this->invoice?->number),
+ 'created_at' => verta($this->created_at)->format('%Y/%m/%d - H:i'),
+ ];
+ }
+}
diff --git a/app/Http/Resources/Client/WithdrawRequestResource.php b/app/Http/Resources/Client/WithdrawRequestResource.php
new file mode 100644
index 0000000..ae56c73
--- /dev/null
+++ b/app/Http/Resources/Client/WithdrawRequestResource.php
@@ -0,0 +1,24 @@
+ $this->id,
+ 'amount' => $this->amount,
+ 'status' => $this->status,
+ 'admin_note' => $this->admin_note,
+ 'bank_card' => $this->relationLoaded('bankCard') && $this->bankCard
+ ? $this->bankCard->only(['id', 'card_number', 'holder_name'])
+ : null,
+ 'created_at' => verta($this->created_at)->format('%Y/%m/%d - H:i'),
+ 'reviewed_at' => $this->reviewed_at ? verta($this->reviewed_at)->format('%Y/%m/%d - H:i') : null,
+ ];
+ }
+}
diff --git a/app/Models/BankCard.php b/app/Models/BankCard.php
new file mode 100644
index 0000000..c194df1
--- /dev/null
+++ b/app/Models/BankCard.php
@@ -0,0 +1,26 @@
+ 'integer',
+ ];
+
+ public function user()
+ {
+ return $this->belongsTo(User::class);
+ }
+}
diff --git a/app/Models/Document.php b/app/Models/Document.php
index b3dc558..4fdafaf 100644
--- a/app/Models/Document.php
+++ b/app/Models/Document.php
@@ -56,6 +56,11 @@ class Document extends Model
return $this->belongsTo(User::class);
}
+ public function invoice()
+ {
+ return $this->hasOne(Invoice::class);
+ }
+
public function assignments()
{
return $this->hasMany(DocumentAssignment::class);
diff --git a/app/Models/Invoice.php b/app/Models/Invoice.php
new file mode 100644
index 0000000..c8000e9
--- /dev/null
+++ b/app/Models/Invoice.php
@@ -0,0 +1,47 @@
+ InvoiceStatusEnum::class,
+ 'payment_method' => InvoicePaymentMethodEnum::class,
+ 'amount' => 'integer',
+ 'paid_at' => 'datetime',
+ 'document_id' => 'integer',
+ 'user_id' => 'integer',
+ ];
+
+ public function document()
+ {
+ return $this->belongsTo(Document::class);
+ }
+
+ public function user()
+ {
+ return $this->belongsTo(User::class);
+ }
+
+ public function payments()
+ {
+ return $this->hasMany(Payment::class);
+ }
+}
diff --git a/app/Models/Payment.php b/app/Models/Payment.php
new file mode 100644
index 0000000..9795148
--- /dev/null
+++ b/app/Models/Payment.php
@@ -0,0 +1,43 @@
+ PaymentStatusEnum::class,
+ 'purpose' => PaymentPurposeEnum::class,
+ 'amount' => 'integer',
+ 'verified_at' => 'datetime',
+ 'invoice_id' => 'integer',
+ 'payer_id' => 'integer',
+ ];
+
+ public function invoice()
+ {
+ return $this->belongsTo(Invoice::class);
+ }
+
+ public function payer()
+ {
+ return $this->belongsTo(User::class, 'payer_id');
+ }
+}
diff --git a/app/Models/Report.php b/app/Models/Report.php
index 98faa5e..52ec2e6 100644
--- a/app/Models/Report.php
+++ b/app/Models/Report.php
@@ -17,6 +17,10 @@ class Report extends Model
'medium_id',
'status',
'body',
+ 'technique',
+ 'finding',
+ 'diagnose',
+ 'recommendation',
'submitted_at',
];
@@ -43,7 +47,7 @@ class Report extends Model
'media_assignments',
'mediable_id',
'media_id'
- )->withPivot('collection')->withTimestamps();
+ )->withPivot('collection');
}
public function medium()
diff --git a/app/Models/User.php b/app/Models/User.php
index ccbbfb1..3ba074d 100644
--- a/app/Models/User.php
+++ b/app/Models/User.php
@@ -22,6 +22,7 @@ class User extends Authenticatable
protected $fillable = [
'username',
'fullname',
+ 'birthdate',
'national_code',
'level',
'email',
@@ -56,6 +57,7 @@ class User extends Authenticatable
{
return [
'email_verified_at' => 'datetime',
+ 'birthdate' => 'date:Y-m-d',
'password' => 'hashed',
];
}
@@ -76,4 +78,24 @@ class User extends Authenticatable
{
return $this->hasMany(Shift::class);
}
+
+ public function wallet()
+ {
+ return $this->hasOne(Wallet::class);
+ }
+
+ public function invoices()
+ {
+ return $this->hasMany(Invoice::class);
+ }
+
+ public function bankCards()
+ {
+ return $this->hasMany(BankCard::class);
+ }
+
+ public function withdrawRequests()
+ {
+ return $this->hasMany(WithdrawRequest::class);
+ }
}
diff --git a/app/Models/Wallet.php b/app/Models/Wallet.php
new file mode 100644
index 0000000..1637648
--- /dev/null
+++ b/app/Models/Wallet.php
@@ -0,0 +1,31 @@
+ 'integer',
+ 'user_id' => 'integer',
+ ];
+
+ public function user()
+ {
+ return $this->belongsTo(User::class);
+ }
+
+ public function transactions()
+ {
+ return $this->hasMany(WalletTransaction::class);
+ }
+}
diff --git a/app/Models/WalletTransaction.php b/app/Models/WalletTransaction.php
new file mode 100644
index 0000000..8dcdc0b
--- /dev/null
+++ b/app/Models/WalletTransaction.php
@@ -0,0 +1,56 @@
+ WalletTransactionTypeEnum::class,
+ 'reason' => WalletTransactionReasonEnum::class,
+ 'amount' => 'integer',
+ 'balance_after' => 'integer',
+ 'wallet_id' => 'integer',
+ 'invoice_id' => 'integer',
+ 'payment_id' => 'integer',
+ 'withdraw_request_id' => 'integer',
+ ];
+
+ public function wallet()
+ {
+ return $this->belongsTo(Wallet::class);
+ }
+
+ public function invoice()
+ {
+ return $this->belongsTo(Invoice::class);
+ }
+
+ public function payment()
+ {
+ return $this->belongsTo(Payment::class);
+ }
+
+ public function withdrawRequest()
+ {
+ return $this->belongsTo(WithdrawRequest::class);
+ }
+}
diff --git a/app/Models/WithdrawRequest.php b/app/Models/WithdrawRequest.php
new file mode 100644
index 0000000..dbb7705
--- /dev/null
+++ b/app/Models/WithdrawRequest.php
@@ -0,0 +1,46 @@
+ WithdrawRequestStatusEnum::class,
+ 'amount' => 'integer',
+ 'reviewed_at' => 'datetime',
+ 'reviewed_by' => 'integer',
+ 'user_id' => 'integer',
+ 'bank_card_id' => 'integer',
+ ];
+
+ public function user()
+ {
+ return $this->belongsTo(User::class);
+ }
+
+ public function bankCard()
+ {
+ return $this->belongsTo(BankCard::class);
+ }
+
+ public function reviewedBy()
+ {
+ return $this->belongsTo(User::class, 'reviewed_by');
+ }
+}
diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php
index ddcc809..fa5f5aa 100644
--- a/app/Providers/AppServiceProvider.php
+++ b/app/Providers/AppServiceProvider.php
@@ -45,6 +45,7 @@ class AppServiceProvider extends ServiceProvider
return [
'User' => $user,
'permissions' => $permissions,
+ 'wallet_balance' => (int) ($user?->wallet?->balance ?? 0),
];
},
]);
@@ -52,6 +53,7 @@ class AppServiceProvider extends ServiceProvider
return [
'success' => Session::get('success'),
'error' => Session::get('error'),
+ 'payment_result' => Session::get('payment_result'),
];
});
}
diff --git a/app/Services/Document/CaseService.php b/app/Services/Document/CaseService.php
index c6bf93d..e92b899 100644
--- a/app/Services/Document/CaseService.php
+++ b/app/Services/Document/CaseService.php
@@ -3,25 +3,167 @@
namespace App\Services\Document;
use App\Enums\DocumentStatusEnum;
+use App\Enums\InvoiceStatusEnum;
+use App\Enums\MediaCollectionEnum;
+use App\Enums\ReportStatusEnum;
+use App\Enums\UserEnum;
+use App\Enums\WalletTransactionReasonEnum;
use App\Models\Document;
+use App\Models\Report;
use App\Models\User;
+use App\Services\FileStorageService;
+use App\Services\Shift\ShiftScheduleService;
+use App\Services\Wallet\WalletService;
use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Validation\ValidationException;
class CaseService
{
+ protected FileStorageService $fileStorageService;
+
+ protected WalletService $walletService;
+
+ protected ShiftScheduleService $shiftScheduleService;
+
+ public function __construct(FileStorageService $fileStorageService, WalletService $walletService, ShiftScheduleService $shiftScheduleService)
+ {
+ $this->fileStorageService = $fileStorageService;
+ $this->walletService = $walletService;
+ $this->shiftScheduleService = $shiftScheduleService;
+ }
+
public function pendingForUser(User $user): Builder
{
+ $statuses = $this->isDoctor($user)
+ ? [DocumentStatusEnum::InProgress->value]
+ : [DocumentStatusEnum::Registered->value, DocumentStatusEnum::InProgress->value];
+
return Document::with(['pet', 'requester'])
- ->whereIn('status', [
- DocumentStatusEnum::Registered->value,
- DocumentStatusEnum::InProgress->value,
- ])
- ->where(function ($query) use ($user) {
- $query->where('requester_id', $user->id)
- ->orWhereHas('assignments', function ($assignmentQuery) use ($user) {
- $assignmentQuery->where('user_id', $user->id);
- });
- })
+ ->whereIn('status', $statuses)
+ ->where(fn ($query) => $this->scopeVisibleTo($query, $user))
->latest('submitted_at');
}
+
+ public function canAccess(Document $document, User $user): bool
+ {
+ return $document->requester_id === $user->id
+ || $document->assignments()->where('user_id', $user->id)->exists();
+ }
+
+ public function canReport(Document $document, User $user): bool
+ {
+ return $this->isDoctor($user)
+ && $document->status === DocumentStatusEnum::InProgress
+ && $document->assignments()->where('user_id', $user->id)->exists();
+ }
+
+ public function submitReport(Document $document, User $doctor, array $data, array $files = []): Report
+ {
+ return DB::transaction(function () use ($document, $doctor, $data, $files) {
+ if (!$this->canReport($document, $doctor)) {
+ throw ValidationException::withMessages(['document' => 'امکان ثبت گزارش برای این پرونده وجود ندارد.']);
+ }
+
+ $report = $document->reports()->create([
+ 'doctor_id' => $doctor->id,
+ 'status' => ReportStatusEnum::Approved->value,
+ 'technique' => $data['technique'] ?? null,
+ 'finding' => $data['finding'] ?? null,
+ 'diagnose' => $data['diagnose'] ?? null,
+ 'recommendation' => $data['recommendation'] ?? null,
+ 'submitted_at' => now(),
+ ]);
+
+ foreach ($files as $file) {
+ $medium = $this->fileStorageService->storeFile($file);
+ $report->media()->attach($medium->id, ['collection' => MediaCollectionEnum::Attachments->value]);
+ }
+
+ $document->update([
+ 'status' => DocumentStatusEnum::Completed->value,
+ 'finalized_at' => now(),
+ 'finalized_by' => $doctor->id,
+ ]);
+
+ return $report;
+ });
+ }
+
+ public function reject(Document $document, User $doctor, string $reason): Document
+ {
+ return DB::transaction(function () use ($document, $doctor, $reason) {
+ if (!$this->canReport($document, $doctor)) {
+ throw ValidationException::withMessages(['document' => 'امکان رد این پرونده وجود ندارد.']);
+ }
+
+ $document->update([
+ 'status' => DocumentStatusEnum::Rejected->value,
+ 'finalized_at' => now(),
+ 'finalized_by' => $doctor->id,
+ 'rejection_reason' => $reason,
+ ]);
+
+ $invoice = $document->invoice;
+
+ if ($invoice && $invoice->status === InvoiceStatusEnum::Paid) {
+ $invoice->update(['status' => InvoiceStatusEnum::Canceled->value]);
+ $this->walletService->deposit($document->requester, $invoice->amount, WalletTransactionReasonEnum::Refund);
+ }
+
+ return $document;
+ });
+ }
+
+ public function statsForUser(User $user): array
+ {
+ $monthStart = now()->startOfMonth();
+ $isDoctor = $this->isDoctor($user);
+
+ $reviewedQuery = Document::where('status', DocumentStatusEnum::Completed->value)
+ ->where('finalized_at', '>=', $monthStart);
+
+ if ($isDoctor) {
+ $reviewedQuery->where('finalized_by', $user->id);
+ } else {
+ $reviewedQuery->where('requester_id', $user->id);
+ }
+
+ $amountQuery = $isDoctor
+ ? Document::where('documents.finalized_by', $user->id)
+ ->where('documents.status', DocumentStatusEnum::Completed->value)
+ ->where('documents.finalized_at', '>=', $monthStart)
+ ->join('invoices', 'invoices.document_id', '=', 'documents.id')
+ ->where('invoices.status', InvoiceStatusEnum::Paid->value)
+ : Document::where('documents.requester_id', $user->id)
+ ->join('invoices', 'invoices.document_id', '=', 'documents.id')
+ ->where('invoices.status', InvoiceStatusEnum::Paid->value)
+ ->where('invoices.paid_at', '>=', $monthStart);
+
+ $nextShift = $isDoctor ? $this->shiftScheduleService->nextShiftStart($user, now()) : null;
+
+ return [
+ 'pending_cases' => $this->pendingForUser($user)->count(),
+ 'monthly_reviewed' => $reviewedQuery->count(),
+ 'monthly_amount' => (int) $amountQuery->sum('invoices.amount'),
+ 'next_shift_hours' => $nextShift ? (int) ceil(now()->diffInMinutes($nextShift, true) / 60) : null,
+ 'is_doctor' => $isDoctor,
+ ];
+ }
+
+ protected function scopeVisibleTo($query, User $user): void
+ {
+ if ($this->isDoctor($user)) {
+ $query->whereHas('assignments', fn ($assignmentQuery) => $assignmentQuery->where('user_id', $user->id));
+ return;
+ }
+
+ $query->where('requester_id', $user->id)
+ ->orWhereHas('assignments', fn ($assignmentQuery) => $assignmentQuery->where('user_id', $user->id));
+ }
+
+ protected function isDoctor(User $user): bool
+ {
+ return $user->level === UserEnum::Doctor->value;
+ }
}
diff --git a/app/Services/Document/FastRequestService.php b/app/Services/Document/FastRequestService.php
index 4a16d6a..0c324b4 100644
--- a/app/Services/Document/FastRequestService.php
+++ b/app/Services/Document/FastRequestService.php
@@ -10,6 +10,7 @@ use App\Models\Document;
use App\Models\Pet;
use App\Models\User;
use App\Services\FileStorageService;
+use App\Services\Invoice\InvoiceService;
use App\Services\Shift\ShiftScheduleService;
use Illuminate\Support\Facades\DB;
@@ -19,10 +20,13 @@ class FastRequestService
protected ShiftScheduleService $shiftScheduleService;
- public function __construct(FileStorageService $fileStorageService, ShiftScheduleService $shiftScheduleService)
+ protected InvoiceService $invoiceService;
+
+ public function __construct(FileStorageService $fileStorageService, ShiftScheduleService $shiftScheduleService, InvoiceService $invoiceService)
{
$this->fileStorageService = $fileStorageService;
$this->shiftScheduleService = $shiftScheduleService;
+ $this->invoiceService = $invoiceService;
}
public function create(User $requester, array $data, array $historyFiles = [], array $scanFiles = []): Document
@@ -39,6 +43,8 @@ class FastRequestService
'requester_id' => $requester->id,
]);
+ $this->invoiceService->createForDocument($document);
+
$this->attachFiles($document, $historyFiles, MediaCollectionEnum::History);
$this->attachFiles($document, $scanFiles, MediaCollectionEnum::Primary);
diff --git a/app/Services/Invoice/InvoiceService.php b/app/Services/Invoice/InvoiceService.php
new file mode 100644
index 0000000..cbad981
--- /dev/null
+++ b/app/Services/Invoice/InvoiceService.php
@@ -0,0 +1,48 @@
+ 'pending',
+ 'amount' => DocumentModalityEnum::from($document->modality)->price(),
+ 'status' => InvoiceStatusEnum::Pending->value,
+ 'document_id' => $document->id,
+ 'user_id' => $document->requester_id,
+ ]);
+
+ $invoice->update(['number' => str_pad((string) $invoice->id, 5, '0', STR_PAD_LEFT)]);
+
+ return $invoice;
+ }
+
+ public function markPaid(Invoice $invoice, InvoicePaymentMethodEnum $method): Invoice
+ {
+ $invoice->update([
+ 'status' => InvoiceStatusEnum::Paid->value,
+ 'payment_method' => $method->value,
+ 'paid_at' => now(),
+ ]);
+
+ $document = $invoice->document;
+
+ if ($document && $document->status === DocumentStatusEnum::Registered) {
+ $document->update([
+ 'status' => DocumentStatusEnum::InProgress->value,
+ 'processing_deadline' => now()->addHours($document->priority->processingHours()),
+ ]);
+ }
+
+ return $invoice;
+ }
+}
diff --git a/app/Services/Payment/ZibalPaymentService.php b/app/Services/Payment/ZibalPaymentService.php
new file mode 100644
index 0000000..f19a023
--- /dev/null
+++ b/app/Services/Payment/ZibalPaymentService.php
@@ -0,0 +1,84 @@
+ $this->merchant(),
+ 'amount' => $amount * 10,
+ 'callbackUrl' => $this->callbackUrl(),
+ 'orderId' => $orderId,
+ ])->json();
+
+ if (($response['result'] ?? null) !== 100) {
+ throw new RuntimeException('خطا در ایجاد درخواست پرداخت: ' . ($response['message'] ?? 'پاسخ نامعتبر از درگاه'));
+ }
+
+ return Payment::create(array_merge([
+ 'track_id' => (string) $response['trackId'],
+ 'amount' => $amount,
+ 'status' => PaymentStatusEnum::Pending->value,
+ 'payer_id' => $payer->id,
+ ], $attributes));
+ }
+
+ public function paymentUrl(Payment $payment): string
+ {
+ return self::START_URL . '/' . $payment->track_id;
+ }
+
+ public function verify(Payment $payment): Payment
+ {
+ if ($payment->status !== PaymentStatusEnum::Pending) {
+ return $payment;
+ }
+
+ $response = Http::post(self::API_URL . '/verify', [
+ 'merchant' => $this->merchant(),
+ 'trackId' => $payment->track_id,
+ ])->json();
+
+ if (in_array($response['result'] ?? null, [100, 201], true)) {
+ $payment->update([
+ 'status' => PaymentStatusEnum::Paid->value,
+ 'ref_number' => $response['refNumber'] ?? null,
+ 'verified_at' => now(),
+ ]);
+ } else {
+ $payment->update(['status' => PaymentStatusEnum::Failed->value]);
+ }
+
+ return $payment;
+ }
+
+ public function markFailed(Payment $payment): Payment
+ {
+ if ($payment->status === PaymentStatusEnum::Pending) {
+ $payment->update(['status' => PaymentStatusEnum::Failed->value]);
+ }
+
+ return $payment;
+ }
+
+ protected function merchant(): string
+ {
+ return config('services.zibal.sandbox') ? 'zibal' : config('services.zibal.merchant');
+ }
+
+ protected function callbackUrl(): string
+ {
+ return config('services.zibal.callback_url') ?: route('dashboard.payments.callback');
+ }
+}
diff --git a/app/Services/Shift/ShiftScheduleService.php b/app/Services/Shift/ShiftScheduleService.php
index 4cd9a79..d66f026 100644
--- a/app/Services/Shift/ShiftScheduleService.php
+++ b/app/Services/Shift/ShiftScheduleService.php
@@ -81,6 +81,33 @@ class ShiftScheduleService
->first();
}
+ public function nextShiftStart(User $doctor, Carbon $from): ?Carbon
+ {
+ $shifts = Shift::active()->where('user_id', $doctor->id)->get();
+
+ return $shifts
+ ->map(function (Shift $shift) use ($from) {
+ for ($offset = 0; $offset <= 7; $offset++) {
+ $day = $from->copy()->addDays($offset)->startOfDay();
+
+ if (!$this->coversDate($shift, $day)) {
+ continue;
+ }
+
+ $start = $day->copy()->addMinutes($this->toMinutes($shift->start_time));
+
+ if ($start->greaterThanOrEqualTo($from)) {
+ return $start;
+ }
+ }
+
+ return null;
+ })
+ ->filter()
+ ->sort()
+ ->first();
+ }
+
public function hasConflict(array $data, ?int $ignoreId = null): bool
{
if (empty($data['user_id'])) {
diff --git a/app/Services/Wallet/WalletService.php b/app/Services/Wallet/WalletService.php
new file mode 100644
index 0000000..c6f85c8
--- /dev/null
+++ b/app/Services/Wallet/WalletService.php
@@ -0,0 +1,113 @@
+invoiceService = $invoiceService;
+ }
+
+ public function forUser(User $user): Wallet
+ {
+ return Wallet::firstOrCreate(['user_id' => $user->id]);
+ }
+
+ public function system(): Wallet
+ {
+ return Wallet::firstOrCreate(['user_id' => null]);
+ }
+
+ public function deposit(User $user, int $amount, WalletTransactionReasonEnum $reason, ?Payment $payment = null): WalletTransaction
+ {
+ return $this->depositToWallet($this->forUser($user), $amount, $reason, $payment);
+ }
+
+ public function depositToWallet(Wallet $wallet, int $amount, WalletTransactionReasonEnum $reason, ?Payment $payment = null, ?WithdrawRequest $withdrawRequest = null): WalletTransaction
+ {
+ return DB::transaction(function () use ($wallet, $amount, $reason, $payment, $withdrawRequest) {
+ $wallet = $this->lockedWallet($wallet);
+ $wallet->update(['balance' => $wallet->balance + $amount]);
+
+ return $wallet->transactions()->create([
+ 'type' => WalletTransactionTypeEnum::Deposit->value,
+ 'reason' => $reason->value,
+ 'amount' => $amount,
+ 'balance_after' => $wallet->balance,
+ 'payment_id' => $payment?->id,
+ 'withdraw_request_id' => $withdrawRequest?->id,
+ ]);
+ });
+ }
+
+ public function withdraw(User $user, int $amount, WalletTransactionReasonEnum $reason, ?Invoice $invoice = null, ?WithdrawRequest $withdrawRequest = null): WalletTransaction
+ {
+ return DB::transaction(function () use ($user, $amount, $reason, $invoice, $withdrawRequest) {
+ $wallet = $this->lockedWallet($this->forUser($user));
+
+ if ($wallet->balance < $amount) {
+ throw ValidationException::withMessages(['amount' => 'موجودی کیف پول کافی نیست']);
+ }
+
+ $wallet->update(['balance' => $wallet->balance - $amount]);
+
+ return $wallet->transactions()->create([
+ 'type' => WalletTransactionTypeEnum::Withdrawal->value,
+ 'reason' => $reason->value,
+ 'amount' => $amount,
+ 'balance_after' => $wallet->balance,
+ 'invoice_id' => $invoice?->id,
+ 'withdraw_request_id' => $withdrawRequest?->id,
+ ]);
+ });
+ }
+
+ public function payInvoice(User $user, Invoice $invoice): Invoice
+ {
+ return DB::transaction(function () use ($user, $invoice) {
+ if ($invoice->status !== InvoiceStatusEnum::Pending) {
+ throw ValidationException::withMessages(['invoice' => 'این فاکتور قابل پرداخت نیست']);
+ }
+
+ $this->withdraw($user, $invoice->amount, WalletTransactionReasonEnum::InvoicePayment, $invoice);
+
+ return $this->invoiceService->markPaid($invoice, InvoicePaymentMethodEnum::Wallet);
+ });
+ }
+
+ public function recordGatewayInvoicePayment(Invoice $invoice, Payment $payment): WalletTransaction
+ {
+ $wallet = $this->forUser($invoice->user);
+
+ return $wallet->transactions()->create([
+ 'type' => WalletTransactionTypeEnum::Withdrawal->value,
+ 'reason' => WalletTransactionReasonEnum::InvoicePayment->value,
+ 'amount' => $invoice->amount,
+ 'balance_after' => $wallet->balance,
+ 'invoice_id' => $invoice->id,
+ 'payment_id' => $payment->id,
+ ]);
+ }
+
+ protected function lockedWallet(Wallet $wallet): Wallet
+ {
+ return Wallet::whereKey($wallet->id)->lockForUpdate()->first();
+ }
+}
diff --git a/app/Services/Wallet/WithdrawRequestService.php b/app/Services/Wallet/WithdrawRequestService.php
new file mode 100644
index 0000000..00e3e6c
--- /dev/null
+++ b/app/Services/Wallet/WithdrawRequestService.php
@@ -0,0 +1,97 @@
+walletService = $walletService;
+ }
+
+ public function request(User $user, int $amount, BankCard $bankCard): WithdrawRequest
+ {
+ if ($bankCard->user_id !== $user->id) {
+ throw ValidationException::withMessages(['bank_card_id' => 'کارت بانکی انتخابشده معتبر نیست.']);
+ }
+
+ $balance = $this->walletService->forUser($user)->balance;
+
+ $pendingAmount = WithdrawRequest::where('user_id', $user->id)
+ ->where('status', WithdrawRequestStatusEnum::Pending->value)
+ ->sum('amount');
+
+ if ($amount + $pendingAmount > $balance) {
+ throw ValidationException::withMessages(['amount' => 'مجموع مبلغ درخواستهای برداشت از موجودی کیف پول شما بیشتر است.']);
+ }
+
+ return WithdrawRequest::create([
+ 'amount' => $amount,
+ 'status' => WithdrawRequestStatusEnum::Pending->value,
+ 'user_id' => $user->id,
+ 'bank_card_id' => $bankCard->id,
+ ]);
+ }
+
+ public function approve(WithdrawRequest $withdrawRequest, User $admin): WithdrawRequest
+ {
+ return DB::transaction(function () use ($withdrawRequest, $admin) {
+ $this->ensurePending($withdrawRequest);
+
+ $this->walletService->withdraw(
+ $withdrawRequest->user,
+ $withdrawRequest->amount,
+ WalletTransactionReasonEnum::Withdraw,
+ null,
+ $withdrawRequest
+ );
+
+ $this->walletService->depositToWallet(
+ $this->walletService->system(),
+ $withdrawRequest->amount,
+ WalletTransactionReasonEnum::Withdraw,
+ null,
+ $withdrawRequest
+ );
+
+ $withdrawRequest->update([
+ 'status' => WithdrawRequestStatusEnum::Approved->value,
+ 'reviewed_at' => now(),
+ 'reviewed_by' => $admin->id,
+ ]);
+
+ return $withdrawRequest;
+ });
+ }
+
+ public function reject(WithdrawRequest $withdrawRequest, User $admin, ?string $note = null): WithdrawRequest
+ {
+ $this->ensurePending($withdrawRequest);
+
+ $withdrawRequest->update([
+ 'status' => WithdrawRequestStatusEnum::Rejected->value,
+ 'admin_note' => $note,
+ 'reviewed_at' => now(),
+ 'reviewed_by' => $admin->id,
+ ]);
+
+ return $withdrawRequest;
+ }
+
+ protected function ensurePending(WithdrawRequest $withdrawRequest): void
+ {
+ if ($withdrawRequest->status !== WithdrawRequestStatusEnum::Pending) {
+ throw ValidationException::withMessages(['withdraw_request' => 'این درخواست قبلا بررسی شده است.']);
+ }
+ }
+}
diff --git a/config/services.php b/config/services.php
index f8589b3..e40e06d 100644
--- a/config/services.php
+++ b/config/services.php
@@ -35,6 +35,12 @@ return [
],
],
+ 'zibal' => [
+ 'merchant' => env('ZIBAL_MERCHANT', 'zibal'),
+ 'sandbox' => env('ZIBAL_SANDBOX', true),
+ 'callback_url' => env('ZIBAL_CALLBACK_URL'),
+ ],
+
'iranpayamak' => [
'api_url' => env('IRANPAYAMAK_SMS_API_URL', 'https://api.iranpayamak.com/ws/v1/sms/pattern'),
'line_number' => env('IRANPAYAMAK_SMS_API_URL', '50002178584000'),
diff --git a/database/migrations/2026_08_26_000000_create_invoices_table.php b/database/migrations/2026_08_26_000000_create_invoices_table.php
new file mode 100644
index 0000000..7303701
--- /dev/null
+++ b/database/migrations/2026_08_26_000000_create_invoices_table.php
@@ -0,0 +1,54 @@
+id();
+ $table->string('number')->unique();
+ $table->unsignedBigInteger('amount');
+ $table->string('status')->default(InvoiceStatusEnum::Pending->value);
+ $table->string('payment_method')->nullable();
+ $table->timestamp('paid_at')->nullable();
+ $table->timestamps();
+
+ $table->foreignId('document_id')->nullable()->constrained('documents')->cascadeOnDelete();
+ $table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
+ });
+
+ DB::table('documents')->orderBy('id')->each(function ($document) {
+ $modality = DocumentModalityEnum::tryFrom($document->modality);
+
+ if (!$modality) {
+ return;
+ }
+
+ $id = DB::table('invoices')->insertGetId([
+ 'number' => 'pending',
+ 'amount' => $modality->price(),
+ 'status' => InvoiceStatusEnum::Pending->value,
+ 'document_id' => $document->id,
+ 'user_id' => $document->requester_id,
+ 'created_at' => $document->created_at,
+ 'updated_at' => $document->created_at,
+ ]);
+
+ DB::table('invoices')->where('id', $id)->update([
+ 'number' => str_pad((string) $id, 5, '0', STR_PAD_LEFT),
+ ]);
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('invoices');
+ }
+};
diff --git a/database/migrations/2026_08_26_000000_create_payments_table.php b/database/migrations/2026_08_26_000000_create_payments_table.php
new file mode 100644
index 0000000..e3b4d3e
--- /dev/null
+++ b/database/migrations/2026_08_26_000000_create_payments_table.php
@@ -0,0 +1,32 @@
+id();
+ $table->string('track_id')->unique();
+ $table->unsignedBigInteger('amount');
+ $table->string('status')->default(PaymentStatusEnum::Pending->value);
+ $table->string('purpose')->default(PaymentPurposeEnum::Invoice->value);
+ $table->string('ref_number')->nullable();
+ $table->timestamp('verified_at')->nullable();
+ $table->timestamps();
+
+ $table->foreignId('invoice_id')->nullable()->constrained('invoices')->cascadeOnDelete();
+ $table->foreignId('payer_id')->constrained('users')->cascadeOnDelete();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('payments');
+ }
+};
diff --git a/database/migrations/2026_08_26_000001_create_wallets_table.php b/database/migrations/2026_08_26_000001_create_wallets_table.php
new file mode 100644
index 0000000..2ccfdb8
--- /dev/null
+++ b/database/migrations/2026_08_26_000001_create_wallets_table.php
@@ -0,0 +1,24 @@
+id();
+ $table->unsignedBigInteger('balance')->default(0);
+ $table->timestamps();
+
+ $table->foreignId('user_id')->unique()->constrained('users')->cascadeOnDelete();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('wallets');
+ }
+};
diff --git a/database/migrations/2026_08_26_000002_add_birthdate_to_users_table.php b/database/migrations/2026_08_26_000002_add_birthdate_to_users_table.php
new file mode 100644
index 0000000..bea5160
--- /dev/null
+++ b/database/migrations/2026_08_26_000002_add_birthdate_to_users_table.php
@@ -0,0 +1,22 @@
+date('birthdate')->nullable()->after('fullname');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('users', function (Blueprint $table) {
+ $table->dropColumn('birthdate');
+ });
+ }
+};
diff --git a/database/migrations/2026_08_26_000002_create_wallet_transactions_table.php b/database/migrations/2026_08_26_000002_create_wallet_transactions_table.php
new file mode 100644
index 0000000..3139be0
--- /dev/null
+++ b/database/migrations/2026_08_26_000002_create_wallet_transactions_table.php
@@ -0,0 +1,30 @@
+id();
+ $table->string('type');
+ $table->string('reason');
+ $table->unsignedBigInteger('amount');
+ $table->unsignedBigInteger('balance_after');
+ $table->string('description')->nullable();
+ $table->timestamps();
+
+ $table->foreignId('wallet_id')->constrained('wallets')->cascadeOnDelete();
+ $table->foreignId('invoice_id')->nullable()->constrained('invoices')->nullOnDelete();
+ $table->foreignId('payment_id')->nullable()->constrained('payments')->nullOnDelete();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('wallet_transactions');
+ }
+};
diff --git a/database/migrations/2026_08_26_000003_add_section_columns_to_reports_table.php b/database/migrations/2026_08_26_000003_add_section_columns_to_reports_table.php
new file mode 100644
index 0000000..be3af5c
--- /dev/null
+++ b/database/migrations/2026_08_26_000003_add_section_columns_to_reports_table.php
@@ -0,0 +1,25 @@
+text('technique')->nullable()->after('body');
+ $table->text('finding')->nullable()->after('technique');
+ $table->text('diagnose')->nullable()->after('finding');
+ $table->text('recommendation')->nullable()->after('diagnose');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('reports', function (Blueprint $table) {
+ $table->dropColumn(['technique', 'finding', 'diagnose', 'recommendation']);
+ });
+ }
+};
diff --git a/database/migrations/2026_08_27_000000_make_wallets_user_id_nullable.php b/database/migrations/2026_08_27_000000_make_wallets_user_id_nullable.php
new file mode 100644
index 0000000..09a84c3
--- /dev/null
+++ b/database/migrations/2026_08_27_000000_make_wallets_user_id_nullable.php
@@ -0,0 +1,26 @@
+dropForeign(['user_id']);
+ $table->foreignId('user_id')->nullable()->change();
+ $table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('wallets', function (Blueprint $table) {
+ $table->dropForeign(['user_id']);
+ $table->foreignId('user_id')->nullable(false)->change();
+ $table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete();
+ });
+ }
+};
diff --git a/database/migrations/2026_08_27_000001_create_bank_cards_table.php b/database/migrations/2026_08_27_000001_create_bank_cards_table.php
new file mode 100644
index 0000000..b027207
--- /dev/null
+++ b/database/migrations/2026_08_27_000001_create_bank_cards_table.php
@@ -0,0 +1,25 @@
+id();
+ $table->string('card_number', 16);
+ $table->string('holder_name');
+ $table->timestamps();
+
+ $table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('bank_cards');
+ }
+};
diff --git a/database/migrations/2026_08_27_000002_create_withdraw_requests_table.php b/database/migrations/2026_08_27_000002_create_withdraw_requests_table.php
new file mode 100644
index 0000000..d8acc96
--- /dev/null
+++ b/database/migrations/2026_08_27_000002_create_withdraw_requests_table.php
@@ -0,0 +1,30 @@
+id();
+ $table->unsignedBigInteger('amount');
+ $table->string('status')->default(WithdrawRequestStatusEnum::Pending->value)->index();
+ $table->text('admin_note')->nullable();
+ $table->timestamp('reviewed_at')->nullable();
+ $table->timestamps();
+
+ $table->foreignId('reviewed_by')->nullable()->constrained('users')->nullOnDelete();
+ $table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
+ $table->foreignId('bank_card_id')->nullable()->constrained('bank_cards')->nullOnDelete();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('withdraw_requests');
+ }
+};
diff --git a/database/migrations/2026_08_27_000003_add_withdraw_request_id_to_wallet_transactions_table.php b/database/migrations/2026_08_27_000003_add_withdraw_request_id_to_wallet_transactions_table.php
new file mode 100644
index 0000000..5fda6f2
--- /dev/null
+++ b/database/migrations/2026_08_27_000003_add_withdraw_request_id_to_wallet_transactions_table.php
@@ -0,0 +1,22 @@
+foreignId('withdraw_request_id')->nullable()->after('payment_id')->constrained('withdraw_requests')->nullOnDelete();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('wallet_transactions', function (Blueprint $table) {
+ $table->dropConstrainedForeignId('withdraw_request_id');
+ });
+ }
+};
diff --git a/database/seeders/PermissionSeeder.php b/database/seeders/PermissionSeeder.php
index b757fec..1f21510 100644
--- a/database/seeders/PermissionSeeder.php
+++ b/database/seeders/PermissionSeeder.php
@@ -109,6 +109,12 @@ class PermissionSeeder extends Seeder
'own' => 'دسترسی به شیفت های خود',
],
],
+ 'withdrawals' => [
+ 'label' => 'دسترسی به درخواستهای برداشت',
+ 'children' => [
+ 'index' => 'دسترسی به لیست درخواستهای برداشت',
+ ],
+ ],
];
foreach ($permissions as $key => $data) {
$parent = Permission::create([
diff --git a/package.json b/package.json
index 6ede199..e290b77 100644
--- a/package.json
+++ b/package.json
@@ -24,7 +24,7 @@
"@fullcalendar/core": "^6.1.21",
"@fullcalendar/interaction": "^6.1.21",
"@fullcalendar/timegrid": "^6.1.21",
- "@fullcalendar/vue3": "^7.0.2",
+ "@fullcalendar/vue3": "^6.1.21",
"@inertiajs/inertia": "^0.11.1",
"@inertiajs/inertia-vue3": "^0.6.0",
"@inertiajs/progress": "^0.2.7",
diff --git a/resources/css/Admin/input.css b/resources/css/Admin/input.css
index 3904831..2777a13 100644
--- a/resources/css/Admin/input.css
+++ b/resources/css/Admin/input.css
@@ -1,3 +1,51 @@
-.p-formfield .p-select{
- @apply !py-0.5;
+.p-inputtext,
+.p-select,
+.p-multiselect,
+.p-password,
+.vpd-input-group {
+ @apply h-10;
+}
+
+.p-multiselect {
+ @apply h-auto min-h-10;
+}
+
+.p-select,
+.p-multiselect {
+ @apply items-center;
+}
+
+.p-password .p-inputtext {
+ @apply !h-full;
+}
+
+.p-inputtext,
+.p-textarea,
+.p-select-label,
+.p-multiselect-label,
+.p-select-option,
+.p-multiselect-option {
+ @apply !text-sm;
+}
+
+.p-inputtext::placeholder,
+.p-textarea::placeholder {
+ @apply !text-sm font-light;
+}
+
+.p-select-label,
+.p-multiselect-label {
+ @apply flex items-center !py-0;
+}
+
+.p-textarea {
+ @apply min-h-28;
+}
+
+.p-formfield select {
+ @apply w-full rounded-md border border-gray-300 bg-white p-2 text-sm text-client-Fontcolor;
+}
+
+.vpd-input-group input.form-control {
+ @apply !h-full !w-full !rounded-md !border !border-gray-300 !bg-white !px-3 !text-sm !text-client-Fontcolor;
}
diff --git a/resources/css/Client/ui/button.css b/resources/css/Client/ui/button.css
index aca1455..5d84a77 100644
--- a/resources/css/Client/ui/button.css
+++ b/resources/css/Client/ui/button.css
@@ -40,6 +40,22 @@
+.ui-button--success.p-button {
+ @apply !bg-success !border-success font-normal !text-white;
+}
+
+.ui-button--success.p-button:hover {
+ @apply !bg-success-active !border-success-active;
+}
+
+.ui-button--success.p-button:active {
+ @apply !bg-success-active !border-success-active;
+}
+
+.ui-button--success.p-button:focus {
+ box-shadow: 0 0 0 2px rgb(80 205 137 / 0.25);
+}
+
.ui-button-icon {
@apply transition-all duration-200;
}
diff --git a/resources/css/Client/ui/flags.css b/resources/css/Client/ui/flags.css
index 5fbc1c9..5be13fe 100644
--- a/resources/css/Client/ui/flags.css
+++ b/resources/css/Client/ui/flags.css
@@ -1,19 +1,4 @@
/* Flag Icons using Emoji */
-.fi {
- font-style: normal;
- font-weight: normal;
- font-variant: normal;
- text-transform: none;
- line-height: 1;
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
-}
-
-.fi:before {
- content: attr(data-flag);
- font-size: 1.2em;
-}
-
/* Country Flags */
.fi.fi-af:before { content: "🇦🇫"; }
.fi.fi-al:before { content: "🇦🇱"; }
diff --git a/resources/css/Shared/shift-calendar.css b/resources/css/Shared/shift-calendar.css
index 6936a50..5b96b0e 100644
--- a/resources/css/Shared/shift-calendar.css
+++ b/resources/css/Shared/shift-calendar.css
@@ -99,3 +99,8 @@
.shift-calendar .shift-event--inactive {
opacity: 0.55;
}
+
+.shift-calendar .fc-highlight {
+ background: rgba(87, 105, 137, 0.15);
+ border-radius: 8px;
+}
diff --git a/resources/css/admin.css b/resources/css/admin.css
index 9caf8f6..6037db3 100644
--- a/resources/css/admin.css
+++ b/resources/css/admin.css
@@ -13,9 +13,6 @@ body{
@apply font-dana font-normal bg-client-gray/5;
}
-.vpd-input-group{
- @apply !h-9;
-}
.ql-container {
direction: rtl;
text-align: right;
diff --git a/resources/js/Admin/Pages/Documents/create_and_edit.vue b/resources/js/Admin/Pages/Documents/create_and_edit.vue
index 28d14c1..c938684 100644
--- a/resources/js/Admin/Pages/Documents/create_and_edit.vue
+++ b/resources/js/Admin/Pages/Documents/create_and_edit.vue
@@ -118,17 +118,17 @@ const removeHistoryFile = (index) => {