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) => { - + {{ documentStore.errorsCreateForm?.submitted_at[0] }} - + {{ documentStore.errorsCreateForm?.processing_deadline[0] }} - +
+
+ + + + diff --git a/resources/js/Admin/components/SidebarComponent.vue b/resources/js/Admin/components/SidebarComponent.vue index fc6d9af..77c678a 100644 --- a/resources/js/Admin/components/SidebarComponent.vue +++ b/resources/js/Admin/components/SidebarComponent.vue @@ -86,6 +86,14 @@ const items: Ref = ref([ permission: "reports.index", expanded: false, }, + { + label: "درخواست‌های برداشت", + icon: "fi fi-rr-wallet", + to: "/admin/withdraw-requests", + key: 'withdrawals', + permission: "withdrawals.index", + expanded: false, + }, { label: "مدیریت شیفت ها", icon: "fi fi-rr-calendar", diff --git a/resources/js/Admin/components/TableGeneric.vue b/resources/js/Admin/components/TableGeneric.vue index 01da1c9..805bed0 100644 --- a/resources/js/Admin/components/TableGeneric.vue +++ b/resources/js/Admin/components/TableGeneric.vue @@ -251,7 +251,6 @@ const getColumnStyle = (column) => {
{ optionLabel="label" optionValue="value" size="small" - :pt="{label : '!text-[12px] !pt-2.5' , option : '!text-[12px] !font-normal'}" - class="min-w-[130px] max-sm:!w-full max-w-[200px] !text-[12px] !h-10" + class="min-w-[130px] max-sm:!w-full max-w-[200px]" :loading="renderFilter(column).loading" :disabled="renderFilter(column).loading" :filter="renderFilter(column).search" @@ -297,8 +295,7 @@ const getColumnStyle = (column) => { optionLabel="label" optionValue="value" size="small" - :pt="{label : '!text-[12px] !pt-2.5' , option : '!text-[12px] !font-normal'}" - class="min-w-[150px] max-sm:!w-full max-w-[200px] !text-[12px] !h-10" + class="min-w-[150px] max-sm:!w-full max-w-[200px]" :loading="renderFilter(column).loading" :disabled="renderFilter(column).loading" :filter="renderFilter(column).search" diff --git a/resources/js/Admin/components/shifts/ShiftCalendar.vue b/resources/js/Admin/components/shifts/ShiftCalendar.vue index 07aacb0..c97fa53 100644 --- a/resources/js/Admin/components/shifts/ShiftCalendar.vue +++ b/resources/js/Admin/components/shifts/ShiftCalendar.vue @@ -11,10 +11,14 @@ const props = defineProps({ shifts: { type: Array, required: true + }, + selectable: { + type: Boolean, + default: false } }) -const emit = defineEmits(['select-shift']); +const emit = defineEmits(['select-shift', 'select-range']); const doctorPalette = ['#576989', '#800E13', '#50CD89', '#7239EA', '#FFC700', '#F1416C', '#009EF7', '#965457']; @@ -71,6 +75,11 @@ const calendarOptions = computed(() => ({ eventClick: (info) => { emit('select-shift', info.event.extendedProps.shift); }, + selectable: props.selectable, + selectMirror: true, + select: (info) => { + emit('select-range', { start: info.start, end: info.end }); + }, })) const goPrev = () => calendarRef.value?.getApi().prev(); diff --git a/resources/js/Admin/components/ui/UISelect.vue b/resources/js/Admin/components/ui/UISelect.vue index 82bf542..fe47802 100644 --- a/resources/js/Admin/components/ui/UISelect.vue +++ b/resources/js/Admin/components/ui/UISelect.vue @@ -45,15 +45,11 @@ const props = defineProps({ }, class: { type: String, - default: '!text-xs !h-full' + default: 'w-full' }, pt: { type: Object, - default: () => ({ - option: '!text-sm', - placeholder: '!text-sm', - label: '!pt-3' - }) + default: () => ({}) } }) diff --git a/resources/js/Admin/store/WithdrawRequest.js b/resources/js/Admin/store/WithdrawRequest.js new file mode 100644 index 0000000..ef4b82c --- /dev/null +++ b/resources/js/Admin/store/WithdrawRequest.js @@ -0,0 +1,110 @@ +import { defineStore } from 'pinia' +import axios from "axios"; +import { Inertia } from '@inertiajs/inertia' + +export const useWithdrawRequestStore = defineStore('WithdrawRequestStore', { + state: () => ({ + columns: [ + { + key: 'id', + title: 'شناسه', + width: 100, + }, + { + key: 'user', + title: 'درخواست‌کننده', + slot: 'user', + }, + { + key: 'amount', + title: 'مبلغ', + render: (data) => `${Number(data.amount).toLocaleString('fa-IR')} تومان`, + }, + { + key: 'bank_card', + title: 'کارت مقصد', + slot: 'bank_card', + width: 220, + }, + { + key: 'status', + title: 'وضعیت', + slot: 'status', + }, + { + key: 'created_at', + title: 'تاریخ درخواست', + width: 180, + }, + { + key: 'reviewed_by', + title: 'بررسی‌کننده', + render: (data) => data.reviewed_by ? `${data.reviewed_by.fullname} - ${data.reviewed_at}` : 'بررسی نشده', + }, + { + key: 'actions', + title: 'عملیات', + hasSort: false, + slot: true, + }, + ], + endpoints: { + index: '/admin/withdraw-requests', + approve: (id) => `/admin/withdraw-requests/${id}/approve`, + reject: (id) => `/admin/withdraw-requests/${id}/reject`, + } + }), + + getters: { + indexPageTitle: () => { + return 'درخواست‌های برداشت'; + } + }, + + actions: { + approve(id) { + return new Promise((resolve, reject) => { + axios.post(this.endpoints.approve(id)) + .then(response => { + Inertia.get(this.endpoints.index); + resolve(response); + }) + .catch(error => { + reject(error); + }); + }); + }, + + reject(id, note) { + return new Promise((resolve, rejectPromise) => { + axios.post(this.endpoints.reject(id), { note }) + .then(response => { + Inertia.get(this.endpoints.index); + resolve(response); + }) + .catch(error => { + rejectPromise(error); + }); + }); + }, + + search(params) { + return Inertia.get(this.endpoints.index, params, { + preserveState: false + }); + }, + + navigateToPage(page) { + return Inertia.get(this.endpoints.index, { page }, { preserveState: true }); + }, + + getStatusSeverity(status) { + const statusSeverities = { + 'در انتظار بررسی': 'warning', + 'تایید شده': 'success', + 'رد شده': 'danger', + }; + return statusSeverities[status] || 'secondary'; + } + } +}) diff --git a/resources/js/Client/Pages/Dashboard/Cases/show.vue b/resources/js/Client/Pages/Dashboard/Cases/show.vue new file mode 100644 index 0000000..ae27566 --- /dev/null +++ b/resources/js/Client/Pages/Dashboard/Cases/show.vue @@ -0,0 +1,231 @@ + + + diff --git a/resources/js/Client/Pages/Dashboard/Dashboard.vue b/resources/js/Client/Pages/Dashboard/Dashboard.vue index 5b3ccc9..164b53e 100644 --- a/resources/js/Client/Pages/Dashboard/Dashboard.vue +++ b/resources/js/Client/Pages/Dashboard/Dashboard.vue @@ -7,9 +7,10 @@ import UiButton from '../../components/ui/UiButton.vue'; import EyeSvg from '../../components/svg/eye.svg'; import UiCard from '../../components/ui/UiCard.vue'; import RequestCard from '../../components/dashboard/RequestCard.vue'; -import { ref } from 'vue'; -import { Link } from '@inertiajs/inertia-vue3'; +import { onMounted, ref } from 'vue'; +import { Link, usePage } from '@inertiajs/inertia-vue3'; import FastRequestModal from '../../components/dashboard/FastRequestModal.vue'; +import PaymentResultModal from '../../components/dashboard/PaymentResultModal.vue'; import { useFastRequestStore } from '../../store/FastRequest'; import { modalities } from '../../data/fastRequestOptions'; import { caseColumns } from '../../data/casesTableColumns'; @@ -22,6 +23,17 @@ const openFastRequestModal = (type) => { fastRequestStore.requestType = type; } +const paymentResultVisible = ref(false); +const paymentResult = ref(null); + +onMounted(() => { + const result = usePage().props.value.flash?.payment_result; + if (result) { + paymentResult.value = result; + paymentResultVisible.value = true; + } +}) + defineProps({ user: { type: Object, @@ -30,13 +42,17 @@ defineProps({ cases: { type: Object, required: true + }, + stats: { + type: Object, + required: true } }) diff --git a/resources/js/Client/Pages/Dashboard/Documents/show.vue b/resources/js/Client/Pages/Dashboard/Documents/show.vue deleted file mode 100644 index a6f4385..0000000 --- a/resources/js/Client/Pages/Dashboard/Documents/show.vue +++ /dev/null @@ -1,26 +0,0 @@ - - - - diff --git a/resources/js/Client/Pages/Dashboard/Invoices/Index.vue b/resources/js/Client/Pages/Dashboard/Invoices/Index.vue new file mode 100644 index 0000000..35a7f08 --- /dev/null +++ b/resources/js/Client/Pages/Dashboard/Invoices/Index.vue @@ -0,0 +1,55 @@ + + + diff --git a/resources/js/Client/Pages/Dashboard/Reports/Index.vue b/resources/js/Client/Pages/Dashboard/Reports/Index.vue index 2c5f4c2..3db09ac 100644 --- a/resources/js/Client/Pages/Dashboard/Reports/Index.vue +++ b/resources/js/Client/Pages/Dashboard/Reports/Index.vue @@ -1,11 +1,42 @@ diff --git a/resources/js/Client/Pages/Dashboard/Wallet/Index.vue b/resources/js/Client/Pages/Dashboard/Wallet/Index.vue new file mode 100644 index 0000000..5a78959 --- /dev/null +++ b/resources/js/Client/Pages/Dashboard/Wallet/Index.vue @@ -0,0 +1,189 @@ + + + diff --git a/resources/js/Client/components/dashboard/ActivitiesWidget.vue b/resources/js/Client/components/dashboard/ActivitiesWidget.vue index 6e805e5..d9443c8 100644 --- a/resources/js/Client/components/dashboard/ActivitiesWidget.vue +++ b/resources/js/Client/components/dashboard/ActivitiesWidget.vue @@ -1,18 +1,24 @@ diff --git a/resources/js/Client/components/dashboard/EditProfileModal.vue b/resources/js/Client/components/dashboard/EditProfileModal.vue new file mode 100644 index 0000000..11bff3d --- /dev/null +++ b/resources/js/Client/components/dashboard/EditProfileModal.vue @@ -0,0 +1,132 @@ + + + diff --git a/resources/js/Client/components/dashboard/FastRequestModal.vue b/resources/js/Client/components/dashboard/FastRequestModal.vue index 098b637..3ac591e 100644 --- a/resources/js/Client/components/dashboard/FastRequestModal.vue +++ b/resources/js/Client/components/dashboard/FastRequestModal.vue @@ -1,6 +1,6 @@ + + diff --git a/resources/js/Client/components/dashboard/cases/CaseSpecsCard.vue b/resources/js/Client/components/dashboard/cases/CaseSpecsCard.vue new file mode 100644 index 0000000..a941a0e --- /dev/null +++ b/resources/js/Client/components/dashboard/cases/CaseSpecsCard.vue @@ -0,0 +1,27 @@ + + + diff --git a/resources/js/Client/components/dashboard/cases/ReportSectionCard.vue b/resources/js/Client/components/dashboard/cases/ReportSectionCard.vue new file mode 100644 index 0000000..9730abe --- /dev/null +++ b/resources/js/Client/components/dashboard/cases/ReportSectionCard.vue @@ -0,0 +1,42 @@ + + + diff --git a/resources/js/Client/components/dashboard/fastRequest/InvoiceModal.vue b/resources/js/Client/components/dashboard/fastRequest/InvoiceModal.vue index d23a666..c38930b 100644 --- a/resources/js/Client/components/dashboard/fastRequest/InvoiceModal.vue +++ b/resources/js/Client/components/dashboard/fastRequest/InvoiceModal.vue @@ -1,8 +1,12 @@ diff --git a/resources/js/Client/components/dashboard/wallet/WithdrawRequestCard.vue b/resources/js/Client/components/dashboard/wallet/WithdrawRequestCard.vue new file mode 100644 index 0000000..0f8735d --- /dev/null +++ b/resources/js/Client/components/dashboard/wallet/WithdrawRequestCard.vue @@ -0,0 +1,193 @@ + + + diff --git a/resources/js/Client/components/header/HeaderComponent.vue b/resources/js/Client/components/header/HeaderComponent.vue index b5913e7..1b3101e 100644 --- a/resources/js/Client/components/header/HeaderComponent.vue +++ b/resources/js/Client/components/header/HeaderComponent.vue @@ -1,14 +1,47 @@ - diff --git a/resources/js/Client/components/sidebar/SidebarComponent.vue b/resources/js/Client/components/sidebar/SidebarComponent.vue index 12e48cb..80e3d27 100644 --- a/resources/js/Client/components/sidebar/SidebarComponent.vue +++ b/resources/js/Client/components/sidebar/SidebarComponent.vue @@ -10,6 +10,8 @@ import LogoutSvg from "../svg/logout.svg" import CalendarSvg from "../svg/calendar.svg" import StatusUpSvg from "../svg/statusUp.svg" import PetSvg from "../svg/pet.svg" +import WalletSvg from "../svg/wallet.svg" +import InvoiceSvg from "../svg/invoice.svg" interface SidebarItem extends MenuItem { label: string; @@ -87,6 +89,20 @@ const items: Ref = ref([ key: 'reports', permission: null, }, + { + label: "کیف پول", + icon: WalletSvg, + to: "/dashboard/wallet", + key: 'wallet', + permission: null, + }, + { + label: "فاکتورها", + icon: InvoiceSvg, + to: "/dashboard/invoices", + key: 'invoices', + permission: null, + }, { label: "شیفت ها", icon: CalendarSvg, diff --git a/resources/js/Client/components/svg/invoice.svg b/resources/js/Client/components/svg/invoice.svg new file mode 100644 index 0000000..031e2bd --- /dev/null +++ b/resources/js/Client/components/svg/invoice.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/resources/js/Client/components/svg/paymentFailed.svg b/resources/js/Client/components/svg/paymentFailed.svg new file mode 100644 index 0000000..643eb41 --- /dev/null +++ b/resources/js/Client/components/svg/paymentFailed.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/resources/js/Client/components/svg/paymentSuccess.svg b/resources/js/Client/components/svg/paymentSuccess.svg new file mode 100644 index 0000000..55e141e --- /dev/null +++ b/resources/js/Client/components/svg/paymentSuccess.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/resources/js/Client/components/svg/wallet.svg b/resources/js/Client/components/svg/wallet.svg new file mode 100644 index 0000000..579c86d --- /dev/null +++ b/resources/js/Client/components/svg/wallet.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/resources/js/Client/components/ui/Pagination.vue b/resources/js/Client/components/ui/Pagination.vue index eafb703..2a9aa04 100644 --- a/resources/js/Client/components/ui/Pagination.vue +++ b/resources/js/Client/components/ui/Pagination.vue @@ -7,50 +7,39 @@ const props = defineProps([ 'perPage', 'links', ]) + +const pageItemClass = 'flex items-center justify-center w-8 h-8 rounded-lg border text-[11px] font-semibold transition-colors'; - - diff --git a/resources/js/Client/components/ui/UiButton.vue b/resources/js/Client/components/ui/UiButton.vue index b0aa2dd..3fd6dbd 100644 --- a/resources/js/Client/components/ui/UiButton.vue +++ b/resources/js/Client/components/ui/UiButton.vue @@ -133,6 +133,12 @@ const buttonClasses = computed(() => { if (props.severity === 'secondary') { classes.push('!font-[600] !text-[11px] !rounded-[6px]') } + if (props.severity === 'success') { + classes.push('ui-button--success !font-[600] !text-[11px] !rounded-[6px]') + } + if (props.severity === 'danger') { + classes.push('!font-[600] !text-[11px] !rounded-[6px]') + } return classes.join(' ') }) diff --git a/resources/js/Client/data/invoicesTableColumns.js b/resources/js/Client/data/invoicesTableColumns.js new file mode 100644 index 0000000..a4ab9f7 --- /dev/null +++ b/resources/js/Client/data/invoicesTableColumns.js @@ -0,0 +1,46 @@ +import { modalityTitle } from './fastRequestOptions' +import { convertEnglishToPersian, formatPrice } from '../utils/numberUtils' + +export const invoiceColumns = [ + { + title: 'شماره فاکتور', + key: 'number', + render: (row) => convertEnglishToPersian(row.number) + }, + { + title: 'شماره پرونده', + key: 'document', + render: (row) => row.document?.code ?? '-' + }, + { + title: 'نوع درخواست', + key: 'modality', + render: (row) => row.document ? modalityTitle(row.document.modality) : '-' + }, + { + title: 'مبلغ', + key: 'amount', + render: (row) => `${formatPrice(row.amount)} تومان` + }, + { + title: 'وضعیت', + key: 'status' + }, + { + title: 'روش پرداخت', + key: 'payment_method', + render: (row) => row.payment_method ?? '-' + }, + { + title: 'تاریخ ثبت', + key: 'created_at', + render: (row) => row.created_at ? convertEnglishToPersian(new Date(row.created_at).toLocaleDateString('fa-IR')) : '-' + }, + { + title: '', + key: 'actions', + width: 100, + style: 'grid grid-cols-1', + slot: true, + } +] diff --git a/resources/js/Client/data/reportsTableColumns.js b/resources/js/Client/data/reportsTableColumns.js new file mode 100644 index 0000000..37c70db --- /dev/null +++ b/resources/js/Client/data/reportsTableColumns.js @@ -0,0 +1,36 @@ +import { modalityTitle } from './fastRequestOptions' +import { convertEnglishToPersian } from '../utils/numberUtils' + +export const reportColumns = [ + { + title: 'شماره پرونده', + key: 'code', + render: (row) => row.document?.code || '-' + }, + { + title: 'نوع درخواست', + key: 'modality', + render: (row) => row.document ? modalityTitle(row.document.modality) : '-' + }, + { + title: 'وضعیت گزارش', + key: 'status' + }, + { + title: 'پزشک', + key: 'doctor', + render: (row) => row.doctor || '-' + }, + { + title: 'تاریخ ثبت گزارش', + key: 'submitted_at', + render: (row) => row.submitted_at ? convertEnglishToPersian(row.submitted_at) : '-' + }, + { + title: '', + key: 'actions', + width: 80, + style: 'grid grid-cols-1', + slot: true, + } +] diff --git a/resources/js/Client/data/walletTransactionsTableColumns.js b/resources/js/Client/data/walletTransactionsTableColumns.js new file mode 100644 index 0000000..0e65f4a --- /dev/null +++ b/resources/js/Client/data/walletTransactionsTableColumns.js @@ -0,0 +1,38 @@ +import { convertEnglishToPersian, formatPrice } from '../utils/numberUtils' + +export const walletTransactionColumns = [ + { + title: 'نوع', + key: 'type', + slot: true, + }, + { + title: 'بابت', + key: 'reason' + }, + { + title: 'مبلغ', + key: 'amount', + render: (row) => `${formatPrice(row.amount)} تومان` + }, + { + title: 'روش', + key: 'via_gateway', + render: (row) => row.via_gateway ? 'درگاه پرداخت' : 'کیف پول' + }, + { + title: 'شماره فاکتور', + key: 'invoice_number', + render: (row) => row.invoice_number ? convertEnglishToPersian(row.invoice_number) : '-' + }, + { + title: 'موجودی پس از تراکنش', + key: 'balance_after', + render: (row) => `${formatPrice(row.balance_after)} تومان` + }, + { + title: 'تاریخ', + key: 'created_at', + render: (row) => row.created_at ? convertEnglishToPersian(row.created_at) : '-' + } +] diff --git a/resources/js/Client/data/withdrawRequestsTableColumns.js b/resources/js/Client/data/withdrawRequestsTableColumns.js new file mode 100644 index 0000000..5836907 --- /dev/null +++ b/resources/js/Client/data/withdrawRequestsTableColumns.js @@ -0,0 +1,34 @@ +import { convertEnglishToPersian, formatPrice } from '../utils/numberUtils' + +export const withdrawRequestColumns = [ + { + title: 'مبلغ', + key: 'amount', + render: (row) => `${formatPrice(row.amount)} تومان` + }, + { + title: 'کارت مقصد', + key: 'bank_card', + slot: true, + }, + { + title: 'وضعیت', + key: 'status', + slot: true, + }, + { + title: 'تاریخ درخواست', + key: 'created_at', + render: (row) => convertEnglishToPersian(row.created_at) + }, + { + title: 'تاریخ بررسی', + key: 'reviewed_at', + render: (row) => row.reviewed_at ? convertEnglishToPersian(row.reviewed_at) : '-' + }, + { + title: 'توضیح', + key: 'admin_note', + render: (row) => row.admin_note || '-' + }, +] diff --git a/resources/js/Shared/shiftCalendarEvents.js b/resources/js/Shared/shiftCalendarEvents.js index 220e153..bc11b14 100644 --- a/resources/js/Shared/shiftCalendarEvents.js +++ b/resources/js/Shared/shiftCalendarEvents.js @@ -35,6 +35,20 @@ export function buildShiftEvent(shift, overrides = {}) { } } +const WEEKDAY_NAMES = { + 6: 'شنبه', + 0: 'یکشنبه', + 1: 'دوشنبه', + 2: 'سه‌شنبه', + 3: 'چهارشنبه', + 4: 'پنج‌شنبه', + 5: 'جمعه', +} + +export function weekdayName(dayIndex) { + return WEEKDAY_NAMES[dayIndex] ?? null +} + export function formatJalaliDay(date) { return new Intl.DateTimeFormat('fa-IR', { day: 'numeric', month: 'short' }).format(date) } diff --git a/routes/admin.php b/routes/admin.php index a3ae652..fb463eb 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -11,6 +11,7 @@ use App\Http\Controllers\Admin\DocumentController; use App\Http\Controllers\Admin\ReportController; use App\Http\Controllers\Admin\PetController; use App\Http\Controllers\Admin\ShiftController; +use App\Http\Controllers\Admin\WithdrawRequestController; use Illuminate\Support\Facades\Route; Route::group(['middleware' => ['auth', 'admin.access']], function () { @@ -25,6 +26,11 @@ Route::group(['middleware' => ['auth', 'admin.access']], function () { //Reports' routes Route::resource('/reports', ReportController::class); + //Withdraw requests' routes + Route::get('/withdraw-requests', [WithdrawRequestController::class, 'index'])->name('withdraw-requests.index'); + Route::post('/withdraw-requests/{withdrawRequest}/approve', [WithdrawRequestController::class, 'approve'])->name('withdraw-requests.approve'); + Route::post('/withdraw-requests/{withdrawRequest}/reject', [WithdrawRequestController::class, 'reject'])->name('withdraw-requests.reject'); + //Shifts' routes Route::resource('/shifts', ShiftController::class); Route::post('/shifts-bulk-delete', [ShiftController::class , 'bulkDelete']); diff --git a/routes/web.php b/routes/web.php index 75e1a5b..dc9237d 100644 --- a/routes/web.php +++ b/routes/web.php @@ -4,6 +4,10 @@ use Illuminate\Support\Facades\Route; use Inertia\Inertia; use App\Http\Controllers\Client\AuthController; use App\Http\Controllers\Client\FastRequestController; +use App\Http\Controllers\Client\InvoiceController; +use App\Http\Controllers\Client\PaymentController; +use App\Http\Controllers\Client\ProfileController; +use App\Http\Controllers\Client\WalletController; use App\Http\Controllers\Client\Doctor\DashboardController; use App\Http\Controllers\Client\Doctor\CaseController; use App\Http\Controllers\Client\Doctor\DocumentController; @@ -14,6 +18,14 @@ Route::get('/', function () { return Inertia::client('index', []); }); +// TODO: temporary local-only debug login, remove before commit +if (app()->environment('local')) { + Route::get('/__dev-login-admin', function () { + \Illuminate\Support\Facades\Auth::login(\App\Models\User::where('email', 'admin@gmail.com')->firstOrFail()); + return redirect('/admin/shifts/create'); + }); +} + Route::group(['prefix' => 'auth','middleware' => 'guest'] , function (){ Route::prefix('login')->group(function () { @@ -42,8 +54,23 @@ Route::group(['prefix' => 'dashboard' , 'middleware' => 'auth' , 'as' => 'dashbo Route::get('fast-requests/create', [FastRequestController::class, 'create'])->name('fastRequests.create'); Route::post('fast-requests', [FastRequestController::class, 'store'])->name('fastRequests.store'); + Route::post('profile', [ProfileController::class, 'update'])->name('profile.update'); + + Route::post('payments', [PaymentController::class, 'store'])->name('payments.store'); + Route::get('payments/callback', [PaymentController::class, 'callback'])->name('payments.callback'); + + Route::get('wallet', [WalletController::class, 'index'])->name('wallet.index'); + Route::post('wallet/deposit', [WalletController::class, 'deposit'])->name('wallet.deposit'); + Route::post('wallet/withdraw-requests', [WalletController::class, 'storeWithdrawRequest'])->name('wallet.withdrawRequests.store'); + Route::post('wallet/bank-cards', [WalletController::class, 'storeBankCard'])->name('wallet.bankCards.store'); + Route::delete('wallet/bank-cards/{bankCard}', [WalletController::class, 'destroyBankCard'])->name('wallet.bankCards.destroy'); + + Route::get('invoices', [InvoiceController::class, 'index'])->name('invoices.index'); + Route::post('invoices/{invoice}/pay-wallet', [InvoiceController::class, 'payWithWallet'])->name('invoices.payWallet'); + Route::resource('/documents', DocumentController::class); Route::resource('cases', CaseController::class); + Route::post('cases/{case}/reject', [CaseController::class, 'reject'])->name('cases.reject'); Route::resource('reports', ReportController::class); Route::resource('shifts', ShiftController::class)->only(['index', 'update', 'destroy']); diff --git a/vite.config.js b/vite.config.js index ac93381..29c4f52 100644 --- a/vite.config.js +++ b/vite.config.js @@ -32,4 +32,12 @@ export default defineConfig({ '@': '/public', }, }, + optimizeDeps: { + include: [ + '@fullcalendar/core', + '@fullcalendar/vue3', + '@fullcalendar/timegrid', + '@fullcalendar/interaction', + ], + }, });