feat: add withdraw request functionality and UI components

This commit is contained in:
2026-08-27 02:39:57 +03:30
parent aeef95339d
commit 4ce663c669
115 changed files with 4064 additions and 315 deletions
+23
View File
@@ -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
}
]
}
+8
View File
@@ -8,5 +8,13 @@ enum DocumentPriorityEnum: string
case Medium = 'عادی'; case Medium = 'عادی';
case High = 'اورژانسی'; case High = 'اورژانسی';
public function processingHours(): int
{
return match ($this) {
self::Medium => 24,
self::High => 3,
};
}
} }
+11
View File
@@ -0,0 +1,11 @@
<?php
namespace App\Enums;
enum InvoicePaymentMethodEnum: string
{
use EnumHasOptions;
case Wallet = 'کیف پول';
case Gateway = 'درگاه پرداخت';
}
+12
View File
@@ -0,0 +1,12 @@
<?php
namespace App\Enums;
enum InvoiceStatusEnum: string
{
use EnumHasOptions;
case Pending = 'در انتظار پرداخت';
case Paid = 'پرداخت شده';
case Canceled = 'لغو شده';
}
+11
View File
@@ -0,0 +1,11 @@
<?php
namespace App\Enums;
enum PaymentPurposeEnum: string
{
use EnumHasOptions;
case Invoice = 'پرداخت فاکتور';
case WalletTopUp = 'شارژ کیف پول';
}
+12
View File
@@ -0,0 +1,12 @@
<?php
namespace App\Enums;
enum PaymentStatusEnum: string
{
use EnumHasOptions;
case Pending = 'در انتظار پرداخت';
case Paid = 'پرداخت شده';
case Failed = 'ناموفق';
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace App\Enums;
enum WalletTransactionReasonEnum: string
{
use EnumHasOptions;
case TopUp = 'شارژ کیف پول';
case Commission = 'کمیسیون پزشک';
case InvoicePayment = 'پرداخت فاکتور';
case Refund = 'بازگشت وجه';
case Withdraw = 'برداشت وجه';
}
+11
View File
@@ -0,0 +1,11 @@
<?php
namespace App\Enums;
enum WalletTransactionTypeEnum: string
{
use EnumHasOptions;
case Deposit = 'واریز';
case Withdrawal = 'برداشت';
}
+12
View File
@@ -0,0 +1,12 @@
<?php
namespace App\Enums;
enum WithdrawRequestStatusEnum: string
{
use EnumHasOptions;
case Pending = 'در انتظار بررسی';
case Approved = 'تایید شده';
case Rejected = 'رد شده';
}
@@ -0,0 +1,77 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Enums\WithdrawRequestStatusEnum;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\WithdrawRejectRequest;
use App\Http\Resources\Admin\WithdrawRequestResource;
use App\Models\WithdrawRequest;
use App\Services\Wallet\WalletService;
use App\Services\Wallet\WithdrawRequestService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Inertia\Inertia;
class WithdrawRequestController extends Controller
{
protected WithdrawRequestService $withdrawRequestService;
protected WalletService $walletService;
public function __construct(WithdrawRequestService $withdrawRequestService, WalletService $walletService)
{
$this->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);
}
}
@@ -3,7 +3,10 @@
namespace App\Http\Controllers\Client\Doctor; namespace App\Http\Controllers\Client\Doctor;
use App\Http\Controllers\Controller; 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\Http\Resources\Client\CaseResource;
use App\Models\Document;
use App\Services\Document\CaseService; use App\Services\Document\CaseService;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Inertia\Inertia; use Inertia\Inertia;
@@ -28,4 +31,25 @@ class CaseController extends Controller
'cases' => CaseResource::collection($cases), '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');
}
} }
@@ -23,6 +23,7 @@ class DashboardController extends Controller
return Inertia::client('Dashboard/Dashboard', [ return Inertia::client('Dashboard/Dashboard', [
'cases' => CaseResource::collection($cases), 'cases' => CaseResource::collection($cases),
'stats' => $this->caseService->statsForUser(Auth::user()),
]); ]);
} }
} }
@@ -32,8 +32,6 @@ class DocumentController extends Controller
public function show(Document $document) public function show(Document $document)
{ {
return Inertia::client('Dashboard/Documents/show', [ return redirect()->route('dashboard.cases.show', $document);
'document' => $document,
]);
} }
} }
@@ -3,13 +3,53 @@
namespace App\Http\Controllers\Client\Doctor; namespace App\Http\Controllers\Client\Doctor;
use App\Http\Controllers\Controller; 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; use Inertia\Inertia;
class ReportController extends Controller class ReportController extends Controller
{ {
protected CaseService $caseService;
public function __construct(CaseService $caseService)
{
$this->caseService = $caseService;
}
public function index() 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');
} }
} }
@@ -45,7 +45,7 @@ class FastRequestController extends Controller
); );
return response()->json([ return response()->json([
'document' => new FastRequestResource($document->refresh()->load('pet')), 'document' => new FastRequestResource($document->refresh()->load(['pet', 'invoice'])),
]); ]);
} }
} }
@@ -0,0 +1,45 @@
<?php
namespace App\Http\Controllers\Client;
use App\Http\Controllers\Controller;
use App\Http\Resources\Client\InvoiceResource;
use App\Models\Invoice;
use App\Services\Wallet\WalletService;
use Illuminate\Support\Facades\Auth;
use Inertia\Inertia;
class InvoiceController extends Controller
{
protected WalletService $walletService;
public function __construct(WalletService $walletService)
{
$this->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),
]);
}
}
@@ -0,0 +1,97 @@
<?php
namespace App\Http\Controllers\Client;
use App\Enums\InvoicePaymentMethodEnum;
use App\Enums\InvoiceStatusEnum;
use App\Enums\PaymentPurposeEnum;
use App\Enums\PaymentStatusEnum;
use App\Enums\WalletTransactionReasonEnum;
use App\Http\Controllers\Controller;
use App\Http\Requests\Client\PaymentRequest;
use App\Http\Resources\Client\PaymentResource;
use App\Models\Invoice;
use App\Models\Payment;
use App\Services\Invoice\InvoiceService;
use App\Services\Payment\ZibalPaymentService;
use App\Services\Wallet\WalletService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class PaymentController extends Controller
{
protected ZibalPaymentService $zibalPaymentService;
protected WalletService $walletService;
protected InvoiceService $invoiceService;
public function __construct(ZibalPaymentService $zibalPaymentService, WalletService $walletService, InvoiceService $invoiceService)
{
$this->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);
}
}
}
@@ -0,0 +1,42 @@
<?php
namespace App\Http\Controllers\Client;
use App\Http\Controllers\Controller;
use App\Http\Requests\Client\ProfileUpdateRequest;
use App\Models\Medium;
use App\Services\FileStorageService;
use Illuminate\Support\Facades\Auth;
class ProfileController extends Controller
{
protected FileStorageService $fileStorageService;
public function __construct(FileStorageService $fileStorageService)
{
$this->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();
}
}
@@ -0,0 +1,103 @@
<?php
namespace App\Http\Controllers\Client;
use App\Enums\PaymentPurposeEnum;
use App\Http\Controllers\Controller;
use App\Http\Requests\Client\BankCardStoreRequest;
use App\Http\Requests\Client\WalletDepositRequest;
use App\Http\Requests\Client\WithdrawRequestStoreRequest;
use App\Http\Resources\Client\BankCardResource;
use App\Http\Resources\Client\PaymentResource;
use App\Http\Resources\Client\WalletTransactionResource;
use App\Http\Resources\Client\WithdrawRequestResource;
use App\Models\BankCard;
use App\Services\Payment\ZibalPaymentService;
use App\Services\Wallet\WalletService;
use App\Services\Wallet\WithdrawRequestService;
use Illuminate\Support\Facades\Auth;
use Inertia\Inertia;
class WalletController extends Controller
{
protected WalletService $walletService;
protected ZibalPaymentService $zibalPaymentService;
protected WithdrawRequestService $withdrawRequestService;
public function __construct(WalletService $walletService, ZibalPaymentService $zibalPaymentService, WithdrawRequestService $withdrawRequestService)
{
$this->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');
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest;
class WithdrawRejectRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'note' => ['nullable', 'string', 'max:1000'],
];
}
}
@@ -0,0 +1,21 @@
<?php
namespace App\Http\Requests\Client;
use Illuminate\Foundation\Http\FormRequest;
class BankCardStoreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'card_number' => ['required', 'digits:16'],
'holder_name' => ['required', 'string', 'max:255'],
];
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Http\Requests\Client;
use Illuminate\Foundation\Http\FormRequest;
class CaseRejectRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'reason' => ['required', 'string', 'max:1000'],
];
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Http\Requests\Client;
use Illuminate\Foundation\Http\FormRequest;
class PaymentRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'invoice_id' => ['required', 'integer', 'exists:invoices,id'],
];
}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Http\Requests\Client;
use Illuminate\Foundation\Http\FormRequest;
class ProfileUpdateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'fullname' => ['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'],
];
}
}
@@ -0,0 +1,26 @@
<?php
namespace App\Http\Requests\Client;
use Illuminate\Foundation\Http\FormRequest;
class ReportSubmitRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'document_id' => ['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'],
];
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Http\Requests\Client;
use Illuminate\Foundation\Http\FormRequest;
class WalletDepositRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'amount' => ['required', 'integer', 'min:10000', 'max:500000000'],
];
}
}
@@ -0,0 +1,21 @@
<?php
namespace App\Http\Requests\Client;
use Illuminate\Foundation\Http\FormRequest;
class WithdrawRequestStoreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'amount' => ['required', 'integer', 'min:10000', 'max:500000000'],
'bank_card_id' => ['required', 'exists:bank_cards,id'],
];
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Http\Resources\Admin;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class WithdrawRequestResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
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,
];
}
}
@@ -0,0 +1,18 @@
<?php
namespace App\Http\Resources\Client;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class BankCardResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'card_number' => $this->card_number,
'holder_name' => $this->holder_name,
];
}
}
@@ -0,0 +1,76 @@
<?php
namespace App\Http\Resources\Client;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class CaseDetailResource extends JsonResource
{
public function toArray(Request $request): array
{
$report = $this->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,
];
}
}
@@ -2,7 +2,6 @@
namespace App\Http\Resources\Client; namespace App\Http\Resources\Client;
use App\Enums\DocumentModalityEnum;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
@@ -17,10 +16,7 @@ class FastRequestResource extends JsonResource
'status' => $this->status, 'status' => $this->status,
'priority' => $this->priority, 'priority' => $this->priority,
'pet' => new PetArchiveResource($this->whenLoaded('pet')), 'pet' => new PetArchiveResource($this->whenLoaded('pet')),
'invoice' => [ 'invoice' => new InvoiceResource($this->whenLoaded('invoice')),
'number' => str_pad((string) $this->id, 5, '0', STR_PAD_LEFT),
'amount' => DocumentModalityEnum::from($this->modality)->price(),
],
]; ];
} }
} }
@@ -0,0 +1,27 @@
<?php
namespace App\Http\Resources\Client;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class InvoiceResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $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,
]),
];
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Http\Resources\Client;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class PaymentResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'track_id' => $this->track_id,
'amount' => $this->amount,
'status' => $this->status,
'ref_number' => $this->ref_number,
];
}
}
@@ -0,0 +1,25 @@
<?php
namespace App\Http\Resources\Client;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class ReportResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $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),
];
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Http\Resources\Client;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class WalletTransactionResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $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'),
];
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Http\Resources\Client;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class WithdrawRequestResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $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,
];
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class BankCard extends Model
{
use HasFactory;
protected $fillable = [
'card_number',
'holder_name',
'user_id',
];
protected $casts = [
'user_id' => 'integer',
];
public function user()
{
return $this->belongsTo(User::class);
}
}
+5
View File
@@ -56,6 +56,11 @@ class Document extends Model
return $this->belongsTo(User::class); return $this->belongsTo(User::class);
} }
public function invoice()
{
return $this->hasOne(Invoice::class);
}
public function assignments() public function assignments()
{ {
return $this->hasMany(DocumentAssignment::class); return $this->hasMany(DocumentAssignment::class);
+47
View File
@@ -0,0 +1,47 @@
<?php
namespace App\Models;
use App\Enums\InvoicePaymentMethodEnum;
use App\Enums\InvoiceStatusEnum;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Invoice extends Model
{
use HasFactory;
protected $fillable = [
'number',
'amount',
'status',
'payment_method',
'paid_at',
'document_id',
'user_id',
];
protected $casts = [
'status' => 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);
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace App\Models;
use App\Enums\PaymentPurposeEnum;
use App\Enums\PaymentStatusEnum;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Payment extends Model
{
use HasFactory;
protected $fillable = [
'track_id',
'amount',
'status',
'purpose',
'ref_number',
'verified_at',
'invoice_id',
'payer_id',
];
protected $casts = [
'status' => 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');
}
}
+5 -1
View File
@@ -17,6 +17,10 @@ class Report extends Model
'medium_id', 'medium_id',
'status', 'status',
'body', 'body',
'technique',
'finding',
'diagnose',
'recommendation',
'submitted_at', 'submitted_at',
]; ];
@@ -43,7 +47,7 @@ class Report extends Model
'media_assignments', 'media_assignments',
'mediable_id', 'mediable_id',
'media_id' 'media_id'
)->withPivot('collection')->withTimestamps(); )->withPivot('collection');
} }
public function medium() public function medium()
+22
View File
@@ -22,6 +22,7 @@ class User extends Authenticatable
protected $fillable = [ protected $fillable = [
'username', 'username',
'fullname', 'fullname',
'birthdate',
'national_code', 'national_code',
'level', 'level',
'email', 'email',
@@ -56,6 +57,7 @@ class User extends Authenticatable
{ {
return [ return [
'email_verified_at' => 'datetime', 'email_verified_at' => 'datetime',
'birthdate' => 'date:Y-m-d',
'password' => 'hashed', 'password' => 'hashed',
]; ];
} }
@@ -76,4 +78,24 @@ class User extends Authenticatable
{ {
return $this->hasMany(Shift::class); 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);
}
} }
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Wallet extends Model
{
use HasFactory;
protected $fillable = [
'balance',
'user_id',
];
protected $casts = [
'balance' => 'integer',
'user_id' => 'integer',
];
public function user()
{
return $this->belongsTo(User::class);
}
public function transactions()
{
return $this->hasMany(WalletTransaction::class);
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace App\Models;
use App\Enums\WalletTransactionReasonEnum;
use App\Enums\WalletTransactionTypeEnum;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class WalletTransaction extends Model
{
use HasFactory;
protected $fillable = [
'type',
'reason',
'amount',
'balance_after',
'description',
'wallet_id',
'invoice_id',
'payment_id',
'withdraw_request_id',
];
protected $casts = [
'type' => 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);
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace App\Models;
use App\Enums\WithdrawRequestStatusEnum;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class WithdrawRequest extends Model
{
use HasFactory;
protected $fillable = [
'amount',
'status',
'admin_note',
'reviewed_at',
'reviewed_by',
'user_id',
'bank_card_id',
];
protected $casts = [
'status' => 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');
}
}
+2
View File
@@ -45,6 +45,7 @@ class AppServiceProvider extends ServiceProvider
return [ return [
'User' => $user, 'User' => $user,
'permissions' => $permissions, 'permissions' => $permissions,
'wallet_balance' => (int) ($user?->wallet?->balance ?? 0),
]; ];
}, },
]); ]);
@@ -52,6 +53,7 @@ class AppServiceProvider extends ServiceProvider
return [ return [
'success' => Session::get('success'), 'success' => Session::get('success'),
'error' => Session::get('error'), 'error' => Session::get('error'),
'payment_result' => Session::get('payment_result'),
]; ];
}); });
} }
+152 -10
View File
@@ -3,25 +3,167 @@
namespace App\Services\Document; namespace App\Services\Document;
use App\Enums\DocumentStatusEnum; 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\Document;
use App\Models\Report;
use App\Models\User; 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\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class CaseService 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 public function pendingForUser(User $user): Builder
{ {
$statuses = $this->isDoctor($user)
? [DocumentStatusEnum::InProgress->value]
: [DocumentStatusEnum::Registered->value, DocumentStatusEnum::InProgress->value];
return Document::with(['pet', 'requester']) return Document::with(['pet', 'requester'])
->whereIn('status', [ ->whereIn('status', $statuses)
DocumentStatusEnum::Registered->value, ->where(fn ($query) => $this->scopeVisibleTo($query, $user))
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);
});
})
->latest('submitted_at'); ->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;
}
} }
+7 -1
View File
@@ -10,6 +10,7 @@ use App\Models\Document;
use App\Models\Pet; use App\Models\Pet;
use App\Models\User; use App\Models\User;
use App\Services\FileStorageService; use App\Services\FileStorageService;
use App\Services\Invoice\InvoiceService;
use App\Services\Shift\ShiftScheduleService; use App\Services\Shift\ShiftScheduleService;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -19,10 +20,13 @@ class FastRequestService
protected ShiftScheduleService $shiftScheduleService; 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->fileStorageService = $fileStorageService;
$this->shiftScheduleService = $shiftScheduleService; $this->shiftScheduleService = $shiftScheduleService;
$this->invoiceService = $invoiceService;
} }
public function create(User $requester, array $data, array $historyFiles = [], array $scanFiles = []): Document public function create(User $requester, array $data, array $historyFiles = [], array $scanFiles = []): Document
@@ -39,6 +43,8 @@ class FastRequestService
'requester_id' => $requester->id, 'requester_id' => $requester->id,
]); ]);
$this->invoiceService->createForDocument($document);
$this->attachFiles($document, $historyFiles, MediaCollectionEnum::History); $this->attachFiles($document, $historyFiles, MediaCollectionEnum::History);
$this->attachFiles($document, $scanFiles, MediaCollectionEnum::Primary); $this->attachFiles($document, $scanFiles, MediaCollectionEnum::Primary);
+48
View File
@@ -0,0 +1,48 @@
<?php
namespace App\Services\Invoice;
use App\Enums\DocumentModalityEnum;
use App\Enums\DocumentStatusEnum;
use App\Enums\InvoicePaymentMethodEnum;
use App\Enums\InvoiceStatusEnum;
use App\Models\Document;
use App\Models\Invoice;
class InvoiceService
{
public function createForDocument(Document $document): Invoice
{
$invoice = Invoice::create([
'number' => '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;
}
}
@@ -0,0 +1,84 @@
<?php
namespace App\Services\Payment;
use App\Enums\PaymentStatusEnum;
use App\Models\Payment;
use App\Models\User;
use Illuminate\Support\Facades\Http;
use RuntimeException;
class ZibalPaymentService
{
protected const API_URL = 'https://gateway.zibal.ir/v1';
protected const START_URL = 'https://gateway.zibal.ir/start';
public function request(User $payer, int $amount, string $orderId, array $attributes = []): Payment
{
$response = Http::post(self::API_URL . '/request', [
'merchant' => $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');
}
}
@@ -81,6 +81,33 @@ class ShiftScheduleService
->first(); ->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 public function hasConflict(array $data, ?int $ignoreId = null): bool
{ {
if (empty($data['user_id'])) { if (empty($data['user_id'])) {
+113
View File
@@ -0,0 +1,113 @@
<?php
namespace App\Services\Wallet;
use App\Enums\InvoicePaymentMethodEnum;
use App\Enums\InvoiceStatusEnum;
use App\Enums\WalletTransactionReasonEnum;
use App\Enums\WalletTransactionTypeEnum;
use App\Models\Invoice;
use App\Models\Payment;
use App\Models\User;
use App\Models\Wallet;
use App\Models\WalletTransaction;
use App\Models\WithdrawRequest;
use App\Services\Invoice\InvoiceService;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class WalletService
{
protected InvoiceService $invoiceService;
public function __construct(InvoiceService $invoiceService)
{
$this->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();
}
}
@@ -0,0 +1,97 @@
<?php
namespace App\Services\Wallet;
use App\Enums\WalletTransactionReasonEnum;
use App\Enums\WithdrawRequestStatusEnum;
use App\Models\BankCard;
use App\Models\User;
use App\Models\WithdrawRequest;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class WithdrawRequestService
{
protected WalletService $walletService;
public function __construct(WalletService $walletService)
{
$this->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' => 'این درخواست قبلا بررسی شده است.']);
}
}
}
+6
View File
@@ -35,6 +35,12 @@ return [
], ],
], ],
'zibal' => [
'merchant' => env('ZIBAL_MERCHANT', 'zibal'),
'sandbox' => env('ZIBAL_SANDBOX', true),
'callback_url' => env('ZIBAL_CALLBACK_URL'),
],
'iranpayamak' => [ 'iranpayamak' => [
'api_url' => env('IRANPAYAMAK_SMS_API_URL', 'https://api.iranpayamak.com/ws/v1/sms/pattern'), 'api_url' => env('IRANPAYAMAK_SMS_API_URL', 'https://api.iranpayamak.com/ws/v1/sms/pattern'),
'line_number' => env('IRANPAYAMAK_SMS_API_URL', '50002178584000'), 'line_number' => env('IRANPAYAMAK_SMS_API_URL', '50002178584000'),
@@ -0,0 +1,54 @@
<?php
use App\Enums\DocumentModalityEnum;
use App\Enums\InvoiceStatusEnum;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('invoices', function (Blueprint $table) {
$table->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');
}
};
@@ -0,0 +1,32 @@
<?php
use App\Enums\PaymentPurposeEnum;
use App\Enums\PaymentStatusEnum;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('payments', function (Blueprint $table) {
$table->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');
}
};
@@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('wallets', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('balance')->default(0);
$table->timestamps();
$table->foreignId('user_id')->unique()->constrained('users')->cascadeOnDelete();
});
}
public function down(): void
{
Schema::dropIfExists('wallets');
}
};
@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->date('birthdate')->nullable()->after('fullname');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('birthdate');
});
}
};
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('wallet_transactions', function (Blueprint $table) {
$table->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');
}
};
@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('reports', function (Blueprint $table) {
$table->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']);
});
}
};
@@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('wallets', function (Blueprint $table) {
$table->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();
});
}
};
@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('bank_cards', function (Blueprint $table) {
$table->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');
}
};
@@ -0,0 +1,30 @@
<?php
use App\Enums\WithdrawRequestStatusEnum;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('withdraw_requests', function (Blueprint $table) {
$table->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');
}
};
@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('wallet_transactions', function (Blueprint $table) {
$table->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');
});
}
};
+6
View File
@@ -109,6 +109,12 @@ class PermissionSeeder extends Seeder
'own' => 'دسترسی به شیفت های خود', 'own' => 'دسترسی به شیفت های خود',
], ],
], ],
'withdrawals' => [
'label' => 'دسترسی به درخواست‌های برداشت',
'children' => [
'index' => 'دسترسی به لیست درخواست‌های برداشت',
],
],
]; ];
foreach ($permissions as $key => $data) { foreach ($permissions as $key => $data) {
$parent = Permission::create([ $parent = Permission::create([
+1 -1
View File
@@ -24,7 +24,7 @@
"@fullcalendar/core": "^6.1.21", "@fullcalendar/core": "^6.1.21",
"@fullcalendar/interaction": "^6.1.21", "@fullcalendar/interaction": "^6.1.21",
"@fullcalendar/timegrid": "^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": "^0.11.1",
"@inertiajs/inertia-vue3": "^0.6.0", "@inertiajs/inertia-vue3": "^0.6.0",
"@inertiajs/progress": "^0.2.7", "@inertiajs/progress": "^0.2.7",
+50 -2
View File
@@ -1,3 +1,51 @@
.p-formfield .p-select{ .p-inputtext,
@apply !py-0.5; .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;
} }
+16
View File
@@ -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 { .ui-button-icon {
@apply transition-all duration-200; @apply transition-all duration-200;
} }
-15
View File
@@ -1,19 +1,4 @@
/* Flag Icons using Emoji */ /* 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 */ /* Country Flags */
.fi.fi-af:before { content: "🇦🇫"; } .fi.fi-af:before { content: "🇦🇫"; }
.fi.fi-al:before { content: "🇦🇱"; } .fi.fi-al:before { content: "🇦🇱"; }
+5
View File
@@ -99,3 +99,8 @@
.shift-calendar .shift-event--inactive { .shift-calendar .shift-event--inactive {
opacity: 0.55; opacity: 0.55;
} }
.shift-calendar .fc-highlight {
background: rgba(87, 105, 137, 0.15);
border-radius: 8px;
}
-3
View File
@@ -13,9 +13,6 @@ body{
@apply font-dana font-normal bg-client-gray/5; @apply font-dana font-normal bg-client-gray/5;
} }
.vpd-input-group{
@apply !h-9;
}
.ql-container { .ql-container {
direction: rtl; direction: rtl;
text-align: right; text-align: right;
@@ -118,17 +118,17 @@ const removeHistoryFile = (index) => {
</FormField> </FormField>
<FormField v-slot="$field" name="submitted_at" initialValue="" class="flex flex-col gap-1"> <FormField v-slot="$field" name="submitted_at" initialValue="" class="flex flex-col gap-1">
<label>تاریخ ثبت</label> <label>تاریخ ثبت</label>
<CustomDatePicker v-model="documentStore.createForm.submitted_at" class="!text-xs" type="datetime" compact-time></CustomDatePicker> <CustomDatePicker v-model="documentStore.createForm.submitted_at" type="datetime" compact-time></CustomDatePicker>
<Message v-if="documentStore.errorsCreateForm?.submitted_at" severity="error" size="small" variant="simple">{{ documentStore.errorsCreateForm?.submitted_at[0] }}</Message> <Message v-if="documentStore.errorsCreateForm?.submitted_at" severity="error" size="small" variant="simple">{{ documentStore.errorsCreateForm?.submitted_at[0] }}</Message>
</FormField> </FormField>
<FormField v-slot="$field" name="processing_deadline" initialValue="" class="flex flex-col gap-1"> <FormField v-slot="$field" name="processing_deadline" initialValue="" class="flex flex-col gap-1">
<label>مهلت پردازش</label> <label>مهلت پردازش</label>
<CustomDatePicker v-model="documentStore.createForm.processing_deadline" class="!text-xs" type="datetime" compact-time></CustomDatePicker> <CustomDatePicker v-model="documentStore.createForm.processing_deadline" type="datetime" compact-time></CustomDatePicker>
<Message v-if="documentStore.errorsCreateForm?.processing_deadline" severity="error" size="small" variant="simple">{{ documentStore.errorsCreateForm?.processing_deadline[0] }}</Message> <Message v-if="documentStore.errorsCreateForm?.processing_deadline" severity="error" size="small" variant="simple">{{ documentStore.errorsCreateForm?.processing_deadline[0] }}</Message>
</FormField> </FormField>
<FormField v-slot="$field" name="note" initialValue="" class="flex flex-col col-span-2 gap-1"> <FormField v-slot="$field" name="note" initialValue="" class="flex flex-col col-span-2 gap-1">
<label>متن درخواست</label> <label>متن درخواست</label>
<Textarea type="text" class="placeholder:!text-sm" v-model="documentStore.createForm.note" placeholder="متن درخواست را وارد کنید" /> <Textarea type="text" v-model="documentStore.createForm.note" placeholder="متن درخواست را وارد کنید" />
</FormField> </FormField>
<div class="grid grid-cols-2 col-span-2 gap-4"> <div class="grid grid-cols-2 col-span-2 gap-4">
<Card class="mt-4"> <Card class="mt-4">
@@ -155,7 +155,7 @@ const removeHistoryFile = (index) => {
<div class="p-4 grid grid-cols-1 gap-4 rounded-2xl"> <div class="p-4 grid grid-cols-1 gap-4 rounded-2xl">
<div class="grid grid-cols-1 justify-between items-center"> <div class="grid grid-cols-1 justify-between items-center">
<span>زمان انتشار:</span> <span>زمان انتشار:</span>
<CustomDatePicker v-model="documentStore.createForm.datetime" class="!text-xs mt-2" type="datetime" compact-time></CustomDatePicker> <CustomDatePicker v-model="documentStore.createForm.datetime" class="mt-2" type="datetime" compact-time></CustomDatePicker>
</div> </div>
<div class="grid grid-cols-1 justify-start"> <div class="grid grid-cols-1 justify-start">
<Button :loading="documentStore.loaderCreateForm" @click="documentStore.update_and_store" type="button" severity="secondary" :label="documentStore.buttonLabel" /> <Button :loading="documentStore.loaderCreateForm" @click="documentStore.update_and_store" type="button" severity="secondary" :label="documentStore.buttonLabel" />
@@ -166,29 +166,3 @@ const removeHistoryFile = (index) => {
</section> </section>
</master> </master>
</template> </template>
<style>
.p-multiselect {
height: auto;
min-height: 40px;
}
.p-multiselect-label {
line-height: 24px;
}
.p-multiselect .p-multiselect-header {
line-height: 24px;
}
.p-multiselect .p-multiselect-filter {
max-height: calc(100px);
overflow-y: auto;
}
.p-multiselect .p-multiselect-item {
font-size: 0.875rem;
line-height: 44px;
}
</style>
@@ -56,24 +56,18 @@ onUnmounted(() => {
<Form ref="formRef" v-slot="$form" class="flex flex-col gap-4 !w-full"> <Form ref="formRef" v-slot="$form" class="flex flex-col gap-4 !w-full">
<FormField v-slot="$field" name="name" initialValue="" class="flex flex-col gap-1"> <FormField v-slot="$field" name="name" initialValue="" class="flex flex-col gap-1">
<label>نام سطح</label> <label>نام سطح</label>
<InputText type="text" :class="[ <InputText type="text" :class="permissionStore.errorsCreateForm?.name && !permissionStore.createForm.name ? 'p-invalid' : ''" v-model="permissionStore.createForm.name" placeholder="نام سطح را وارد کنید" />
'placeholder:!text-sm',
permissionStore.errorsCreateForm?.name && !permissionStore.createForm.name ? 'p-invalid' : ''
]" v-model="permissionStore.createForm.name" placeholder="نام سطح را وارد کنید" />
<Message v-if="permissionStore.errorsCreateForm?.name" <Message v-if="permissionStore.errorsCreateForm?.name"
severity="error" size="small" variant="simple">{{ permissionStore.errorsCreateForm?.name[0] }}</Message> severity="error" size="small" variant="simple">{{ permissionStore.errorsCreateForm?.name[0] }}</Message>
</FormField> </FormField>
<FormField v-slot="$field" name="label" initialValue="" class="flex flex-col gap-1"> <FormField v-slot="$field" name="label" initialValue="" class="flex flex-col gap-1">
<label>نام فارسی سطح</label> <label>نام فارسی سطح</label>
<InputText type="text" :class="[ <InputText type="text" :class="permissionStore.errorsCreateForm?.label && !permissionStore.createForm.label ? 'p-invalid' : ''" v-model="permissionStore.createForm.label" placeholder="نام فارسی سطح را وارد کنید" />
'placeholder:!text-sm',
permissionStore.errorsCreateForm?.label && !permissionStore.createForm.label ? 'p-invalid' : ''
]" v-model="permissionStore.createForm.label" placeholder="نام فارسی سطح را وارد کنید" />
<Message v-if="permissionStore.errorsCreateForm?.label" severity="error" size="small" variant="simple">{{ permissionStore.errorsCreateForm?.label[0] }}</Message> <Message v-if="permissionStore.errorsCreateForm?.label" severity="error" size="small" variant="simple">{{ permissionStore.errorsCreateForm?.label[0] }}</Message>
</FormField> </FormField>
<FormField v-slot="$field" name="parent_id" initialValue="" class="flex flex-col gap-1"> <FormField v-slot="$field" name="parent_id" initialValue="" class="flex flex-col gap-1">
<label> والد (مرتبط با) </label> <label> والد (مرتبط با) </label>
<Select v-model="permissionStore.createForm.parent_id" optionLabel="label" optionValue="id" :pt="{option : '!text-sm'}" <Select v-model="permissionStore.createForm.parent_id" optionLabel="label" optionValue="id"
:options="permissions.data" filter :options="permissions.data" filter
placeholder="انتخاب سطح" class="w-full"> placeholder="انتخاب سطح" class="w-full">
<template #empty> <template #empty>
@@ -54,41 +54,38 @@ onUnmounted(() => {
<Form ref="formRef" v-slot="$form" class=" p-4 grid grid-cols-2 gap-4 !w-full"> <Form ref="formRef" v-slot="$form" class=" p-4 grid grid-cols-2 gap-4 !w-full">
<FormField v-slot="$field" name="name" initialValue="" class="flex flex-col gap-1"> <FormField v-slot="$field" name="name" initialValue="" class="flex flex-col gap-1">
<label>نام کیس</label> <label>نام کیس</label>
<InputText type="text" :class="[ <InputText type="text" :class="petStore.errorsCreateForm?.name && !petStore.createForm.name ? 'p-invalid' : ''" v-model="petStore.createForm.name" placeholder="نام کیس خود را وارد کنید" />
'placeholder:!text-sm',
petStore.errorsCreateForm?.name && !petStore.createForm.name ? 'p-invalid' : ''
]" v-model="petStore.createForm.name" placeholder="نام کیس خود را وارد کنید" />
<Message v-if="petStore.errorsCreateForm?.name" <Message v-if="petStore.errorsCreateForm?.name"
severity="error" size="small" variant="simple">{{ petStore.errorsCreateForm?.name[0] }}</Message> severity="error" size="small" variant="simple">{{ petStore.errorsCreateForm?.name[0] }}</Message>
</FormField> </FormField>
<FormField v-slot="$field" name="email" initialValue="" class="flex flex-col gap-1"> <FormField v-slot="$field" name="email" initialValue="" class="flex flex-col gap-1">
<label>نوع کیس</label> <label>نوع کیس</label>
<Select class="!text-xs !h-full" placeholder="انتخاب نوع کیس" v-model="petStore.createForm.type" :options="petStore.typeOptions" optionValue="value" optionLabel="label" :pt="{ option : '!text-sm' , placeholder : '!text-sm' , label : '!pt-3' }" /> <Select placeholder="انتخاب نوع کیس" v-model="petStore.createForm.type" :options="petStore.typeOptions" optionValue="value" optionLabel="label" />
<Message v-if="petStore.errorsCreateForm?.type" severity="error" size="small" variant="simple">{{ petStore.errorsCreateForm?.type[0] }}</Message> <Message v-if="petStore.errorsCreateForm?.type" severity="error" size="small" variant="simple">{{ petStore.errorsCreateForm?.type[0] }}</Message>
</FormField> </FormField>
<FormField v-slot="$field" name="breed" initialValue="" class="flex flex-col gap-1"> <FormField v-slot="$field" name="breed" initialValue="" class="flex flex-col gap-1">
<label>نژاد</label> <label>نژاد</label>
<InputText type="text" class="placeholder:!text-sm" v-model="petStore.createForm.breed" placeholder="نژاد را وارد کنید" /> <InputText type="text" v-model="petStore.createForm.breed" placeholder="نژاد را وارد کنید" />
</FormField> </FormField>
<FormField v-slot="$field" name="weight" initialValue="" class="flex flex-col gap-1"> <FormField v-slot="$field" name="weight" initialValue="" class="flex flex-col gap-1">
<label>وزن</label> <label>وزن</label>
<InputText type="text" class="placeholder:!text-sm" v-model="petStore.createForm.weight" placeholder="وزن را وارد کنید" /> <InputText type="text" v-model="petStore.createForm.weight" placeholder="وزن را وارد کنید" />
<Message v-if="$field?.invalid" severity="error" size="small" variant="simple">{{ $field.error?.message }}</Message> <Message v-if="$field?.invalid" severity="error" size="small" variant="simple">{{ $field.error?.message }}</Message>
</FormField> </FormField>
<FormField v-slot="$field" name="sterilized" initialValue="" class="flex flex-col gap-1"> <FormField v-slot="$field" name="sterilized" initialValue="" class="flex flex-col gap-1">
<label>عقیم سازی</label> <label>عقیم سازی</label>
<Select class="!text-xs !h-full" placeholder="انتخاب عقیم سازی" v-model="petStore.createForm.sterilized" optionLabel="label" optionValue="value" :options="petStore.sterilizedOptions" :pt="{ option : '!text-sm' , placeholder : '!text-sm' , label : '!pt-3' }" /> <Select placeholder="انتخاب عقیم سازی" v-model="petStore.createForm.sterilized" optionLabel="label" optionValue="value" :options="petStore.sterilizedOptions" />
<Message v-if="petStore.errorsCreateForm?.sterilized" <Message v-if="petStore.errorsCreateForm?.sterilized"
severity="error" size="small" variant="simple">{{ petStore.errorsCreateForm?.sterilized[0] }}</Message> severity="error" size="small" variant="simple">{{ petStore.errorsCreateForm?.sterilized[0] }}</Message>
</FormField> </FormField>
<FormField v-slot="$field" name="gender" initialValue="" class="flex flex-col gap-1"> <FormField v-slot="$field" name="gender" initialValue="" class="flex flex-col gap-1">
<label>جنسیت</label> <label>جنسیت</label>
<Select class="!text-xs !h-full" placeholder="انتخاب جنسیت" v-model="petStore.createForm.gender" optionLabel="label" optionValue="value" :options="petStore.genderOptions" :pt="{ option : '!text-sm' , placeholder : '!text-sm' , label : '!pt-3' }" /> <Select placeholder="انتخاب جنسیت" v-model="petStore.createForm.gender" optionLabel="label" optionValue="value" :options="petStore.genderOptions" />
<Message v-if="petStore.errorsCreateForm?.gender" severity="error" size="small" variant="simple">{{ petStore.errorsCreateForm?.gender[0] }}</Message> <Message v-if="petStore.errorsCreateForm?.gender" severity="error" size="small" variant="simple">{{ petStore.errorsCreateForm?.gender[0] }}</Message>
</FormField> </FormField>
<FormField v-slot="$field" name="birthday" initialValue="" class="flex flex-col gap-1"> <FormField v-slot="$field" name="birthday" initialValue="" class="flex flex-col gap-1">
<label>تاریخ تولد</label> <label>تاریخ تولد</label>
<CustomDatePicker v-model="petStore.createForm.birthday" class="!text-xs mt-2" type="datetime" compact-time></CustomDatePicker> <CustomDatePicker v-model="petStore.createForm.birthday" type="datetime" compact-time></CustomDatePicker>
<Message v-if="petStore.errorsCreateForm?.birthday" severity="error" size="small" variant="simple">{{ petStore.errorsCreateForm?.birthday[0] }}</Message> <Message v-if="petStore.errorsCreateForm?.birthday" severity="error" size="small" variant="simple">{{ petStore.errorsCreateForm?.birthday[0] }}</Message>
</FormField> </FormField>
</Form> </Form>
@@ -105,7 +102,7 @@ onUnmounted(() => {
<div class="p-4 grid grid-cols-1 gap-4 rounded-2xl"> <div class="p-4 grid grid-cols-1 gap-4 rounded-2xl">
<div class="grid grid-cols-1 justify-between items-center"> <div class="grid grid-cols-1 justify-between items-center">
<span>زمان انتشار:</span> <span>زمان انتشار:</span>
<CustomDatePicker v-model="petStore.createForm.datetime" class="!text-xs mt-2" type="datetime" compact-time></CustomDatePicker> <CustomDatePicker v-model="petStore.createForm.datetime" class="mt-2" type="datetime" compact-time></CustomDatePicker>
</div> </div>
<div class="grid grid-cols-1 justify-start"> <div class="grid grid-cols-1 justify-start">
<Button :loading="petStore.loaderCreateForm" @click="petStore.update_and_store" type="button" severity="secondary" :label="petStore.buttonLabel" /> <Button :loading="petStore.loaderCreateForm" @click="petStore.update_and_store" type="button" severity="secondary" :label="petStore.buttonLabel" />
@@ -116,29 +113,3 @@ onUnmounted(() => {
</section> </section>
</master> </master>
</template> </template>
<style>
.p-multiselect {
height: auto;
min-height: 40px;
}
.p-multiselect-label {
line-height: 24px;
}
.p-multiselect .p-multiselect-header {
line-height: 24px;
}
.p-multiselect .p-multiselect-filter {
max-height: calc(100px);
overflow-y: auto;
}
.p-multiselect .p-multiselect-item {
font-size: 0.875rem;
line-height: 44px;
}
</style>
@@ -74,19 +74,13 @@ const selectAllWithOwn = () => {
<Form ref="formRef" v-slot="$form" class="bg-white p-4 rounded-2xl flex flex-col gap-4 !w-full"> <Form ref="formRef" v-slot="$form" class="bg-white p-4 rounded-2xl flex flex-col gap-4 !w-full">
<FormField v-slot="$field" name="name" initialValue="" class="flex flex-col gap-1"> <FormField v-slot="$field" name="name" initialValue="" class="flex flex-col gap-1">
<label>نام مقام</label> <label>نام مقام</label>
<InputText type="text" :class="[ <InputText type="text" :class="roleStore.errorsCreateForm?.name && !roleStore.createForm.name ? 'p-invalid' : ''" v-model="roleStore.createForm.name" placeholder="نام مقام را وارد کنید" />
'placeholder:!text-sm',
roleStore.errorsCreateForm?.name && !roleStore.createForm.name ? 'p-invalid' : ''
]" v-model="roleStore.createForm.name" placeholder="نام مقام را وارد کنید" />
<Message v-if="roleStore.errorsCreateForm?.name" <Message v-if="roleStore.errorsCreateForm?.name"
severity="error" size="small" variant="simple">{{ roleStore.errorsCreateForm?.name[0] }}</Message> severity="error" size="small" variant="simple">{{ roleStore.errorsCreateForm?.name[0] }}</Message>
</FormField> </FormField>
<FormField v-slot="$field" name="label" initialValue="" class="flex flex-col gap-1"> <FormField v-slot="$field" name="label" initialValue="" class="flex flex-col gap-1">
<label>نام فارسی مقام</label> <label>نام فارسی مقام</label>
<InputText type="text" :class="[ <InputText type="text" :class="roleStore.errorsCreateForm?.label && !roleStore.createForm.label ? 'p-invalid' : ''" v-model="roleStore.createForm.label" placeholder="نام فارسی مقام را وارد کنید" />
'placeholder:!text-sm',
roleStore.errorsCreateForm?.label && !roleStore.createForm.label ? 'p-invalid' : ''
]" v-model="roleStore.createForm.label" placeholder="نام فارسی مقام را وارد کنید" />
<Message v-if="roleStore.errorsCreateForm?.label" severity="error" size="small" variant="simple">{{ roleStore.errorsCreateForm?.label[0] }}</Message> <Message v-if="roleStore.errorsCreateForm?.label" severity="error" size="small" variant="simple">{{ roleStore.errorsCreateForm?.label[0] }}</Message>
</FormField> </FormField>
<FormField v-slot="$field" name="permissions_parent" initialValue="" class="flex flex-col gap-1"> <FormField v-slot="$field" name="permissions_parent" initialValue="" class="flex flex-col gap-1">
@@ -109,7 +103,7 @@ const selectAllWithOwn = () => {
<div class="p-4 bg-white grid grid-cols-1 gap-4 rounded-2xl"> <div class="p-4 bg-white grid grid-cols-1 gap-4 rounded-2xl">
<div class="grid grid-cols-1 justify-between items-center"> <div class="grid grid-cols-1 justify-between items-center">
<span>زمان انتشار:</span> <span>زمان انتشار:</span>
<CustomDatePicker v-model="roleStore.createForm.datetime" class="!text-xs mt-2" type="datetime" compact-time></CustomDatePicker> <CustomDatePicker v-model="roleStore.createForm.datetime" class="mt-2" type="datetime" compact-time></CustomDatePicker>
</div> </div>
<div class="grid grid-cols-2 gap-x-3 mt-1 justify-between items-center"> <div class="grid grid-cols-2 gap-x-3 mt-1 justify-between items-center">
<span class="col-span-2 mb-2">عملیات سریع:</span> <span class="col-span-2 mb-2">عملیات سریع:</span>
@@ -98,34 +98,25 @@ const uploadProfile = () => {
<Form @submit="onFormSubmit" class="grid grid-cols-1 gap-4 w-full !border-0 !text-sm"> <Form @submit="onFormSubmit" class="grid grid-cols-1 gap-4 w-full !border-0 !text-sm">
<FormField v-slot="$field" name="name" initialValue="" class="flex flex-col gap-1"> <FormField v-slot="$field" name="name" initialValue="" class="flex flex-col gap-1">
<label>نام کاربری</label> <label>نام کاربری</label>
<InputText type="text" :class="[ <InputText type="text" :class="errors?.name && !errors.name ? 'p-invalid' : ''" v-model="user_form.name" placeholder="نام کاربری را وارد کنید" />
'placeholder:!text-sm !text-sm',
errors?.name && !errors.name ? 'p-invalid' : ''
]" v-model="user_form.name" placeholder="نام کاربری را وارد کنید" />
<Message v-if="errors?.name" <Message v-if="errors?.name"
severity="error" size="small" variant="simple">{{ errors?.name[0] }}</Message> severity="error" size="small" variant="simple">{{ errors?.name[0] }}</Message>
</FormField> </FormField>
<FormField v-slot="$field" name="first_name" initialValue="" class="flex flex-col gap-1"> <FormField v-slot="$field" name="first_name" initialValue="" class="flex flex-col gap-1">
<label>نام کوچک</label> <label>نام کوچک</label>
<InputText type="text" :class="[ <InputText type="text" :class="errors?.first_name && !errors.first_name ? 'p-invalid' : ''" v-model="user_form.first_name" placeholder="نام کوچک خود را وارد کنید" />
'placeholder:!text-sm !text-sm',
errors?.first_name && !errors.first_name ? 'p-invalid' : ''
]" v-model="user_form.first_name" placeholder="نام کوچک خود را وارد کنید" />
<Message v-if="errors?.first_name" <Message v-if="errors?.first_name"
severity="error" size="small" variant="simple">{{ errors?.first_name[0] }}</Message> severity="error" size="small" variant="simple">{{ errors?.first_name[0] }}</Message>
</FormField> </FormField>
<FormField v-slot="$field" name="last_name" initialValue="" class="flex flex-col gap-1"> <FormField v-slot="$field" name="last_name" initialValue="" class="flex flex-col gap-1">
<label>نام خانوادگی</label> <label>نام خانوادگی</label>
<InputText type="text" :class="[ <InputText type="text" :class="errors?.last_name && !errors.last_name ? 'p-invalid' : ''" v-model="user_form.last_name" placeholder="نام کوچک خود را وارد کنید" />
'placeholder:!text-sm !text-sm',
errors?.last_name && !errors.last_name ? 'p-invalid' : ''
]" v-model="user_form.last_name" placeholder="نام کوچک خود را وارد کنید" />
<Message v-if="errors?.last_name" <Message v-if="errors?.last_name"
severity="error" size="small" variant="simple">{{ errors?.last_name[0] }}</Message> severity="error" size="small" variant="simple">{{ errors?.last_name[0] }}</Message>
</FormField> </FormField>
<FormField v-slot="$field" asChild name="email" :initialValue="globalStore.current_user.email"> <FormField v-slot="$field" asChild name="email" :initialValue="globalStore.current_user.email">
<section class="flex flex-col gap-2"> <section class="flex flex-col gap-2">
<InputText type="email" class="flex pt-2 !text-sm !font-light" disabled id="email" fluid /> <InputText type="email" class="!font-light" disabled id="email" fluid />
</section> </section>
</FormField> </FormField>
<Button type="submit" :loading="loading" severity="secondary" label="ذخیره اطلاعات" /> <Button type="submit" :loading="loading" severity="secondary" label="ذخیره اطلاعات" />
@@ -60,7 +60,7 @@ const ChangePasswordHandler = () => {
<Password id="password" type="password" :feedback="false" <Password id="password" type="password" :feedback="false"
appendTo="self" toggleMask appendTo="self" toggleMask
v-model="ChangePasswordForm.password" v-model="ChangePasswordForm.password"
class="placeholder:!font-light !text-sm !bg-white h-12 !border-gray-500/20 !text-client-Fontcolor"/> class="placeholder:!font-light !bg-white !border-gray-500/20 !text-client-Fontcolor"/>
<label for="on_label" class="!text-client-Fontcolor !bg-white !text-sm font-light">کلمه عبور فعلی</label> <label for="on_label" class="!text-client-Fontcolor !bg-white !text-sm font-light">کلمه عبور فعلی</label>
</FloatLabel> </FloatLabel>
<Message <Message
@@ -74,7 +74,7 @@ const ChangePasswordHandler = () => {
<Password id="password" type="password" :feedback="false" <Password id="password" type="password" :feedback="false"
appendTo="self" toggleMask appendTo="self" toggleMask
v-model="ChangePasswordForm.newPassword" v-model="ChangePasswordForm.newPassword"
class="placeholder:!font-light !text-sm !bg-white h-12 !border-gray-500/20 !text-client-Fontcolor"/> class="placeholder:!font-light !bg-white !border-gray-500/20 !text-client-Fontcolor"/>
<label for="on_label" class="!text-client-Fontcolor !bg-white !text-sm font-light"> کلمه عبور جدید</label> <label for="on_label" class="!text-client-Fontcolor !bg-white !text-sm font-light"> کلمه عبور جدید</label>
</FloatLabel> </FloatLabel>
<Message <Message
@@ -88,7 +88,7 @@ const ChangePasswordHandler = () => {
<Password id="password" type="password" :feedback="false" <Password id="password" type="password" :feedback="false"
appendTo="self" toggleMask appendTo="self" toggleMask
v-model="ChangePasswordForm.newPassword_confirmation" v-model="ChangePasswordForm.newPassword_confirmation"
class="placeholder:!font-light !text-sm !bg-white h-12 !border-gray-500/20 !text-client-Fontcolor"/> class="placeholder:!font-light !bg-white !border-gray-500/20 !text-client-Fontcolor"/>
<label for="on_label" class="!text-client-Fontcolor !bg-white !text-sm font-light">تکرار کلمه عبور جدید</label> <label for="on_label" class="!text-client-Fontcolor !bg-white !text-sm font-light">تکرار کلمه عبور جدید</label>
</FloatLabel> </FloatLabel>
<Message <Message
@@ -4,6 +4,8 @@ import {onMounted , onUnmounted} from 'vue';
import {useShiftStore} from "../../store/Shift.js"; import {useShiftStore} from "../../store/Shift.js";
import {Link} from "@inertiajs/inertia-vue3"; import {Link} from "@inertiajs/inertia-vue3";
import Card from "../../components/ui/Card.vue"; import Card from "../../components/ui/Card.vue";
import ShiftCalendar from "../../components/shifts/ShiftCalendar.vue";
import { weekdayName } from "../../../Shared/shiftCalendarEvents";
const shiftStore = useShiftStore(); const shiftStore = useShiftStore();
@@ -18,8 +20,22 @@ const props = defineProps({
}, },
}); });
const pad = (value) => String(value).padStart(2, '0');
const onRangeSelect = ({ start, end }) => {
shiftStore.createForm.start_time = `${pad(start.getHours())}:${pad(start.getMinutes())}`;
shiftStore.createForm.end_time = `${pad(end.getHours())}:${pad(end.getMinutes())}`;
if (shiftStore.createForm.schedule_type === 'date') {
shiftStore.createForm.date = `${start.getFullYear()}-${pad(start.getMonth() + 1)}-${pad(start.getDate())}`;
} else {
shiftStore.createForm.day_of_week = weekdayName(start.getDay());
}
};
onMounted(() => { onMounted(() => {
shiftStore.fetchDoctorOptions(); shiftStore.fetchDoctorOptions();
shiftStore.fetchCalendarData();
if (props.is_edit) { if (props.is_edit) {
shiftStore.setEditData(props.shift); shiftStore.setEditData(props.shift);
} }
@@ -52,36 +68,36 @@ onUnmounted(() => {
<Form ref="formRef" v-slot="$form" class=" p-4 grid grid-cols-2 gap-4 !w-full"> <Form ref="formRef" v-slot="$form" class=" p-4 grid grid-cols-2 gap-4 !w-full">
<FormField v-slot="$field" name="user_id" initialValue="" class="flex flex-col gap-1"> <FormField v-slot="$field" name="user_id" initialValue="" class="flex flex-col gap-1">
<label>پزشک</label> <label>پزشک</label>
<Select class="!text-xs !h-full" placeholder="انتخاب پزشک (خالی = شیفت آزاد)" v-model="shiftStore.createForm.user_id" :options="shiftStore.doctorOptions" optionValue="value" optionLabel="label" showClear :pt="{ option : '!text-sm' , placeholder : '!text-sm' , label : '!pt-3' }" /> <Select placeholder="انتخاب پزشک (خالی = شیفت آزاد)" v-model="shiftStore.createForm.user_id" :options="shiftStore.doctorOptions" optionValue="value" optionLabel="label" showClear />
<Message v-if="shiftStore.errorsCreateForm?.user_id" severity="error" size="small" variant="simple">{{ shiftStore.errorsCreateForm?.user_id[0] }}</Message> <Message v-if="shiftStore.errorsCreateForm?.user_id" severity="error" size="small" variant="simple">{{ shiftStore.errorsCreateForm?.user_id[0] }}</Message>
</FormField> </FormField>
<FormField v-slot="$field" name="schedule_type" initialValue="" class="flex flex-col gap-1"> <FormField v-slot="$field" name="schedule_type" initialValue="" class="flex flex-col gap-1">
<label>نوع زمان‌بندی</label> <label>نوع زمان‌بندی</label>
<Select class="!text-xs !h-full" placeholder="انتخاب نوع زمان‌بندی" v-model="shiftStore.createForm.schedule_type" :options="shiftStore.scheduleTypeOptions" optionValue="value" optionLabel="label" :pt="{ option : '!text-sm' , placeholder : '!text-sm' , label : '!pt-3' }" /> <Select placeholder="انتخاب نوع زمان‌بندی" v-model="shiftStore.createForm.schedule_type" :options="shiftStore.scheduleTypeOptions" optionValue="value" optionLabel="label" />
</FormField> </FormField>
<FormField v-if="shiftStore.createForm.schedule_type === 'weekly'" v-slot="$field" name="day_of_week" initialValue="" class="flex flex-col gap-1"> <FormField v-if="shiftStore.createForm.schedule_type === 'weekly'" v-slot="$field" name="day_of_week" initialValue="" class="flex flex-col gap-1">
<label>روز هفته</label> <label>روز هفته</label>
<Select class="!text-xs !h-full" placeholder="انتخاب روز هفته" v-model="shiftStore.createForm.day_of_week" :options="shiftStore.dayOptions" optionValue="value" optionLabel="label" :pt="{ option : '!text-sm' , placeholder : '!text-sm' , label : '!pt-3' }" /> <Select placeholder="انتخاب روز هفته" v-model="shiftStore.createForm.day_of_week" :options="shiftStore.dayOptions" optionValue="value" optionLabel="label" />
<Message v-if="shiftStore.errorsCreateForm?.day_of_week" severity="error" size="small" variant="simple">{{ shiftStore.errorsCreateForm?.day_of_week[0] }}</Message> <Message v-if="shiftStore.errorsCreateForm?.day_of_week" severity="error" size="small" variant="simple">{{ shiftStore.errorsCreateForm?.day_of_week[0] }}</Message>
</FormField> </FormField>
<FormField v-else v-slot="$field" name="date" initialValue="" class="flex flex-col gap-1"> <FormField v-else v-slot="$field" name="date" initialValue="" class="flex flex-col gap-1">
<label>تاریخ</label> <label>تاریخ</label>
<CustomDatePicker v-model="shiftStore.createForm.date" class="!text-xs mt-2" type="date" format="YYYY-MM-DD" placeholder="تاریخ شیفت را انتخاب کنید"></CustomDatePicker> <CustomDatePicker v-model="shiftStore.createForm.date" type="date" format="YYYY-MM-DD" placeholder="تاریخ شیفت را انتخاب کنید"></CustomDatePicker>
<Message v-if="shiftStore.errorsCreateForm?.date" severity="error" size="small" variant="simple">{{ shiftStore.errorsCreateForm?.date[0] }}</Message> <Message v-if="shiftStore.errorsCreateForm?.date" severity="error" size="small" variant="simple">{{ shiftStore.errorsCreateForm?.date[0] }}</Message>
</FormField> </FormField>
<FormField v-slot="$field" name="start_time" initialValue="" class="flex flex-col gap-1"> <FormField v-slot="$field" name="start_time" initialValue="" class="flex flex-col gap-1">
<label>ساعت شروع</label> <label>ساعت شروع</label>
<CustomDatePicker v-model="shiftStore.createForm.start_time" class="!text-xs mt-2" type="time" format="HH:mm" display-format="HH:mm" placeholder="ساعت شروع را انتخاب کنید"></CustomDatePicker> <CustomDatePicker v-model="shiftStore.createForm.start_time" type="time" format="HH:mm" display-format="HH:mm" placeholder="ساعت شروع را انتخاب کنید"></CustomDatePicker>
<Message v-if="shiftStore.errorsCreateForm?.start_time" severity="error" size="small" variant="simple">{{ shiftStore.errorsCreateForm?.start_time[0] }}</Message> <Message v-if="shiftStore.errorsCreateForm?.start_time" severity="error" size="small" variant="simple">{{ shiftStore.errorsCreateForm?.start_time[0] }}</Message>
</FormField> </FormField>
<FormField v-slot="$field" name="end_time" initialValue="" class="flex flex-col gap-1"> <FormField v-slot="$field" name="end_time" initialValue="" class="flex flex-col gap-1">
<label>ساعت پایان</label> <label>ساعت پایان</label>
<CustomDatePicker v-model="shiftStore.createForm.end_time" class="!text-xs mt-2" type="time" format="HH:mm" display-format="HH:mm" placeholder="ساعت پایان را انتخاب کنید"></CustomDatePicker> <CustomDatePicker v-model="shiftStore.createForm.end_time" type="time" format="HH:mm" display-format="HH:mm" placeholder="ساعت پایان را انتخاب کنید"></CustomDatePicker>
<Message v-if="shiftStore.errorsCreateForm?.end_time" severity="error" size="small" variant="simple">{{ shiftStore.errorsCreateForm?.end_time[0] }}</Message> <Message v-if="shiftStore.errorsCreateForm?.end_time" severity="error" size="small" variant="simple">{{ shiftStore.errorsCreateForm?.end_time[0] }}</Message>
</FormField> </FormField>
<FormField v-slot="$field" name="is_active" initialValue="" class="flex flex-col gap-1"> <FormField v-slot="$field" name="is_active" initialValue="" class="flex flex-col gap-1">
<label>وضعیت</label> <label>وضعیت</label>
<Select class="!text-xs !h-full" placeholder="انتخاب وضعیت" v-model="shiftStore.createForm.is_active" :options="shiftStore.statusOptions" optionValue="value" optionLabel="label" :pt="{ option : '!text-sm' , placeholder : '!text-sm' , label : '!pt-3' }" /> <Select placeholder="انتخاب وضعیت" v-model="shiftStore.createForm.is_active" :options="shiftStore.statusOptions" optionValue="value" optionLabel="label" />
<Message v-if="shiftStore.errorsCreateForm?.is_active" severity="error" size="small" variant="simple">{{ shiftStore.errorsCreateForm?.is_active[0] }}</Message> <Message v-if="shiftStore.errorsCreateForm?.is_active" severity="error" size="small" variant="simple">{{ shiftStore.errorsCreateForm?.is_active[0] }}</Message>
</FormField> </FormField>
</Form> </Form>
@@ -97,5 +113,18 @@ onUnmounted(() => {
</Card> </Card>
</div> </div>
</section> </section>
<Card class="mt-4">
<div class="p-4 rounded-2xl">
<div class="flex items-center justify-between pb-2">
<h5 class="font-bold text-client-Fontcolor">برنامه فعلی شیفت‌ها</h5>
<span class="text-xs text-client-Fontcolor/60">برای تنظیم سریع زمان شیفت، بازه مورد نظر را روی تقویم بکشید</span>
</div>
<ShiftCalendar
:shifts="shiftStore.calendarShifts"
selectable
@select-range="onRangeSelect"
/>
</div>
</Card>
</master> </master>
</template> </template>
@@ -58,41 +58,32 @@ onUnmounted(() => {
<Form ref="formRef" v-slot="$form" class=" p-4 flex flex-col gap-4 !w-full"> <Form ref="formRef" v-slot="$form" class=" p-4 flex flex-col gap-4 !w-full">
<FormField v-slot="$field" name="username" initialValue="" class="flex flex-col gap-1"> <FormField v-slot="$field" name="username" initialValue="" class="flex flex-col gap-1">
<label>نام کاربری</label> <label>نام کاربری</label>
<InputText type="text" :class="[ <InputText type="text" :class="userStore.errorsCreateForm?.username && !userStore.createForm.username ? 'p-invalid' : ''" v-model="userStore.createForm.username" placeholder="نام کاربری خود را وارد کنید" />
'placeholder:!text-sm',
userStore.errorsCreateForm?.username && !userStore.createForm.username ? 'p-invalid' : ''
]" v-model="userStore.createForm.username" placeholder="نام کاربری خود را وارد کنید" />
<Message v-if="userStore.errorsCreateForm?.username" <Message v-if="userStore.errorsCreateForm?.username"
severity="error" size="small" variant="simple">{{ userStore.errorsCreateForm?.username[0] }}</Message> severity="error" size="small" variant="simple">{{ userStore.errorsCreateForm?.username[0] }}</Message>
</FormField> </FormField>
<FormField v-slot="$field" name="email" initialValue="" class="flex flex-col gap-1"> <FormField v-slot="$field" name="email" initialValue="" class="flex flex-col gap-1">
<label>ایمیل کاربری</label> <label>ایمیل کاربری</label>
<InputText type="text" :class="[ <InputText type="text" :class="userStore.errorsCreateForm?.email && !userStore.createForm.email ? 'p-invalid' : ''" v-model="userStore.createForm.email" placeholder="ایمیل خود را وارد کنید" />
'placeholder:!text-sm',
userStore.errorsCreateForm?.email && !userStore.createForm.email ? 'p-invalid' : ''
]" v-model="userStore.createForm.email" placeholder="ایمیل خود را وارد کنید" />
<Message v-if="userStore.errorsCreateForm?.email" severity="error" size="small" variant="simple">{{ userStore.errorsCreateForm?.email[0] }}</Message> <Message v-if="userStore.errorsCreateForm?.email" severity="error" size="small" variant="simple">{{ userStore.errorsCreateForm?.email[0] }}</Message>
</FormField> </FormField>
<FormField v-slot="$field" name="national_code" initialValue="" class="flex flex-col gap-1"> <FormField v-slot="$field" name="national_code" initialValue="" class="flex flex-col gap-1">
<label>کد ملی</label> <label>کد ملی</label>
<InputText type="text" :class="[ <InputText type="text" :class="userStore.errorsCreateForm?.national_code && !userStore.createForm.national_code ? 'p-invalid' : ''" v-model="userStore.createForm.national_code" placeholder="کد ملی خود را وارد کنید" />
'placeholder:!text-sm',
userStore.errorsCreateForm?.national_code && !userStore.createForm.national_code ? 'p-invalid' : ''
]" v-model="userStore.createForm.national_code" placeholder="کد ملی خود را وارد کنید" />
<Message v-if="userStore.errorsCreateForm?.national_code" severity="error" size="small" variant="simple">{{ userStore.errorsCreateForm?.national_code[0] }}</Message> <Message v-if="userStore.errorsCreateForm?.national_code" severity="error" size="small" variant="simple">{{ userStore.errorsCreateForm?.national_code[0] }}</Message>
</FormField> </FormField>
<FormField v-slot="$field" name="phone" initialValue="" class="flex flex-col gap-1"> <FormField v-slot="$field" name="phone" initialValue="" class="flex flex-col gap-1">
<label>شماره تماس</label> <label>شماره تماس</label>
<InputText type="text" class="placeholder:!text-sm" v-model="userStore.createForm.phone" placeholder="شماره تماس خود را وارد کنید" /> <InputText type="text" v-model="userStore.createForm.phone" placeholder="شماره تماس خود را وارد کنید" />
</FormField> </FormField>
<FormField v-slot="$field" name="fullname" initialValue="" class="flex flex-col gap-1"> <FormField v-slot="$field" name="fullname" initialValue="" class="flex flex-col gap-1">
<label>نام خانوادگی</label> <label>نام خانوادگی</label>
<InputText type="text" class="placeholder:!text-sm" v-model="userStore.createForm.fullname" placeholder="نام خانوادگی خود را وارد کنید" /> <InputText type="text" v-model="userStore.createForm.fullname" placeholder="نام خانوادگی خود را وارد کنید" />
<Message v-if="$field?.invalid" severity="error" size="small" variant="simple">{{ $field.error?.message }}</Message> <Message v-if="$field?.invalid" severity="error" size="small" variant="simple">{{ $field.error?.message }}</Message>
</FormField> </FormField>
<FormField v-slot="$field" name="password" initialValue="" class="flex flex-col gap-1"> <FormField v-slot="$field" name="password" initialValue="" class="flex flex-col gap-1">
<label>کلمه عبور</label> <label>کلمه عبور</label>
<Password type="text" :inputClass="['placeholder:!text-sm' , { 'p-invalid' : userStore.errorsCreateForm?.password && !userStore.createForm.password}]" <Password type="text" :inputClass="{ 'p-invalid' : userStore.errorsCreateForm?.password && !userStore.createForm.password}"
v-model="userStore.createForm.password" placeholder="کلمه عبور خود را وارد کنید" :feedback="false" toggleMask fluid /> v-model="userStore.createForm.password" placeholder="کلمه عبور خود را وارد کنید" :feedback="false" toggleMask fluid />
<Message v-if="userStore.errorsCreateForm?.password" <Message v-if="userStore.errorsCreateForm?.password"
severity="error" size="small" variant="simple">{{ userStore.errorsCreateForm?.password[0] }}</Message> severity="error" size="small" variant="simple">{{ userStore.errorsCreateForm?.password[0] }}</Message>
@@ -100,7 +91,7 @@ onUnmounted(() => {
</FormField> </FormField>
<FormField v-slot="$field" name="password_confirmation" initialValue="" class="flex flex-col gap-1"> <FormField v-slot="$field" name="password_confirmation" initialValue="" class="flex flex-col gap-1">
<label>تکرار کلمه عبور</label> <label>تکرار کلمه عبور</label>
<Password type="text" inputClass="placeholder:!text-sm" v-model="userStore.createForm.password_confirmation" placeholder="تکرار کلمه عبور خود را وارد کنید" :feedback="false" toggleMask fluid /> <Password type="text" v-model="userStore.createForm.password_confirmation" placeholder="تکرار کلمه عبور خود را وارد کنید" :feedback="false" toggleMask fluid />
<Message v-if="$field?.invalid" severity="error" size="small" variant="simple">{{ $field.error?.message }}</Message> <Message v-if="$field?.invalid" severity="error" size="small" variant="simple">{{ $field.error?.message }}</Message>
</FormField> </FormField>
</Form> </Form>
@@ -117,7 +108,7 @@ onUnmounted(() => {
<div class="mt-4 p-4 grid grid-cols-1 gap-4 rounded-2xl"> <div class="mt-4 p-4 grid grid-cols-1 gap-4 rounded-2xl">
<div class="grid grid-cols-1 justify-between items-center"> <div class="grid grid-cols-1 justify-between items-center">
<span>زمان انتشار:</span> <span>زمان انتشار:</span>
<CustomDatePicker v-model="userStore.createForm.datetime" class="!text-xs mt-2" type="datetime" compact-time></CustomDatePicker> <CustomDatePicker v-model="userStore.createForm.datetime" class="mt-2" type="datetime" compact-time></CustomDatePicker>
</div> </div>
<div class="grid grid-cols-2a justify-between items-center"> <div class="grid grid-cols-2a justify-between items-center">
<span class="flex">سطح:</span> <span class="flex">سطح:</span>
@@ -125,9 +116,7 @@ onUnmounted(() => {
</div> </div>
<div class="grid grid-cols-2a justify-between items-center"> <div class="grid grid-cols-2a justify-between items-center">
<span class="flex">مقام:</span> <span class="flex">مقام:</span>
<MultiSelect class="!text-xs" <MultiSelect v-model="userStore.createForm.role"
v-model="userStore.createForm.role"
:pt="{ option : '!text-sm' }"
placeholder="انتخاب مقام" placeholder="انتخاب مقام"
optionLabel="label" optionLabel="label"
optionValue="id" optionValue="id"
@@ -142,29 +131,3 @@ onUnmounted(() => {
</section> </section>
</master> </master>
</template> </template>
<style>
.p-multiselect {
height: auto;
min-height: 40px;
}
.p-multiselect-label {
line-height: 24px;
}
.p-multiselect .p-multiselect-header {
line-height: 24px;
}
.p-multiselect .p-multiselect-filter {
max-height: calc(100px);
overflow-y: auto;
}
.p-multiselect .p-multiselect-item {
font-size: 0.875rem;
line-height: 44px;
}
</style>
@@ -0,0 +1,267 @@
<script setup>
import Master from "../../layouts/master.vue";
import { ref, reactive, watch } from 'vue';
import TableGeneric from "../../components/TableGeneric.vue";
import { useConfirm } from 'primevue/useconfirm';
import { useToast } from 'primevue/usetoast';
import { useWithdrawRequestStore } from "../../store/WithdrawRequest.js";
import UIModal from "../../components/ui/UIModal.vue";
const withdrawRequestStore = useWithdrawRequestStore();
const toast = useToast();
const props = defineProps({
withdrawRequests: {
type: Object,
},
stats: {
type: Object,
},
search: {
type: String
},
status: {
type: String
},
})
const withdrawRequests = ref(props.withdrawRequests ?? []);
const searchLoader = ref(false);
const actionLoader = ref(false);
const columns = ref(withdrawRequestStore.columns)
const statusOptions = [
'همه وضعیت ها',
'در انتظار بررسی',
'تایید شده',
'رد شده',
];
const queryParams = reactive({
search: props.search ?? '',
status: props.status ?? 'همه وضعیت ها',
})
const confirm = useConfirm();
watch(() => props.withdrawRequests, (newRequests) => {
if (newRequests) {
withdrawRequests.value = newRequests;
}
}, { deep: true, immediate: true });
const onPageChange = (event) => {
withdrawRequestStore.navigateToPage(event.page + 1);
};
const searchData = () => {
searchLoader.value = true;
withdrawRequestStore.search(queryParams).then(() => {
searchLoader.value = false;
});
}
const onSearchChange = (value) => {
queryParams.search = value;
searchData();
};
const formatCardNumber = (cardNumber) => cardNumber.replace(/(\d{4})(?=\d)/g, '$1-');
const showApproveConfirm = (data) => {
confirm.require({
group: 'deleteTemplate',
header: 'تایید درخواست برداشت',
message: `مبلغ ${Number(data.amount).toLocaleString('fa-IR')} تومان از کیف پول «${data.user?.fullname}» کسر و به ولت سایت منتقل می‌شود.`,
icon: 'fi fi-rr-info',
rejectProps: {
label: 'انصراف',
icon: 'fi fi-rr-ban',
outlined: true,
size: 'small',
class: '!text-xs !flex pt-1 !item-center',
iconClass: '!flex !text-xs',
},
acceptProps: {
label: 'تایید برداشت',
icon: 'fi fi-rr-check',
size: 'small',
class: '!text-xs !flex pt-1 !item-center',
iconClass: '!flex !text-xs',
},
accept: () => {
actionLoader.value = true;
withdrawRequestStore.approve(data.id)
.catch((error) => {
toast.add({ severity: 'error', summary: 'خطا', detail: error.response?.data?.message || 'خطا در تایید درخواست', life: 4000 });
})
.finally(() => {
actionLoader.value = false;
});
},
reject: () => {
}
});
}
const rejectModalVisible = ref(false);
const rejectingRequest = ref(null);
const rejectNote = ref('');
const openRejectModal = (data) => {
rejectingRequest.value = data;
rejectNote.value = '';
rejectModalVisible.value = true;
}
const submitReject = () => {
actionLoader.value = true;
withdrawRequestStore.reject(rejectingRequest.value.id, rejectNote.value)
.then(() => {
rejectModalVisible.value = false;
})
.catch((error) => {
toast.add({ severity: 'error', summary: 'خطا', detail: error.response?.data?.message || 'خطا در رد درخواست', life: 4000 });
})
.finally(() => {
actionLoader.value = false;
});
}
</script>
<template>
<master>
<div class="mb-4 grid grid-cols-2a justify-between items-center bg-white border-[1px] border-client-gray/20 py-4 px-4 rounded-2xl">
<h2 class="font-bold text-client-Fontcolor">{{ withdrawRequestStore.indexPageTitle }}</h2>
</div>
<div class="mb-4 grid grid-cols-3 max-lg:grid-cols-1 gap-4">
<div class="bg-white border-[1px] border-client-gray/20 rounded-2xl p-4 flex flex-col gap-2">
<span class="text-xs text-gray-500">درخواست‌های در انتظار بررسی</span>
<strong class="text-lg text-client-Fontcolor">{{ Number(stats.pending_count).toLocaleString('fa-IR') }} درخواست</strong>
</div>
<div class="bg-white border-[1px] border-client-gray/20 rounded-2xl p-4 flex flex-col gap-2">
<span class="text-xs text-gray-500">مجموع مبلغ در انتظار برداشت</span>
<strong class="text-lg text-client-Fontcolor">{{ Number(stats.pending_amount).toLocaleString('fa-IR') }} تومان</strong>
</div>
<div class="bg-white border-[1px] border-client-gray/20 rounded-2xl p-4 flex flex-col gap-2">
<span class="text-xs text-gray-500">موجودی ولت سایت</span>
<strong class="text-lg text-client-Fontcolor">{{ Number(stats.system_wallet_balance).toLocaleString('fa-IR') }} تومان</strong>
</div>
</div>
<div class="card border-[1px] border-client-gray/20 bg-white rounded-2xl">
<TableGeneric
:data="withdrawRequests.data"
:columns="columns"
:selectable="false"
:pagination="withdrawRequests.meta"
:searchable="true"
:searchValue="queryParams.search"
:model-value="[]"
@page-change="onPageChange"
@search-change="onSearchChange"
>
<template #toolbar-actions>
<div class="grid grid-cols-a gap-4 justify-start">
<Dropdown
v-model="queryParams.status"
:options="statusOptions"
placeholder="انتخاب وضعیت..."
class="!text-xs"
@change="searchData"
/>
</div>
</template>
<template #column-user="{ data }">
<div class="flex flex-col gap-1">
<span class="text-sm">{{ data.user?.fullname }}</span>
<span class="text-xs text-gray-500">{{ data.user?.level }} - {{ data.user?.phone }}</span>
</div>
</template>
<template #column-bank_card="{ data }">
<div v-if="data.bank_card" class="flex flex-col gap-1">
<pre dir="ltr" class="text-right text-xs">{{ formatCardNumber(data.bank_card.card_number) }}</pre>
<span class="text-xs text-gray-500">{{ data.bank_card.holder_name }}</span>
</div>
<span v-else class="text-xs text-gray-500">حذف شده</span>
</template>
<template #column-status="{ data }">
<div class="flex flex-col gap-1 items-start">
<Tag
class="!text-xs"
:value="data.status"
:severity="withdrawRequestStore.getStatusSeverity(data.status)"
/>
<span v-if="data.admin_note" class="text-xs text-gray-500">{{ data.admin_note }}</span>
</div>
</template>
<template #column-actions="{ data }">
<div v-if="data.status === 'در انتظار بررسی'" class="grid grid-cols-2a justify-start gap-2">
<Button
label="تایید"
size="small"
severity="success"
class="!text-xs"
:disabled="actionLoader"
@click="showApproveConfirm(data)"
/>
<Button
label="رد"
size="small"
severity="danger"
variant="outlined"
class="!text-xs"
:disabled="actionLoader"
@click="openRejectModal(data)"
/>
</div>
</template>
</TableGeneric>
</div>
<UIModal
v-model:visible="rejectModalVisible"
title="رد درخواست برداشت"
size="small"
>
<div class="flex flex-col gap-3">
<p class="text-sm text-gray-600">
درخواست برداشت «{{ rejectingRequest ? Number(rejectingRequest.amount).toLocaleString('fa-IR') : '' }} تومان» کاربر «{{ rejectingRequest?.user?.fullname }}» رد می‌شود.
</p>
<textarea
v-model="rejectNote"
rows="4"
placeholder="توضیح برای کاربر (اختیاری)"
class="w-full border border-gray-200 rounded-xl p-3 text-sm outline-none focus:border-gray-400 resize-y"
></textarea>
<div class="flex items-center justify-start gap-2">
<Button
label="رد درخواست"
size="small"
severity="danger"
class="!text-xs"
:loading="actionLoader"
:disabled="actionLoader"
@click="submitReject"
/>
<Button
label="انصراف"
size="small"
severity="secondary"
variant="outlined"
class="!text-xs"
:disabled="actionLoader"
@click="rejectModalVisible = false"
/>
</div>
</div>
</UIModal>
</master>
</template>
@@ -86,6 +86,14 @@ const items: Ref<SidebarItem[]> = ref([
permission: "reports.index", permission: "reports.index",
expanded: false, expanded: false,
}, },
{
label: "درخواست‌های برداشت",
icon: "fi fi-rr-wallet",
to: "/admin/withdraw-requests",
key: 'withdrawals',
permission: "withdrawals.index",
expanded: false,
},
{ {
label: "مدیریت شیفت ها", label: "مدیریت شیفت ها",
icon: "fi fi-rr-calendar", icon: "fi fi-rr-calendar",
@@ -251,7 +251,6 @@ const getColumnStyle = (column) => {
<div class="grid grid-cols-1 items-center justify-start"> <div class="grid grid-cols-1 items-center justify-start">
<InputText <InputText
size="small" size="small"
class="placeholder:!font-light placeholder:!text-xs !h-10"
placeholder="جستجو..." placeholder="جستجو..."
type="text" type="text"
v-model="localSearchValue" v-model="localSearchValue"
@@ -274,8 +273,7 @@ const getColumnStyle = (column) => {
optionLabel="label" optionLabel="label"
optionValue="value" optionValue="value"
size="small" 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]"
class="min-w-[130px] max-sm:!w-full max-w-[200px] !text-[12px] !h-10"
:loading="renderFilter(column).loading" :loading="renderFilter(column).loading"
:disabled="renderFilter(column).loading" :disabled="renderFilter(column).loading"
:filter="renderFilter(column).search" :filter="renderFilter(column).search"
@@ -297,8 +295,7 @@ const getColumnStyle = (column) => {
optionLabel="label" optionLabel="label"
optionValue="value" optionValue="value"
size="small" 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]"
class="min-w-[150px] max-sm:!w-full max-w-[200px] !text-[12px] !h-10"
:loading="renderFilter(column).loading" :loading="renderFilter(column).loading"
:disabled="renderFilter(column).loading" :disabled="renderFilter(column).loading"
:filter="renderFilter(column).search" :filter="renderFilter(column).search"
@@ -11,10 +11,14 @@ const props = defineProps({
shifts: { shifts: {
type: Array, type: Array,
required: true 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']; const doctorPalette = ['#576989', '#800E13', '#50CD89', '#7239EA', '#FFC700', '#F1416C', '#009EF7', '#965457'];
@@ -71,6 +75,11 @@ const calendarOptions = computed(() => ({
eventClick: (info) => { eventClick: (info) => {
emit('select-shift', info.event.extendedProps.shift); 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(); const goPrev = () => calendarRef.value?.getApi().prev();
@@ -45,15 +45,11 @@ const props = defineProps({
}, },
class: { class: {
type: String, type: String,
default: '!text-xs !h-full' default: 'w-full'
}, },
pt: { pt: {
type: Object, type: Object,
default: () => ({ default: () => ({})
option: '!text-sm',
placeholder: '!text-sm',
label: '!pt-3'
})
} }
}) })
+110
View File
@@ -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';
}
}
})
@@ -0,0 +1,231 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { useForm } from '@inertiajs/inertia-vue3';
import { Inertia } from '@inertiajs/inertia';
import Dialog from 'primevue/dialog';
import { toast } from 'vue3-toastify';
import ClientLayout from '../../../layouts/ClientLayout.vue';
import UiButton from '../../../components/ui/UiButton.vue';
import CaseSpecsCard from '../../../components/dashboard/cases/CaseSpecsCard.vue';
import ReportSectionCard from '../../../components/dashboard/cases/ReportSectionCard.vue';
import FileUploadBox from '../../../components/dashboard/fastRequest/FileUploadBox.vue';
import { modalityTitle } from '../../../data/fastRequestOptions';
import { convertEnglishToPersian } from '../../../utils/numberUtils';
const props = defineProps({
case: {
type: Object,
required: true
},
canReport: {
type: Boolean,
required: true
}
});
const petRows = computed(() => [
{ label: 'نام', value: props.case.pet?.name },
{ label: 'نوع', value: props.case.pet?.type },
{ label: 'جنسیت', value: props.case.pet?.gender },
{ label: 'نژاد', value: props.case.pet?.breed },
{ label: 'سن', value: props.case.pet?.age !== null && props.case.pet?.age !== undefined ? `${convertEnglishToPersian(props.case.pet.age)} سال` : null },
{ label: 'وزن', value: props.case.pet?.weight ? `${convertEnglishToPersian(props.case.pet.weight)} کیلو گرم` : null },
{ label: 'وضعیت عقیم‌سازی', value: props.case.pet?.sterilized === null ? null : (props.case.pet?.sterilized ? 'عقیم شده' : 'عقیم نشده') },
]);
const requesterRows = computed(() => {
const requester = props.case.requester || {};
return [
{ label: 'نوع', value: requester.level },
{ label: 'نام و نام خانوادگی', value: requester.fullname },
{ label: 'شماره موبایل', value: requester.phone ? convertEnglishToPersian(requester.phone) : null },
{ label: 'تخصص', value: requester.specialty },
{ label: 'کلینیک', value: requester.clinic_name },
{ label: 'تلفن کلینیک', value: requester.clinic_phone ? convertEnglishToPersian(requester.clinic_phone) : null },
{ label: 'محل اقامت', value: [requester.province, requester.city].filter(Boolean).join('، ') || null },
].filter((row) => row.value);
});
const remainingLabel = ref(null);
let timerId = null;
const updateRemaining = () => {
if (!props.case.processing_deadline) {
remainingLabel.value = null;
return;
}
const diff = Math.floor((new Date(props.case.processing_deadline).getTime() - Date.now()) / 1000);
if (diff <= 0) {
remainingLabel.value = 'اتمام فرصت رسیدگی';
return;
}
const pad = (value) => String(value).padStart(2, '0');
remainingLabel.value = convertEnglishToPersian(`${pad(Math.floor(diff / 3600))}:${pad(Math.floor((diff % 3600) / 60))}:${pad(diff % 60)}`) + ' تا پایان زمان ثبت گزارش';
};
onMounted(() => {
updateRemaining();
timerId = setInterval(updateRemaining, 1000);
});
onUnmounted(() => clearInterval(timerId));
const openFiles = (files) => {
files.forEach((file) => window.open(file.url, '_blank'));
};
const reportForm = useForm({
document_id: props.case.id,
technique: props.case.report?.technique || '',
finding: props.case.report?.finding || '',
diagnose: props.case.report?.diagnose || '',
recommendation: props.case.report?.recommendation || '',
files: [],
});
const submitReport = () => {
reportForm.post('/dashboard/reports', {
forceFormData: true,
onError: () => toast.error('لطفا موارد مشخص‌شده را بررسی و اصلاح کنید.'),
});
};
const rejectModalVisible = ref(false);
const rejectReason = ref('');
const rejecting = ref(false);
const submitReject = () => {
if (!rejectReason.value.trim()) {
toast.error('لطفا دلیل رد درخواست را وارد کنید.');
return;
}
rejecting.value = true;
Inertia.post(`/dashboard/cases/${props.case.id}/reject`, { reason: rejectReason.value }, {
onFinish: () => {
rejecting.value = false;
rejectModalVisible.value = false;
},
});
};
</script>
<template>
<ClientLayout>
<div class="flex flex-col gap-4">
<header class="bg-white border border-complement-50 rounded-2xl px-4 py-3 flex items-center justify-between flex-wrap gap-2">
<h1 class="text-[13px] font-bold text-[#3E4C63]">پرونده شماره {{ convertEnglishToPersian(props.case.code) }}</h1>
<div class="flex items-center gap-3 text-[11px]">
<span v-if="canReport && remainingLabel" class="font-semibold text-[#7A1419]">{{ remainingLabel }}</span>
<span v-else class="font-normal text-[#576989]">وضعیت فعلی</span>
<span class="font-semibold text-[#3E4C63] border-r border-[#DEE3EB] pr-3">{{ props.case.status }}</span>
</div>
</header>
<div class="grid grid-cols-[16rem_1fr] max-lg:grid-cols-1 gap-4 items-start">
<aside class="flex flex-col gap-4">
<CaseSpecsCard title="مشخصات کیس" :rows="petRows" />
<CaseSpecsCard v-if="canReport && requesterRows.length" title="مشخصات درخواست‌دهنده" :rows="requesterRows" />
</aside>
<div class="flex flex-col gap-4">
<section class="bg-white border border-complement-50 rounded-2xl p-4 flex flex-col gap-3">
<h2 class="flex items-center gap-2 text-[12px] font-bold text-[#3E4C63]">
<span class="w-1.5 h-1.5 rounded-full bg-[#7A1419]"></span>
جزئیات درخواست
</h2>
<div class="flex items-center gap-3 flex-wrap text-[11px]">
<p><span class="font-normal text-[#576989]">گزارش درخواست‌شده:</span> <span class="font-semibold text-[#7A1419]">{{ modalityTitle(props.case.modality) }}</span></p>
<span class="text-[#DEE3EB]">|</span>
<p><span class="font-normal text-[#576989]">نوع درخواست:</span> <span class="font-semibold text-[#7A1419]">{{ props.case.priority }}</span></p>
<span class="text-[#DEE3EB]">|</span>
<p><span class="font-normal text-[#576989]">زمان درخواست:</span> <span class="font-semibold text-[#3E4C63]">{{ props.case.submitted_at ? convertEnglishToPersian(props.case.submitted_at) : '-' }}</span></p>
</div>
<div class="text-[12px] leading-6">
<p class="font-normal text-[#576989]">تاریخچه بیماری:</p>
<p class="font-normal text-[#3E4C63] whitespace-pre-line">{{ props.case.note || 'توضیحی ثبت نشده است.' }}</p>
</div>
<div class="flex items-center gap-3 flex-wrap pt-2 border-t border-[#EBEBEB]">
<UiButton label="دانلود فایل درخواست" severity="secondary" size="small" :disabled="!props.case.primary_files.length" @click="openFiles(props.case.primary_files)" />
<UiButton label="دانلود سوابق پزشکی قبلی" severity="secondary" size="small" :disabled="!props.case.history_files.length" @click="openFiles(props.case.history_files)" />
<UiButton v-if="!canReport && props.case.report" label="دانلود فایل گزارش" severity="secondary" size="small" :disabled="!props.case.report.attachments.length" @click="openFiles(props.case.report.attachments)" />
</div>
</section>
<p v-if="props.case.rejection_reason" class="bg-[#F8E9EA] border border-[#E7C3C5] rounded-2xl p-4 text-[12px] font-normal text-[#7A1419] leading-6">
دلیل رد درخواست: {{ props.case.rejection_reason }}
</p>
<template v-if="canReport">
<ReportSectionCard v-model="reportForm.technique" title="تکنیک (Technique)" editable placeholder="شرح تکنیک را وارد کنید" :error="reportForm.errors.technique" />
<ReportSectionCard v-model="reportForm.finding" title="یافته‌ها (Finding)" editable placeholder="یافته‌ها را وارد کنید" :error="reportForm.errors.finding" />
<ReportSectionCard v-model="reportForm.diagnose" title="تشخیص (Diagnose)" editable placeholder="تشخیص را وارد کنید" :error="reportForm.errors.diagnose" />
<ReportSectionCard v-model="reportForm.recommendation" title="پیشنهادات (Recommendation)" editable placeholder="پیشنهادات را وارد کنید" :error="reportForm.errors.recommendation" />
<section class="bg-white border border-complement-50 rounded-2xl p-4 flex flex-col gap-3">
<h2 class="flex items-center gap-2 text-[12px] font-bold text-[#3E4C63]">
<span class="w-1.5 h-1.5 rounded-full bg-[#7A1419]"></span>
آپلود فایل و مستندات
</h2>
<FileUploadBox v-model="reportForm.files" title="آپلود فایل و مستندات گزارش" />
</section>
<footer class="bg-white border border-complement-50 rounded-2xl p-4 flex items-center justify-between flex-wrap gap-3">
<p class="text-[11px] font-normal text-[#576989]">با تایید گزارش و یا رد درخواست دیگر امکان ویرایش آن را نخواهید داشت.</p>
<div class="flex items-center gap-3">
<button type="button" class="text-[11px] font-semibold text-[#7A1419]" @click="rejectModalVisible = true">رد درخواست</button>
<UiButton label="تایید و ثبت گزارش" severity="success" size="small" :loading="reportForm.processing" :disabled="reportForm.processing" @click="submitReport" />
</div>
</footer>
</template>
<template v-else>
<ReportSectionCard :model-value="props.case.report?.technique || ''" title="تکنیک (Technique)" />
<ReportSectionCard :model-value="props.case.report?.finding || ''" title="یافته‌ها (Finding)" />
<ReportSectionCard :model-value="props.case.report?.diagnose || ''" title="تشخیص (Diagnose)" />
<ReportSectionCard :model-value="props.case.report?.recommendation || ''" title="پیشنهادات (Recommendation)" />
<section class="bg-white border border-complement-50 rounded-2xl p-4 flex flex-col gap-3">
<h2 class="flex items-center gap-2 text-[12px] font-bold text-[#3E4C63]">
<span class="w-1.5 h-1.5 rounded-full bg-[#7A1419]"></span>
آپلود فایل و مستندات
</h2>
<div v-if="props.case.report?.attachments?.length" class="flex flex-wrap gap-4">
<a v-for="file in props.case.report.attachments" :key="file.id" :href="file.url" target="_blank" class="relative w-[5rem] h-[5rem] rounded-lg border border-gray-200 overflow-hidden">
<img :alt="file.filename" :src="file.url" class="absolute top-0 left-0 w-full h-full object-cover" />
</a>
</div>
<p v-else class="w-full min-h-[5rem] bg-[#F8FAFC] border border-[#DEE3EB] rounded-xl p-4 text-[12px] font-normal text-[#576989] leading-6 flex items-center justify-center text-center">
عکسی برای این پرونده آپلود نشده است.
</p>
</section>
</template>
</div>
</div>
</div>
<Dialog v-model:visible="rejectModalVisible" :modal="true" :closable="true" :dismissableMask="false" :style="{ width: '480px' }" class="fast-request-dialog" :pt="{ header: '!bg-client-primaryBlue' }">
<template #header>
<div class="w-full flex items-center justify-start">
<h2 class="text-[14px] font-[700] text-[#576989]">رد درخواست</h2>
</div>
</template>
<div class="w-full flex flex-col gap-3 border-t border-[#EBEBEB] pt-4">
<p class="text-[12px] font-normal text-[#3E4C63] leading-6">پس از رد درخواست، مبلغ فاکتور به کیف پول درخواست‌کننده بازگردانده می‌شود.</p>
<textarea
v-model="rejectReason"
rows="4"
placeholder="دلیل رد درخواست را وارد کنید"
class="w-full bg-[#F8FAFC] border border-[#DEE3EB] rounded-xl p-4 text-[12px] font-normal text-[#3E4C63] leading-6 outline-none focus:border-[#C1D0E8] resize-y"
></textarea>
</div>
<div class="w-full pt-3 flex items-center justify-start gap-2 border-t border-[#EBEBEB]">
<UiButton label="رد درخواست" severity="danger" size="small" :loading="rejecting" :disabled="rejecting" @click="submitReject" />
<UiButton label="انصراف" severity="secondary" size="small" :disabled="rejecting" @click="rejectModalVisible = false" />
</div>
</Dialog>
</ClientLayout>
</template>
@@ -7,9 +7,10 @@ import UiButton from '../../components/ui/UiButton.vue';
import EyeSvg from '../../components/svg/eye.svg'; import EyeSvg from '../../components/svg/eye.svg';
import UiCard from '../../components/ui/UiCard.vue'; import UiCard from '../../components/ui/UiCard.vue';
import RequestCard from '../../components/dashboard/RequestCard.vue'; import RequestCard from '../../components/dashboard/RequestCard.vue';
import { ref } from 'vue'; import { onMounted, ref } from 'vue';
import { Link } from '@inertiajs/inertia-vue3'; import { Link, usePage } from '@inertiajs/inertia-vue3';
import FastRequestModal from '../../components/dashboard/FastRequestModal.vue'; import FastRequestModal from '../../components/dashboard/FastRequestModal.vue';
import PaymentResultModal from '../../components/dashboard/PaymentResultModal.vue';
import { useFastRequestStore } from '../../store/FastRequest'; import { useFastRequestStore } from '../../store/FastRequest';
import { modalities } from '../../data/fastRequestOptions'; import { modalities } from '../../data/fastRequestOptions';
import { caseColumns } from '../../data/casesTableColumns'; import { caseColumns } from '../../data/casesTableColumns';
@@ -22,6 +23,17 @@ const openFastRequestModal = (type) => {
fastRequestStore.requestType = 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({ defineProps({
user: { user: {
type: Object, type: Object,
@@ -30,13 +42,17 @@ defineProps({
cases: { cases: {
type: Object, type: Object,
required: true required: true
},
stats: {
type: Object,
required: true
} }
}) })
</script> </script>
<template> <template>
<ClientLayout> <ClientLayout>
<ActivitiesWidget /> <ActivitiesWidget :stats="stats" />
<UiCard title="ثبت آنی درخواست جدید" variant="default" class="mt-4"> <UiCard title="ثبت آنی درخواست جدید" variant="default" class="mt-4">
<p class="w-full flex items-center justify-start font-[400] text-[11px]">فقط چند قدم ساده برای ثبت درخواست کافیه.</p> <p class="w-full flex items-center justify-start font-[400] text-[11px]">فقط چند قدم ساده برای ثبت درخواست کافیه.</p>
<div class="w-full h-[179px] grid grid-cols-5 gap-3 justify-start pt-[10px]"> <div class="w-full h-[179px] grid grid-cols-5 gap-3 justify-start pt-[10px]">
@@ -77,5 +93,6 @@ defineProps({
<ActivitiesChart /> <ActivitiesChart />
</UiCard> </UiCard>
<fast-request-modal v-model:visible="fastRequestModalVisible"></fast-request-modal> <fast-request-modal v-model:visible="fastRequestModalVisible"></fast-request-modal>
<payment-result-modal v-model:visible="paymentResultVisible" :result="paymentResult"></payment-result-modal>
</ClientLayout> </ClientLayout>
</template> </template>
@@ -1,26 +0,0 @@
<script setup>
import ClientLayout from '../../../layouts/ClientLayout.vue';
const props = defineProps({
document: {
type: Object,
required: true,
}
});
</script>
<template>
<ClientLayout>
<h1>{{ document.title }}</h1>
<p>{{ document.description }}</p>
<p>{{ document.created_at }}</p>
<p>{{ document.updated_at }}</p>
<p>{{ document.status }}</p>
<p>{{ document.priority }}</p>
<p>{{ document.note }}</p>
<p>{{ document.modality }}</p>
<p>{{ document.pet_id }}</p>
<p>{{ document.requester_id }}</p>
</ClientLayout>
</template>
@@ -0,0 +1,55 @@
<script setup>
import { ref } from 'vue';
import ClientLayout from '../../../layouts/ClientLayout.vue';
import TableGeneric from '../../../components/ui/TableGeneric.vue';
import UiCard from '../../../components/ui/UiCard.vue';
import UiButton from '../../../components/ui/UiButton.vue';
import InvoiceModal from '../../../components/dashboard/fastRequest/InvoiceModal.vue';
import { invoiceColumns } from '../../../data/invoicesTableColumns';
defineProps({
invoices: {
type: Object,
required: true
}
});
const selectedInvoice = ref(null);
const invoiceModalVisible = ref(false);
const openInvoice = (invoice) => {
selectedInvoice.value = invoice;
invoiceModalVisible.value = true;
};
</script>
<template>
<ClientLayout>
<UiCard title="لیست فاکتورها" variant="default">
<TableGeneric
class="bg-white !rounded-2xl"
:data="invoices.data"
customStyle="!text-[11px] !font-normal"
:searchable="false"
:columns="invoiceColumns"
:selectable="false"
:pagination="invoices.meta"
:model-value="[]"
>
<template #column-actions="{ data }">
<div class="flex items-center justify-end">
<UiButton
v-if="data.status === 'در انتظار پرداخت'"
label="پرداخت"
severity="success"
size="small"
@click="openInvoice(data)"
/>
</div>
</template>
</TableGeneric>
</UiCard>
<InvoiceModal v-model:visible="invoiceModalVisible" :invoice="selectedInvoice" />
</ClientLayout>
</template>
@@ -1,11 +1,42 @@
<script setup> <script setup>
import ClientLayout from '../../../layouts/ClientLayout.vue' import ClientLayout from '../../../layouts/ClientLayout.vue';
import TableGeneric from '../../../components/ui/TableGeneric.vue';
import UiCard from '../../../components/ui/UiCard.vue';
import EyeSvg from '../../../components/svg/eye.svg';
import { Link } from '@inertiajs/inertia-vue3';
import { reportColumns } from '../../../data/reportsTableColumns';
defineProps({
reports: {
type: Object,
required: true
}
})
</script> </script>
<template> <template>
<ClientLayout> <ClientLayout>
<h1 class="text-2xl font-bold"> <UiCard title="گزارشات" variant="default">
Reports <TableGeneric
</h1> class="bg-white !rounded-2xl"
:data="reports.data"
customStyle="!text-[11px] !font-normal"
:searchable="false"
:columns="reportColumns"
:selectable="false"
:pagination="reports.meta"
:model-value="[]"
>
<template #column-actions="{ data }">
<div class="flex items-center justify-end">
<Link :href="`/dashboard/cases/${data.document?.id}`"
class="text-primary-500 bg-complement-100 flex items-center justify-end p-1.5 rounded-md"
>
<EyeSvg class="w-4 h-4 text-primary-500" />
</Link>
</div>
</template>
</TableGeneric>
</UiCard>
</ClientLayout> </ClientLayout>
</template> </template>
@@ -0,0 +1,189 @@
<script setup>
import { computed, ref } from 'vue';
import { usePage } from '@inertiajs/inertia-vue3';
import { toast } from 'vue3-toastify';
import ClientLayout from '../../../layouts/ClientLayout.vue';
import TableGeneric from '../../../components/ui/TableGeneric.vue';
import UiCard from '../../../components/ui/UiCard.vue';
import UiInput from '../../../components/ui/UiInput.vue';
import UiButton from '../../../components/ui/UiButton.vue';
import WalletSvg from '../../../components/svg/wallet.svg';
import WithdrawRequestCard from '../../../components/dashboard/wallet/WithdrawRequestCard.vue';
import { walletTransactionColumns } from '../../../data/walletTransactionsTableColumns';
import { withdrawRequestColumns } from '../../../data/withdrawRequestsTableColumns';
import { convertEnglishToPersian, convertPersianToEnglish, formatPrice } from '../../../utils/numberUtils';
const props = defineProps({
transactions: {
type: Object,
required: true
},
withdrawRequests: {
type: Array,
required: true
},
bankCards: {
type: Array,
required: true
}
});
const { props: pageProps } = usePage();
const quickAmounts = [100000, 200000, 500000];
const amount = ref('');
const depositing = ref(false);
const numericAmount = computed(() => Number(convertPersianToEnglish(amount.value).replace(/\D/g, '')) || 0);
const formattedBalance = computed(() => formatPrice(pageProps.value.auth?.wallet_balance || 0));
const pendingWithdrawAmount = computed(() => props.withdrawRequests
.filter((request) => request.status === 'در انتظار بررسی')
.reduce((sum, request) => sum + request.amount, 0));
const withdrawStatusClasses = (status) => {
if (status === 'تایید شده') {
return 'text-[#1F7A4D] bg-[#E6F4EC]';
}
if (status === 'رد شده') {
return 'text-[#7A1419] bg-[#F8E9EA]';
}
return 'text-[#576989] bg-[#EEF2F8]';
};
const formatCardNumber = (cardNumber) => convertEnglishToPersian(cardNumber.replace(/(\d{4})(?=\d)/g, '$1 - '));
const selectQuickAmount = (value) => {
amount.value = String(value);
};
const handleDeposit = async () => {
if (numericAmount.value < 10000) {
toast.error('حداقل مبلغ شارژ ۱۰٬۰۰۰ تومان است.');
return;
}
depositing.value = true;
try {
const { data } = await window.axios.post('/dashboard/wallet/deposit', { amount: numericAmount.value });
window.location.href = data.url;
} catch (error) {
depositing.value = false;
toast.error(error.response?.data?.message || 'خطا در اتصال به درگاه پرداخت. لطفا دوباره تلاش کنید.');
}
};
</script>
<template>
<ClientLayout>
<div class="flex flex-col gap-4">
<UiCard title="کیف پول" variant="default">
<div class="grid grid-cols-2 max-sm:grid-cols-1 gap-4">
<div class="flex items-center gap-3 bg-[#EEF2F8] border border-[#DEE3EB] rounded-[12px] px-5 py-4">
<WalletSvg class="w-6 h-6" />
<div class="flex flex-col gap-1">
<span class="text-[11px] font-normal text-[#576989]">موجودی کیف پول</span>
<strong class="text-[16px] font-bold text-[#3E4C63]">{{ formattedBalance }} تومان</strong>
</div>
</div>
<div class="flex items-center gap-3 bg-[#F8FAFC] border border-[#DEE3EB] rounded-[12px] px-5 py-4">
<div class="flex flex-col gap-1">
<span class="text-[11px] font-normal text-[#576989]">مبلغ در انتظار برداشت</span>
<strong class="text-[16px] font-bold text-[#3E4C63]">{{ formatPrice(pendingWithdrawAmount) }} تومان</strong>
</div>
</div>
</div>
</UiCard>
<UiCard title="شارژ کیف پول" variant="default">
<form class="flex flex-col gap-4" @submit.prevent="handleDeposit">
<p class="text-[11px] font-normal text-[#576989] leading-6">
مبلغ مورد نظر را وارد کنید؛ پس از پرداخت از طریق درگاه بانکی، موجودی کیف پول شما بلافاصله افزایش می‌یابد.
</p>
<div class="flex items-end gap-3 max-sm:flex-col max-sm:items-stretch">
<UiInput
v-model="amount"
label="مبلغ شارژ"
placeholder="مبلغ مورد نظر را به تومان وارد کنید"
prefixEnd="تومان"
prefixClasses="!border-dominant-50"
class="!w-[22rem] max-sm:!w-full"
customClass="!border-dominant-50 !font-normal !text-[12px]"
/>
<UiButton
label="افزایش موجودی"
severity="success"
size="small"
:loading="depositing"
:disabled="depositing"
@click="handleDeposit"
/>
</div>
<div class="flex items-center gap-2 flex-wrap">
<button
v-for="value in quickAmounts"
:key="value"
type="button"
class="text-[11px] font-normal text-[#576989] bg-[#F5F7FA] border border-[#DEE3EB] rounded-lg px-3 py-1.5 hover:bg-[#EEF2F8]"
@click="selectQuickAmount(value)"
>
{{ formatPrice(value) }} تومان
</button>
</div>
</form>
</UiCard>
<WithdrawRequestCard :bank-cards="bankCards" />
<UiCard title="درخواست‌های برداشت" variant="default">
<TableGeneric
class="bg-white !rounded-2xl"
:data="withdrawRequests"
customStyle="!text-[11px] !font-normal"
:searchable="false"
:columns="withdrawRequestColumns"
:selectable="false"
:pagination="null"
:model-value="[]"
>
<template #column-bank_card="{ data }">
<span v-if="data.bank_card" dir="ltr">{{ formatCardNumber(data.bank_card.card_number) }}</span>
<span v-else>-</span>
</template>
<template #column-status="{ data }">
<span class="text-[11px] font-normal rounded-md px-2 py-1" :class="withdrawStatusClasses(data.status)">
{{ data.status }}
</span>
</template>
</TableGeneric>
</UiCard>
<UiCard title="تراکنش‌ها" variant="default">
<TableGeneric
class="bg-white !rounded-2xl"
:data="transactions.data"
customStyle="!text-[11px] !font-normal"
:searchable="false"
:columns="walletTransactionColumns"
:selectable="false"
:pagination="transactions.meta"
:model-value="[]"
>
<template #column-type="{ data }">
<span
class="text-[11px] font-normal rounded-md px-2 py-1"
:class="data.type === 'واریز' ? 'text-[#1F7A4D] bg-[#E6F4EC]' : 'text-[#7A1419] bg-[#F8E9EA]'"
>
{{ data.type }}
</span>
</template>
</TableGeneric>
</UiCard>
</div>
</ClientLayout>
</template>
@@ -1,18 +1,24 @@
<script setup> <script setup>
import { Link } from '@inertiajs/inertia-vue3'
import ActivityCard from './ActivityCard.vue' import ActivityCard from './ActivityCard.vue'
import { formatPrice } from '../../utils/numberUtils' import { formatPrice } from '../../utils/numberUtils'
const props = defineProps({
stats: {
type: Object,
required: true
}
})
</script> </script>
<template> <template>
<section class="grid grid-cols-4 max-lg:grid-cols-2 max-sm:grid-cols-1 gap-4"> <section class="grid grid-cols-4 max-lg:grid-cols-2 max-sm:grid-cols-1 gap-4">
<ActivityCard title="کیس در انتظار بررسی" <ActivityCard title="کیس در انتظار بررسی"
:count="5" link="/dashboard/doctor/cases" label-link="مشاهده و بررسی" /> :count="stats.pending_cases" link="/dashboard/cases" label-link="مشاهده و بررسی" />
<ActivityCard title="کیس بررسی شده در ماه" <ActivityCard title="کیس بررسی شده در ماه"
:count="999" link="/dashboard/doctor/cases" label-link="مشاهده گزارشات کیس‌ها" /> :count="stats.monthly_reviewed" link="/dashboard/reports" label-link="مشاهده گزارشات کیس‌ها" />
<ActivityCard title="تومان درآمد" <ActivityCard :title="stats.is_doctor ? 'تومان درآمد' : 'تومان هزینه در ماه'"
:count="formatPrice(50000000)" link="/dashboard/doctor/cases" label-link="مشاهده گزارشات مالی " /> :count="formatPrice(stats.monthly_amount)" :link="stats.is_doctor ? '/dashboard/wallet' : '/dashboard/invoices'" label-link="مشاهده گزارشات مالی" />
<ActivityCard title="ساعت تا شیت بعدی" <ActivityCard v-if="stats.next_shift_hours !== null" title="ساعت تا شیفت بعدی"
:count="4" link="/dashboard/doctor/cases" label-link="مشاهده شیفت‌ها" /> :count="stats.next_shift_hours" link="/dashboard/shifts" label-link="مشاهده شیفت‌ها" />
</section> </section>
</template> </template>
@@ -0,0 +1,132 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { Inertia } from '@inertiajs/inertia';
import { usePage } from '@inertiajs/inertia-vue3';
import Dialog from 'primevue/dialog';
import UiButton from '../ui/UiButton.vue';
import UiInput from '../ui/UiInput.vue';
import { useGlobalStore } from '../../store/Global';
const visible = defineModel('visible');
const globalStore = useGlobalStore();
const { props } = usePage();
const form = ref({
fullname: '',
birthdate: '',
national_code: '',
email: '',
province: '',
city: '',
address: '',
});
const avatarFile = ref(null);
const avatarPreview = ref('');
const avatarInput = ref(null);
const errors = ref({});
const submitting = ref(false);
const avatarUrl = computed(() => avatarPreview.value || globalStore.current_user.profile_url || '');
watch(visible, (isOpen) => {
if (!isOpen) return;
const user = props.value.auth?.User || {};
form.value = {
fullname: user.fullname || '',
birthdate: user.birthdate || '',
national_code: user.national_code || '',
email: user.email || '',
province: user.province || '',
city: user.city || '',
address: user.address || '',
};
avatarFile.value = null;
avatarPreview.value = '';
errors.value = {};
});
const errorFor = (field) => {
const error = errors.value[field];
return Array.isArray(error) ? error[0] : (error || '');
};
const openAvatarPicker = () => {
avatarInput.value?.click();
};
const handleAvatarChange = (event) => {
const file = event.target.files?.[0];
if (!file) return;
avatarFile.value = file;
avatarPreview.value = URL.createObjectURL(file);
};
const submit = () => {
submitting.value = true;
Inertia.post('/dashboard/profile', {
...form.value,
avatar: avatarFile.value,
}, {
forceFormData: true,
preserveScroll: true,
onSuccess: () => {
globalStore.current_user = props.value.auth.User;
visible.value = false;
},
onError: (formErrors) => {
errors.value = formErrors;
},
onFinish: () => {
submitting.value = false;
},
});
};
</script>
<template>
<Dialog v-model:visible="visible" :modal="true" :closable="true" :dismissableMask="false" :maximizable="false" :style="{ width: '688px' , minHeight: '376px' }" class="fast-request-dialog" :pt="{header : '!bg-client-primaryBlue'}">
<template #header>
<div class="w-full flex items-center justify-start">
<h2 class="text-[14px] font-[700] text-[#576989]">ویرایش حساب کاربری</h2>
</div>
</template>
<form class="w-full flex flex-col gap-5 border-t border-[#EBEBEB] pt-5" @submit.prevent="submit">
<div class="flex items-center gap-4">
<div class="relative">
<span class="flex items-center justify-center w-20 h-20 rounded-full bg-[#F6F7F9] border border-[#DEE3EB] overflow-hidden">
<img v-if="avatarUrl" :src="avatarUrl" alt="تصویر پروفایل" class="w-full h-full object-cover" />
<i v-else class="fi fi-rr-user flex text-2xl text-[#576989]"></i>
</span>
<button type="button" class="absolute -bottom-1 -left-1 flex items-center justify-center w-7 h-7 rounded-full bg-dominant text-white border-2 border-white cursor-pointer" @click="openAvatarPicker">
<i class="fi fi-rr-camera flex text-[11px]"></i>
</button>
<input ref="avatarInput" type="file" accept="image/png,image/jpeg" class="hidden" @change="handleAvatarChange" />
</div>
<div class="flex flex-col gap-1">
<span class="text-[13px] font-[700] text-[#3E4C63]">تصویر پروفایل</span>
<span class="text-[11px] font-[400] text-[#576989]">فرمت‌های مجاز: png ،jpg — حداکثر ۱۰ مگابایت</span>
<span v-if="errorFor('avatar')" class="text-[11px] text-[#7A1419]">{{ errorFor('avatar') }}</span>
</div>
</div>
<div class="grid grid-cols-2 max-sm:grid-cols-1 gap-4">
<UiInput v-model="form.fullname" label="نام و نام خانوادگی" placeholder="نام و نام خانوادگی خود را وارد کنید" :error="errorFor('fullname')" />
<div class="flex flex-col gap-1">
<label class="ui-input-label">تاریخ تولد</label>
<CustomDatePicker v-model="form.birthdate" type="date" format="YYYY-MM-DD" display-format="jYYYY/jMM/jDD" class="w-full" />
<span v-if="errorFor('birthdate')" class="text-[11px] text-[#7A1419]">{{ errorFor('birthdate') }}</span>
</div>
<UiInput v-model="form.national_code" label="کد ملی" placeholder="کد ملی خود را وارد کنید" convertPersianNumbers :error="errorFor('national_code')" />
<UiInput v-model="form.email" label="ایمیل" type="email" placeholder="ایمیل خود را وارد کنید" direction="ltr" :error="errorFor('email')" />
<UiInput v-model="form.province" label="استان" placeholder="استان محل سکونت" :error="errorFor('province')" />
<UiInput v-model="form.city" label="شهر" placeholder="شهر محل سکونت" :error="errorFor('city')" />
</div>
<UiInput v-model="form.address" label="آدرس" placeholder="آدرس خود را وارد کنید" :error="errorFor('address')" />
<div class="w-full pt-3 flex items-center justify-start gap-2 border-t border-[#EBEBEB]">
<UiButton label="ذخیره تغییرات" severity="success" size="small" :loading="submitting" :disabled="submitting" @click="submit" />
<UiButton label="انصراف" severity="secondary" size="small" :disabled="submitting" @click="visible = false" />
</div>
</form>
</Dialog>
</template>
@@ -1,6 +1,6 @@
<script setup> <script setup>
import UiButton from '../../components/ui/UiButton.vue'; import UiButton from '../../components/ui/UiButton.vue';
import { computed, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import Dialog from 'primevue/dialog'; import Dialog from 'primevue/dialog';
import ProgressBar from 'primevue/progressbar'; import ProgressBar from 'primevue/progressbar';
import { toast } from 'vue3-toastify'; import { toast } from 'vue3-toastify';
@@ -9,11 +9,15 @@ import RequestCase from './fastRequest/RequestCase.vue';
import RequestDetails from './fastRequest/RequestDetails.vue'; import RequestDetails from './fastRequest/RequestDetails.vue';
import RequestDoctor from './fastRequest/RequestDoctor.vue'; import RequestDoctor from './fastRequest/RequestDoctor.vue';
import RequestReview from './fastRequest/RequestReview.vue'; import RequestReview from './fastRequest/RequestReview.vue';
import InvoiceModal from './fastRequest/InvoiceModal.vue';
import { useFastRequestStore } from '../../store/FastRequest'; import { useFastRequestStore } from '../../store/FastRequest';
const visible = defineModel('visible'); const visible = defineModel('visible');
const fastRequestStore = useFastRequestStore(); const fastRequestStore = useFastRequestStore();
const invoiceVisible = ref(false);
const invoiceDocument = ref(null);
const steps = [ const steps = [
{ label: 'نوع درخواست', component: RequestType }, { label: 'نوع درخواست', component: RequestType },
{ label: 'انتخاب کیس', component: RequestCase }, { label: 'انتخاب کیس', component: RequestCase },
@@ -49,8 +53,9 @@ const handleNext = async () => {
try { try {
const document = await fastRequestStore.submit(); const document = await fastRequestStore.submit();
toast.success(`درخواست شما با شماره پرونده ${document.code} با موفقیت ثبت شد.`); invoiceDocument.value = document;
visible.value = false; visible.value = false;
invoiceVisible.value = true;
} catch (error) { } catch (error) {
toast.error(error.response?.data?.message || 'خطا در ثبت درخواست. لطفا دوباره تلاش کنید.'); toast.error(error.response?.data?.message || 'خطا در ثبت درخواست. لطفا دوباره تلاش کنید.');
} }
@@ -93,4 +98,5 @@ const handleNext = async () => {
/> />
</div> </div>
</Dialog> </Dialog>
<invoice-modal v-model:visible="invoiceVisible" :document="invoiceDocument"></invoice-modal>
</template> </template>
@@ -0,0 +1,46 @@
<script setup>
import { computed } from 'vue';
import Dialog from 'primevue/dialog';
import UiButton from '../ui/UiButton.vue';
import PaymentSuccessSvg from '../svg/paymentSuccess.svg';
import PaymentFailedSvg from '../svg/paymentFailed.svg';
const visible = defineModel('visible');
const props = defineProps({
result: {
type: Object,
default: null
}
});
const isPaid = computed(() => props.result?.status === 'paid');
const isWalletTopUp = computed(() => props.result?.purpose === 'WalletTopUp');
const closeModal = () => {
visible.value = false;
}
</script>
<template>
<Dialog v-model:visible="visible" :modal="true" :closable="true" :dismissableMask="false" :maximizable="false" :style="{ width: '688px' , minHeight: '376px' }" class="fast-request-dialog">
<div v-if="result" class="w-full flex flex-col items-center justify-center gap-2 py-6">
<component :is="isPaid ? PaymentSuccessSvg : PaymentFailedSvg" class="w-[180px] h-[150px]" />
<h2 class="text-[15px] font-[700] text-[#3E4C63] mt-2">
<template v-if="isPaid">{{ isWalletTopUp ? 'کیف پول شما با موفقیت شارژ شد.' : 'پرداخت شما با موفقیت انجام شد.' }}</template>
<template v-else>متاسفانه پرداخت شما با خطا روبه‌رو شده است.</template>
</h2>
<p class="max-w-[480px] text-center text-[13px] font-[400] text-[#576989] leading-7">
<template v-if="isPaid && isWalletTopUp">مبلغ پرداختی به موجودی کیف پول شما اضافه شد و می‌توانید از آن برای پرداخت فاکتورها استفاده کنید.</template>
<template v-else-if="isPaid">کارشناسان پرداخت و درخواست شما را به زودی بررسی خواهند کرد.</template>
<template v-else>لطفا دوباره نسبت به پرداخت اقدام نمایید. در صورتیکه مبلغ از حساب شما کسر شده و یا فکر می‌کنید اشتباهی رخ داده است، با کارشناسان ما در تماس باشید.</template>
</p>
<p class="text-[13px] font-[700] text-[#3E4C63] mt-2">شماره پیگیری: {{ result.track_id }}</p>
<div class="flex items-center justify-center gap-2 mt-4">
<ui-button label="بازگشت به داشبورد" severity="success" size="small" @click="closeModal" />
<ui-button v-if="!isPaid" label="تماس با پشتیبانی" severity="secondary" size="small" @click="closeModal" />
</div>
</div>
</Dialog>
</template>
@@ -0,0 +1,27 @@
<script setup>
defineProps({
title: {
type: String,
required: true
},
rows: {
type: Array,
required: true
}
})
</script>
<template>
<section class="bg-white border border-complement-50 rounded-2xl p-4">
<h2 class="flex items-center gap-2 text-[12px] font-bold text-[#3E4C63]">
<span class="w-1.5 h-1.5 rounded-full bg-[#7A1419]"></span>
{{ title }}
</h2>
<ul class="mt-3 flex flex-col gap-2">
<li v-for="row in rows" :key="row.label" class="flex items-start gap-2 text-[11px]">
<span class="font-normal text-[#576989]">{{ row.label }}:</span>
<span class="font-semibold text-[#3E4C63]">{{ row.value ?? '-' }}</span>
</li>
</ul>
</section>
</template>
@@ -0,0 +1,42 @@
<script setup>
const value = defineModel({ type: String, default: '' })
defineProps({
title: {
type: String,
required: true
},
editable: {
type: Boolean,
default: false
},
placeholder: {
type: String,
default: ''
},
error: {
type: String,
default: null
}
})
</script>
<template>
<section class="bg-white border border-complement-50 rounded-2xl p-4 flex flex-col gap-3">
<h2 class="flex items-center gap-2 text-[12px] font-bold text-[#3E4C63]">
<span class="w-1.5 h-1.5 rounded-full bg-[#7A1419]"></span>
{{ title }}
</h2>
<textarea
v-if="editable"
v-model="value"
rows="6"
:placeholder="placeholder"
class="w-full bg-[#F8FAFC] border border-[#DEE3EB] rounded-xl p-4 text-[12px] font-normal text-[#3E4C63] leading-6 outline-none focus:border-[#C1D0E8] resize-y"
></textarea>
<div v-else class="w-full min-h-[7rem] bg-[#F8FAFC] border border-[#DEE3EB] rounded-xl p-4 text-[12px] font-normal text-[#3E4C63] leading-6 whitespace-pre-line">
{{ value || 'موردی ثبت نشده است.' }}
</div>
<p v-if="error" class="text-[11px] text-[#7A1419]">{{ error }}</p>
</section>
</template>
@@ -1,8 +1,12 @@
<script setup> <script setup>
import { computed } from 'vue'; import { computed, ref } from 'vue';
import Dialog from 'primevue/dialog'; import Dialog from 'primevue/dialog';
import { usePage } from '@inertiajs/inertia-vue3';
import { Inertia } from '@inertiajs/inertia';
import UiButton from '../../ui/UiButton.vue'; import UiButton from '../../ui/UiButton.vue';
import { toast } from 'vue3-toastify';
import { modalityTitle } from '../../../data/fastRequestOptions'; import { modalityTitle } from '../../../data/fastRequestOptions';
import { formatPrice } from '../../../utils/numberUtils';
const visible = defineModel('visible'); const visible = defineModel('visible');
@@ -10,31 +14,69 @@ const props = defineProps({
document: { document: {
type: Object, type: Object,
default: null default: null
},
invoice: {
type: Object,
default: null
} }
}); });
const formattedAmount = computed(() => { const { props: pageProps } = usePage();
return Number(props.document?.invoice?.amount || 0).toLocaleString('fa-IR');
});
const handlePay = () => { const paying = ref(false);
visible.value = false; const payingWithWallet = ref(false);
const currentInvoice = computed(() => props.invoice ?? props.document?.invoice ?? null);
const formattedAmount = computed(() => formatPrice(currentInvoice.value?.amount || 0));
const walletBalance = computed(() => Number(pageProps.value.auth?.wallet_balance || 0));
const formattedWalletBalance = computed(() => formatPrice(walletBalance.value));
const hasEnoughBalance = computed(() => walletBalance.value >= Number(currentInvoice.value?.amount || 0));
const handlePay = async () => {
paying.value = true;
try {
const { data } = await window.axios.post('/dashboard/payments', { invoice_id: currentInvoice.value.id });
window.location.href = data.url;
} catch (error) {
paying.value = false;
toast.error(error.response?.data?.message || 'خطا در اتصال به درگاه پرداخت. لطفا دوباره تلاش کنید.');
}
}
const handleWalletPay = async () => {
payingWithWallet.value = true;
try {
await window.axios.post(`/dashboard/invoices/${currentInvoice.value.id}/pay-wallet`);
toast.success('فاکتور با موفقیت از کیف پول پرداخت شد.');
visible.value = false;
Inertia.reload();
} catch (error) {
toast.error(error.response?.data?.message || 'خطا در پرداخت از کیف پول. لطفا دوباره تلاش کنید.');
} finally {
payingWithWallet.value = false;
}
} }
</script> </script>
<template> <template>
<Dialog v-model:visible="visible" :modal="true" :closable="true" :dismissableMask="false" :maximizable="false" :style="{ width: '1032px' }"> <Dialog v-model:visible="visible" :modal="true" :closable="true" :dismissableMask="false" :maximizable="false" :style="{ width: '688px' , minHeight: '376px' }" class="fast-request-dialog" :pt="{header : '!bg-client-primaryBlue'}">
<template #header> <template #header>
<h2 class="text-[14px] font-[700] text-[#576989]">فاکتور پرداخت «{{ document?.invoice?.number }}»</h2> <div class="w-full flex items-center justify-start">
<h2 class="text-[14px] font-[700] text-[#576989]">فاکتور پرداخت «{{ currentInvoice?.number }}»</h2>
</div>
</template> </template>
<div v-if="document" class="w-full flex flex-col gap-4 border-t border-[#EBEBEB] pt-4"> <div v-if="currentInvoice" class="w-full flex flex-col gap-4 border-t border-[#EBEBEB] pt-4">
<div class="w-full flex items-center gap-3 bg-[#EEF2F8] border border-[#DEE3EB] rounded-[12px] px-4 py-4"> <div class="w-full flex items-center gap-3 bg-[#EEF2F8] border border-[#DEE3EB] rounded-[12px] px-4 py-3">
<i class="fi-rr-info text-[20px] text-[#576989]"></i> <i class="fi-rr-info text-[18px] text-[#576989]"></i>
<p class="text-[13px] font-[400] text-[#3E4C63]"> <p class="text-[12px] font-[400] text-[#3E4C63] leading-6">
یک فاکتور به مبلغ « <span class="font-[700]">{{ formattedAmount }} تومان</span> » برای شما ایجاد شد. لطفا جهت ادامه مراحل درخواست نسبت به پرداخت اقدام نمایید. یک فاکتور به مبلغ « <span class="font-[700]">{{ formattedAmount }} تومان</span> » برای شما ایجاد شد. لطفا جهت ادامه مراحل درخواست نسبت به پرداخت اقدام نمایید.
</p> </p>
</div> </div>
<p class="text-[13px] font-[400] text-[#3E4C63] leading-7"> <p v-if="document" class="text-[13px] font-[400] text-[#3E4C63] leading-7">
این فاکتور جهت پردازش درخواست این فاکتور جهت پردازش درخواست
<span class="font-[600] text-[#7A1419]">(({{ modalityTitle(document.modality) }}))</span> <span class="font-[600] text-[#7A1419]">(({{ modalityTitle(document.modality) }}))</span>
به شناسه به شناسه
@@ -48,9 +90,14 @@ const handlePay = () => {
<br> <br>
بدیهیست که در صورت عدم تایید کارشناسان مبلغ مربوطه به کیف پول شما بازگردانده خواهد شد. بدیهیست که در صورت عدم تایید کارشناسان مبلغ مربوطه به کیف پول شما بازگردانده خواهد شد.
</p> </p>
<p class="text-[12px] font-[400] text-[#576989]">
موجودی کیف پول شما: <span class="font-[700] text-[#3E4C63]">{{ formattedWalletBalance }} تومان</span>
<span v-if="!hasEnoughBalance" class="text-[#7A1419]"> (موجودی کیف پول کافی نیست)</span>
</p>
</div> </div>
<div class="w-full pt-3 flex items-center justify-start border-t border-[#EBEBEB]"> <div class="w-full pt-3 flex items-center justify-start gap-2 border-t border-[#EBEBEB]">
<ui-button label="تایید و پرداخت" severity="success" size="small" @click="handlePay" /> <ui-button label="پرداخت با کیف پول" severity="primary" size="small" :loading="payingWithWallet" :disabled="payingWithWallet || paying || !hasEnoughBalance" @click="handleWalletPay" />
<ui-button label="پرداخت از طریق درگاه" severity="success" size="small" :loading="paying" :disabled="paying || payingWithWallet" @click="handlePay" />
</div> </div>
</Dialog> </Dialog>
</template> </template>
@@ -0,0 +1,193 @@
<script setup>
import { computed, ref } from 'vue';
import { useForm, usePage } from '@inertiajs/inertia-vue3';
import { Inertia } from '@inertiajs/inertia';
import Dialog from 'primevue/dialog';
import { toast } from 'vue3-toastify';
import UiCard from '../../ui/UiCard.vue';
import UiInput from '../../ui/UiInput.vue';
import UiButton from '../../ui/UiButton.vue';
import { convertEnglishToPersian, convertPersianToEnglish, formatPrice } from '../../../utils/numberUtils';
const props = defineProps({
bankCards: {
type: Array,
required: true
}
});
const { props: pageProps } = usePage();
const walletBalance = computed(() => Number(pageProps.value.auth?.wallet_balance || 0));
const amount = ref('');
const selectedCardId = ref(props.bankCards[0]?.id ?? null);
const numericAmount = computed(() => Number(convertPersianToEnglish(amount.value).replace(/\D/g, '')) || 0);
const formatCardNumber = (cardNumber) => convertEnglishToPersian(cardNumber.replace(/(\d{4})(?=\d)/g, '$1 - '));
const selectFullBalance = () => {
amount.value = String(walletBalance.value);
};
const withdrawForm = useForm({
amount: null,
bank_card_id: null,
});
const submitWithdraw = () => {
if (numericAmount.value < 10000) {
toast.error('حداقل مبلغ برداشت ۱۰٬۰۰۰ تومان است.');
return;
}
if (!selectedCardId.value) {
toast.error('لطفا کارت بانکی مقصد را انتخاب کنید.');
return;
}
withdrawForm.amount = numericAmount.value;
withdrawForm.bank_card_id = selectedCardId.value;
withdrawForm.post('/dashboard/wallet/withdraw-requests', {
preserveScroll: true,
onSuccess: () => {
amount.value = '';
toast.success('درخواست برداشت شما ثبت شد و در انتظار بررسی است.');
},
onError: (errors) => toast.error(Object.values(errors)[0] || 'خطا در ثبت درخواست برداشت.'),
});
};
const cardModalVisible = ref(false);
const cardForm = useForm({
card_number: '',
holder_name: '',
});
const submitCard = () => {
cardForm.card_number = convertPersianToEnglish(cardForm.card_number).replace(/\D/g, '');
cardForm.post('/dashboard/wallet/bank-cards', {
preserveScroll: true,
onSuccess: () => {
cardForm.reset();
cardModalVisible.value = false;
toast.success('کارت بانکی با موفقیت ثبت شد.');
},
onError: (errors) => toast.error(Object.values(errors)[0] || 'خطا در ثبت کارت بانکی.'),
});
};
const deleteCard = (card) => {
Inertia.delete(`/dashboard/wallet/bank-cards/${card.id}`, {
preserveScroll: true,
onSuccess: () => {
if (selectedCardId.value === card.id) {
selectedCardId.value = null;
}
},
});
};
</script>
<template>
<UiCard title="درخواست برداشت" variant="default">
<div class="grid grid-cols-2 max-lg:grid-cols-1 gap-6 items-start">
<div class="flex flex-col gap-4">
<p class="text-[11px] font-normal text-[#576989] leading-6">
مبلغ مورد نظر پس از بررسی و تایید توسط کارشناسان، از کیف پول شما کسر و به کارت انتخابی واریز می‌شود.
</p>
<div class="flex items-end gap-3 max-sm:flex-col max-sm:items-stretch">
<UiInput
v-model="amount"
label="مبلغ برداشت"
placeholder="مبلغ مورد نظر را به تومان وارد کنید"
prefixEnd="تومان"
prefixClasses="!border-dominant-50"
class="!w-full"
customClass="!border-dominant-50 !font-normal !text-[12px]"
/>
</div>
<div class="flex items-center gap-2 flex-wrap">
<button
type="button"
class="text-[11px] font-normal text-[#576989] bg-[#F5F7FA] border border-[#DEE3EB] rounded-lg px-3 py-1.5 hover:bg-[#EEF2F8]"
@click="selectFullBalance"
>
کل موجودی ({{ formatPrice(walletBalance) }} تومان)
</button>
</div>
<UiButton
label="ثبت درخواست برداشت"
severity="success"
size="small"
class="self-start"
:loading="withdrawForm.processing"
:disabled="withdrawForm.processing"
@click="submitWithdraw"
/>
</div>
<div class="flex flex-col gap-3">
<div class="flex items-center justify-between">
<span class="text-[11px] font-semibold text-[#3E4C63]">کارت بانکی مقصد</span>
<button type="button" class="text-[11px] font-semibold text-[#365891]" @click="cardModalVisible = true">
+ افزودن کارت
</button>
</div>
<p v-if="!bankCards.length" class="bg-[#F8FAFC] border border-dashed border-[#C1D0E8] rounded-xl p-4 text-[11px] font-normal text-[#576989] text-center leading-6">
هنوز کارتی ثبت نکرده‌اید. برای ثبت درخواست برداشت ابتدا یک کارت بانکی اضافه کنید.
</p>
<label
v-for="card in bankCards"
:key="card.id"
class="flex items-center gap-3 border rounded-xl px-4 py-3 cursor-pointer transition-colors"
:class="selectedCardId === card.id ? 'border-[#365891] bg-[#EEF2F8]' : 'border-[#DEE3EB] bg-white hover:bg-[#F8FAFC]'"
>
<input
v-model="selectedCardId"
type="radio"
name="bank_card"
:value="card.id"
class="accent-[#365891]"
/>
<span class="flex flex-col gap-1 flex-1">
<span class="text-[12px] font-semibold text-[#3E4C63]" dir="ltr">{{ formatCardNumber(card.card_number) }}</span>
<span class="text-[11px] font-normal text-[#576989]">{{ card.holder_name }}</span>
</span>
<button type="button" class="text-[11px] font-semibold text-[#7A1419]" @click.prevent="deleteCard(card)">حذف</button>
</label>
</div>
</div>
<Dialog v-model:visible="cardModalVisible" :modal="true" :closable="true" :dismissableMask="false" :style="{ width: '420px' }" class="fast-request-dialog" :pt="{ header: '!bg-client-primaryBlue' }">
<template #header>
<div class="w-full flex items-center justify-start">
<h2 class="text-[14px] font-[700] text-[#576989]">افزودن کارت بانکی</h2>
</div>
</template>
<div class="w-full flex flex-col gap-4 border-t border-[#EBEBEB] pt-4">
<UiInput
v-model="cardForm.card_number"
label="شماره کارت"
placeholder="شماره کارت ۱۶ رقمی"
maxlength="16"
customClass="!border-dominant-50 !font-normal !text-[12px]"
/>
<UiInput
v-model="cardForm.holder_name"
label="نام صاحب حساب"
placeholder="نام و نام خانوادگی صاحب کارت"
customClass="!border-dominant-50 !font-normal !text-[12px]"
/>
</div>
<div class="w-full pt-4 flex items-center justify-start gap-2 border-t border-[#EBEBEB] mt-4">
<UiButton label="ثبت کارت" severity="success" size="small" :loading="cardForm.processing" :disabled="cardForm.processing" @click="submitCard" />
<UiButton label="انصراف" severity="secondary" size="small" :disabled="cardForm.processing" @click="cardModalVisible = false" />
</div>
</Dialog>
</UiCard>
</template>
@@ -1,14 +1,47 @@
<script setup> <script setup>
import { ref } from 'vue'; import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { Link, usePage } from '@inertiajs/inertia-vue3';
import { useGlobalStore } from '../../store/Global'; import { useGlobalStore } from '../../store/Global';
import UiInput from '../ui/UiInput.vue'; import UiInput from '../ui/UiInput.vue';
import ArrowDownSvg from '../svg/arrowDown.svg';
import GlobalSvg from '../svg/global.svg'; import GlobalSvg from '../svg/global.svg';
import EditProfileModal from '../dashboard/EditProfileModal.vue';
import { formatPrice } from '../../utils/numberUtils';
const globalStore = useGlobalStore(); const globalStore = useGlobalStore();
const { props } = usePage();
const walletBalance = computed(() => formatPrice(props.value.auth?.wallet_balance || 0));
const toggleSidebar = () => { const toggleSidebar = () => {
globalStore.sidebarOpen = !globalStore.sidebarOpen; globalStore.sidebarOpen = !globalStore.sidebarOpen;
}; };
const userMenuOpen = ref(false);
const userMenuRef = ref(null);
const editProfileVisible = ref(false);
const toggleUserMenu = () => {
userMenuOpen.value = !userMenuOpen.value;
};
const openEditProfile = () => {
userMenuOpen.value = false;
editProfileVisible.value = true;
};
const closeOnClickOutside = (event) => {
if (userMenuRef.value && !userMenuRef.value.contains(event.target)) {
userMenuOpen.value = false;
}
};
onMounted(() => document.addEventListener('click', closeOnClickOutside));
onBeforeUnmount(() => document.removeEventListener('click', closeOnClickOutside));
const menuItems = [
{ label: 'کیف پول', icon: 'fi-rr-wallet', href: '/dashboard/wallet' },
{ label: 'فاکتورها', icon: 'fi-rr-receipt', href: '/dashboard/invoices' },
{ label: 'برنامه شیفت‌ها', icon: 'fi-rr-calendar-clock', href: '/dashboard/shifts' },
];
</script> </script>
<template> <template>
@@ -39,17 +72,63 @@ const toggleSidebar = () => {
/> />
</div> </div>
</div> </div>
<div class="flex gap-4"> <div class="flex items-center gap-4">
<Link href="/dashboard/wallet" class="flex items-center gap-2.5 rounded-lg bg-dominant-light border border-dominant-50 ps-1.5 pe-3 py-1.5 transition-colors hover:border-dominant-100">
<span class="flex items-center justify-center w-8 h-8 rounded-lg bg-white border border-dominant-50 text-dominant-400">
<i class="fi fi-rr-wallet flex text-[15px]"></i>
</span>
<span class="flex flex-col max-sm:hidden">
<span class="text-[10px] font-normal text-dominant-200">موجودی کیف پول</span>
<span class="text-[12px] font-bold text-dominant-500">{{ walletBalance }} <span class="text-[10px] font-normal text-dominant-300">تومان</span></span>
</span>
</Link>
<div class="flex items-center justify-center border-l-[1px] !border-dominant-50 pl-4"> <div class="flex items-center justify-center border-l-[1px] !border-dominant-50 pl-4">
<GlobalSvg /> <GlobalSvg />
</div> </div>
<div class="grid grid-cols-2a justify-start items-center gap-2"> <div ref="userMenuRef" class="relative">
<span class="text-dominant-300 text-[12px] font-normal"> <button type="button" class="flex items-center gap-2 cursor-pointer" @click="toggleUserMenu">
{{ globalStore.current_user.fullname }} <span class="flex items-center justify-center w-9 h-9 rounded-full bg-dominant-light border border-dominant-50 overflow-hidden">
</span> <img v-if="globalStore.current_user.profile_url" :src="globalStore.current_user.profile_url" alt="تصویر پروفایل" class="w-full h-full object-cover" />
<ArrowDownSvg class="!w-4 !h-4 flex -mt-0.5"/> <i v-else class="fi fi-rr-user flex text-sm text-dominant-300"></i>
</span>
<span class="text-dominant-300 text-[12px] font-normal max-sm:hidden">
{{ globalStore.current_user.fullname }}
</span>
<i class="fi fi-rr-angle-small-down flex text-sm text-dominant-300 transition-transform duration-200" :class="{ 'rotate-180': userMenuOpen }"></i>
</button>
<Transition
enter-active-class="transition duration-150 ease-out"
enter-from-class="opacity-0 -translate-y-1"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition duration-100 ease-in"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 -translate-y-1"
>
<nav v-if="userMenuOpen" class="absolute left-0 top-full mt-2 w-52 rounded-xl bg-white border border-complement-100 shadow-all py-2 z-30">
<ul class="flex flex-col">
<li>
<button type="button" class="w-full flex items-center gap-2.5 px-4 py-2.5 text-[12px] font-normal text-complement-900 cursor-pointer transition-colors hover:bg-complement-50" @click="openEditProfile">
<i class="fi fi-rr-user-pen flex text-[14px] text-complement-700"></i>
ویرایش حساب کاربری
</button>
</li>
<li v-for="item in menuItems" :key="item.href">
<Link :href="item.href" class="flex items-center gap-2.5 px-4 py-2.5 text-[12px] font-normal text-complement-900 transition-colors hover:bg-complement-50" @click="userMenuOpen = false">
<i class="fi flex text-[14px] text-complement-700" :class="item.icon"></i>
{{ item.label }}
</Link>
</li>
<li class="border-t border-complement-100 mt-1 pt-1">
<Link href="/dashboard/logout" class="flex items-center gap-2.5 px-4 py-2.5 text-[12px] font-normal text-dominant-400 transition-colors hover:bg-dominant-light">
<i class="fi fi-rr-sign-out-alt flex text-[14px]"></i>
خروج از حساب
</Link>
</li>
</ul>
</nav>
</Transition>
</div> </div>
</div> </div>
<EditProfileModal v-model:visible="editProfileVisible" />
</header> </header>
</template> </template>

Some files were not shown because too many files have changed in this diff Show More