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
@@ -1,18 +1,24 @@
<script setup>
import { Link } from '@inertiajs/inertia-vue3'
import ActivityCard from './ActivityCard.vue'
import { formatPrice } from '../../utils/numberUtils'
const props = defineProps({
stats: {
type: Object,
required: true
}
})
</script>
<template>
<section class="grid grid-cols-4 max-lg:grid-cols-2 max-sm:grid-cols-1 gap-4">
<ActivityCard title="کیس در انتظار بررسی"
:count="5" link="/dashboard/doctor/cases" label-link="مشاهده و بررسی" />
:count="stats.pending_cases" link="/dashboard/cases" label-link="مشاهده و بررسی" />
<ActivityCard title="کیس بررسی شده در ماه"
:count="999" link="/dashboard/doctor/cases" label-link="مشاهده گزارشات کیس‌ها" />
<ActivityCard title="تومان درآمد"
:count="formatPrice(50000000)" link="/dashboard/doctor/cases" label-link="مشاهده گزارشات مالی " />
<ActivityCard title="ساعت تا شیت بعدی"
:count="4" link="/dashboard/doctor/cases" label-link="مشاهده شیفت‌ها" />
:count="stats.monthly_reviewed" link="/dashboard/reports" label-link="مشاهده گزارشات کیس‌ها" />
<ActivityCard :title="stats.is_doctor ? 'تومان درآمد' : 'تومان هزینه در ماه'"
:count="formatPrice(stats.monthly_amount)" :link="stats.is_doctor ? '/dashboard/wallet' : '/dashboard/invoices'" label-link="مشاهده گزارشات مالی" />
<ActivityCard v-if="stats.next_shift_hours !== null" title="ساعت تا شیفت بعدی"
:count="stats.next_shift_hours" link="/dashboard/shifts" label-link="مشاهده شیفت‌ها" />
</section>
</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>
import UiButton from '../../components/ui/UiButton.vue';
import { computed, watch } from 'vue';
import { computed, ref, watch } from 'vue';
import Dialog from 'primevue/dialog';
import ProgressBar from 'primevue/progressbar';
import { toast } from 'vue3-toastify';
@@ -9,11 +9,15 @@ import RequestCase from './fastRequest/RequestCase.vue';
import RequestDetails from './fastRequest/RequestDetails.vue';
import RequestDoctor from './fastRequest/RequestDoctor.vue';
import RequestReview from './fastRequest/RequestReview.vue';
import InvoiceModal from './fastRequest/InvoiceModal.vue';
import { useFastRequestStore } from '../../store/FastRequest';
const visible = defineModel('visible');
const fastRequestStore = useFastRequestStore();
const invoiceVisible = ref(false);
const invoiceDocument = ref(null);
const steps = [
{ label: 'نوع درخواست', component: RequestType },
{ label: 'انتخاب کیس', component: RequestCase },
@@ -49,8 +53,9 @@ const handleNext = async () => {
try {
const document = await fastRequestStore.submit();
toast.success(`درخواست شما با شماره پرونده ${document.code} با موفقیت ثبت شد.`);
invoiceDocument.value = document;
visible.value = false;
invoiceVisible.value = true;
} catch (error) {
toast.error(error.response?.data?.message || 'خطا در ثبت درخواست. لطفا دوباره تلاش کنید.');
}
@@ -93,4 +98,5 @@ const handleNext = async () => {
/>
</div>
</Dialog>
<invoice-modal v-model:visible="invoiceVisible" :document="invoiceDocument"></invoice-modal>
</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>
import { computed } from 'vue';
import { computed, ref } from 'vue';
import Dialog from 'primevue/dialog';
import { usePage } from '@inertiajs/inertia-vue3';
import { Inertia } from '@inertiajs/inertia';
import UiButton from '../../ui/UiButton.vue';
import { toast } from 'vue3-toastify';
import { modalityTitle } from '../../../data/fastRequestOptions';
import { formatPrice } from '../../../utils/numberUtils';
const visible = defineModel('visible');
@@ -10,31 +14,69 @@ const props = defineProps({
document: {
type: Object,
default: null
},
invoice: {
type: Object,
default: null
}
});
const formattedAmount = computed(() => {
return Number(props.document?.invoice?.amount || 0).toLocaleString('fa-IR');
});
const { props: pageProps } = usePage();
const handlePay = () => {
visible.value = false;
const paying = ref(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>
<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>
<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>
<div v-if="document" 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">
<i class="fi-rr-info text-[20px] text-[#576989]"></i>
<p class="text-[13px] font-[400] text-[#3E4C63]">
<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-3">
<i class="fi-rr-info text-[18px] text-[#576989]"></i>
<p class="text-[12px] font-[400] text-[#3E4C63] leading-6">
یک فاکتور به مبلغ « <span class="font-[700]">{{ formattedAmount }} تومان</span> » برای شما ایجاد شد. لطفا جهت ادامه مراحل درخواست نسبت به پرداخت اقدام نمایید.
</p>
</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>
به شناسه
@@ -48,9 +90,14 @@ const handlePay = () => {
<br>
بدیهیست که در صورت عدم تایید کارشناسان مبلغ مربوطه به کیف پول شما بازگردانده خواهد شد.
</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 class="w-full pt-3 flex items-center justify-start border-t border-[#EBEBEB]">
<ui-button label="تایید و پرداخت" severity="success" size="small" @click="handlePay" />
<div class="w-full pt-3 flex items-center justify-start gap-2 border-t border-[#EBEBEB]">
<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>
</Dialog>
</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>
import { ref } from 'vue';
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { Link, usePage } from '@inertiajs/inertia-vue3';
import { useGlobalStore } from '../../store/Global';
import UiInput from '../ui/UiInput.vue';
import ArrowDownSvg from '../svg/arrowDown.svg';
import GlobalSvg from '../svg/global.svg';
import EditProfileModal from '../dashboard/EditProfileModal.vue';
import { formatPrice } from '../../utils/numberUtils';
const globalStore = useGlobalStore();
const { props } = usePage();
const walletBalance = computed(() => formatPrice(props.value.auth?.wallet_balance || 0));
const toggleSidebar = () => {
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>
<template>
@@ -39,17 +72,63 @@ const toggleSidebar = () => {
/>
</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">
<GlobalSvg />
</div>
<div class="grid grid-cols-2a justify-start items-center gap-2">
<span class="text-dominant-300 text-[12px] font-normal">
{{ globalStore.current_user.fullname }}
</span>
<ArrowDownSvg class="!w-4 !h-4 flex -mt-0.5"/>
<div ref="userMenuRef" class="relative">
<button type="button" class="flex items-center gap-2 cursor-pointer" @click="toggleUserMenu">
<span class="flex items-center justify-center w-9 h-9 rounded-full bg-dominant-light border border-dominant-50 overflow-hidden">
<img v-if="globalStore.current_user.profile_url" :src="globalStore.current_user.profile_url" alt="تصویر پروفایل" class="w-full h-full object-cover" />
<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>
<EditProfileModal v-model:visible="editProfileVisible" />
</header>
</template>
@@ -10,6 +10,8 @@ import LogoutSvg from "../svg/logout.svg"
import CalendarSvg from "../svg/calendar.svg"
import StatusUpSvg from "../svg/statusUp.svg"
import PetSvg from "../svg/pet.svg"
import WalletSvg from "../svg/wallet.svg"
import InvoiceSvg from "../svg/invoice.svg"
interface SidebarItem extends MenuItem {
label: string;
@@ -87,6 +89,20 @@ const items: Ref<SidebarItem[]> = ref([
key: 'reports',
permission: null,
},
{
label: "کیف پول",
icon: WalletSvg,
to: "/dashboard/wallet",
key: 'wallet',
permission: null,
},
{
label: "فاکتورها",
icon: InvoiceSvg,
to: "/dashboard/invoices",
key: 'invoices',
permission: null,
},
{
label: "شیفت ها",
icon: CalendarSvg,
@@ -0,0 +1,6 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.5 5.25V12.75C16.5 15 15.375 16.5 12.75 16.5H5.25C2.625 16.5 1.5 15 1.5 12.75V5.25C1.5 3 2.625 1.5 5.25 1.5H12.75C15.375 1.5 16.5 3 16.5 5.25Z" stroke="#965457" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5.4375 5.8125H12.5625" stroke="#965457" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5.4375 9H12.5625" stroke="#965457" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5.4375 12.1875H9.75" stroke="#965457" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 691 B

@@ -0,0 +1,9 @@
<svg width="220" height="180" viewBox="0 0 220 180" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="46" cy="34" r="4" fill="#F5A9AB"/>
<circle cx="178" cy="28" r="3" fill="#F5A9AB"/>
<circle cx="188" cy="120" r="4" fill="#F5A9AB"/>
<circle cx="34" cy="128" r="3" fill="#F5A9AB"/>
<circle cx="110" cy="164" r="3" fill="#F5A9AB"/>
<circle cx="110" cy="90" r="52" fill="#E5484D"/>
<path d="M92 72L128 108M128 72L92 108" stroke="white" stroke-width="9" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 493 B

@@ -0,0 +1,19 @@
<svg width="220" height="180" viewBox="0 0 220 180" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="38" y="24" width="4" height="14" rx="2" transform="rotate(-24 38 24)" fill="#3E6D9C"/>
<rect x="176" y="18" width="4" height="12" rx="2" transform="rotate(32 176 18)" fill="#E5484D"/>
<rect x="24" y="96" width="4" height="12" rx="2" transform="rotate(58 24 96)" fill="#E5484D"/>
<rect x="188" y="92" width="4" height="14" rx="2" transform="rotate(-40 188 92)" fill="#3E6D9C"/>
<rect x="66" y="150" width="4" height="12" rx="2" transform="rotate(20 66 150)" fill="#8BC34A"/>
<rect x="150" y="152" width="4" height="12" rx="2" transform="rotate(-28 150 152)" fill="#E5484D"/>
<circle cx="30" cy="58" r="3" fill="#8BC34A"/>
<circle cx="58" cy="12" r="3" fill="#E5484D"/>
<circle cx="118" cy="8" r="3" fill="#64B5F6"/>
<circle cx="196" cy="52" r="3" fill="#8BC34A"/>
<circle cx="200" cy="136" r="3" fill="#64B5F6"/>
<circle cx="96" cy="168" r="3" fill="#E5484D"/>
<circle cx="20" cy="140" r="3" fill="#64B5F6"/>
<circle cx="164" cy="120" r="2.5" fill="#E5484D"/>
<circle cx="52" cy="118" r="2.5" fill="#8BC34A"/>
<circle cx="110" cy="90" r="52" fill="#5384AD"/>
<path d="M88 91L103 106L133 76" stroke="white" stroke-width="9" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,8 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.75 9.9075H10.995C10.29 9.9075 9.7125 9.33 9.7125 8.625C9.7125 7.92 10.29 7.3425 10.995 7.3425H12.75" stroke="#965457" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11.235 8.61H11.145" stroke="#965457" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6.1875 2.25H10.6875C12.5325 2.25 12.9375 2.7 12.9375 4.005V7.3425" stroke="#965457" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4.65 11.055C4.65 11.4675 4.3125 11.805 3.9 11.805C3.4875 11.805 3.15 11.4675 3.15 11.055C3.15 10.6425 3.4875 10.305 3.9 10.305C4.3125 10.305 4.65 10.6425 4.65 11.055Z" fill="#965457"/>
<path d="M1.5 8.8125V12.375C1.5 14.445 3.18 16.125 5.25 16.125H12.75C14.82 16.125 16.5 14.445 16.5 12.375V6.375C16.5 4.305 14.82 2.625 12.75 2.625" stroke="#965457" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M1.5 8.8125V5.25C1.5 4.0575 2.235 2.9925 3.3525 2.5725" stroke="#965457" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -7,50 +7,39 @@ const props = defineProps([
'perPage',
'links',
])
const pageItemClass = 'flex items-center justify-center w-8 h-8 rounded-lg border text-[11px] font-semibold transition-colors';
</script>
<template>
<section class="pagination" v-if="lastPage > 1">
<nav class="flex items-center gap-1.5 p-4" v-if="lastPage > 1">
<template v-for="(link , index) in links" :key="index">
<div class="paginate" v-if="link.label == 'قبلی'">
<Link v-if="link.url" :href="link.url">
<i class="fi fi-rr-angle-small-right flex text-xl"></i>
<template v-if="link.label == 'قبلی'">
<Link v-if="link.url" :href="link.url" :class="pageItemClass" class="border-complement-100 text-complement-700 hover:bg-complement-50">
<i class="fi fi-rr-angle-small-right flex text-base"></i>
</Link>
</div>
<div class="paginate" v-else-if="link.label == 'بعدی'">
<Link v-if="link.url" :href="link.url">
<i class="fi fi-rr-angle-small-left flex text-xl"></i>
<span v-else :class="pageItemClass" class="border-complement-100 text-complement-200">
<i class="fi fi-rr-angle-small-right flex text-base"></i>
</span>
</template>
<template v-else-if="link.label == 'بعدی'">
<Link v-if="link.url" :href="link.url" :class="pageItemClass" class="border-complement-100 text-complement-700 hover:bg-complement-50">
<i class="fi fi-rr-angle-small-left flex text-base"></i>
</Link>
</div>
<div class="paginate flex items-center" :class="{'!text-white !bg-client-primary' : currentPage == link.label }" v-else>
<Link v-if="link.url" :href="link.url" class="flex pt-1">{{link.label}}</Link>
<Link v-else href="" class="flex">{{link.label}}</Link>
</div>
<span v-else :class="pageItemClass" class="border-complement-100 text-complement-200">
<i class="fi fi-rr-angle-small-left flex text-base"></i>
</span>
</template>
<template v-else>
<Link
v-if="link.url"
:href="link.url"
:class="[pageItemClass, currentPage == link.label ? 'bg-dominant border-dominant text-white' : 'border-complement-100 text-complement-900 hover:bg-complement-50']"
>
{{ link.label }}
</Link>
<span v-else :class="pageItemClass" class="border-transparent text-complement-700">{{ link.label }}</span>
</template>
</template>
</section>
</nav>
</template>
<style lang="postcss">
section.pagination{
padding: 1rem;
display: flex;
align-items: center;
}
section.pagination div.paginate:first-child a{
border-top-right-radius: .4rem;
border-bottom-right-radius: .4rem;
}
section.pagination div.paginate:last-child a{
border-top-left-radius: .4rem;
border-bottom-left-radius: .4rem;
}
section.pagination div.paginate a{
display: flex;
height: 2.2rem;
width: 2.2rem;
align-items: center;
justify-content: center;
border: 1px solid #e6e6e6;
@apply text-client-Fontcolor
}
</style>
@@ -133,6 +133,12 @@ const buttonClasses = computed(() => {
if (props.severity === 'secondary') {
classes.push('!font-[600] !text-[11px] !rounded-[6px]')
}
if (props.severity === 'success') {
classes.push('ui-button--success !font-[600] !text-[11px] !rounded-[6px]')
}
if (props.severity === 'danger') {
classes.push('!font-[600] !text-[11px] !rounded-[6px]')
}
return classes.join(' ')
})