feat: implement media download functionality and enhance case handling in reports

This commit is contained in:
2026-09-21 02:20:10 +03:30
parent 4ce663c669
commit 51b13631a3
16 changed files with 334 additions and 164 deletions
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Client\Doctor;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Client\ReportSubmitRequest; use App\Http\Requests\Client\ReportSubmitRequest;
use App\Http\Resources\Client\CaseResource;
use App\Http\Resources\Client\ReportResource; use App\Http\Resources\Client\ReportResource;
use App\Models\Document; use App\Models\Document;
use App\Models\Report; use App\Models\Report;
@@ -32,8 +33,13 @@ class ReportController extends Controller
->withQueryString() ->withQueryString()
->onEachSide(1); ->onEachSide(1);
$pendingCases = $this->caseService->isDoctor(Auth::user())
? CaseResource::collection($this->caseService->pendingForUser(Auth::user())->get())
: null;
return Inertia::client('Dashboard/Reports/Index', [ return Inertia::client('Dashboard/Reports/Index', [
'reports' => ReportResource::collection($reports), 'reports' => ReportResource::collection($reports),
'pendingCases' => $pendingCases,
]); ]);
} }
@@ -0,0 +1,28 @@
<?php
namespace App\Http\Controllers\Client;
use App\Http\Controllers\Controller;
use App\Models\Medium;
use App\Services\Document\CaseService;
use App\Services\FileStorageService;
use Illuminate\Support\Facades\Auth;
class MediaController extends Controller
{
protected CaseService $caseService;
protected FileStorageService $fileStorageService;
public function __construct(CaseService $caseService, FileStorageService $fileStorageService)
{
$this->caseService = $caseService;
$this->fileStorageService = $fileStorageService;
}
public function download(Medium $medium)
{
abort_unless($this->caseService->canAccessMedium($medium, Auth::user()), 403);
return $this->fileStorageService->downloadFile($medium);
}
}
@@ -69,7 +69,7 @@ class CaseDetailResource extends JsonResource
return [ return [
'id' => $medium->id, 'id' => $medium->id,
'filename' => $medium->filename, 'filename' => $medium->filename,
'url' => $medium->url, 'url' => $medium->isPrivate() ? route('dashboard.media.download', $medium) : $medium->url,
'mime_type' => $medium->mime_type, 'mime_type' => $medium->mime_type,
]; ];
} }
+10 -1
View File
@@ -9,6 +9,7 @@ use App\Enums\ReportStatusEnum;
use App\Enums\UserEnum; use App\Enums\UserEnum;
use App\Enums\WalletTransactionReasonEnum; use App\Enums\WalletTransactionReasonEnum;
use App\Models\Document; use App\Models\Document;
use App\Models\Medium;
use App\Models\Report; use App\Models\Report;
use App\Models\User; use App\Models\User;
use App\Services\FileStorageService; use App\Services\FileStorageService;
@@ -51,6 +52,14 @@ class CaseService
|| $document->assignments()->where('user_id', $user->id)->exists(); || $document->assignments()->where('user_id', $user->id)->exists();
} }
public function canAccessMedium(Medium $medium, User $user): bool
{
$documents = $medium->documents()->get()
->merge($medium->reports()->with('document')->get()->pluck('document'));
return $documents->contains(fn ($document) => $this->canAccess($document, $user));
}
public function canReport(Document $document, User $user): bool public function canReport(Document $document, User $user): bool
{ {
return $this->isDoctor($user) return $this->isDoctor($user)
@@ -162,7 +171,7 @@ class CaseService
->orWhereHas('assignments', fn ($assignmentQuery) => $assignmentQuery->where('user_id', $user->id)); ->orWhereHas('assignments', fn ($assignmentQuery) => $assignmentQuery->where('user_id', $user->id));
} }
protected function isDoctor(User $user): bool public function isDoctor(User $user): bool
{ {
return $user->level === UserEnum::Doctor->value; return $user->level === UserEnum::Doctor->value;
} }
+1 -1
View File
@@ -44,7 +44,7 @@ return [
'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'),
'api_key' => env('IRANPAYAMAK_SMS_API_URL', 'jnCyVq4ein7uGsgIGjHsVRrWz0112IA2v2qbZMHq0kHtEXubsh'), 'api_key' => env('IRANPAYAMAK_SMS_API_URL', 'jnCyVq4ein7uGsgIGjHsVRrWz0112IA2v2qbZM5q0kHtEXubsh'),
'patterns' => [ 'patterns' => [
+2 -10
View File
@@ -41,19 +41,11 @@
.ui-button--success.p-button { .ui-button--success.p-button {
@apply !bg-success !border-success font-normal !text-white; @apply !bg-complement-700 !border-complement-700 !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 { .ui-button--success.p-button:focus {
box-shadow: 0 0 0 2px rgb(80 205 137 / 0.25); box-shadow: 0 0 0 2px rgb(87 105 137 / 0.25);
} }
.ui-button-icon { .ui-button-icon {
-29
View File
@@ -41,12 +41,6 @@
padding: 8px 16px !important; padding: 8px 16px !important;
} }
.fast-request-dialog .ui-button .p-button.p-button-success {
background: #576989 !important;
border-color: #576989 !important;
color: #FFFFFF !important;
}
.fast-request-dialog .p-inputtext::placeholder { .fast-request-dialog .p-inputtext::placeholder {
color: #6E91C9; color: #6E91C9;
} }
@@ -87,26 +81,3 @@
background: #576989 !important; background: #576989 !important;
} }
.fast-request-dialog .p-fileupload-choose-button,
.fast-request-dialog .p-fileupload-header {
display: none !important;
}
.fast-request-dialog .p-fileupload {
width: 100% !important;
height: 100% !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
}
.fast-request-dialog .p-fileupload-content {
pointer-events: auto !important;
width: 100%;
}
.fast-request-dialog .p-fileupload-advanced {
border: 2px solid #C1D0E8 !important;
border-style: dashed !important;
border-radius: 6px !important;
}
+41
View File
@@ -0,0 +1,41 @@
.ui-file-upload .p-fileupload {
@apply w-full;
border: 1.5px dashed #C1D0E8;
border-radius: 16px;
background: #F8FAFC;
transition: border-color 0.2s, background-color 0.2s;
}
.ui-file-upload .p-fileupload-header {
display: none;
}
.ui-file-upload--filled .p-fileupload {
border-style: solid;
border-color: #DEE3EB;
background: #FFFFFF;
}
.ui-file-upload:hover .p-fileupload,
.ui-file-upload--dragging .p-fileupload {
border-color: #365891;
background: #EEF2F8;
}
.ui-file-upload .p-fileupload-content {
@apply w-full p-3;
pointer-events: auto;
}
.ui-file-upload-dropzone {
@apply w-full min-h-[8.5rem] flex flex-col items-center justify-center gap-1 text-center cursor-pointer;
}
.ui-file-upload-icon {
@apply w-11 h-11 mb-1 flex items-center justify-center rounded-full bg-[#EEF2F8] text-[#365891] transition-transform;
}
.ui-file-upload:hover .ui-file-upload-icon,
.ui-file-upload--dragging .ui-file-upload-icon {
@apply scale-110 bg-white;
}
+1
View File
@@ -8,6 +8,7 @@
@import "./Client/ui/card.css"; @import "./Client/ui/card.css";
@import "./Client/ui/select.css"; @import "./Client/ui/select.css";
@import "./Client/ui/fast-request.css"; @import "./Client/ui/fast-request.css";
@import "./Client/ui/file-upload.css";
@import "./Client/ui/table.css"; @import "./Client/ui/table.css";
@import "./Shared/shift-calendar.css"; @import "./Shared/shift-calendar.css";
@import "tailwindcss"; @import "tailwindcss";
@@ -11,6 +11,7 @@ import ReportSectionCard from '../../../components/dashboard/cases/ReportSection
import FileUploadBox from '../../../components/dashboard/fastRequest/FileUploadBox.vue'; import FileUploadBox from '../../../components/dashboard/fastRequest/FileUploadBox.vue';
import { modalityTitle } from '../../../data/fastRequestOptions'; import { modalityTitle } from '../../../data/fastRequestOptions';
import { convertEnglishToPersian } from '../../../utils/numberUtils'; import { convertEnglishToPersian } from '../../../utils/numberUtils';
import { downloadFiles } from '../../../utils/fileUtils';
const props = defineProps({ const props = defineProps({
case: { case: {
@@ -73,10 +74,6 @@ onMounted(() => {
onUnmounted(() => clearInterval(timerId)); onUnmounted(() => clearInterval(timerId));
const openFiles = (files) => {
files.forEach((file) => window.open(file.url, '_blank'));
};
const reportForm = useForm({ const reportForm = useForm({
document_id: props.case.id, document_id: props.case.id,
technique: props.case.report?.technique || '', technique: props.case.report?.technique || '',
@@ -149,9 +146,9 @@ const submitReject = () => {
<p class="font-normal text-[#3E4C63] whitespace-pre-line">{{ props.case.note || 'توضیحی ثبت نشده است.' }}</p> <p class="font-normal text-[#3E4C63] whitespace-pre-line">{{ props.case.note || 'توضیحی ثبت نشده است.' }}</p>
</div> </div>
<div class="flex items-center gap-3 flex-wrap pt-2 border-t border-[#EBEBEB]"> <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.primary_files.length" @click="downloadFiles(props.case.primary_files)" />
<UiButton label="دانلود سوابق پزشکی قبلی" severity="secondary" size="small" :disabled="!props.case.history_files.length" @click="openFiles(props.case.history_files)" /> <UiButton label="دانلود سوابق پزشکی قبلی" severity="secondary" size="small" :disabled="!props.case.history_files.length" @click="downloadFiles(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)" /> <UiButton v-if="!canReport && props.case.report" label="دانلود فایل گزارش" severity="secondary" size="small" :disabled="!props.case.report.attachments.length" @click="downloadFiles(props.case.report.attachments)" />
</div> </div>
</section> </section>
@@ -41,6 +41,7 @@ const openInvoice = (invoice) => {
<UiButton <UiButton
v-if="data.status === 'در انتظار پرداخت'" v-if="data.status === 'در انتظار پرداخت'"
label="پرداخت" label="پرداخت"
icon="fi fi-rr-credit-card"
severity="success" severity="success"
size="small" size="small"
@click="openInvoice(data)" @click="openInvoice(data)"
@@ -2,41 +2,69 @@
import ClientLayout from '../../../layouts/ClientLayout.vue'; import ClientLayout from '../../../layouts/ClientLayout.vue';
import TableGeneric from '../../../components/ui/TableGeneric.vue'; import TableGeneric from '../../../components/ui/TableGeneric.vue';
import UiCard from '../../../components/ui/UiCard.vue'; import UiCard from '../../../components/ui/UiCard.vue';
import UiButton from '../../../components/ui/UiButton.vue';
import EyeSvg from '../../../components/svg/eye.svg'; import EyeSvg from '../../../components/svg/eye.svg';
import { Link } from '@inertiajs/inertia-vue3'; import { Link } from '@inertiajs/inertia-vue3';
import { Inertia } from '@inertiajs/inertia';
import { reportColumns } from '../../../data/reportsTableColumns'; import { reportColumns } from '../../../data/reportsTableColumns';
import { caseColumns } from '../../../data/casesTableColumns';
defineProps({ defineProps({
reports: { reports: {
type: Object, type: Object,
required: true required: true
},
pendingCases: {
type: Object,
default: null
} }
}) })
</script> </script>
<template> <template>
<ClientLayout> <ClientLayout>
<UiCard title="گزارشات" variant="default"> <div class="flex flex-col gap-4">
<TableGeneric <UiCard v-if="pendingCases" title="پرونده‌های در انتظار گزارش" variant="default">
class="bg-white !rounded-2xl" <TableGeneric
:data="reports.data" class="bg-white !rounded-2xl"
customStyle="!text-[11px] !font-normal" :data="pendingCases.data"
:searchable="false" customStyle="!text-[11px] !font-normal"
:columns="reportColumns" :searchable="false"
:selectable="false" :columns="caseColumns"
:pagination="reports.meta" :selectable="false"
:model-value="[]" :pagination="null"
> :model-value="[]"
<template #column-actions="{ data }"> >
<div class="flex items-center justify-end"> <template #column-actions="{ data }">
<Link :href="`/dashboard/cases/${data.document?.id}`" <div class="flex items-center justify-end">
class="text-primary-500 bg-complement-100 flex items-center justify-end p-1.5 rounded-md" <UiButton label="ثبت گزارش" severity="success" size="small" @click="Inertia.visit(`/dashboard/cases/${data.id}`)" />
> </div>
<EyeSvg class="w-4 h-4 text-primary-500" /> </template>
</Link> </TableGeneric>
</div> </UiCard>
</template>
</TableGeneric> <UiCard title="گزارشات" variant="default">
</UiCard> <TableGeneric
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>
</div>
</ClientLayout> </ClientLayout>
</template> </template>
@@ -1,8 +1,8 @@
<script setup> <script setup>
import { ref } from 'vue'; import { computed, ref } from 'vue';
import FileUpload from 'primevue/fileupload'; import FileUpload from 'primevue/fileupload';
import Badge from 'primevue/badge';
import { formatFileSize } from '../../../utils/fileUtils'; import { formatFileSize } from '../../../utils/fileUtils';
import { convertEnglishToPersian } from '../../../utils/numberUtils';
const files = defineModel({ type: Array, default: () => [] }); const files = defineModel({ type: Array, default: () => [] });
@@ -13,56 +13,106 @@ defineProps({
} }
}); });
const MAX_FILE_SIZE = 10485760;
const fileUpload = ref(null); const fileUpload = ref(null);
const dragging = ref(false);
const totalSize = computed(() => files.value.reduce((sum, file) => sum + file.size, 0));
const openFileDialog = () => { const openFileDialog = () => {
fileUpload.value.choose(); fileUpload.value.choose();
} };
const onSelectedFiles = (event) => { const onSelectedFiles = (event) => {
files.value = event.files; files.value = event.files;
} };
const onRemoveFile = (removeFileCallback, index) => { const onRemoveFile = (removeFileCallback, index) => {
removeFileCallback(index); removeFileCallback(index);
files.value = fileUpload.value.files; files.value = fileUpload.value.files;
} };
const onClearFiles = (clearCallback) => {
clearCallback();
files.value = [];
};
</script> </script>
<template> <template>
<FileUpload <div
ref="fileUpload" class="ui-file-upload"
class="w-full !border-dashed !border-[#C1D0E8] !min-h-[100px] flex items-center justify-center" :class="{ 'ui-file-upload--dragging': dragging, 'ui-file-upload--filled': files.length }"
name="files[]" @dragover="dragging = true"
:customUpload="true" @dragleave="dragging = false"
:showUploadButton="false" @drop="dragging = false"
:showCancelButton="false"
:showChooseButton="false"
:multiple="true"
accept="image/*"
:maxFileSize="10485760"
@select="onSelectedFiles"
> >
<template #content="{ files: selectedFiles, removeFileCallback }"> <FileUpload
<div v-if="selectedFiles.length > 0" class="flex flex-wrap gap-4 pt-4"> ref="fileUpload"
<div v-for="(file, index) of selectedFiles" :key="file.name + file.type + file.size" class="relative w-[5rem] h-[5rem] rounded-lg border border-gray-200 flex flex-col items-center overflow-hidden"> name="files[]"
<img role="presentation" :alt="file.name" :src="file.objectURL" class="absolute top-0 left-0 w-full h-full object-cover"/> :customUpload="true"
<div class="absolute bottom-0 left-0 w-full flex flex-col items-center gap-1 bg-white/80 p-1"> :showUploadButton="false"
<span class="w-full text-[10px] font-[400] text-center text-ellipsis whitespace-nowrap overflow-hidden">{{ file.name }}</span> :showCancelButton="false"
<span class="text-[9px]">{{ formatFileSize(file.size) }}</span> :multiple="true"
accept="image/*"
:maxFileSize="MAX_FILE_SIZE"
@select="onSelectedFiles"
>
<template #content="{ files: selectedFiles, removeFileCallback, clearCallback }">
<div v-if="selectedFiles.length > 0" class="flex flex-col gap-3">
<ul class="grid grid-cols-[repeat(auto-fill,5rem)] gap-2">
<li
v-for="(file, index) of selectedFiles"
:key="file.name + file.type + file.size"
class="group relative aspect-square rounded-xl overflow-hidden border border-[#DEE3EB] bg-[#F8FAFC]"
>
<img role="presentation" :alt="file.name" :src="file.objectURL" class="w-full h-full object-cover" />
<div class="absolute inset-x-0 bottom-0 px-2 pb-1.5 pt-4 bg-gradient-to-t from-[#2F343D]/80 to-transparent text-white">
<p class="text-[10px] font-[600] truncate" dir="ltr">{{ file.name }}</p>
<p class="text-[9px] font-normal opacity-80" dir="ltr">{{ formatFileSize(file.size) }}</p>
</div>
<button
type="button"
class="absolute top-1.5 left-1.5 w-5 h-5 flex items-center justify-center rounded-full bg-white/90 text-[#7A1419] shadow-sm transition-opacity opacity-0 group-hover:opacity-100 focus:opacity-100"
:aria-label="`حذف ${file.name}`"
@click.stop="onRemoveFile(removeFileCallback, index)"
>
<i class="fi fi-rr-cross-small text-[12px] leading-none"></i>
</button>
</li>
<li>
<button
type="button"
class="w-full aspect-square rounded-xl border border-dashed border-[#C1D0E8] bg-white flex flex-col items-center justify-center gap-1 text-[#365891] transition-colors hover:border-[#365891] hover:bg-[#EEF2F8]"
@click="openFileDialog"
>
<i class="fi fi-rr-plus text-[16px] leading-none"></i>
<span class="text-[10px] font-[600]">افزودن</span>
</button>
</li>
</ul>
<div class="flex items-center justify-between gap-2 border-t border-[#EBEBEB] pt-2">
<p class="text-[11px] font-normal text-[#576989]">
{{ convertEnglishToPersian(String(selectedFiles.length)) }} فایل · <span dir="ltr">{{ formatFileSize(totalSize) }}</span>
</p>
<button type="button" class="text-[11px] font-[600] text-[#7A1419]" @click="onClearFiles(clearCallback)">حذف همه</button>
</div> </div>
<button type="button" class="absolute top-1 left-1 w-4 h-4 flex items-center justify-center rounded-full bg-white text-[#7A1419] text-[10px] cursor-pointer" @click.stop="onRemoveFile(removeFileCallback, index)">✕</button>
</div> </div>
<button type="button" class="w-[5rem] h-[5rem] rounded-lg border border-dashed border-[#C1D0E8] text-[11px] text-[#365891] cursor-pointer" @click="openFileDialog">افزودن</button> </template>
</div> <template #empty>
</template> <button type="button" class="ui-file-upload-dropzone" @click="openFileDialog">
<template #empty> <span class="ui-file-upload-icon">
<div class="w-full flex items-center justify-center flex-col cursor-pointer" @click="openFileDialog"> <i class="fi fi-rr-cloud-upload text-[20px] leading-none"></i>
<p class="text-[11px] font-[600] text-[#365891]">{{ title }}</p> </span>
<p class="mt-1 mb-0 text-[11px] font-[400] text-[#365891]"> <span class="text-[12px] font-[700] text-[#3E4C63]">{{ title }}</span>
جهت آپلود تصویر کلیک کنید و یا فایل خود را کشیده و در این قسمت رها (Drag and Drop) کنید. <span class="text-[11px] font-normal text-[#576989] leading-5">
</p> فایل را اینجا رها کنید یا برای انتخاب کلیک کنید
</div> </span>
</template> <span class="mt-1 text-[10px] font-normal text-[#576989] bg-white border border-[#DEE3EB] rounded-md px-2 py-0.5">
</FileUpload> JPG، PNG · حداکثر {{ formatFileSize(MAX_FILE_SIZE) }}
</span>
</button>
</template>
</FileUpload>
</div>
</template> </template>
@@ -23,8 +23,21 @@ const walletBalance = computed(() => Number(pageProps.value.auth?.wallet_balance
const amount = ref(''); const amount = ref('');
const selectedCardId = ref(props.bankCards[0]?.id ?? null); const selectedCardId = ref(props.bankCards[0]?.id ?? null);
const MIN_WITHDRAW_AMOUNT = 10000;
const numericAmount = computed(() => Number(convertPersianToEnglish(amount.value).replace(/\D/g, '')) || 0); const numericAmount = computed(() => Number(convertPersianToEnglish(amount.value).replace(/\D/g, '')) || 0);
const selectedCard = computed(() => props.bankCards.find((card) => card.id === selectedCardId.value) ?? null);
const amountError = computed(() => {
if (!amount.value) return '';
if (numericAmount.value < MIN_WITHDRAW_AMOUNT) return `حداقل مبلغ برداشت ${formatPrice(MIN_WITHDRAW_AMOUNT)} تومان است.`;
if (numericAmount.value > walletBalance.value) return 'مبلغ برداشت از موجودی کیف پول بیشتر است.';
return '';
});
const canSubmit = computed(() => Boolean(selectedCard.value) && numericAmount.value >= MIN_WITHDRAW_AMOUNT && !amountError.value);
const formatCardNumber = (cardNumber) => convertEnglishToPersian(cardNumber.replace(/(\d{4})(?=\d)/g, '$1 - ')); const formatCardNumber = (cardNumber) => convertEnglishToPersian(cardNumber.replace(/(\d{4})(?=\d)/g, '$1 - '));
const selectFullBalance = () => { const selectFullBalance = () => {
@@ -37,15 +50,7 @@ const withdrawForm = useForm({
}); });
const submitWithdraw = () => { const submitWithdraw = () => {
if (numericAmount.value < 10000) { if (!canSubmit.value) return;
toast.error('حداقل مبلغ برداشت ۱۰٬۰۰۰ تومان است.');
return;
}
if (!selectedCardId.value) {
toast.error('لطفا کارت بانکی مقصد را انتخاب کنید.');
return;
}
withdrawForm.amount = numericAmount.value; withdrawForm.amount = numericAmount.value;
withdrawForm.bank_card_id = selectedCardId.value; withdrawForm.bank_card_id = selectedCardId.value;
@@ -95,72 +100,99 @@ const deleteCard = (card) => {
<template> <template>
<UiCard title="درخواست برداشت" variant="default"> <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-5">
<div class="flex flex-col gap-4"> <div class="flex items-start justify-between gap-4 max-md:flex-col">
<p class="text-[11px] font-normal text-[#576989] leading-6"> <p class="text-[11px] font-normal text-[#576989] leading-6 max-w-xl">
مبلغ مورد نظر پس از بررسی و تایید توسط کارشناسان، از کیف پول شما کسر و به کارت انتخابی واریز می‌شود. مبلغ مورد نظر پس از بررسی و تایید توسط کارشناسان، از کیف پول شما کسر و به کارت انتخابی واریز می‌شود.
</p> </p>
<div class="flex items-end gap-3 max-sm:flex-col max-sm:items-stretch"> <div class="flex items-center gap-2 bg-[#F5F7FA] border border-[#DEE3EB] rounded-lg px-3 py-2 shrink-0">
<span class="text-[11px] font-normal text-[#576989]">موجودی قابل برداشت:</span>
<span class="text-[12px] font-semibold text-[#3E4C63]">{{ formatPrice(walletBalance) }} تومان</span>
</div>
</div>
<section class="flex flex-col gap-3 border-t border-[#EBEBEB] pt-4">
<div class="flex items-center justify-between">
<h3 class="text-[12px] font-semibold text-[#3E4C63]">۱. کارت بانکی مقصد</h3>
<button v-if="bankCards.length" type="button" class="text-[11px] font-semibold text-[#365891]" @click="cardModalVisible = true">
+ افزودن کارت
</button>
</div>
<div v-if="!bankCards.length" class="flex flex-col items-center gap-3 bg-[#F8FAFC] border border-dashed border-[#C1D0E8] rounded-xl p-5">
<p class="text-[11px] font-normal text-[#576989] text-center leading-6">
هنوز کارتی ثبت نکرده‌اید. برای ثبت درخواست برداشت ابتدا یک کارت بانکی اضافه کنید.
</p>
<UiButton label="افزودن کارت بانکی" severity="secondary" size="small" @click="cardModalVisible = true" />
</div>
<div v-else class="grid grid-cols-2 max-lg:grid-cols-1 gap-3">
<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>
</section>
<section class="flex flex-col gap-3 border-t border-[#EBEBEB] pt-4">
<h3 class="text-[12px] font-semibold text-[#3E4C63]">۲. مبلغ برداشت</h3>
<div class="flex flex-col gap-2 max-w-md">
<UiInput <UiInput
v-model="amount" v-model="amount"
label="مبلغ برداشت"
placeholder="مبلغ مورد نظر را به تومان وارد کنید" placeholder="مبلغ مورد نظر را به تومان وارد کنید"
prefixEnd="تومان" prefixEnd="تومان"
prefixClasses="!border-dominant-50" prefixClasses="!border-dominant-50"
class="!w-full" class="!w-full"
customClass="!border-dominant-50 !font-normal !text-[12px]" customClass="!border-dominant-50 !font-normal !text-[12px]"
:error="amountError"
/> />
<div class="flex items-center justify-between gap-2 flex-wrap">
<span class="text-[11px] font-normal text-[#576989]">حداقل مبلغ برداشت {{ formatPrice(MIN_WITHDRAW_AMOUNT) }} تومان است.</span>
<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] disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="walletBalance < MIN_WITHDRAW_AMOUNT"
@click="selectFullBalance"
>
برداشت کل موجودی
</button>
</div>
</div> </div>
<div class="flex items-center gap-2 flex-wrap"> </section>
<button
type="button" <div class="flex items-center justify-between gap-4 max-md:flex-col max-md:items-stretch border-t border-[#EBEBEB] pt-4">
class="text-[11px] font-normal text-[#576989] bg-[#F5F7FA] border border-[#DEE3EB] rounded-lg px-3 py-1.5 hover:bg-[#EEF2F8]" <p v-if="canSubmit" class="text-[11px] font-normal text-[#576989] leading-6">
@click="selectFullBalance" <span class="font-[600] text-[#7A1419]">{{ formatPrice(numericAmount) }} تومان</span>
> به کارت
کل موجودی ({{ formatPrice(walletBalance) }} تومان) <span class="font-[600] text-[#3E4C63]" dir="ltr">{{ formatCardNumber(selectedCard.card_number) }}</span>
</button> واریز می‌شود.
</div> </p>
<p v-else class="text-[11px] font-normal text-[#576989] leading-6">
برای ثبت درخواست، کارت مقصد را انتخاب و مبلغ را وارد کنید.
</p>
<UiButton <UiButton
label="ثبت درخواست برداشت" label="ثبت درخواست برداشت"
severity="success" severity="success"
size="small" size="small"
class="self-start" class="shrink-0"
:loading="withdrawForm.processing" :loading="withdrawForm.processing"
:disabled="withdrawForm.processing" :disabled="withdrawForm.processing || !canSubmit"
@click="submitWithdraw" @click="submitWithdraw"
/> />
</div> </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> </div>
<Dialog v-model:visible="cardModalVisible" :modal="true" :closable="true" :dismissableMask="false" :style="{ width: '420px' }" class="fast-request-dialog" :pt="{ header: '!bg-client-primaryBlue' }"> <Dialog v-model:visible="cardModalVisible" :modal="true" :closable="true" :dismissableMask="false" :style="{ width: '420px' }" class="fast-request-dialog" :pt="{ header: '!bg-client-primaryBlue' }">
+11
View File
@@ -11,3 +11,14 @@ export const formatFileSize = (bytes) => {
return `${formattedSize} ${sizes[i]}` return `${formattedSize} ${sizes[i]}`
} }
export const downloadFiles = (files) => {
files.forEach((file) => {
const link = document.createElement('a')
link.href = file.url
link.download = file.filename || ''
document.body.appendChild(link)
link.click()
link.remove()
})
}
+3
View File
@@ -5,6 +5,7 @@ use Inertia\Inertia;
use App\Http\Controllers\Client\AuthController; use App\Http\Controllers\Client\AuthController;
use App\Http\Controllers\Client\FastRequestController; use App\Http\Controllers\Client\FastRequestController;
use App\Http\Controllers\Client\InvoiceController; use App\Http\Controllers\Client\InvoiceController;
use App\Http\Controllers\Client\MediaController;
use App\Http\Controllers\Client\PaymentController; use App\Http\Controllers\Client\PaymentController;
use App\Http\Controllers\Client\ProfileController; use App\Http\Controllers\Client\ProfileController;
use App\Http\Controllers\Client\WalletController; use App\Http\Controllers\Client\WalletController;
@@ -65,6 +66,8 @@ Route::group(['prefix' => 'dashboard' , 'middleware' => 'auth' , 'as' => 'dashbo
Route::post('wallet/bank-cards', [WalletController::class, 'storeBankCard'])->name('wallet.bankCards.store'); Route::post('wallet/bank-cards', [WalletController::class, 'storeBankCard'])->name('wallet.bankCards.store');
Route::delete('wallet/bank-cards/{bankCard}', [WalletController::class, 'destroyBankCard'])->name('wallet.bankCards.destroy'); Route::delete('wallet/bank-cards/{bankCard}', [WalletController::class, 'destroyBankCard'])->name('wallet.bankCards.destroy');
Route::get('media/{medium}/download', [MediaController::class, 'download'])->name('media.download');
Route::get('invoices', [InvoiceController::class, 'index'])->name('invoices.index'); Route::get('invoices', [InvoiceController::class, 'index'])->name('invoices.index');
Route::post('invoices/{invoice}/pay-wallet', [InvoiceController::class, 'payWithWallet'])->name('invoices.payWallet'); Route::post('invoices/{invoice}/pay-wallet', [InvoiceController::class, 'payWithWallet'])->name('invoices.payWallet');