feat(web): continue Ant settings migration and tighten page chrome

Migrate remaining settings screens to Ant Design Vue, densify teams/roles UI, drop page-header subtitles project-wide, and polish notes cards plus contacts row actions.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-04 10:05:28 +03:30
co-authored by Cursor
parent b11affbdfd
commit cb3a488b21
46 changed files with 6024 additions and 7679 deletions
+192 -284
View File
@@ -6,7 +6,37 @@ import { api } from '@/lib/api';
import { canAssignToOthers } from '@/lib/assign-access';
import { useAuthStore } from '@/stores/auth';
import type { OrgUserDto, ProjectCardDto } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import {
Affix as AAffix,
Alert as AAlert,
Breadcrumb as ABreadcrumb,
BreadcrumbItem as ABreadcrumbItem,
Button as AButton,
Card as ACard,
Col as ACol,
Descriptions as ADescriptions,
DescriptionsItem as ADescriptionsItem,
Divider as ADivider,
Flex as AFlex,
Form as AForm,
FormItem as AFormItem,
Input as AInput,
Row as ARow,
Select as ASelect,
Space as ASpace,
Spin as ASpin,
Textarea as ATextarea,
Typography,
} from 'ant-design-vue';
import {
CheckOutlined,
CloseOutlined,
DeleteOutlined,
FileTextOutlined,
SaveOutlined,
} from '@ant-design/icons-vue';
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
const props = withDefaults(
defineProps<{
@@ -43,6 +73,29 @@ const tagsRaw = ref('');
const selectedAssigneeIds = ref<string[]>([]);
const users = ref<OrgUserDto[]>([]);
const userOptions = computed(() =>
users.value.map((u) => ({ value: u.id, label: u.fullName })),
);
const parsedTags = computed(() => parseTags(tagsRaw.value));
const summaryItems = computed(() => [
{ label: 'عنوان', value: title.value.trim() || '—' },
{ label: 'برچسب‌ها', value: parsedTags.value.length ? parsedTags.value.join('، ') : '—' },
{
label: 'مسئولان',
value: selectedAssigneeIds.value.length
? users.value
.filter((u) => selectedAssigneeIds.value.includes(u.id))
.map((u) => u.fullName)
.join('، ') || `${selectedAssigneeIds.value.length} نفر`
: '—',
},
{ label: 'یادداشت', value: notes.value.trim() ? 'دارد' : 'ندارد' },
]);
const canSave = computed(() => !saving.value && title.value.trim().length >= 2);
function parseTags(raw: string): string[] {
return [
...new Set(
@@ -161,293 +214,148 @@ onMounted(async () => {
</script>
<template>
<section class="page" :class="{ embedded }">
<div v-if="embedded" class="embed-actions">
<button
class="btn primary"
type="button"
:disabled="saving || title.trim().length < 2"
@click="save"
>
<a-flex vertical :gap="16">
<a-flex v-if="embedded" wrap="wrap" :gap="8">
<a-button type="primary" :loading="saving" :disabled="!canSave" @click="save">
<template #icon><SaveOutlined /></template>
{{ saving ? 'در حال ذخیره…' : 'ذخیره' }}
</button>
<button class="btn cancel" type="button" @click="cancel">انصراف</button>
<button v-if="!isNew" class="btn danger" type="button" @click="removeCard">حذف</button>
</div>
<header v-if="!embedded" class="desktop-head">
<div class="title-block">
<span class="doc-ico"><UiIcon name="document" :size="22" /></span>
<div>
<h1>{{ pageTitle }}</h1>
<p class="crumb">
</a-button>
<a-button @click="cancel">انصراف</a-button>
<a-button v-if="!isNew" danger @click="removeCard">
<template #icon><DeleteOutlined /></template>
حذف
</a-button>
</a-flex>
<a-flex
v-else
justify="space-between"
align="flex-start"
wrap="wrap"
:gap="12"
>
<a-flex vertical :gap="4">
<a-breadcrumb>
<a-breadcrumb-item>
<RouterLink :to="{ name: 'projects' }">پروژه‌ها</RouterLink>
<span>›</span>
<strong>{{ pageTitle }}</strong>
</p>
</div>
</div>
<div class="desk-actions">
<button
class="btn primary"
type="button"
:disabled="saving || title.trim().length < 2"
@click="save"
>
</a-breadcrumb-item>
<a-breadcrumb-item>{{ pageTitle }}</a-breadcrumb-item>
</a-breadcrumb>
<a-flex align="center" :gap="8">
<FileTextOutlined />
<a-typography-title :level="3" :style="{ margin: 0 }">{{ pageTitle }}</a-typography-title>
</a-flex>
</a-flex>
<a-space wrap>
<a-button type="primary" :loading="saving" :disabled="!canSave" @click="save">
<template #icon><CheckOutlined /></template>
{{ saving ? 'در حال ذخیره…' : 'ذخیره' }}
</button>
<button class="btn cancel" type="button" @click="cancel">انصراف</button>
<button v-if="!isNew" class="btn danger" type="button" @click="removeCard">حذف</button>
</div>
</header>
</a-button>
<a-button @click="cancel">
<template #icon><CloseOutlined /></template>
انصراف
</a-button>
<a-button v-if="!isNew" danger @click="removeCard">
<template #icon><DeleteOutlined /></template>
حذف
</a-button>
</a-space>
</a-flex>
<header v-if="!embedded" class="mobile-head">
<button
class="icon-btn"
type="button"
aria-label="ذخیره"
:disabled="saving || title.trim().length < 2"
@click="save"
>
<UiIcon name="check-plain" :size="22" />
</button>
<h1>{{ pageTitle }}</h1>
<button class="icon-btn" type="button" aria-label="بستن" @click="cancel">
<UiIcon name="x" :size="20" />
</button>
</header>
<a-alert v-if="error" type="error" show-icon :message="error" />
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
<a-spin :spinning="loading">
<a-row v-if="!loading" :gutter="[24, 24]" align="top">
<a-col :xs="24" :lg="17" :xl="18">
<a-card :bordered="false">
<a-form layout="vertical" @finish="save">
<a-divider orientation="right">اطلاعات کارت</a-divider>
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="عنوان" required>
<a-input
v-model:value="title"
:maxlength="200"
placeholder="عنوان کارت"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="برچسب‌ها">
<a-input
v-model:value="tagsRaw"
placeholder="فوری، پشتیبانی (با کاما جدا کنید)"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="مسئولان کارت">
<a-select
v-model:value="selectedAssigneeIds"
mode="multiple"
:options="userOptions"
:disabled="!canAssignOthers"
placeholder="انتخاب مسئولان"
style="width: 100%"
option-filter-prop="label"
/>
<a-typography-text type="secondary">
<template v-if="canAssignOthers">
می‌توانید چند نفر را انتخاب کنید.
</template>
<template v-else>
بدون دسترسی «تخصیص کارت پروژه به دیگران» کارت فقط برای خودتان ثبت می‌شود.
</template>
</a-typography-text>
</a-form-item>
</a-col>
</a-row>
<form v-else class="card" @submit.prevent="save">
<div class="field">
<label for="card-title">عنوان</label>
<input id="card-title" v-model="title" type="text" required minlength="2" maxlength="200" />
</div>
<div class="field">
<label for="card-tags">برچسب‌ها</label>
<input
id="card-tags"
v-model="tagsRaw"
type="text"
placeholder="فوری، پشتیبانی (با کاما جدا کنید)"
/>
</div>
<div class="field">
<label for="card-assignees">مسئولان کارت</label>
<select id="card-assignees" v-model="selectedAssigneeIds" multiple class="multi" :disabled="!canAssignOthers">
<option v-for="u in users" :key="u.id" :value="u.id">{{ u.fullName }}</option>
</select>
<small v-if="canAssignOthers" class="hint">با نگه داشتن Ctrl می‌توانید چند نفر را انتخاب کنید.</small>
<small v-else class="hint">بدون دسترسی «تخصیص کارت پروژه به دیگران» کارت فقط برای خودتان ثبت می‌شود.</small>
</div>
<div class="field">
<label for="card-notes">یادداشت</label>
<textarea id="card-notes" v-model="notes" rows="5" maxlength="4000" />
</div>
<div v-if="!embedded" class="mobile-actions">
<button class="btn primary" type="submit" :disabled="saving || title.trim().length < 2">
{{ saving ? 'در حال ذخیره…' : 'ذخیره' }}
</button>
<button v-if="!isNew" class="btn danger" type="button" @click="removeCard">حذف</button>
</div>
</form>
</section>
<a-divider orientation="right">یادداشت</a-divider>
<a-row :gutter="[16, 0]">
<a-col :span="24">
<a-form-item label="یادداشت">
<a-textarea
v-model:value="notes"
:rows="5"
:maxlength="4000"
style="width: 100%"
show-count
/>
</a-form-item>
</a-col>
</a-row>
<a-flex v-if="!embedded" justify="flex-end" wrap="wrap" :gap="8">
<a-button type="primary" html-type="submit" :loading="saving" :disabled="!canSave">
<template #icon><SaveOutlined /></template>
{{ saving ? 'در حال ذخیره…' : 'ذخیره' }}
</a-button>
<a-button v-if="!isNew" danger @click="removeCard">
<template #icon><DeleteOutlined /></template>
حذف
</a-button>
</a-flex>
</a-form>
</a-card>
</a-col>
<a-col :xs="24" :lg="7" :xl="6">
<a-affix :offset-top="24">
<a-card title="خلاصه کارت" :bordered="false">
<a-descriptions :column="1" size="small" bordered>
<a-descriptions-item
v-for="item in summaryItems"
:key="item.label"
:label="item.label"
>
{{ item.value }}
</a-descriptions-item>
</a-descriptions>
</a-card>
</a-affix>
</a-col>
</a-row>
</a-spin>
</a-flex>
</template>
<style scoped>
.page {
display: grid;
gap: 0.85rem;
max-width: 100%;
min-width: 0;
width: 100%;
}
.page.embedded {
max-width: none;
}
.embed-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.desktop-head {
display: none;
justify-content: space-between;
gap: 1rem;
align-items: center;
}
.title-block {
display: flex;
align-items: center;
gap: 0.75rem;
}
.doc-ico {
width: 44px;
height: 44px;
border-radius: var(--radius);
display: grid;
place-items: center;
background: #ecfdf5;
color: #0f766e;
}
h1 {
margin: 0;
font-size: 1.35rem;
}
.crumb {
margin: 0.2rem 0 0;
display: flex;
align-items: center;
gap: 0.35rem;
color: #94a3b8;
font-size: 0.82rem;
}
.crumb a {
color: #0f766e;
font-weight: 700;
text-decoration: none;
}
.desk-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.mobile-head {
display: grid;
grid-template-columns: 40px 1fr 40px;
align-items: center;
}
.mobile-head h1 {
text-align: center;
font-size: 1.05rem;
}
.icon-btn {
width: 40px;
height: 40px;
border: 0;
background: none;
color: #0f766e;
display: grid;
place-items: center;
cursor: pointer;
}
.card {
display: grid;
gap: 0.85rem;
background: #fff;
border-radius: var(--radius);
padding: 1rem;
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.04);
}
.field {
display: grid;
gap: 0.35rem;
}
.field label {
color: #64748b;
font-size: 0.88rem;
font-weight: 700;
}
input,
textarea,
select {
width: 100%;
min-width: 0;
border: 1px solid #e2e8f0;
border-radius: var(--radius);
min-height: var(--control-h);
padding: 0 0.75rem;
font: inherit;
box-sizing: border-box;
}
select.multi {
min-height: 110px;
padding: 0.4rem;
}
.hint {
color: #64748b;
font-size: 0.8rem;
}
textarea {
min-height: 5.5rem;
padding: 0.65rem 0.85rem;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
min-height: var(--control-h);
padding: 0 0.95rem;
border-radius: var(--radius);
font-weight: 700;
border: 1px solid transparent;
cursor: pointer;
font: inherit;
}
.btn.primary {
background: #14b8a6;
color: #fff;
box-shadow: 0 8px 18px rgba(20, 184, 166, 0.24);
}
.btn.cancel {
background: #fff;
color: #64748b;
border-color: #e2e8f0;
}
.btn.danger {
background: #fff;
color: #b91c1c;
border-color: #fecaca;
}
.mobile-actions {
display: grid;
gap: 0.5rem;
}
.error {
color: #b91c1c;
margin: 0;
}
.muted {
color: #64748b;
margin: 0;
}
@media (min-width: 960px) {
.desktop-head {
display: flex;
}
.mobile-head,
.mobile-actions {
display: none;
}
}
</style>