diff --git a/apps/api/prisma/migrations/20261101120000_note_custom_fields/migration.sql b/apps/api/prisma/migrations/20261101120000_note_custom_fields/migration.sql new file mode 100644 index 0000000..ee052c0 --- /dev/null +++ b/apps/api/prisma/migrations/20261101120000_note_custom_fields/migration.sql @@ -0,0 +1,3 @@ +ALTER TYPE "CustomFieldEntity" ADD VALUE IF NOT EXISTS 'note'; + +ALTER TABLE "Note" ADD COLUMN IF NOT EXISTS "customFields" JSONB NOT NULL DEFAULT '{}'; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 744e6b7..503cda1 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -52,6 +52,7 @@ enum CustomFieldEntity { person company deal + note } enum CustomFieldType { @@ -941,6 +942,8 @@ model Note { checklist Json? locationText String? analyzeEnabled Boolean @default(true) + /// مقادیر فیلدهای سفارشی یادداشت + customFields Json @default("{}") personId String? person Person? @relation(fields: [personId], references: [id], onDelete: SetNull) companyId String? diff --git a/apps/api/src/custom-fields/dto.ts b/apps/api/src/custom-fields/dto.ts index a0c0e94..1c2cc3c 100644 --- a/apps/api/src/custom-fields/dto.ts +++ b/apps/api/src/custom-fields/dto.ts @@ -15,6 +15,7 @@ export enum CustomFieldEntityDto { person = 'person', company = 'company', deal = 'deal', + note = 'note', } export enum CustomFieldTypeDto { diff --git a/apps/api/src/notes/dto.ts b/apps/api/src/notes/dto.ts index 41345d5..8dfb5a1 100644 --- a/apps/api/src/notes/dto.ts +++ b/apps/api/src/notes/dto.ts @@ -5,6 +5,7 @@ import { IsDateString, IsIn, IsInt, + IsObject, IsOptional, IsString, Max, @@ -171,6 +172,10 @@ export class CreateNoteDto { @ValidateIf((_, v) => v !== '' && v != null) @IsString() activityId?: string | null; + + @IsOptional() + @IsObject() + customFields?: Record; } export class UpdateNoteDto { @@ -248,6 +253,10 @@ export class UpdateNoteDto { @ValidateIf((_, v) => v !== '' && v != null) @IsString() activityId?: string | null; + + @IsOptional() + @IsObject() + customFields?: Record; } class NoteAiNextActionDtoClass { diff --git a/apps/api/src/notes/notes.service.ts b/apps/api/src/notes/notes.service.ts index 5bc668f..ac3a88d 100644 --- a/apps/api/src/notes/notes.service.ts +++ b/apps/api/src/notes/notes.service.ts @@ -20,6 +20,7 @@ import { PrismaService } from '../prisma/prisma.service'; import { buildSearchWhere, ci } from '../common/search'; import { LocalFileStorage } from '../storage/local-file.storage'; import { GamificationService } from '../gamification/gamification.service'; +import { asCustomFields, toJsonCustomFields } from '../common/custom-fields'; import { type Actor, noteAccessWhere, @@ -45,6 +46,7 @@ type NoteRow = { checklist: Prisma.JsonValue | null; locationText: string | null; analyzeEnabled: boolean; + customFields: Prisma.JsonValue; createdAt: Date; updatedAt: Date; person: { id: string; firstName: string; lastName: string } | null; @@ -224,6 +226,7 @@ export class NotesService { : Prisma.JsonNull, locationText: emptyToNull(dto.locationText ?? null), analyzeEnabled, + customFields: toJsonCustomFields(dto.customFields), personId: emptyToNull(dto.personId ?? null), companyId: emptyToNull(dto.companyId ?? null), dealId: emptyToNull(dto.dealId ?? null), @@ -280,6 +283,9 @@ export class NotesService { if (dto.analyzeEnabled !== undefined) { data.analyzeEnabled = dto.analyzeEnabled; } + if ('customFields' in dto) { + data.customFields = toJsonCustomFields(dto.customFields); + } if ('personId' in dto) data.personId = emptyToNull(dto.personId ?? null); if ('companyId' in dto) data.companyId = emptyToNull(dto.companyId ?? null); if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? null); @@ -616,6 +622,7 @@ export class NotesService { checklist: parseChecklist(row.checklist), locationText: row.locationText ?? null, analyzeEnabled: row.analyzeEnabled ?? true, + customFields: asCustomFields(row.customFields), person: row.person ? { id: row.person.id, diff --git a/apps/web/src/components/notes/NoteCustomFields.vue b/apps/web/src/components/notes/NoteCustomFields.vue new file mode 100644 index 0000000..1d8fc9f --- /dev/null +++ b/apps/web/src/components/notes/NoteCustomFields.vue @@ -0,0 +1,128 @@ + + + + + diff --git a/apps/web/src/components/notes/NoteFieldsManager.vue b/apps/web/src/components/notes/NoteFieldsManager.vue new file mode 100644 index 0000000..a5ca514 --- /dev/null +++ b/apps/web/src/components/notes/NoteFieldsManager.vue @@ -0,0 +1,313 @@ + + + + + diff --git a/apps/web/src/views/CustomFieldsSettingsView.vue b/apps/web/src/views/CustomFieldsSettingsView.vue index 9d3a1fb..bdd5df5 100644 --- a/apps/web/src/views/CustomFieldsSettingsView.vue +++ b/apps/web/src/views/CustomFieldsSettingsView.vue @@ -36,6 +36,7 @@ const entityLabels: Record = { person: 'شخص', company: 'شرکت', deal: 'معامله', + note: 'یادداشت', }; const typeLabels: Record = { @@ -126,7 +127,7 @@ onMounted(load);
@@ -147,6 +148,7 @@ onMounted(load); + @@ -197,6 +199,7 @@ onMounted(load); + diff --git a/apps/web/src/views/NotesView.vue b/apps/web/src/views/NotesView.vue index 400bc14..1713b67 100644 --- a/apps/web/src/views/NotesView.vue +++ b/apps/web/src/views/NotesView.vue @@ -5,10 +5,14 @@ import { useRoute } from 'vue-router'; 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 JalaliDateInput from '@/components/JalaliDateInput.vue'; import VoiceToTextButton from '@/components/VoiceToTextButton.vue'; import UiIcon from '@/components/UiIcon.vue'; import { appendSpokenText } from '@/lib/spoken-text'; +import { canManageSettings } from '@/lib/roles'; +import { useAuthStore } from '@/stores/auth'; import { calendarDaysBetween, formatClockFa, @@ -28,6 +32,7 @@ import type { PaginatedResponse, PersonDto, PipelineBoardDto, + CustomFieldDefinitionDto, } from '@mento/shared'; import { NOTE_VISIBILITY_LABELS } from '@mento/shared'; @@ -37,6 +42,8 @@ type ColorKey = 'sand' | 'rose' | 'sky' | 'mint' | 'lavender' | 'default'; type OpenMode = 'default' | 'image' | 'mic' | 'attach' | 'audio' | 'checklist'; const route = useRoute(); +const auth = useAuthStore(); +const canManageNoteFields = computed(() => canManageSettings(auth.user?.role)); const loading = ref(false); const saving = ref(false); @@ -74,8 +81,11 @@ const draftAnalyze = ref(true); const draftPinned = ref(false); const draftVisibility = ref('team'); const draftChecklist = ref([]); +const draftCustomFields = ref>({}); const draftFiles = ref([]); const existingAttachments = ref([]); +const noteFieldDefs = ref([]); +const fieldsOpen = ref(false); const historyOpen = ref(false); const historyLoading = ref(false); @@ -229,6 +239,28 @@ function previewBody(body: string) { return `${t.slice(0, 160)}…`; } +function customFieldLines(note: NoteDto) { + const values = note.customFields ?? {}; + return noteFieldDefs.value.flatMap((field) => { + const value = values[field.key]; + if (value == null || value === '') return []; + const text = field.fieldType === 'boolean' ? (value ? 'بله' : 'خیر') : String(value); + return [{ key: field.key, label: field.label, text }]; + }); +} + +async function loadNoteFields() { + try { + noteFieldDefs.value = ( + await api.get('/custom-fields', { + params: { entityType: 'note', activeOnly: true }, + }) + ).data; + } catch { + noteFieldDefs.value = []; + } +} + function isImageAtt(att: NoteAttachmentDto) { return att.mimeType.startsWith('image/'); } @@ -383,6 +415,7 @@ function resetDraft() { draftPinned.value = false; draftVisibility.value = 'team'; draftChecklist.value = []; + draftCustomFields.value = {}; draftFiles.value = []; existingAttachments.value = []; showChecklistEditor.value = false; @@ -408,6 +441,7 @@ function openEditor(mode: OpenMode = 'default', note?: NoteDto) { draftVisibility.value = note.visibility === 'personal' ? 'personal' : 'team'; draftChecklist.value = [...(note.checklist ?? [])]; + draftCustomFields.value = { ...(note.customFields ?? {}) }; draftFiles.value = []; existingAttachments.value = [...note.attachments]; showChecklistEditor.value = (note.checklist?.length ?? 0) > 0; @@ -503,6 +537,16 @@ async function saveNote() { error.value = 'متن یادداشت یا چک‌لیست لازم است.'; return; } + const missingField = noteFieldDefs.value.find((field) => { + if (!field.required) return false; + const value = draftCustomFields.value[field.key]; + if (field.fieldType === 'boolean') return value !== true && value !== false; + return value == null || String(value).trim() === ''; + }); + if (missingField) { + error.value = `فیلد «${missingField.label}» لازم است.`; + return; + } saving.value = true; error.value = ''; try { @@ -523,6 +567,7 @@ async function saveNote() { analyzeEnabled: draftAnalyze.value, pinned: draftPinned.value, checklist, + customFields: draftCustomFields.value, }; const pending = [...draftFiles.value]; let noteId = editingId.value; @@ -581,6 +626,9 @@ async function openHistory(note: NoteDto) { } useEscClose(() => editorOpen.value, () => closeEditor()); +useEscClose(() => fieldsOpen.value, () => { + fieldsOpen.value = false; +}); useEscClose(() => moreSheetOpen.value, () => { moreSheetOpen.value = false; }); useEscClose(() => historyOpen.value, () => closeHistory()); @@ -683,6 +731,7 @@ onMounted(() => { } void load(); void loadActivities(); + void loadNoteFields(); }); onBeforeUnmount(() => { @@ -699,6 +748,14 @@ onBeforeUnmount(() => {

یادداشت‌ها

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

+ @@ -921,6 +978,11 @@ onBeforeUnmount(() => {

{{ previewBody(note.body) }}

+
    +
  • + {{ line.label }}: {{ line.text }} +
  • +
{
+ {

پس از ذخیره، پیشنهاد کار بعدی و حافظه مشتری استخراج می‌شود.

+
@@ -1379,6 +1443,7 @@ onBeforeUnmount(() => {

پس از ذخیره اجرا می‌شود.

+ @@ -1388,6 +1453,12 @@ onBeforeUnmount(() => { + +