feat(notes): let organizations edit the note category bar

The filter chips were fixed labels. Owners can rename, hide, reorder, and add categories.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-01 16:34:34 +03:30
co-authored by Cursor
parent 8e1e94a439
commit 81edf61065
10 changed files with 645 additions and 390 deletions
@@ -0,0 +1,20 @@
CREATE TABLE "NoteCategory" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"key" TEXT NOT NULL,
"label" TEXT NOT NULL,
"tone" TEXT NOT NULL DEFAULT 'general',
"position" INTEGER NOT NULL DEFAULT 0,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"showInBar" BOOLEAN NOT NULL DEFAULT true,
"isSystem" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "NoteCategory_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "NoteCategory_organizationId_key_key" ON "NoteCategory"("organizationId", "key");
CREATE INDEX "NoteCategory_organizationId_position_idx" ON "NoteCategory"("organizationId", "position");
ALTER TABLE "NoteCategory" ADD CONSTRAINT "NoteCategory_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+21
View File
@@ -170,6 +170,7 @@ model Organization {
deals Deal[]
activities Activity[]
notes Note[]
noteCategories NoteCategory[]
noteRevisions NoteRevision[]
customFields CustomFieldDefinition[]
products Product[]
@@ -973,6 +974,26 @@ model Note {
@@index([authorId])
}
/// دسته‌های نوار فیلتر یادداشت؛ برای هر سازمان قابل تغییر است
model NoteCategory {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
key String
label String
tone String @default("general")
position Int @default(0)
isActive Boolean @default(true)
/// در نوار «همه / معمولی / پیگیری / …» دیده شود
showInBar Boolean @default(true)
isSystem Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([organizationId, key])
@@index([organizationId, position])
}
/// اسنپ‌شات متن قبل از هر ویرایش معنادار یادداشت
model NoteRevision {
id String @id @default(cuid())
+68 -19
View File
@@ -16,19 +16,6 @@ import {
ValidateNested,
} from 'class-validator';
const NOTE_TYPES = [
'general',
'call_result',
'meeting',
'important',
'warning',
'next_action',
'follow_up',
'idea',
'suggestion',
'personal',
] as const;
const NOTE_VISIBILITIES = ['team', 'personal'] as const;
const NOTE_SORTS = ['updatedAt', 'createdAt', 'reminderAt'] as const;
@@ -74,8 +61,10 @@ export class ListNotesQueryDto {
tag?: string;
@IsOptional()
@IsIn(NOTE_TYPES)
noteType?: (typeof NOTE_TYPES)[number];
@IsString()
@MinLength(1)
@MaxLength(64)
noteType?: string;
@IsOptional()
@IsIn(NOTE_VISIBILITIES)
@@ -111,8 +100,10 @@ export class CreateNoteDto {
body!: string;
@IsOptional()
@IsIn(NOTE_TYPES)
noteType?: (typeof NOTE_TYPES)[number];
@IsString()
@MinLength(1)
@MaxLength(64)
noteType?: string;
@IsOptional()
@IsIn(NOTE_VISIBILITIES)
@@ -192,8 +183,10 @@ export class UpdateNoteDto {
body?: string;
@IsOptional()
@IsIn(NOTE_TYPES)
noteType?: (typeof NOTE_TYPES)[number];
@IsString()
@MinLength(1)
@MaxLength(64)
noteType?: string;
@IsOptional()
@IsIn(NOTE_VISIBILITIES)
@@ -309,3 +302,59 @@ export class ApplyNoteAiSuggestionDto {
@Type(() => ApplyMemoryEditDto)
memoryItems?: ApplyMemoryEditDto[];
}
const NOTE_CATEGORY_TONES = [
'general',
'follow_up',
'idea',
'suggestion',
'meeting',
'personal',
] as const;
export class CreateNoteCategoryDto {
@IsString()
@MinLength(1)
@MaxLength(40)
label!: string;
@IsOptional()
@IsIn(NOTE_CATEGORY_TONES)
tone?: (typeof NOTE_CATEGORY_TONES)[number];
@IsOptional()
@IsBoolean()
showInBar?: boolean;
}
export class UpdateNoteCategoryDto {
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(40)
label?: string;
@IsOptional()
@IsIn(NOTE_CATEGORY_TONES)
tone?: (typeof NOTE_CATEGORY_TONES)[number];
@IsOptional()
@IsBoolean()
showInBar?: boolean;
@IsOptional()
@IsBoolean()
isActive?: boolean;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
position?: number;
}
export class ReorderNoteCategoriesDto {
@IsArray()
@IsString({ each: true })
ids!: string[];
}
@@ -0,0 +1,165 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type { NoteCategoryDto } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
import type {
CreateNoteCategoryDto,
UpdateNoteCategoryDto,
} from './dto';
const DEFAULTS: Array<{
key: string;
label: string;
tone: string;
showInBar: boolean;
}> = [
{ key: 'general', label: 'معمولی', tone: 'general', showInBar: true },
{ key: 'follow_up', label: 'پیگیری', tone: 'follow_up', showInBar: true },
{ key: 'idea', label: 'ایده', tone: 'idea', showInBar: true },
{ key: 'suggestion', label: 'پیشنهاد', tone: 'suggestion', showInBar: true },
{ key: 'meeting', label: 'جلسه', tone: 'meeting', showInBar: true },
{ key: 'personal', label: 'شخصی', tone: 'personal', showInBar: true },
{ key: 'call_result', label: 'نتیجه تماس', tone: 'follow_up', showInBar: false },
{ key: 'important', label: 'نکته مهم', tone: 'suggestion', showInBar: false },
{ key: 'warning', label: 'هشدار', tone: 'follow_up', showInBar: false },
{ key: 'next_action', label: 'کار بعدی', tone: 'follow_up', showInBar: false },
];
@Injectable()
export class NoteCategoriesService {
constructor(private readonly prisma: PrismaService) {}
async list(organizationId: string): Promise<NoteCategoryDto[]> {
await this.ensureDefaults(organizationId);
const rows = await this.prisma.noteCategory.findMany({
where: { organizationId },
orderBy: [{ position: 'asc' }, { createdAt: 'asc' }],
});
return rows.map((row) => this.toDto(row));
}
async create(
organizationId: string,
dto: CreateNoteCategoryDto,
): Promise<NoteCategoryDto> {
await this.ensureDefaults(organizationId);
const label = dto.label.trim();
if (!label) throw new BadRequestException('عنوان دسته لازم است');
const max = await this.prisma.noteCategory.aggregate({
where: { organizationId },
_max: { position: true },
});
const row = await this.prisma.noteCategory.create({
data: {
organizationId,
key: `cat_${Date.now().toString(36)}`,
label,
tone: dto.tone ?? 'general',
showInBar: dto.showInBar ?? true,
position: (max._max.position ?? -1) + 1,
isSystem: false,
},
});
return this.toDto(row);
}
async update(
organizationId: string,
id: string,
dto: UpdateNoteCategoryDto,
): Promise<NoteCategoryDto> {
const existing = await this.prisma.noteCategory.findFirst({
where: { id, organizationId },
});
if (!existing) throw new NotFoundException('دسته پیدا نشد');
const label = dto.label?.trim();
if (dto.label !== undefined && !label) {
throw new BadRequestException('عنوان دسته لازم است');
}
const row = await this.prisma.noteCategory.update({
where: { id },
data: {
...(label ? { label } : {}),
...(dto.tone !== undefined ? { tone: dto.tone } : {}),
...(dto.showInBar !== undefined ? { showInBar: dto.showInBar } : {}),
...(dto.isActive !== undefined ? { isActive: dto.isActive } : {}),
...(dto.position !== undefined ? { position: dto.position } : {}),
},
});
return this.toDto(row);
}
async reorder(organizationId: string, ids: string[]): Promise<NoteCategoryDto[]> {
const rows = await this.prisma.noteCategory.findMany({
where: { organizationId },
select: { id: true },
});
const known = new Set(rows.map((row) => row.id));
const ordered = ids.filter((id) => known.has(id));
await this.prisma.$transaction(
ordered.map((id, index) =>
this.prisma.noteCategory.update({
where: { id },
data: { position: index },
}),
),
);
return this.list(organizationId);
}
async remove(organizationId: string, id: string): Promise<void> {
const existing = await this.prisma.noteCategory.findFirst({
where: { id, organizationId },
});
if (!existing) throw new NotFoundException('دسته پیدا نشد');
if (existing.isSystem) {
throw new BadRequestException('دستهٔ پیش‌فرض حذف نمی‌شود؛ می‌توانید از نوار پنهانش کنید');
}
await this.prisma.noteCategory.delete({ where: { id } });
}
private async ensureDefaults(organizationId: string) {
const count = await this.prisma.noteCategory.count({
where: { organizationId },
});
if (count > 0) return;
await this.prisma.noteCategory.createMany({
data: DEFAULTS.map((item, index) => ({
organizationId,
key: item.key,
label: item.label,
tone: item.tone,
position: index,
showInBar: item.showInBar,
isSystem: true,
isActive: true,
})),
skipDuplicates: true,
});
}
private toDto(row: {
id: string;
key: string;
label: string;
tone: string;
position: number;
isActive: boolean;
showInBar: boolean;
isSystem: boolean;
}): NoteCategoryDto {
return {
id: row.id,
key: row.key,
label: row.label,
tone: row.tone,
position: row.position,
isActive: row.isActive,
showInBar: row.showInBar,
isSystem: row.isSystem,
};
}
}
+57 -1
View File
@@ -15,10 +15,21 @@ import {
import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { Roles } from '../auth/roles.decorator';
import { RolesGuard } from '../auth/roles.guard';
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { CreateNoteDto, ListNotesQueryDto, UpdateNoteDto, ApplyNoteAiSuggestionDto } from './dto';
import {
CreateNoteDto,
ListNotesQueryDto,
UpdateNoteDto,
ApplyNoteAiSuggestionDto,
CreateNoteCategoryDto,
UpdateNoteCategoryDto,
ReorderNoteCategoriesDto,
} from './dto';
import { NoteAiService } from './note-ai.service';
import { NoteCategoriesService } from './note-categories.service';
import { NotesService } from './notes.service';
import type { DataVisibility } from '@mento/shared';
@@ -37,8 +48,53 @@ export class NotesController {
constructor(
private readonly notesService: NotesService,
private readonly noteAi: NoteAiService,
private readonly categories: NoteCategoriesService,
) {}
@Get('categories')
listCategories(@CurrentUser() user: AuthUser) {
return this.categories.list(user.organizationId);
}
@Post('categories')
@UseGuards(RolesGuard)
@Roles('owner', 'admin')
createCategory(
@CurrentUser() user: AuthUser,
@Body() dto: CreateNoteCategoryDto,
) {
return this.categories.create(user.organizationId, dto);
}
@Patch('categories/reorder')
@UseGuards(RolesGuard)
@Roles('owner', 'admin')
reorderCategories(
@CurrentUser() user: AuthUser,
@Body() dto: ReorderNoteCategoriesDto,
) {
return this.categories.reorder(user.organizationId, dto.ids);
}
@Patch('categories/:id')
@UseGuards(RolesGuard)
@Roles('owner', 'admin')
updateCategory(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Body() dto: UpdateNoteCategoryDto,
) {
return this.categories.update(user.organizationId, id, dto);
}
@Delete('categories/:id')
@UseGuards(RolesGuard)
@Roles('owner', 'admin')
async removeCategory(@CurrentUser() user: AuthUser, @Param('id') id: string) {
await this.categories.remove(user.organizationId, id);
return { ok: true };
}
@Get()
list(
@CurrentUser() user: AuthUser,
+2 -1
View File
@@ -2,12 +2,13 @@ import { Module } from '@nestjs/common';
import { MemoryModule } from '../memory/memory.module';
import { NotesController } from './notes.controller';
import { NoteAiService } from './note-ai.service';
import { NoteCategoriesService } from './note-categories.service';
import { NotesService } from './notes.service';
@Module({
imports: [MemoryModule],
controllers: [NotesController],
providers: [NotesService, NoteAiService],
providers: [NotesService, NoteAiService, NoteCategoriesService],
exports: [NotesService],
})
export class NotesModule {}
@@ -0,0 +1,204 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import axios from 'axios';
import { api } from '@/lib/api';
import UiIcon from '@/components/UiIcon.vue';
import type { NoteCategoryDto, NoteCategoryTone } from '@mento/shared';
const emit = defineEmits<{
close: [];
saved: [];
}>();
const tones: Array<{ value: NoteCategoryTone; label: string }> = [
{ value: 'general', label: 'معمولی' },
{ value: 'follow_up', label: 'پیگیری' },
{ value: 'idea', label: 'ایده' },
{ value: 'suggestion', label: 'پیشنهاد' },
{ value: 'meeting', label: 'جلسه' },
{ value: 'personal', label: 'شخصی' },
];
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const items = ref<NoteCategoryDto[]>([]);
const label = ref('');
async function load() {
loading.value = true;
error.value = '';
try {
items.value = (await api.get<NoteCategoryDto[]>('/notes/categories')).data;
} catch {
error.value = 'بارگذاری دسته‌ها ناموفق بود.';
} finally {
loading.value = false;
}
}
async function createCategory() {
if (!label.value.trim()) {
error.value = 'عنوان دسته را وارد کنید.';
return;
}
saving.value = true;
error.value = '';
try {
await api.post('/notes/categories', { label: label.value.trim(), showInBar: true });
label.value = '';
await load();
emit('saved');
} catch (e) {
error.value = axios.isAxiosError(e)
? 'ثبت دسته ناموفق بود.'
: 'ثبت دسته ناموفق بود.';
} finally {
saving.value = false;
}
}
async function patch(item: NoteCategoryDto, data: Partial<NoteCategoryDto>) {
error.value = '';
try {
await api.patch(`/notes/categories/${item.id}`, data);
await load();
emit('saved');
} catch {
error.value = 'ذخیره دسته ناموفق بود.';
}
}
async function move(index: number, dir: -1 | 1) {
const next = index + dir;
if (next < 0 || next >= items.value.length) return;
const ids = items.value.map((item) => item.id);
const [picked] = ids.splice(index, 1);
if (!picked) return;
ids.splice(next, 0, picked);
try {
items.value = (await api.patch<NoteCategoryDto[]>('/notes/categories/reorder', { ids })).data;
emit('saved');
} catch {
error.value = 'جابه‌جایی ناموفق بود.';
}
}
async function remove(item: NoteCategoryDto) {
if (!confirm(`دسته «${item.label}» حذف شود؟`)) return;
try {
await api.delete(`/notes/categories/${item.id}`);
await load();
emit('saved');
} catch {
error.value = 'این دستهٔ پیش‌فرض حذف نمی‌شود. از نوار پنهانش کنید.';
}
}
onMounted(load);
</script>
<template>
<div class="cat-root">
<div class="cat-backdrop" @click="emit('close')" />
<aside class="cat-panel" role="dialog" aria-label="دسته‌های یادداشت">
<header class="cat-head">
<h2>دسته‌های نوار یادداشت</h2>
<button type="button" class="icon-btn" @click="emit('close')">
<UiIcon name="x" :size="20" />
</button>
</header>
<div class="cat-scroll">
<p class="hint">همین عنوان‌ها در نوار بالای یادداشت‌ها دیده می‌شوند. «همه»، «تیمی» و «فقط من» ثابت‌اند.</p>
<p v-if="error" class="banner-error">{{ error }}</p>
<form class="create-form" @submit.prevent="createCategory">
<input v-model="label" type="text" placeholder="دسته جدید، مثلاً قرارداد" />
<button type="submit" class="btn-primary" :disabled="saving">افزودن</button>
</form>
<p v-if="loading" class="hint">در حال بارگذاری…</p>
<ul v-else class="cat-list">
<li v-for="(item, index) in items" :key="item.id">
<input
:value="item.label"
type="text"
@change="patch(item, { label: ($event.target as HTMLInputElement).value })"
/>
<select
:value="item.tone"
@change="patch(item, { tone: ($event.target as HTMLSelectElement).value })"
>
<option v-for="tone in tones" :key="tone.value" :value="tone.value">
{{ tone.label }}
</option>
</select>
<label class="check-line">
<input
type="checkbox"
:checked="item.showInBar && item.isActive"
@change="patch(item, { showInBar: ($event.target as HTMLInputElement).checked, isActive: true })"
/>
<span>در نوار</span>
</label>
<div class="row-actions">
<button type="button" :disabled="index === 0" @click="move(index, -1)">بالا</button>
<button type="button" :disabled="index === items.length - 1" @click="move(index, 1)">پایین</button>
<button v-if="!item.isSystem" type="button" class="danger" @click="remove(item)">حذف</button>
</div>
</li>
</ul>
</div>
</aside>
</div>
</template>
<style scoped>
.cat-root { position: fixed; inset: 0; z-index: 140; }
.cat-backdrop { position: absolute; inset: 0; background: rgba(15, 23, 42, 0.35); }
.cat-panel {
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: min(460px, 100%);
background: #fff;
display: flex;
flex-direction: column;
box-shadow: 0 12px 40px rgba(15, 23, 42, 0.18);
}
.cat-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.9rem 1rem;
border-bottom: 1px solid #e2e8f0;
}
.cat-head h2 { margin: 0; font-size: 1.05rem; }
.cat-scroll { overflow: auto; padding: 1rem; display: flex; flex-direction: column; gap: 0.75rem; }
.hint { margin: 0; color: #64748b; font-size: 0.88rem; }
.banner-error { margin: 0; padding: 0.55rem 0.7rem; border-radius: 10px; background: #fef2f2; color: #b91c1c; }
.create-form { display: flex; gap: 0.45rem; }
.create-form input, .cat-list input, .cat-list select {
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 0.45rem 0.55rem;
font-size: 0.9rem;
color: #0f172a;
background: #fff;
}
.create-form input { flex: 1; }
.btn-primary, .row-actions button, .icon-btn { border: 0; cursor: pointer; }
.btn-primary { background: #0f766e; color: #fff; border-radius: 10px; padding: 0.45rem 0.8rem; font-weight: 700; }
.icon-btn { background: transparent; color: #64748b; }
.cat-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.55rem; }
.cat-list li {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(0, 0.8fr) auto;
gap: 0.4rem;
align-items: center;
}
.row-actions { grid-column: 1 / -1; display: flex; gap: 0.35rem; }
.row-actions button { background: #f8fafc; color: #334155; border-radius: 8px; padding: 0.3rem 0.5rem; }
.row-actions button:disabled { opacity: 0.4; }
.row-actions .danger { color: #b91c1c; }
.check-line { display: flex; align-items: center; gap: 0.3rem; font-size: 0.8rem; color: #334155; white-space: nowrap; }
</style>
@@ -1,313 +0,0 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import axios from 'axios';
import { api } from '@/lib/api';
import UiIcon from '@/components/UiIcon.vue';
import type {
CreateCustomFieldRequest,
CustomFieldDefinitionDto,
CustomFieldType,
} from '@mento/shared';
const emit = defineEmits<{
close: [];
saved: [];
}>();
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const fields = ref<CustomFieldDefinitionDto[]>([]);
const label = ref('');
const fieldType = ref<CustomFieldType>('text');
const optionsRaw = ref('');
const required = ref(false);
const typeLabels: Record<CustomFieldType, string> = {
text: 'متن',
number: 'عدد',
date: 'تاریخ',
boolean: 'بله/خیر',
select: 'لیست انتخابی',
};
async function load() {
loading.value = true;
error.value = '';
try {
fields.value = (
await api.get<CustomFieldDefinitionDto[]>('/custom-fields', {
params: { entityType: 'note' },
})
).data;
} catch {
error.value = 'بارگذاری فیلدها ناموفق بود.';
} finally {
loading.value = false;
}
}
async function createField() {
if (!label.value.trim()) {
error.value = 'عنوان فیلد را وارد کنید.';
return;
}
saving.value = true;
error.value = '';
try {
const payload: CreateCustomFieldRequest = {
entityType: 'note',
label: label.value.trim(),
fieldType: fieldType.value,
required: required.value,
options:
fieldType.value === 'select'
? optionsRaw.value
.split(/[,،\n]/)
.map((item) => item.trim())
.filter(Boolean)
: undefined,
};
await api.post('/custom-fields', payload);
label.value = '';
optionsRaw.value = '';
required.value = false;
fieldType.value = 'text';
await load();
emit('saved');
} catch (e) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
error.value = Array.isArray(msg) ? msg.join('، ') : (msg ?? 'ثبت فیلد ناموفق بود');
} else {
error.value = 'ثبت فیلد ناموفق بود';
}
} finally {
saving.value = false;
}
}
async function toggleActive(field: CustomFieldDefinitionDto) {
await api.patch(`/custom-fields/${field.id}`, { isActive: !field.isActive });
await load();
emit('saved');
}
async function removeField(field: CustomFieldDefinitionDto) {
if (!confirm(`فیلد «${field.label}» حذف شود؟ مقدارهای قبلی روی یادداشت‌ها می‌ماند.`)) return;
await api.delete(`/custom-fields/${field.id}`);
await load();
emit('saved');
}
onMounted(load);
</script>
<template>
<div class="fields-root">
<div class="fields-backdrop" aria-hidden="true" @click="emit('close')" />
<aside class="fields-panel" role="dialog" aria-label="فیلدهای یادداشت">
<header class="fields-head">
<h2>فیلدهای یادداشت</h2>
<button type="button" class="icon-btn" @click="emit('close')">
<UiIcon name="x" :size="20" />
</button>
</header>
<div class="fields-scroll">
<p class="hint">
فیلدهایی که اینجا می‌سازید روی فرم هر یادداشت دیده می‌شوند.
</p>
<p v-if="error" class="banner-error">{{ error }}</p>
<form class="create-form" @submit.prevent="createField">
<label class="field">
<span>عنوان</span>
<input v-model="label" type="text" placeholder="مثلاً نتیجه تماس" />
</label>
<label class="field">
<span>نوع</span>
<select v-model="fieldType">
<option value="text">متن</option>
<option value="number">عدد</option>
<option value="date">تاریخ</option>
<option value="boolean">بله/خیر</option>
<option value="select">لیست انتخابی</option>
</select>
</label>
<label v-if="fieldType === 'select'" class="field">
<span>گزینه‌ها (با کاما جدا کنید)</span>
<input v-model="optionsRaw" type="text" placeholder="مثلاً موفق، پیگیری، ناموفق" />
</label>
<label class="check-line">
<input v-model="required" type="checkbox" />
<span>اجباری</span>
</label>
<button type="submit" class="btn-primary" :disabled="saving">
{{ saving ? 'در حال ثبت…' : 'افزودن فیلد' }}
</button>
</form>
<p v-if="loading" class="hint">در حال بارگذاری…</p>
<p v-else-if="!fields.length" class="hint">هنوز فیلد اختصاصی ندارید.</p>
<ul v-else class="field-list">
<li v-for="field in fields" :key="field.id">
<div>
<strong>{{ field.label }}</strong>
<span>{{ typeLabels[field.fieldType] }}</span>
<span v-if="field.required">اجباری</span>
<span v-if="!field.isActive">غیرفعال</span>
</div>
<div class="row-actions">
<button type="button" @click="toggleActive(field)">
{{ field.isActive ? 'غیرفعال' : 'فعال' }}
</button>
<button type="button" class="danger" @click="removeField(field)">حذف</button>
</div>
</li>
</ul>
</div>
</aside>
</div>
</template>
<style scoped>
.fields-root {
position: fixed;
inset: 0;
z-index: 140;
}
.fields-backdrop {
position: absolute;
inset: 0;
background: rgba(15, 23, 42, 0.35);
}
.fields-panel {
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: min(420px, 100%);
background: #fff;
display: flex;
flex-direction: column;
box-shadow: 0 12px 40px rgba(15, 23, 42, 0.18);
}
.fields-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.9rem 1rem;
border-bottom: 1px solid #e2e8f0;
}
.fields-head h2 {
margin: 0;
font-size: 1.05rem;
}
.fields-scroll {
overflow: auto;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.hint {
margin: 0;
color: #64748b;
font-size: 0.88rem;
}
.banner-error {
margin: 0;
padding: 0.55rem 0.7rem;
border-radius: 10px;
background: #fef2f2;
color: #b91c1c;
font-size: 0.88rem;
}
.create-form,
.field {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.field span,
.check-line {
font-size: 0.82rem;
color: #64748b;
}
.field input,
.field select {
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 0.45rem 0.55rem;
font-size: 0.9rem;
color: #0f172a;
background: #fff;
}
.check-line {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.4rem;
color: #0f172a;
}
.btn-primary,
.row-actions button,
.icon-btn {
border: 0;
cursor: pointer;
}
.btn-primary {
background: #0f766e;
color: #fff;
border-radius: 10px;
padding: 0.55rem 1rem;
font-weight: 700;
}
.btn-primary:disabled {
opacity: 0.65;
}
.icon-btn {
background: transparent;
color: #64748b;
}
.field-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.field-list li {
display: flex;
justify-content: space-between;
gap: 0.75rem;
align-items: center;
padding: 0.65rem 0.7rem;
border: 1px solid #e2e8f0;
border-radius: 10px;
}
.field-list strong {
display: block;
color: #0f172a;
}
.field-list span {
margin-inline-start: 0.35rem;
color: #64748b;
font-size: 0.78rem;
}
.row-actions {
display: flex;
gap: 0.35rem;
}
.row-actions button {
background: #f8fafc;
color: #334155;
border-radius: 8px;
padding: 0.3rem 0.5rem;
}
.row-actions .danger {
color: #b91c1c;
}
</style>
+71 -56
View File
@@ -6,7 +6,7 @@ import axios from 'axios';
import { api } from '@/lib/api';
import NoteAiSuggestionPanel from '@/components/NoteAiSuggestionPanel.vue';
import NoteCustomFields from '@/components/notes/NoteCustomFields.vue';
import NoteFieldsManager from '@/components/notes/NoteFieldsManager.vue';
import NoteCategoriesManager from '@/components/notes/NoteCategoriesManager.vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import VoiceToTextButton from '@/components/VoiceToTextButton.vue';
import UiIcon from '@/components/UiIcon.vue';
@@ -26,8 +26,8 @@ import type {
NoteAiSuggestionDto,
NoteChecklistItemDto,
NoteDto,
NoteCategoryDto,
NoteRevisionDto,
NoteType,
NoteVisibility,
PaginatedResponse,
PersonDto,
@@ -49,7 +49,7 @@ const loading = ref(false);
const saving = ref(false);
const error = ref('');
const q = ref('');
const typeFilter = ref<NoteType | 'all'>('all');
const typeFilter = ref<string>('all');
const visibilityFilter = ref<NoteVisibility | 'all'>('all');
const sortKey = ref<SortKey>('updatedAt');
const viewMode = ref<ViewMode>('grid');
@@ -69,7 +69,7 @@ const editingId = ref<string | null>(null);
const draftTitle = ref('');
const draftBody = ref('');
const draftType = ref<NoteType>('general');
const draftType = ref('general');
const draftColor = ref<ColorKey>('sand');
const draftTags = ref('');
const draftPersonId = ref('');
@@ -85,7 +85,6 @@ const draftCustomFields = ref<Record<string, unknown>>({});
const draftFiles = ref<File[]>([]);
const existingAttachments = ref<NoteAttachmentDto[]>([]);
const noteFieldDefs = ref<CustomFieldDefinitionDto[]>([]);
const fieldsOpen = ref(false);
const historyOpen = ref(false);
const historyLoading = ref(false);
@@ -102,28 +101,28 @@ const MOBILE_MQ = '(max-width: 860px)';
const isMobile = ref(false);
let mq: MediaQueryList | null = null;
const categoryChips: Array<{ value: NoteType | 'all'; label: string; tone: string }> = [
{ value: 'all', label: 'همه', tone: 'all' },
{ value: 'general', label: 'معمولی', tone: 'general' },
{ value: 'follow_up', label: 'پیگیری', tone: 'follow_up' },
{ value: 'idea', label: 'ایده', tone: 'idea' },
{ value: 'suggestion', label: 'پیشنهاد', tone: 'suggestion' },
{ value: 'meeting', label: 'جلسه', tone: 'meeting' },
{ value: 'personal', label: 'شخصی', tone: 'personal' },
];
const noteCategories = ref<NoteCategoryDto[]>([
{ id: 'general', key: 'general', label: 'معمولی', tone: 'general', position: 0, isActive: true, showInBar: true, isSystem: true },
{ id: 'follow_up', key: 'follow_up', label: 'پیگیری', tone: 'follow_up', position: 1, isActive: true, showInBar: true, isSystem: true },
{ id: 'idea', key: 'idea', label: 'ایده', tone: 'idea', position: 2, isActive: true, showInBar: true, isSystem: true },
{ id: 'suggestion', key: 'suggestion', label: 'پیشنهاد', tone: 'suggestion', position: 3, isActive: true, showInBar: true, isSystem: true },
{ id: 'meeting', key: 'meeting', label: 'جلسه', tone: 'meeting', position: 4, isActive: true, showInBar: true, isSystem: true },
{ id: 'personal', key: 'personal', label: 'شخصی', tone: 'personal', position: 5, isActive: true, showInBar: true, isSystem: true },
]);
const categoriesOpen = ref(false);
const noteTypeOptions: Array<{ value: NoteType; label: string }> = [
{ value: 'general', label: 'معمولی' },
{ value: 'follow_up', label: 'پیگیری' },
{ value: 'idea', label: 'ایده' },
{ value: 'suggestion', label: 'پیشنهاد' },
{ value: 'meeting', label: 'جلسه' },
{ value: 'personal', label: 'شخصی' },
{ value: 'call_result', label: 'نتیجه تماس' },
{ value: 'important', label: 'نکته مهم' },
{ value: 'warning', label: 'هشدار' },
{ value: 'next_action', label: 'کار بعدی' },
];
const categoryChips = computed(() => [
{ value: 'all', label: 'همه', tone: 'all' },
...noteCategories.value
.filter((item) => item.isActive && item.showInBar)
.map((item) => ({ value: item.key, label: item.label, tone: item.tone || 'general' })),
]);
const noteTypeOptions = computed(() =>
noteCategories.value
.filter((item) => item.isActive)
.map((item) => ({ value: item.key, label: item.label })),
);
const colorSwatches: Array<{ value: ColorKey; label: string }> = [
{ value: 'sand', label: 'شن' },
@@ -185,13 +184,14 @@ function normalizeColor(raw: string | null | undefined): ColorKey {
}
function typeLabel(t: string) {
return typeLabelMap[t] ?? t;
return noteCategories.value.find((item) => item.key === t)?.label ?? typeLabelMap[t] ?? t;
}
function typeTone(t: string) {
if (t === 'call_result' || t === 'next_action') return 'follow_up';
const tone = noteCategories.value.find((item) => item.key === t)?.tone;
if (tone) return tone;
if (t === 'call_result' || t === 'next_action' || t === 'warning') return 'follow_up';
if (t === 'important') return 'suggestion';
if (t === 'warning') return 'follow_up';
if (
t === 'general' ||
t === 'follow_up' ||
@@ -249,6 +249,20 @@ function customFieldLines(note: NoteDto) {
});
}
async function loadNoteCategories() {
try {
noteCategories.value = (await api.get<NoteCategoryDto[]>('/notes/categories')).data;
const visible = new Set(
noteCategories.value.filter((item) => item.isActive && item.showInBar).map((item) => item.key),
);
if (typeFilter.value !== 'all' && !visible.has(typeFilter.value)) {
typeFilter.value = 'all';
}
} catch {
/* نوار پیش‌فرض می‌ماند */
}
}
async function loadNoteFields() {
try {
noteFieldDefs.value = (
@@ -428,7 +442,7 @@ function openEditor(mode: OpenMode = 'default', note?: NoteDto) {
editingId.value = note.id;
draftTitle.value = note.title ?? '';
draftBody.value = note.body;
draftType.value = (note.noteType as NoteType) || 'general';
draftType.value = note.noteType || 'general';
draftColor.value = normalizeColor(note.color);
draftTags.value = note.tags.join(' ');
draftPersonId.value = note.person?.id ?? '';
@@ -626,8 +640,8 @@ async function openHistory(note: NoteDto) {
}
useEscClose(() => editorOpen.value, () => closeEditor());
useEscClose(() => fieldsOpen.value, () => {
fieldsOpen.value = false;
useEscClose(() => categoriesOpen.value, () => {
categoriesOpen.value = false;
});
useEscClose(() => moreSheetOpen.value, () => { moreSheetOpen.value = false; });
useEscClose(() => historyOpen.value, () => closeHistory());
@@ -732,6 +746,7 @@ onMounted(() => {
void load();
void loadActivities();
void loadNoteFields();
void loadNoteCategories();
});
onBeforeUnmount(() => {
@@ -748,14 +763,6 @@ onBeforeUnmount(() => {
<h1>یادداشت‌ها</h1>
<p class="lead">فکر کنید، یادداشت کنید، هیچ چیز را فراموش نکنید.</p>
</div>
<button
v-if="canManageNoteFields"
type="button"
class="fields-manage"
@click="fieldsOpen = true"
>
فیلدهای یادداشت
</button>
</header>
<p v-if="error" class="banner-error">{{ error }}</p>
@@ -774,6 +781,14 @@ onBeforeUnmount(() => {
<span v-if="chip.value !== 'all'" class="dot" />
{{ chip.label }}
</button>
<button
v-if="canManageNoteFields"
type="button"
class="chip chip-manage"
@click="categoriesOpen = true"
>
دسته‌ها
</button>
<span class="chip-sep" aria-hidden="true" />
<button
type="button"
@@ -888,6 +903,14 @@ onBeforeUnmount(() => {
<span v-if="chip.value !== 'all'" class="dot" />
{{ chip.label }}
</button>
<button
v-if="canManageNoteFields"
type="button"
class="chip chip-manage"
@click="categoriesOpen = true"
>
دسته‌ها
</button>
<span class="chip-sep" aria-hidden="true" />
<button
type="button"
@@ -1453,10 +1476,10 @@ onBeforeUnmount(() => {
</div>
</Teleport>
<NoteFieldsManager
v-if="fieldsOpen"
@close="fieldsOpen = false"
@saved="loadNoteFields"
<NoteCategoriesManager
v-if="categoriesOpen"
@close="categoriesOpen = false"
@saved="loadNoteCategories"
/>
<Teleport to="body">
@@ -1548,16 +1571,6 @@ onBeforeUnmount(() => {
color: #64748b;
font-size: 0.95rem;
}
.fields-manage {
border: 1px solid #e2e8f0;
background: #fff;
color: #0f766e;
border-radius: var(--radius);
padding: 0.45rem 0.8rem;
font-weight: 700;
cursor: pointer;
white-space: nowrap;
}
.card-fields {
margin: 0.4rem 0 0;
padding: 0;
@@ -1677,9 +1690,11 @@ onBeforeUnmount(() => {
.chip-sep {
width: 1px;
align-self: stretch;
margin: 0.15rem 0.2rem;
background: #e2e8f0;
flex-shrink: 0;
margin-inline: 0.15rem;
}
.chip-manage {
border-style: dashed;
}
.chip.tone-team {
color: #0f766e;