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');