Initial commit: admin dashboard for GameNo.
Vue 3 + Vite dashboard with PrimeVue, i18n, and API integration for institution management.
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
<!-- /src/components/common/ConfirmDeleteDialog.vue -->
|
||||
<template>
|
||||
<Dialog
|
||||
v-model:visible="visible"
|
||||
modal
|
||||
:header="title || 'تایید حذف'"
|
||||
:style="{ width: '400px' }"
|
||||
:closable="!loading"
|
||||
>
|
||||
<div class="flex align-items-center gap-3 py-2">
|
||||
<i class="pi pi-exclamation-triangle text-3xl text-red-500"></i>
|
||||
<span class="text-color text-sm">{{ message || 'آیا از حذف این مورد اطمینان دارید؟ این عملیات قابل بازگشت نیست.' }}</span>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-content-end gap-2">
|
||||
<Button label="انصراف" text class="p-button-secondary" :disabled="loading" @click="visible = false" />
|
||||
<Button label="حذف نهایی" icon="pi pi-trash" severity="danger" :loading="loading" @click="onConfirm" />
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import Dialog from 'primevue/dialog';
|
||||
import Button from 'primevue/button';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
message: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'confirm']);
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val)
|
||||
});
|
||||
|
||||
const onConfirm = () => {
|
||||
emit('confirm');
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,188 @@
|
||||
<!-- /src/components/common/DataTableWrapper.vue -->
|
||||
<template>
|
||||
<div class="data-table-wrapper surface-card p-4 border-round border-1 border-color shadow-sm">
|
||||
<!-- Toolbar / Search / Filter Header -->
|
||||
<div class="flex flex-column sm:flex-row align-items-center justify-content-between gap-3 mb-4">
|
||||
<div class="p-input-icon-right w-full sm:w-20rem">
|
||||
<IconField iconPosition="right">
|
||||
<InputIcon class="pi pi-search" />
|
||||
<InputText
|
||||
v-model="searchValue"
|
||||
:placeholder="$t('app.search')"
|
||||
class="w-full text-sm"
|
||||
@input="onSearchInput"
|
||||
/>
|
||||
</IconField>
|
||||
</div>
|
||||
|
||||
<div class="flex align-items-center gap-2 w-full sm:w-auto justify-content-end">
|
||||
<slot name="toolbar" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Skeleton loading -->
|
||||
<TableSkeleton v-if="loading" :rows="skeletonRowCount" :columns="skeletonColumnCount" />
|
||||
|
||||
<!-- Data Table -->
|
||||
<DataTable
|
||||
v-else
|
||||
:value="items"
|
||||
:lazy="true"
|
||||
:sortField="sortBy"
|
||||
:sortOrder="sortOrder === 'asc' ? 1 : -1"
|
||||
:selection="selection"
|
||||
dataKey="_id"
|
||||
responsiveLayout="scroll"
|
||||
class="p-datatable-sm text-sm"
|
||||
@sort="onSort"
|
||||
@update:selection="onSelectionChange"
|
||||
>
|
||||
<template #empty>
|
||||
<EmptyState />
|
||||
</template>
|
||||
|
||||
<Column v-if="selectable" selectionMode="multiple" headerStyle="width: 3rem" />
|
||||
<slot />
|
||||
</DataTable>
|
||||
|
||||
<!-- Custom Paginator -->
|
||||
<div
|
||||
class="flex flex-column sm:flex-row align-items-center justify-content-between gap-3 mt-4 pt-3 border-top-1 border-color"
|
||||
v-if="totalCount > 0 || loading"
|
||||
>
|
||||
<span class="text-muted text-sm">
|
||||
<template v-if="loading">در حال بارگذاری…</template>
|
||||
<template v-else>
|
||||
نمایش {{ toPersianDigits(firstItemIndex) }} تا {{ toPersianDigits(lastItemIndex) }} از {{ toPersianDigits(totalCount) }} مورد
|
||||
</template>
|
||||
</span>
|
||||
|
||||
<Paginator
|
||||
:rows="limit"
|
||||
:totalRecords="Math.max(totalCount, 1)"
|
||||
:first="(page - 1) * limit"
|
||||
:rowsPerPageOptions="[10, 20, 50, 100]"
|
||||
:disabled="loading"
|
||||
@page="onPage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { usePersianDate } from '@/composables/usePersianDate';
|
||||
import DataTable from 'primevue/datatable';
|
||||
import Column from 'primevue/column';
|
||||
import Paginator from 'primevue/paginator';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import IconField from 'primevue/iconfield';
|
||||
import InputIcon from 'primevue/inputicon';
|
||||
import EmptyState from './EmptyState.vue';
|
||||
import TableSkeleton from './TableSkeleton.vue';
|
||||
|
||||
const props = defineProps({
|
||||
items: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
totalCount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
page: {
|
||||
type: Number,
|
||||
default: 1
|
||||
},
|
||||
limit: {
|
||||
type: Number,
|
||||
default: 20
|
||||
},
|
||||
sortBy: {
|
||||
type: String,
|
||||
default: 'createdAt'
|
||||
},
|
||||
sortOrder: {
|
||||
type: String,
|
||||
default: 'desc'
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
selectable: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
selection: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
skeletonColumns: {
|
||||
type: Number,
|
||||
default: 5
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['page-change', 'sort-change', 'search-change', 'update:selection']);
|
||||
|
||||
const { toPersianDigits } = usePersianDate();
|
||||
|
||||
const searchValue = ref('');
|
||||
const skeletonColumnCount = computed(() => Math.max(3, props.skeletonColumns || 5));
|
||||
const skeletonRowCount = computed(() => Math.min(Math.max(props.limit || 20, 5), 12));
|
||||
|
||||
const onSelectionChange = (value) => {
|
||||
emit('update:selection', value || []);
|
||||
};
|
||||
|
||||
let searchTimer = null;
|
||||
const onSearchInput = () => {
|
||||
if (searchTimer) clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => {
|
||||
emit('search-change', searchValue.value);
|
||||
}, 400);
|
||||
};
|
||||
|
||||
const onSort = (event) => {
|
||||
emit('sort-change', {
|
||||
sortBy: event.sortField,
|
||||
sortOrder: event.sortOrder === 1 ? 'asc' : 'desc'
|
||||
});
|
||||
};
|
||||
|
||||
const onPage = (event) => {
|
||||
emit('page-change', {
|
||||
page: event.page + 1,
|
||||
limit: event.rows
|
||||
});
|
||||
};
|
||||
|
||||
const firstItemIndex = computed(() => {
|
||||
if (props.totalCount === 0) return 0;
|
||||
return (props.page - 1) * props.limit + 1;
|
||||
});
|
||||
|
||||
const lastItemIndex = computed(() => {
|
||||
return Math.min(props.page * props.limit, props.totalCount);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.items,
|
||||
() => {
|
||||
if (props.selectable && props.selection?.length) {
|
||||
const ids = new Set(props.items.map((item) => String(item._id || item.id)));
|
||||
const next = props.selection.filter((item) => ids.has(String(item._id || item.id)));
|
||||
if (next.length !== props.selection.length) {
|
||||
emit('update:selection', next);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.data-table-wrapper {
|
||||
background-color: var(--surface-card);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,36 @@
|
||||
<!-- /src/components/common/EmptyState.vue -->
|
||||
<template>
|
||||
<div class="empty-state flex flex-column align-items-center justify-content-center p-5 text-center my-4">
|
||||
<div class="icon-wrapper mb-3 text-muted">
|
||||
<i :class="[icon, 'text-6xl']"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-color mb-2">{{ title }}</h3>
|
||||
<p class="text-muted text-sm mb-4 max-w-26rem">{{ description }}</p>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: 'اطلاعاتی یافت نشد'
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: 'موردی برای نمایش در این بخش وجود ندارد.'
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: 'pi pi-folder-open'
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.empty-state {
|
||||
background: var(--surface-card);
|
||||
border: 1px dashed var(--border-color);
|
||||
border-radius: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,25 @@
|
||||
<!-- /src/components/common/PageHeader.vue -->
|
||||
<template>
|
||||
<div class="page-header flex flex-column md:flex-row align-items-start md:align-items-center justify-content-between mb-4 gap-3">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-color m-0">{{ title }}</h1>
|
||||
<p v-if="subtitle" class="text-muted text-sm mt-1 mb-0">{{ subtitle }}</p>
|
||||
</div>
|
||||
<div class="flex align-items-center gap-2">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
subtitle: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,32 @@
|
||||
<!-- /src/components/common/PermissionGate.vue -->
|
||||
<template>
|
||||
<slot v-if="hasAccess" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { usePermission } from '@/composables/usePermission';
|
||||
|
||||
const props = defineProps({
|
||||
permission: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
anyPermission: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
});
|
||||
|
||||
const { hasPermission, hasAnyPermission } = usePermission();
|
||||
|
||||
const hasAccess = computed(() => {
|
||||
if (props.permission) {
|
||||
return hasPermission(props.permission);
|
||||
}
|
||||
if (props.anyPermission && props.anyPermission.length > 0) {
|
||||
return hasAnyPermission(props.anyPermission);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,101 @@
|
||||
<!-- /src/components/common/StatusTag.vue -->
|
||||
<template>
|
||||
<Tag :severity="severity" :value="label" class="text-xs font-semibold px-2 py-1" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import Tag from 'primevue/tag';
|
||||
|
||||
const props = defineProps({
|
||||
status: {
|
||||
type: [String, Boolean],
|
||||
required: true
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'general' // general | payment | session | notification | contact
|
||||
}
|
||||
});
|
||||
|
||||
const SESSION_LABELS = {
|
||||
scheduled: 'طبق برنامه',
|
||||
Scheduled: 'طبق برنامه',
|
||||
held: 'برگزارشده',
|
||||
cancelled: 'لغوشده',
|
||||
canceled: 'لغوشده'
|
||||
};
|
||||
|
||||
const CONTACT_LABELS = {
|
||||
new: 'جدید',
|
||||
seen: 'مشاهدهشده',
|
||||
call_later: 'تماس بعدی',
|
||||
contacted: 'تماس گرفته شد',
|
||||
closed: 'بسته'
|
||||
};
|
||||
|
||||
const NOTIFICATION_LABELS = {
|
||||
pending: 'در انتظار',
|
||||
sent: 'ارسالشده',
|
||||
delivered: 'تحویلشده',
|
||||
failed: 'ناموفق',
|
||||
retrying: 'تلاش مجدد'
|
||||
};
|
||||
|
||||
const PAYMENT_LABELS = {
|
||||
paid: 'تسویهشده',
|
||||
partial: 'پرداخت جزئی',
|
||||
pending: 'در انتظار پرداخت',
|
||||
overdue: 'معوق'
|
||||
};
|
||||
|
||||
const severity = computed(() => {
|
||||
const val = String(props.status).toLowerCase();
|
||||
|
||||
if (props.type === 'payment') {
|
||||
if (val === 'paid' || val === 'تسویه شده' || val === 'تسویهشده') return 'success';
|
||||
if (val === 'partial' || val === 'پرداخت جزئی') return 'warning';
|
||||
if (val === 'pending' || val === 'در انتظار پرداخت') return 'info';
|
||||
if (val === 'overdue' || val === 'معوق شده' || val === 'معوق') return 'danger';
|
||||
}
|
||||
|
||||
if (props.type === 'session') {
|
||||
if (val === 'scheduled' || val.includes('برنامه')) return 'info';
|
||||
if (val === 'held' || val.includes('برگزار')) return 'success';
|
||||
if (val === 'cancelled' || val === 'canceled' || val.includes('لغو')) return 'danger';
|
||||
}
|
||||
|
||||
if (props.type === 'contact') {
|
||||
if (val === 'new') return 'info';
|
||||
if (val === 'seen') return 'secondary';
|
||||
if (val === 'call_later') return 'warning';
|
||||
if (val === 'contacted') return 'success';
|
||||
if (val === 'closed') return 'contrast';
|
||||
}
|
||||
|
||||
if (props.type === 'notification') {
|
||||
if (val === 'success' || val === 'delivered' || val === 'sent' || val === 'تحویل شده' || val === 'ارسالشده') return 'success';
|
||||
if (val === 'pending' || val === 'retrying' || val === 'در حال تلاش مجدد' || val === 'در انتظار') return 'warning';
|
||||
if (val === 'failed' || val === 'ناموفق') return 'danger';
|
||||
}
|
||||
|
||||
if (val === 'true' || val === 'active' || val === 'فعال') return 'success';
|
||||
if (val === 'false' || val === 'inactive' || val === 'غیرفعال') return 'danger';
|
||||
|
||||
return 'info';
|
||||
});
|
||||
|
||||
const label = computed(() => {
|
||||
const raw = String(props.status);
|
||||
const val = raw.toLowerCase();
|
||||
|
||||
if (props.type === 'session') return SESSION_LABELS[val] || SESSION_LABELS[raw] || raw;
|
||||
if (props.type === 'contact') return CONTACT_LABELS[val] || raw;
|
||||
if (props.type === 'notification') return NOTIFICATION_LABELS[val] || raw;
|
||||
if (props.type === 'payment') return PAYMENT_LABELS[val] || raw;
|
||||
|
||||
if (val === 'true' || val === 'active') return 'فعال';
|
||||
if (val === 'false' || val === 'inactive') return 'غیرفعال';
|
||||
return raw;
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,32 @@
|
||||
<!-- Reusable table skeleton placeholder -->
|
||||
<template>
|
||||
<div class="table-skeleton" aria-busy="true" aria-live="polite">
|
||||
<div class="flex gap-2 mb-3 pb-2 border-bottom-1 border-color">
|
||||
<Skeleton
|
||||
v-for="col in columns"
|
||||
:key="`h-${col}`"
|
||||
height="0.85rem"
|
||||
:width="widths[(col - 1) % widths.length]"
|
||||
/>
|
||||
</div>
|
||||
<div v-for="row in rows" :key="`r-${row}`" class="flex align-items-center gap-2 mb-2">
|
||||
<Skeleton
|
||||
v-for="col in columns"
|
||||
:key="`c-${row}-${col}`"
|
||||
height="1.35rem"
|
||||
:width="widths[(col - 1) % widths.length]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Skeleton from 'primevue/skeleton';
|
||||
|
||||
defineProps({
|
||||
rows: { type: Number, default: 6 },
|
||||
columns: { type: Number, default: 5 }
|
||||
});
|
||||
|
||||
const widths = ['18%', '22%', '16%', '20%', '14%', '12%', '10%', '15%'];
|
||||
</script>
|
||||
Reference in New Issue
Block a user