admin-panel: add shared modal/header/icon/delete-handler components

New src/components/common/:
- BaseFormModal.vue — the standard shell every converted add/edit modal will
  use: header=title, footer=buttons, body=form. Replaces the ad-hoc
  60%/70%/80% <a-modal> widths used across the codebase with 4 named sizes.
- ListPageHeader.vue — the add button that goes above every list page,
  gated on the same rid the create route used (modelled on the one place
  this already existed, ListUser.vue's add-user button).

New src/components/icons.ts: the legacy-Feather-name -> ant-icon map,
extracted out of TooltipBtn.vue so the sidebar can resolve the same names
through the same map instead of a second copy.

New src/composables/useDeleteHandler.ts: the delete-then-refetch-then-toast
handler that was copy-pasted verbatim in 6 list views (city/cityList.vue,
user/ListUser.vue, apps/category/List.vue, finical/list.vue,
apps/product/ProductList.vue, manage/Const.vue). Preserves each site's exact
success-message wording and message.success/info choice, and ProductList's
irregular POST-based delete endpoint.

Verified: pnpm build passes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
s.esmaeili
2026-09-14 15:18:17 +03:30
co-authored by Claude Sonnet 5
parent f0de478940
commit 02d92fc465
11 changed files with 321 additions and 101 deletions
@@ -0,0 +1,76 @@
<template>
<!--
Standard shell for every "add/edit" modal in the panel: header = title,
footer = action buttons, body = the form (passed in via the default slot).
The 16 forms converted from full pages to modals all use this instead of
hand-rolling <a-modal> + <a-card> + a trailing button, which used to be
duplicated 16 times with inconsistent widths (60%/70%/80%/default).
-->
<a-modal
:open="open"
:title="id ? editTitle : addTitle"
:width="WIDTH[width]"
:confirm-loading="submitting"
:ok-text="okText"
cancel-text="انصراف"
:mask-closable="false"
@update:open="onUpdateOpen"
@ok="$emit('submit')"
@cancel="onCancel"
>
<a-spin :spinning="loading">
<a-form layout="vertical">
<slot />
</a-form>
</a-spin>
</a-modal>
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
open: boolean;
/** Presence of `id` switches the modal into edit mode (title only — the
* form body decides its own create/edit behaviour from the same prop). */
id?: string;
addTitle: string;
editTitle: string;
/** Drives the inner a-spin, e.g. while loading the record being edited. */
loading?: boolean;
/** Drives the OK button's loading state while a submit is in flight. */
submitting?: boolean;
/** 520 / 720 / 900 / 1140 — replaces the old ad-hoc 60%/70%/80% widths. */
width?: 'sm' | 'md' | 'lg' | 'xl';
okText?: string;
}>(),
{
id: undefined,
loading: false,
submitting: false,
width: 'md',
okText: 'ثبت اطلاعات',
},
);
const emit = defineEmits<{
(e: 'update:open', value: boolean): void;
(e: 'submit'): void;
(e: 'cancel'): void;
}>();
const WIDTH: Record<'sm' | 'md' | 'lg' | 'xl', number> = {
sm: 520,
md: 720,
lg: 900,
xl: 1140,
};
function onUpdateOpen(value: boolean) {
emit('update:open', value);
}
function onCancel() {
emit('update:open', false);
emit('cancel');
}
</script>
@@ -0,0 +1,42 @@
<template>
<!--
Standard header for every list page: the section title plus, when the
caller has one, an "add" button ABOVE the list — same rid the create
route used to gate on, so permissions are not re-derived by hand.
Modelled on the one place this already existed (ListUser.vue's add-user
button) and generalised so every list page looks the same.
-->
<div class="list-page-header">
<a-button v-if="!rid || hasPermission(rid)" type="primary" @click="$emit('add')">
<template #icon><PlusOutlined /></template>
{{ addLabel }}
</a-button>
</div>
</template>
<script setup lang="ts">
import { PlusOutlined } from '@ant-design/icons-vue';
import { useUsersStore } from '../../stores/users';
withDefaults(
defineProps<{
/** Permission id gating the button; omit if the action has no rid of its own. */
rid?: string;
addLabel?: string;
}>(),
{ rid: undefined, addLabel: 'افزودن' },
);
defineEmits<{ (e: 'add'): void }>();
const users = useUsersStore();
const hasPermission = (id: string) => Boolean(users.hasPermission(id));
</script>
<style scoped>
.list-page-header {
display: flex;
justify-content: flex-end;
margin-bottom: 1rem;
}
</style>
+81
View File
@@ -0,0 +1,81 @@
// Shared legacy-Feather-name -> ant-design-vue icon map.
//
// Originally lived only inside TooltipBtn.vue (row-action icons). Extracted
// here so the sidebar (navMenuItems.ts icon: values) can resolve the SAME
// names through the SAME map instead of duplicating it — one source of
// truth for "legacy Feather icon name" -> "ant component" across the app.
import {
AppstoreOutlined,
BarChartOutlined,
BellOutlined,
BlockOutlined,
BookOutlined,
CameraOutlined,
CheckOutlined,
CloseOutlined,
CreditCardOutlined,
DeleteOutlined,
DollarOutlined,
EditOutlined,
EyeOutlined,
FileOutlined,
FileTextOutlined,
GiftOutlined,
HomeOutlined,
InboxOutlined,
MailOutlined,
MessageOutlined,
PercentageOutlined,
PlusOutlined,
SettingOutlined,
ShoppingOutlined,
StopOutlined,
TeamOutlined,
UnorderedListOutlined,
UserAddOutlined,
UserOutlined,
} from '@ant-design/icons-vue';
/** legacy Feather name -> ant component. */
export const ICONS: Record<string, unknown> = {
// row-action icons (TooltipBtn.vue's original set)
ListIcon: UnorderedListOutlined,
FileIcon: FileOutlined,
TrashIcon: DeleteOutlined,
EditIcon: EditOutlined,
CheckIcon: CheckOutlined,
CameraIcon: CameraOutlined,
EyeIcon: EyeOutlined,
PlusIcon: PlusOutlined,
XIcon: CloseOutlined,
FileTextIcon: FileTextOutlined,
// sidebar icons (navMenuItems.ts), added when the sidebar started
// rendering them for the first time
HomeIcon: HomeOutlined,
PackageIcon: InboxOutlined,
UserIcon: UserOutlined,
UsersIcon: TeamOutlined,
UserPlusIcon: UserAddOutlined,
ShoppingBagIcon: ShoppingOutlined,
BoxIcon: InboxOutlined,
GridIcon: AppstoreOutlined,
LayersIcon: BlockOutlined,
CreditCardIcon: CreditCardOutlined,
DollarSignIcon: DollarOutlined,
PercentIcon: PercentageOutlined,
GiftIcon: GiftOutlined,
SlashIcon: StopOutlined,
BookIcon: BookOutlined,
MessageSquareIcon: MessageOutlined,
MailIcon: MailOutlined,
BellIcon: BellOutlined,
BarChartIcon: BarChartOutlined,
SettingsIcon: SettingOutlined,
InboxIcon: InboxOutlined,
};
export function resolveIcon(name: string | undefined): unknown | undefined {
if (!name) return undefined;
return ICONS[name];
}
@@ -5,8 +5,9 @@
it confirms first, without it fires immediately.
Legacy rendered <feather-icon icon="ListIcon">. vue-feather has no Vue 3
build, so the feather NAME is mapped to an ant-design icon here. Ported views
keep writing icon="ListIcon" unchanged.
build, so the feather NAME is mapped to an ant-design icon via the shared
map in components/icons.ts (also used by the sidebar). Ported views keep
writing icon="ListIcon" unchanged.
-->
<div>
<a-tooltip :title="title">
@@ -33,18 +34,8 @@
<script setup lang="ts">
import { computed } from 'vue';
import {
CameraOutlined,
CheckOutlined,
DeleteOutlined,
EditOutlined,
EyeOutlined,
FileOutlined,
FileTextOutlined,
PlusOutlined,
UnorderedListOutlined,
CloseOutlined,
} from '@ant-design/icons-vue';
import { UnorderedListOutlined } from '@ant-design/icons-vue';
import { resolveIcon } from '../icons';
const props = withDefaults(
defineProps<{
@@ -58,21 +49,7 @@ const props = withDefaults(
const emit = defineEmits<{ (e: 'click'): void }>();
/** feather name -> ant component */
const ICONS: Record<string, unknown> = {
ListIcon: UnorderedListOutlined,
FileIcon: FileOutlined,
TrashIcon: DeleteOutlined,
EditIcon: EditOutlined,
CheckIcon: CheckOutlined,
CameraIcon: CameraOutlined,
EyeIcon: EyeOutlined,
PlusIcon: PlusOutlined,
XIcon: CloseOutlined,
FileTextIcon: FileTextOutlined,
};
const iconComponent = computed(() => ICONS[props.icon ?? ''] ?? UnorderedListOutlined);
const iconComponent = computed(() => resolveIcon(props.icon) ?? UnorderedListOutlined);
function onClick() {
emit('click');
@@ -0,0 +1,51 @@
// Shared "delete a row, refresh the list, show the server's refusal reason"
// handler. This exact try/catch body — api.delete, then either a success
// toast + refetch, or Modal.error with the server's message — was copy-pasted
// verbatim in 6 list views (city/cityList.vue, user/ListUser.vue,
// apps/category/List.vue, finical/list.vue, apps/product/ProductList.vue,
// manage/Const.vue). Centralised here as a composable rather than a component
// since it is pure behaviour with no template.
import { Modal, message } from 'ant-design-vue';
import api from '../api/client';
export function useDeleteHandler(options: {
/** Called after a confirmed, successful delete — typically `table.value?.fetch()`. */
onSuccess: () => void | Promise<void>;
successMessage?: string;
/** Some call sites used message.info rather than message.success; preserved
* per-site rather than silently unified. */
successType?: 'success' | 'info';
/** Shown when the server didn't return its own message. */
fallbackErrorMessage?: string;
}) {
const {
onSuccess,
successMessage = 'با موفقیت حذف شد',
successType = 'success',
fallbackErrorMessage = 'خطا در حذف',
} = options;
async function remove(url: string, opts?: { method?: 'delete' | 'post' }) {
try {
// ProductList's delete is a legacy irregular endpoint (POST, not
// DELETE) — see docs/ADMIN-PORT-PLAN.md's endpoint-mapping notes.
// `method` lets that call site keep using this composable too.
if (opts?.method === 'post') {
await api.post(url);
} else {
await api.delete(url);
}
message[successType](successMessage);
await onSuccess();
} catch (e: any) {
// The backend refuses deletion while other records still reference the
// row (e.g. a city with customers/branches, a brand with products).
Modal.error({
title: e?.response?.data?.message ?? fallbackErrorMessage,
okText: 'بستن',
});
}
}
return { remove };
}
@@ -25,7 +25,7 @@
placement="top"
ok-text="بله"
cancel-text="خیر"
@confirm="confirmDelete(record.id)"
@confirm="confirmDelete(`category/${record.id}`)"
>
<template #title><p>آیا مطمئن به حذف دسته مورد نظر هستید ؟</p></template>
<DeleteOutlined class="cursor-pointer mx-1 text-danger" />
@@ -40,11 +40,12 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { message, Modal } from 'ant-design-vue';
import { message } from 'ant-design-vue';
import { DeleteOutlined, EditOutlined } from '@ant-design/icons-vue';
import XTable from '../../../components/x-table/XTable.vue';
import { buildCategoryModel } from './CategoryTbl';
import { useUsersStore } from '../../../stores/users';
import { useDeleteHandler } from '../../../composables/useDeleteHandler';
import api from '../../../api/client';
const router = useRouter();
@@ -55,16 +56,13 @@ const noImage = '/img/no-items.png';
const model = buildCategoryModel();
const tbl = ref<{ fetch: () => Promise<void> } | null>(null);
async function confirmDelete(id: string) {
try {
await api.delete(`category/${id}`);
message.info('دسته حذف شد');
tbl.value?.fetch();
} catch (e: any) {
// The backend refuses while products or child categories still reference it.
Modal.error({ title: e?.response?.data?.message ?? 'خطا در حذف دسته', okText: 'بستن' });
}
}
// The backend refuses while products or child categories still reference it.
const { remove: confirmDelete } = useDeleteHandler({
onSuccess: () => tbl.value?.fetch(),
successMessage: 'دسته حذف شد',
successType: 'info',
fallbackErrorMessage: 'خطا در حذف دسته',
});
function toEdit(id: string) {
router.push(`/category/edit/${id}`);
@@ -95,7 +95,7 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { message, Modal } from 'ant-design-vue';
import { message } from 'ant-design-vue';
import {
DeleteOutlined,
EditOutlined,
@@ -105,6 +105,7 @@ import XTable from '../../../components/x-table/XTable.vue';
import { buildProductListModel } from './productListModel';
import { useUsersStore } from '../../../stores/users';
import { isBranch } from '../../../utils';
import { useDeleteHandler } from '../../../composables/useDeleteHandler';
import api from '../../../api/client';
const router = useRouter();
@@ -132,19 +133,17 @@ async function showProductModal(record: Record<string, any>) {
visibleProduct.value = true;
}
async function confirmDelete(id: string) {
try {
await api.post(`delete/product/${id}`);
message.info('محصول حذف شد');
tblProducts.value?.fetch();
} catch (e: any) {
// The backend refuses when the product appears in past orders; show its
// Farsi explanation rather than a generic failure.
Modal.error({
title: e?.response?.data?.message ?? 'خطا در حذف محصول',
okText: 'بستن',
});
}
// The backend refuses when the product appears in past orders; show its
// Farsi explanation rather than a generic failure.
const { remove: confirmDeleteById } = useDeleteHandler({
onSuccess: () => tblProducts.value?.fetch(),
successMessage: 'محصول حذف شد',
successType: 'info',
fallbackErrorMessage: 'خطا در حذف محصول',
});
function confirmDelete(id: string) {
return confirmDeleteById(`delete/product/${id}`, { method: 'post' });
}
function toEdit(id: string) {
+9 -12
View File
@@ -25,7 +25,7 @@
placement="top"
ok-text="بله"
cancel-text="خیر"
@confirm="confirmDelete(record.id)"
@confirm="confirmDelete(`cities/${record.id}`)"
>
<template #title><p>آیا مطمئن به حذف شهر مورد نظر هستید ؟</p></template>
<DeleteOutlined class="cursor-pointer mx-1 text-danger" />
@@ -53,11 +53,12 @@
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { message, Modal } from 'ant-design-vue';
import { message } from 'ant-design-vue';
import { DeleteOutlined, EditOutlined } from '@ant-design/icons-vue';
import XTable from '../../components/x-table/XTable.vue';
import { buildCityListModel } from './CityListModel';
import { useUsersStore } from '../../stores/users';
import { useDeleteHandler } from '../../composables/useDeleteHandler';
import api from '../../api/client';
const users = useUsersStore();
@@ -106,15 +107,11 @@ async function save() {
}
}
async function confirmDelete(id: string) {
try {
await api.delete(`cities/${id}`);
message.success('شهر حذف شد');
citiesTable.value?.fetch();
} catch (e: any) {
// The backend refuses while customers, branches or addresses reference it.
Modal.error({ title: e?.response?.data?.message ?? 'خطا در حذف شهر', okText: 'بستن' });
}
}
// The backend refuses while customers, branches or addresses reference it.
const { remove: confirmDelete } = useDeleteHandler({
onSuccess: () => citiesTable.value?.fetch(),
successMessage: 'شهر حذف شد',
fallbackErrorMessage: 'خطا در حذف شهر',
});
</script>
+12 -11
View File
@@ -64,7 +64,7 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { message, Modal } from 'ant-design-vue';
import { message } from 'ant-design-vue';
import {
CheckOutlined,
CloseOutlined,
@@ -74,6 +74,7 @@ import {
import XTable from '../../components/x-table/XTable.vue';
import { buildDocumentListModel } from './list_model';
import { useUsersStore } from '../../stores/users';
import { useDeleteHandler } from '../../composables/useDeleteHandler';
import api from '../../api/client';
const router = useRouter();
@@ -99,16 +100,16 @@ async function changeConfirme(record: Record<string, any>) {
}
}
async function deleteCash(record: Record<string, any>) {
try {
await api.delete(`cash/${record.id}`);
message.success('سند حذف شد');
tbl.value?.fetch();
} catch (e: any) {
// The backend refuses to delete a confirmed entry — it has already moved the
// customer's balance. Surface its Farsi explanation.
Modal.error({ title: e?.response?.data?.message ?? 'خطا در حذف سند', okText: 'بستن' });
}
// The backend refuses to delete a confirmed entry — it has already moved the
// customer's balance. Surface its Farsi explanation.
const { remove: deleteCashById } = useDeleteHandler({
onSuccess: () => tbl.value?.fetch(),
successMessage: 'سند حذف شد',
fallbackErrorMessage: 'خطا در حذف سند',
});
function deleteCash(record: Record<string, any>) {
return deleteCashById(`cash/${record.id}`);
}
function toEdit(id: string) {
+11 -10
View File
@@ -167,9 +167,10 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { message, Modal } from 'ant-design-vue';
import { message } from 'ant-design-vue';
import { DeleteOutlined, EditOutlined } from '@ant-design/icons-vue';
import { useUsersStore } from '../../stores/users';
import { useDeleteHandler } from '../../composables/useDeleteHandler';
import api from '../../api/client';
import ImageUploadInput from '../../components/upload/ImageUploadInput.vue';
@@ -241,15 +242,15 @@ async function saveBrandEdit() {
}
}
async function removeBrand(id: string) {
try {
await api.delete(`brand/${id}`);
await loadBrands();
message.success('برند حذف شد');
} catch (e: any) {
// refused while products reference it
Modal.error({ title: e?.response?.data?.message ?? 'خطا در حذف برند', okText: 'بستن' });
}
// refused while products reference it
const { remove: removeBrandById } = useDeleteHandler({
onSuccess: loadBrands,
successMessage: 'برند حذف شد',
fallbackErrorMessage: 'خطا در حذف برند',
});
function removeBrand(id: string) {
return removeBrandById(`brand/${id}`);
}
// ── tags
+10 -13
View File
@@ -35,7 +35,7 @@
placement="top"
ok-text="بله"
cancel-text="خیر"
@confirm="confirmDelete(record.id)"
@confirm="confirmDelete(`users/${record.id}`)"
>
<template #title><p>آیا مطمئن به حذف کاربر مورد نظر هستید ؟</p></template>
<DeleteOutlined class="cursor-pointer mx-1 text-danger" />
@@ -50,11 +50,12 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { message, Modal } from 'ant-design-vue';
import { message } from 'ant-design-vue';
import { DeleteOutlined, EditOutlined } from '@ant-design/icons-vue';
import XTable from '../../components/x-table/XTable.vue';
import { buildUserListModel } from './userTbl';
import { useUsersStore } from '../../stores/users';
import { useDeleteHandler } from '../../composables/useDeleteHandler';
import api from '../../api/client';
const router = useRouter();
@@ -74,17 +75,13 @@ function edit(record: Record<string, any>) {
router.push(`/user/edit/${record.id}`);
}
async function confirmDelete(id: string) {
try {
await api.delete(`users/${id}`);
message.success('کاربر حذف شد');
tbl.value?.fetch();
} catch (e: any) {
// The backend refuses when the admin owns ledger entries or status events —
// deleting them would erase the audit trail.
Modal.error({ title: e?.response?.data?.message ?? 'خطا در حذف کاربر', okText: 'بستن' });
}
}
// The backend refuses when the admin owns ledger entries or status events —
// deleting them would erase the audit trail.
const { remove: confirmDelete } = useDeleteHandler({
onSuccess: () => tbl.value?.fetch(),
successMessage: 'کاربر حذف شد',
fallbackErrorMessage: 'خطا در حذف کاربر',
});
onMounted(async () => {
try {