From 81edf61065f49bf8050c8615615c0e2ed88158c5 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Sun, 1 Nov 2026 16:34:34 +0330 Subject: [PATCH] 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 --- .../migration.sql | 20 ++ apps/api/prisma/schema.prisma | 21 ++ apps/api/src/notes/dto.ts | 87 +++-- apps/api/src/notes/note-categories.service.ts | 165 +++++++++ apps/api/src/notes/notes.controller.ts | 58 +++- apps/api/src/notes/notes.module.ts | 3 +- .../notes/NoteCategoriesManager.vue | 204 ++++++++++++ .../components/notes/NoteFieldsManager.vue | 313 ------------------ apps/web/src/views/NotesView.vue | 127 +++---- packages/shared/src/index.ts | 37 +++ 10 files changed, 645 insertions(+), 390 deletions(-) create mode 100644 apps/api/prisma/migrations/20261101160000_note_categories/migration.sql create mode 100644 apps/api/src/notes/note-categories.service.ts create mode 100644 apps/web/src/components/notes/NoteCategoriesManager.vue delete mode 100644 apps/web/src/components/notes/NoteFieldsManager.vue diff --git a/apps/api/prisma/migrations/20261101160000_note_categories/migration.sql b/apps/api/prisma/migrations/20261101160000_note_categories/migration.sql new file mode 100644 index 0000000..46ace17 --- /dev/null +++ b/apps/api/prisma/migrations/20261101160000_note_categories/migration.sql @@ -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; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 503cda1..46b7276 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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()) diff --git a/apps/api/src/notes/dto.ts b/apps/api/src/notes/dto.ts index 8dfb5a1..761176f 100644 --- a/apps/api/src/notes/dto.ts +++ b/apps/api/src/notes/dto.ts @@ -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[]; +} diff --git a/apps/api/src/notes/note-categories.service.ts b/apps/api/src/notes/note-categories.service.ts new file mode 100644 index 0000000..0aa3288 --- /dev/null +++ b/apps/api/src/notes/note-categories.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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, + }; + } +} diff --git a/apps/api/src/notes/notes.controller.ts b/apps/api/src/notes/notes.controller.ts index 22749c0..50d5895 100644 --- a/apps/api/src/notes/notes.controller.ts +++ b/apps/api/src/notes/notes.controller.ts @@ -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, diff --git a/apps/api/src/notes/notes.module.ts b/apps/api/src/notes/notes.module.ts index 861ce98..8ea3aa8 100644 --- a/apps/api/src/notes/notes.module.ts +++ b/apps/api/src/notes/notes.module.ts @@ -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 {} diff --git a/apps/web/src/components/notes/NoteCategoriesManager.vue b/apps/web/src/components/notes/NoteCategoriesManager.vue new file mode 100644 index 0000000..52558c2 --- /dev/null +++ b/apps/web/src/components/notes/NoteCategoriesManager.vue @@ -0,0 +1,204 @@ + + + + + diff --git a/apps/web/src/components/notes/NoteFieldsManager.vue b/apps/web/src/components/notes/NoteFieldsManager.vue deleted file mode 100644 index a5ca514..0000000 --- a/apps/web/src/components/notes/NoteFieldsManager.vue +++ /dev/null @@ -1,313 +0,0 @@ - - - - - diff --git a/apps/web/src/views/NotesView.vue b/apps/web/src/views/NotesView.vue index 1713b67..82a81bd 100644 --- a/apps/web/src/views/NotesView.vue +++ b/apps/web/src/views/NotesView.vue @@ -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('all'); +const typeFilter = ref('all'); const visibilityFilter = ref('all'); const sortKey = ref('updatedAt'); const viewMode = ref('grid'); @@ -69,7 +69,7 @@ const editingId = ref(null); const draftTitle = ref(''); const draftBody = ref(''); -const draftType = ref('general'); +const draftType = ref('general'); const draftColor = ref('sand'); const draftTags = ref(''); const draftPersonId = ref(''); @@ -85,7 +85,6 @@ const draftCustomFields = ref>({}); const draftFiles = ref([]); const existingAttachments = ref([]); const noteFieldDefs = ref([]); -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([ + { 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('/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(() => {

یادداشت‌ها

فکر کنید، یادداشت کنید، هیچ چیز را فراموش نکنید.

- @@ -774,6 +781,14 @@ onBeforeUnmount(() => { {{ chip.label }} +