admin-panel: convert user and category add/edit forms into modals

Same pattern as branch/workshop/city: open/id props switch UserCreate.vue and
apps/category/Add.vue into BaseFormModal mode; the /user/create,
/user/edit/:id and /category/add/:id? routes are unchanged as a fallback.
ListUser.vue's pre-existing add button (previously a router.push, added
outside the original port and left there per its own comment) now opens the
modal via ListPageHeader instead.

Verified: pnpm build passes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
s.esmaeili
2026-09-14 16:49:24 +03:30
co-authored by Claude Sonnet 5
parent 1ac3b03ad5
commit a8988c18a2
4 changed files with 205 additions and 29 deletions
@@ -2,6 +2,9 @@
<!-- <!--
Ported from legacy henza_admin/src/views/apps/category/Add.vue. Ported from legacy henza_admin/src/views/apps/category/Add.vue.
Serves BOTH create and edit — Edit.vue is only a wrapper around it. Serves BOTH create and edit — Edit.vue is only a wrapper around it.
Converted to a modal (BaseFormModal): the /category/add/:id? route still
exists and still renders this component directly as a route-based
fallback, but List.vue now opens it as a modal via open/id props.
Legacy's distinctive touch, preserved: once "نوع نمایش" is chosen every Legacy's distinctive touch, preserved: once "نوع نمایش" is chosen every
subsequent label is suffixed with (سایت) or (موبایل), so the operator can see subsequent label is suffixed with (سایت) or (موبایل), so the operator can see
@@ -11,7 +14,68 @@
to the ported `file/create` endpoint and storing the returned URL — the same to the ported `file/create` endpoint and storing the returned URL — the same
contract legacy's SingleUploadInput.vue used. contract legacy's SingleUploadInput.vue used.
--> -->
<a-spin :spinning="loading"> <BaseFormModal
v-if="asModal"
:open="Boolean(open)"
:id="id"
:add-title="`دسته بندی ${typeSuffix}`"
:edit-title="`دسته بندی ${typeSuffix}`"
:loading="loading"
:submitting="loading"
width="lg"
@update:open="$emit('update:open', $event)"
@submit="handleSubmit"
>
<a-form-item label="نوع نمایش" :validate-status="err.type ? 'error' : ''" :help="err.type">
<a-radio-group v-model:value="form.type">
<a-radio :value="1">سایت</a-radio>
<a-radio :value="2">اپلیکشن موبایل</a-radio>
</a-radio-group>
</a-form-item>
<div class="grid-2">
<a-form-item
:label="`عنوان ${typeSuffix}`"
:validate-status="err.title ? 'error' : ''"
:help="err.title"
>
<a-input v-model:value="form.title" />
</a-form-item>
<a-form-item :label="`پدر دسته ${typeSuffix}`">
<a-select
v-model:value="form.parent_id_id"
allow-clear
show-search
placeholder="انتخاب کنید"
:filter-option="filterOption"
>
<a-select-option v-for="c in parentOptions" :key="c.id" :value="c.id">
{{ c.title }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item :label="`تصویر شاخص ${typeSuffix}`">
<ImageUploadInput v-model="form.image" purpose="category.image" placeholder="تصویر" />
</a-form-item>
<a-form-item :label="`آیکون ${typeSuffix}`">
<ImageUploadInput v-model="form.icon" purpose="category.icon" placeholder="آیکون" />
</a-form-item>
<a-form-item :label="`متا تگ ${typeSuffix}`">
<a-input v-model:value="form.meta" />
</a-form-item>
</div>
<a-form-item :label="`شرح دسته ${typeSuffix}`">
<a-textarea v-model:value="form.body_html" :auto-size="{ minRows: 3, maxRows: 8 }" />
</a-form-item>
</BaseFormModal>
<!-- route-based fallback for direct links to /category/add/:id? -->
<a-spin v-else :spinning="loading">
<a-form layout="vertical" @submit.prevent="handleSubmit"> <a-form layout="vertical" @submit.prevent="handleSubmit">
<a-card :title="`دسته بندی ${typeSuffix}`" class="mb-base"> <a-card :title="`دسته بندی ${typeSuffix}`" class="mb-base">
<a-form-item <a-form-item
@@ -75,13 +139,25 @@
import { computed, onMounted, reactive, ref, watch } from 'vue'; import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { message } from 'ant-design-vue'; import { message } from 'ant-design-vue';
import BaseFormModal from '../../../components/common/BaseFormModal.vue';
import api from '../../../api/client'; import api from '../../../api/client';
import ImageUploadInput from '../../../components/upload/ImageUploadInput.vue'; import ImageUploadInput from '../../../components/upload/ImageUploadInput.vue';
const props = defineProps<{
open?: boolean;
id?: string;
}>();
const emit = defineEmits<{
(e: 'update:open', value: boolean): void;
(e: 'success'): void;
}>();
const asModal = props.open !== undefined;
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const id = route.params.id as string | undefined; const id = asModal ? props.id : (route.params.id as string | undefined);
const loading = ref(false); const loading = ref(false);
const allCategories = ref<{ id: string; title: string; type: number }[]>([]); const allCategories = ref<{ id: string; title: string; type: number }[]>([]);
@@ -141,7 +217,12 @@ async function handleSubmit() {
await api.post('category/', form); await api.post('category/', form);
} }
message.info('اطلاعات با موفقیت به ثبت رسید.'); message.info('اطلاعات با موفقیت به ثبت رسید.');
router.push('/category/list'); if (asModal) {
emit('success');
emit('update:open', false);
} else {
router.push('/category/list');
}
} catch (e: any) { } catch (e: any) {
message.error(e?.response?.data?.message ?? 'خطا در ثبت اطلاعات.'); message.error(e?.response?.data?.message ?? 'خطا در ثبت اطلاعات.');
} finally { } finally {
@@ -6,6 +6,8 @@
--> -->
<div id="page-user-list"> <div id="page-user-list">
<a-card title="لیست دسته بندی ها"> <a-card title="لیست دسته بندی ها">
<ListPageHeader rid="collection.create" add-label="افزودن دسته" @add="openAdd" />
<XTable ref="tbl" :model="model"> <XTable ref="tbl" :model="model">
<template #image="{ text }"> <template #image="{ text }">
<img <img
@@ -18,7 +20,7 @@
<template #opt="{ record }"> <template #opt="{ record }">
<a-tooltip v-if="hasPermission('collection.list.edit')" title="ویرایش"> <a-tooltip v-if="hasPermission('collection.list.edit')" title="ویرایش">
<EditOutlined class="cursor-pointer mx-1 text-primary" @click="toEdit(record.id)" /> <EditOutlined class="cursor-pointer mx-1 text-primary" @click="openEdit(record.id)" />
</a-tooltip> </a-tooltip>
<a-tooltip v-if="hasPermission('collection.list.remove')" title="حذف"> <a-tooltip v-if="hasPermission('collection.list.remove')" title="حذف">
<a-popconfirm <a-popconfirm
@@ -33,22 +35,24 @@
</a-tooltip> </a-tooltip>
</template> </template>
</XTable> </XTable>
<CategoryAdd :key="editingId ?? 'new'" :open="modalOpen" :id="editingId" @update:open="modalOpen = $event" @success="tbl?.fetch()" />
</a-card> </a-card>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { message } from 'ant-design-vue'; import { message } from 'ant-design-vue';
import { DeleteOutlined, EditOutlined } from '@ant-design/icons-vue'; import { DeleteOutlined, EditOutlined } from '@ant-design/icons-vue';
import XTable from '../../../components/x-table/XTable.vue'; import XTable from '../../../components/x-table/XTable.vue';
import ListPageHeader from '../../../components/common/ListPageHeader.vue';
import CategoryAdd from './Add.vue';
import { buildCategoryModel } from './CategoryTbl'; import { buildCategoryModel } from './CategoryTbl';
import { useUsersStore } from '../../../stores/users'; import { useUsersStore } from '../../../stores/users';
import { useDeleteHandler } from '../../../composables/useDeleteHandler'; import { useDeleteHandler } from '../../../composables/useDeleteHandler';
import api from '../../../api/client'; import api from '../../../api/client';
const router = useRouter();
const users = useUsersStore(); const users = useUsersStore();
const hasPermission = (rid: string) => Boolean(users.hasPermission(rid)); const hasPermission = (rid: string) => Boolean(users.hasPermission(rid));
@@ -56,6 +60,9 @@ const noImage = '/img/no-items.png';
const model = buildCategoryModel(); const model = buildCategoryModel();
const tbl = ref<{ fetch: () => Promise<void> } | null>(null); const tbl = ref<{ fetch: () => Promise<void> } | null>(null);
const modalOpen = ref(false);
const editingId = ref<string | undefined>(undefined);
// The backend refuses while products or child categories still reference it. // The backend refuses while products or child categories still reference it.
const { remove: confirmDelete } = useDeleteHandler({ const { remove: confirmDelete } = useDeleteHandler({
onSuccess: () => tbl.value?.fetch(), onSuccess: () => tbl.value?.fetch(),
@@ -64,8 +71,14 @@ const { remove: confirmDelete } = useDeleteHandler({
fallbackErrorMessage: 'خطا در حذف دسته', fallbackErrorMessage: 'خطا در حذف دسته',
}); });
function toEdit(id: string) { function openAdd() {
router.push(`/category/edit/${id}`); editingId.value = undefined;
modalOpen.value = true;
}
function openEdit(id: string) {
editingId.value = id;
modalOpen.value = true;
} }
</script> </script>
+18 -18
View File
@@ -6,19 +6,8 @@
--> -->
<div id="page-user-list"> <div id="page-user-list">
<a-card title="لیست مدیران"> <a-card title="لیست مدیران">
<!-- <!-- legacy ListUser.vue:4 — the add-user button, gated on the same rid. -->
legacy ListUser.vue:4 — the add-user button, gated on the same rid. <ListPageHeader rid="user.list.create" add-label="افزودن کاربر" @add="openAdd" />
It was dropped in the original port, which left the create route
(/user/create) reachable only by typing the URL.
-->
<a-button
v-if="hasPermission('user.list.create')"
type="primary"
class="mb-5"
@click="router.push('/user/create')"
>
افزودن کاربر
</a-button>
<XTable ref="tbl" :model="model"> <XTable ref="tbl" :model="model">
<template #ut="{ text }"> <template #ut="{ text }">
@@ -27,7 +16,7 @@
<template #op="{ record }"> <template #op="{ record }">
<a-tooltip v-if="hasPermission('user.list.edit')" title="ویرایش"> <a-tooltip v-if="hasPermission('user.list.edit')" title="ویرایش">
<EditOutlined class="cursor-pointer mx-1 text-primary" @click="edit(record)" /> <EditOutlined class="cursor-pointer mx-1 text-primary" @click="openEdit(record)" />
</a-tooltip> </a-tooltip>
<a-tooltip v-if="hasPermission('user.list.remove')" title="حذف"> <a-tooltip v-if="hasPermission('user.list.remove')" title="حذف">
@@ -43,22 +32,24 @@
</a-tooltip> </a-tooltip>
</template> </template>
</XTable> </XTable>
<UserCreate :key="editingId ?? 'new'" :open="modalOpen" :id="editingId" @update:open="modalOpen = $event" @success="tbl?.fetch()" />
</a-card> </a-card>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue'; import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { message } from 'ant-design-vue'; import { message } from 'ant-design-vue';
import { DeleteOutlined, EditOutlined } from '@ant-design/icons-vue'; import { DeleteOutlined, EditOutlined } from '@ant-design/icons-vue';
import XTable from '../../components/x-table/XTable.vue'; import XTable from '../../components/x-table/XTable.vue';
import ListPageHeader from '../../components/common/ListPageHeader.vue';
import UserCreate from './UserCreate.vue';
import { buildUserListModel } from './userTbl'; import { buildUserListModel } from './userTbl';
import { useUsersStore } from '../../stores/users'; import { useUsersStore } from '../../stores/users';
import { useDeleteHandler } from '../../composables/useDeleteHandler'; import { useDeleteHandler } from '../../composables/useDeleteHandler';
import api from '../../api/client'; import api from '../../api/client';
const router = useRouter();
const users = useUsersStore(); const users = useUsersStore();
const hasPermission = (rid: string) => Boolean(users.hasPermission(rid)); const hasPermission = (rid: string) => Boolean(users.hasPermission(rid));
@@ -66,13 +57,22 @@ const model = buildUserListModel();
const tbl = ref<{ fetch: () => Promise<void> } | null>(null); const tbl = ref<{ fetch: () => Promise<void> } | null>(null);
const userTypes = ref<{ id: number; title: string }[]>([]); const userTypes = ref<{ id: number; title: string }[]>([]);
const modalOpen = ref(false);
const editingId = ref<string | undefined>(undefined);
function userTypeTitle(ut: number | null | undefined) { function userTypeTitle(ut: number | null | undefined) {
if (ut === null || ut === undefined) return '-'; if (ut === null || ut === undefined) return '-';
return userTypes.value.find((t) => t.id === Number(ut))?.title ?? String(ut); return userTypes.value.find((t) => t.id === Number(ut))?.title ?? String(ut);
} }
function edit(record: Record<string, any>) { function openAdd() {
router.push(`/user/edit/${record.id}`); editingId.value = undefined;
modalOpen.value = true;
}
function openEdit(record: Record<string, any>) {
editingId.value = record.id;
modalOpen.value = true;
} }
// The backend refuses when the admin owns ledger entries or status events — // The backend refuses when the admin owns ledger entries or status events —
+85 -3
View File
@@ -2,12 +2,77 @@
<!-- <!--
Ported from legacy henza_admin/src/views/user/UserCreate.vue. Ported from legacy henza_admin/src/views/user/UserCreate.vue.
Serves BOTH create and edit — UserEdit.vue is only a wrapper around it. Serves BOTH create and edit — UserEdit.vue is only a wrapper around it.
Converted to a modal (BaseFormModal): the /user/create and /user/edit/:id
routes still exist and still render this component directly as a
route-based fallback, but ListUser.vue now opens it as a modal via
open/id props.
Legacy fields: fname, lname, mobile, ut, password, password_confirmation. Legacy fields: fname, lname, mobile, ut, password, password_confirmation.
The schema stores one `fullName`, so the two name inputs are joined on submit The schema stores one `fullName`, so the two name inputs are joined on submit
and split back on load. and split back on load.
--> -->
<a-spin :spinning="loading"> <BaseFormModal
v-if="asModal"
:open="Boolean(open)"
:id="id"
add-title="افزودن مدیر"
edit-title="ویرایش مدیر"
:loading="loading"
:submitting="loading"
@update:open="$emit('update:open', $event)"
@submit="handleSubmit"
>
<div class="grid-3">
<a-form-item label="نام" :validate-status="err.fname ? 'error' : ''" :help="err.fname">
<a-input v-model:value="form.fname" />
</a-form-item>
<a-form-item
label="نام خانوادگی"
:validate-status="err.lname ? 'error' : ''"
:help="err.lname"
>
<a-input v-model:value="form.lname" />
</a-form-item>
<a-form-item
label="شماره موبایل"
:validate-status="err.mobile ? 'error' : ''"
:help="err.mobile"
>
<a-input v-model:value="form.mobile" />
</a-form-item>
<a-form-item label="نوع کاربری" :validate-status="err.ut ? 'error' : ''" :help="err.ut">
<a-select v-model:value="form.ut" allow-clear placeholder="انتخاب کنید">
<a-select-option v-for="t in userTypes" :key="t.id" :value="t.id">
{{ t.title }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item
label="رمز عبور"
:validate-status="err.password ? 'error' : ''"
:help="err.password"
>
<a-input-password v-model:value="form.password" />
</a-form-item>
<a-form-item
label="تأیید رمز عبور"
:validate-status="err.password_confirmation ? 'error' : ''"
:help="err.password_confirmation"
>
<a-input-password v-model:value="form.password_confirmation" />
</a-form-item>
</div>
<p v-if="id" class="hint">برای تغییر ندادن رمز عبور، هر دو فیلد رمز را خالی بگذارید.</p>
</BaseFormModal>
<!-- route-based fallback for direct links to /user/create and /user/edit/:id -->
<a-spin v-else :spinning="loading">
<a-form layout="vertical" @submit.prevent="handleSubmit"> <a-form layout="vertical" @submit.prevent="handleSubmit">
<a-card :title="id ? 'ویرایش مدیر' : 'افزودن مدیر'" class="mb-base"> <a-card :title="id ? 'ویرایش مدیر' : 'افزودن مدیر'" class="mb-base">
<div class="grid-3"> <div class="grid-3">
@@ -68,12 +133,24 @@
import { onMounted, reactive, ref } from 'vue'; import { onMounted, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { message } from 'ant-design-vue'; import { message } from 'ant-design-vue';
import BaseFormModal from '../../components/common/BaseFormModal.vue';
import api from '../../api/client'; import api from '../../api/client';
const props = defineProps<{
open?: boolean;
id?: string;
}>();
const emit = defineEmits<{
(e: 'update:open', value: boolean): void;
(e: 'success'): void;
}>();
const asModal = props.open !== undefined;
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const id = route.params.id as string | undefined; const id = asModal ? props.id : (route.params.id as string | undefined);
const loading = ref(false); const loading = ref(false);
const userTypes = ref<{ id: number; title: string }[]>([]); const userTypes = ref<{ id: number; title: string }[]>([]);
@@ -121,7 +198,12 @@ async function handleSubmit() {
await api.post('users', payload); await api.post('users', payload);
} }
message.info('اطلاعات با موفقیت به ثبت رسید.'); message.info('اطلاعات با موفقیت به ثبت رسید.');
router.push('/user/list'); if (asModal) {
emit('success');
emit('update:open', false);
} else {
router.push('/user/list');
}
} catch (e: any) { } catch (e: any) {
message.error(e?.response?.data?.message ?? 'خطا در ثبت اطلاعات.'); message.error(e?.response?.data?.message ?? 'خطا در ثبت اطلاعات.');
} finally { } finally {