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
+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;