Initial commit: admin dashboard for GameNo.

Vue 3 + Vite dashboard with PrimeVue, i18n, and API integration for institution management.
This commit is contained in:
2026-08-09 04:18:09 +02:00
commit 1a34c3b133
94 changed files with 12016 additions and 0 deletions
@@ -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>
+188
View File
@@ -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>
+36
View File
@@ -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>
+25
View File
@@ -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>
+32
View File
@@ -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>
+101
View File
@@ -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>
+32
View File
@@ -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>