Files
CRM_Montzar/apps/web/src/views/NotesView.vue
T

907 lines
24 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import NoteAiSuggestionPanel from '@/components/NoteAiSuggestionPanel.vue';
import type {
DealDto,
NoteAttachmentDto,
NoteDto,
NoteType,
PaginatedResponse,
PersonDto,
PipelineBoardDto,
} from '@mento/shared';
const router = useRouter();
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const q = ref('');
const typeFilter = ref<NoteType | 'all'>('all');
const notes = ref<NoteDto[]>([]);
const people = ref<PersonDto[]>([]);
const deals = ref<DealDto[]>([]);
const draftBody = ref('');
const draftTitle = ref('');
const draftType = ref<NoteType>('general');
const draftTags = ref('');
const draftPersonId = ref('');
const draftDealId = ref('');
const draftReminder = ref('');
const draftFiles = ref<File[]>([]);
const showExtras = ref(false);
const editingId = ref<string | null>(null);
const fileInput = ref<HTMLInputElement | null>(null);
const noteTypeOptions: Array<{ value: NoteType; label: string }> = [
{ value: 'general', label: 'معمولی' },
{ value: 'call_result', label: 'نتیجه تماس' },
{ value: 'meeting', label: 'جلسه' },
{ value: 'important', label: 'نکته مهم' },
{ value: 'warning', label: 'هشدار' },
{ value: 'next_action', label: 'کار بعدی' },
];
const typeLabel = Object.fromEntries(
noteTypeOptions.map((o) => [o.value, o.label]),
) as Record<string, string>;
const pinnedNotes = computed(() => notes.value.filter((n) => n.pinned));
const otherNotes = computed(() => notes.value.filter((n) => !n.pinned));
const colors = ['default', 'mint', 'sand', 'sky', 'rose'] as const;
async function load() {
loading.value = true;
error.value = '';
try {
const [notesRes, peopleRes, boardRes] = await Promise.all([
api.get<PaginatedResponse<NoteDto>>('/notes', {
params: {
q: q.value || undefined,
noteType: typeFilter.value === 'all' ? undefined : typeFilter.value,
limit: 100,
},
}),
api.get<PaginatedResponse<PersonDto>>('/people', { params: { limit: 100 } }),
api.get<PipelineBoardDto>('/pipelines/board'),
]);
notes.value = notesRes.data.items;
people.value = peopleRes.data.items;
deals.value = boardRes.data.stages.flatMap((s) => s.deals);
} catch (e) {
if (axios.isAxiosError(e) && !e.response) {
error.value = 'اتصال به API برقرار نشد.';
} else {
error.value = 'بارگذاری یادداشت‌ها ناموفق بود.';
}
} finally {
loading.value = false;
}
}
function parseTags(raw: string) {
return raw
.split(/[,\s#]+/)
.map((t) => t.trim())
.filter(Boolean);
}
function resetDraft() {
draftBody.value = '';
draftTitle.value = '';
draftType.value = 'general';
draftTags.value = '';
draftPersonId.value = '';
draftDealId.value = '';
draftReminder.value = '';
draftFiles.value = [];
showExtras.value = false;
editingId.value = null;
if (fileInput.value) fileInput.value.value = '';
}
function onDraftFilesSelected(event: Event) {
const input = event.target as HTMLInputElement;
const picked = Array.from(input.files ?? []);
if (!picked.length) return;
draftFiles.value = [...draftFiles.value, ...picked].slice(0, 8);
input.value = '';
}
function removeDraftFile(index: number) {
draftFiles.value = draftFiles.value.filter((_, i) => i !== index);
}
function formatSize(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
async function uploadFiles(noteId: string, files: File[]) {
for (const file of files) {
const form = new FormData();
form.append('file', file);
await api.post(`/notes/${noteId}/attachments`, form);
}
}
async function downloadAttachment(noteId: string, att: NoteAttachmentDto) {
const { data } = await api.get<Blob>(
`/notes/${noteId}/attachments/${att.id}/download`,
{ responseType: 'blob' },
);
const url = URL.createObjectURL(data);
const a = document.createElement('a');
a.href = url;
a.download = att.fileName;
a.click();
URL.revokeObjectURL(url);
}
async function removeAttachment(noteId: string, attachmentId: string) {
if (!confirm('این پیوست حذف شود؟')) return;
await api.delete(`/notes/${noteId}/attachments/${attachmentId}`);
await load();
}
async function onCardFilesSelected(noteId: string, event: Event) {
const input = event.target as HTMLInputElement;
const files = Array.from(input.files ?? []);
input.value = '';
if (!files.length) return;
try {
await uploadFiles(noteId, files);
await load();
} 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 = 'آپلود پیوست ناموفق بود';
}
}
}
function startEdit(note: NoteDto) {
editingId.value = note.id;
draftTitle.value = note.title ?? '';
draftBody.value = note.body;
draftType.value = (note.noteType as NoteType) || 'general';
draftTags.value = note.tags.join(' ');
draftPersonId.value = note.person?.id ?? '';
draftDealId.value = note.deal?.id ?? '';
draftReminder.value = note.reminderAt
? note.reminderAt.slice(0, 16)
: '';
showExtras.value = true;
window.scrollTo({ top: 0, behavior: 'smooth' });
}
async function saveNote() {
if (!draftBody.value.trim()) return;
saving.value = true;
error.value = '';
try {
const payload = {
title: draftTitle.value || undefined,
body: draftBody.value,
noteType: draftType.value,
tags: parseTags(draftTags.value),
personId: draftPersonId.value || null,
dealId: draftDealId.value || null,
reminderAt: draftReminder.value
? new Date(draftReminder.value).toISOString()
: null,
};
const pending = [...draftFiles.value];
const wasNew = !editingId.value;
let noteId = editingId.value;
if (editingId.value) {
await api.patch(`/notes/${editingId.value}`, payload);
} else {
const { data } = await api.post<NoteDto>('/notes', payload);
noteId = data.id;
}
if (noteId && pending.length) {
await uploadFiles(noteId, pending);
}
if (noteId && wasNew && draftBody.value.trim().length >= 8) {
try {
await api.post(`/notes/${noteId}/analyze`);
} catch {
/* panel shows retry */
}
}
resetDraft();
await load();
} 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 togglePin(id: string) {
await api.patch(`/notes/${id}/pin`);
await load();
}
async function removeNote(id: string) {
if (!confirm('این یادداشت حذف شود؟')) return;
await api.delete(`/notes/${id}`);
if (editingId.value === id) resetDraft();
await load();
}
async function convertToTask(id: string) {
const { data } = await api.post<{ activityId: string }>(`/notes/${id}/convert-to-task`);
await router.push({ name: 'activity-detail', params: { id: data.activityId } });
}
async function setColor(id: string, color: string) {
await api.patch(`/notes/${id}`, { color });
await load();
}
function formatWhen(value: string) {
try {
return new Intl.DateTimeFormat('fa-IR', {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(value));
} catch {
return value;
}
}
onMounted(load);
</script>
<template>
<section class="page">
<header class="page-head">
<div>
<p class="eyebrow">Smart Notes</p>
<h1>یادداشت‌ها</h1>
<p class="lead">مثل Keep بنویسید؛ AI کار بعدی و حافظه مشتری را پیشنهاد می‌دهد — فقط با تأیید شما اعمال می‌شود.</p>
</div>
<form class="search" @submit.prevent="load">
<input v-model="q" type="search" placeholder="جستجو در متن و برچسب…" />
<button class="btn btn-ghost" type="submit">جستجو</button>
</form>
</header>
<div class="type-tabs">
<button
type="button"
:class="{ active: typeFilter === 'all' }"
@click="typeFilter = 'all'; load()"
>
همه
</button>
<button
v-for="opt in noteTypeOptions"
:key="opt.value"
type="button"
:class="{ active: typeFilter === opt.value }"
@click="typeFilter = opt.value; load()"
>
{{ opt.label }}
</button>
</div>
<form class="composer" @submit.prevent="saveNote">
<div class="type-row">
<label for="note-type">نوع</label>
<select id="note-type" v-model="draftType">
<option v-for="opt in noteTypeOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
</div>
<input
v-model="draftTitle"
class="title-input"
type="text"
placeholder="عنوان (اختیاری)"
/>
<textarea
v-model="draftBody"
rows="3"
required
placeholder="هر چیزی که الان به ذهنتان رسید بنویسید…"
/>
<div class="composer-actions">
<div class="left-actions">
<button class="linkish" type="button" @click="showExtras = !showExtras">
{{ showExtras ? 'بستن جزئیات' : 'شخص / معامله / یادآوری / برچسب' }}
</button>
<button class="linkish" type="button" @click="fileInput?.click()">
پیوست فایل
</button>
<input
ref="fileInput"
type="file"
class="sr-only"
multiple
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.txt,.csv,.zip"
@change="onDraftFilesSelected"
/>
</div>
<div class="right">
<button v-if="editingId" class="btn btn-ghost" type="button" @click="resetDraft">انصراف</button>
<button class="btn" type="submit" :disabled="saving || !draftBody.trim()">
{{ saving ? 'در حال ثبت…' : editingId ? 'ذخیره تغییرات' : 'ثبت یادداشت' }}
</button>
</div>
</div>
<ul v-if="draftFiles.length" class="pending-files">
<li v-for="(file, idx) in draftFiles" :key="`${file.name}-${idx}`">
<span>{{ file.name }} · {{ formatSize(file.size) }}</span>
<button type="button" @click="removeDraftFile(idx)">حذف</button>
</li>
</ul>
<div v-if="showExtras" class="extras">
<div class="field">
<label>برچسب‌ها</label>
<input v-model="draftTags" placeholder="مثلاً مهم پیگیری قیمت" />
</div>
<div class="field">
<label>شخص</label>
<select v-model="draftPersonId">
<option value="">بدون شخص</option>
<option v-for="p in people" :key="p.id" :value="p.id">{{ p.fullName }}</option>
</select>
</div>
<div class="field">
<label>معامله</label>
<select v-model="draftDealId">
<option value="">بدون معامله</option>
<option v-for="d in deals" :key="d.id" :value="d.id">{{ d.title }}</option>
</select>
</div>
<div class="field">
<label>یادآوری</label>
<input v-model="draftReminder" type="datetime-local" />
</div>
</div>
</form>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
<template v-else>
<section v-if="pinnedNotes.length" class="block">
<h2>سنجاق‌شده</h2>
<div class="cards">
<article
v-for="note in pinnedNotes"
:key="note.id"
class="card"
:class="`tone-${note.color || 'default'}`"
>
<header>
<div class="card-head">
<span class="type-badge" :class="`type-${note.noteType || 'general'}`">
{{ typeLabel[note.noteType || 'general'] || 'معمولی' }}
</span>
<strong v-if="note.title">{{ note.title }}</strong>
</div>
<button type="button" title="برداشتن سنجاق" @click="togglePin(note.id)">📌</button>
</header>
<p class="body">{{ note.body }}</p>
<ul v-if="note.attachments?.length" class="attachments">
<li v-for="att in note.attachments" :key="att.id">
<button type="button" class="att-name" @click="downloadAttachment(note.id, att)">
{{ att.fileName }}
</button>
<span class="att-size">{{ formatSize(att.sizeBytes) }}</span>
<button type="button" class="att-remove" @click="removeAttachment(note.id, att.id)">حذف</button>
</li>
</ul>
<p v-if="note.tags.length" class="tags">
<span v-for="tag in note.tags" :key="tag">#{{ tag }}</span>
</p>
<p class="meta">
<span v-if="note.person">{{ note.person.fullName }}</span>
<span v-if="note.deal"> · {{ note.deal.title }}</span>
<span v-if="note.reminderAt"> · یادآوری {{ formatWhen(note.reminderAt) }}</span>
</p>
<NoteAiSuggestionPanel
:note-id="note.id"
:has-person="Boolean(note.person?.id)"
/>
<footer>
<button type="button" @click="startEdit(note)">ویرایش</button>
<label class="attach-label">
پیوست
<input
type="file"
class="sr-only"
multiple
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.txt,.csv,.zip"
@change="onCardFilesSelected(note.id, $event)"
/>
</label>
<button type="button" @click="convertToTask(note.id)">تبدیل به وظیفه</button>
<button type="button" @click="removeNote(note.id)">حذف</button>
<span class="swatches">
<button
v-for="c in colors"
:key="c"
type="button"
class="swatch"
:class="`tone-${c}`"
@click="setColor(note.id, c)"
/>
</span>
</footer>
</article>
</div>
</section>
<section class="block">
<h2>همه یادداشت‌ها</h2>
<div v-if="otherNotes.length" class="cards">
<article
v-for="note in otherNotes"
:key="note.id"
class="card"
:class="`tone-${note.color || 'default'}`"
>
<header>
<div class="card-head">
<span class="type-badge" :class="`type-${note.noteType || 'general'}`">
{{ typeLabel[note.noteType || 'general'] || 'معمولی' }}
</span>
<strong v-if="note.title">{{ note.title }}</strong>
<strong v-else class="muted-title">بدون عنوان</strong>
</div>
<button type="button" title="سنجاق" @click="togglePin(note.id)">📍</button>
</header>
<p class="body">{{ note.body }}</p>
<ul v-if="note.attachments?.length" class="attachments">
<li v-for="att in note.attachments" :key="att.id">
<button type="button" class="att-name" @click="downloadAttachment(note.id, att)">
{{ att.fileName }}
</button>
<span class="att-size">{{ formatSize(att.sizeBytes) }}</span>
<button type="button" class="att-remove" @click="removeAttachment(note.id, att.id)">حذف</button>
</li>
</ul>
<p v-if="note.tags.length" class="tags">
<span v-for="tag in note.tags" :key="tag">#{{ tag }}</span>
</p>
<p class="meta">
<span v-if="note.person">{{ note.person.fullName }}</span>
<span v-if="note.deal"> · {{ note.deal.title }}</span>
<span v-if="note.reminderAt"> · یادآوری {{ formatWhen(note.reminderAt) }}</span>
</p>
<NoteAiSuggestionPanel
:note-id="note.id"
:has-person="Boolean(note.person?.id)"
/>
<footer>
<button type="button" @click="startEdit(note)">ویرایش</button>
<label class="attach-label">
پیوست
<input
type="file"
class="sr-only"
multiple
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.txt,.csv,.zip"
@change="onCardFilesSelected(note.id, $event)"
/>
</label>
<button type="button" @click="convertToTask(note.id)">تبدیل به وظیفه</button>
<button type="button" @click="removeNote(note.id)">حذف</button>
<span class="swatches">
<button
v-for="c in colors"
:key="c"
type="button"
class="swatch"
:class="`tone-${c}`"
@click="setColor(note.id, c)"
/>
</span>
</footer>
</article>
</div>
<p v-else class="empty">هنوز یادداشتی نیست. از کادر بالا شروع کنید.</p>
</section>
</template>
</section>
</template>
<style scoped>
.page {
display: grid;
gap: 1.2rem;
}
.page-head {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
flex-wrap: wrap;
}
.eyebrow {
margin: 0;
color: var(--accent);
font-weight: 700;
}
h1 {
margin: 0.2rem 0;
font-size: 1.85rem;
}
.lead,
.muted,
.meta,
.empty {
color: var(--muted);
}
.search {
display: flex;
gap: 0.5rem;
flex: 1 1 220px;
min-width: 0;
}
.search input {
min-width: 0;
flex: 1;
}
.type-tabs {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
max-width: 100%;
}
.type-tabs button {
border: 1px solid var(--line);
background: var(--surface);
color: var(--muted);
border-radius: 999px;
padding: 0.4rem 0.75rem;
cursor: pointer;
font: inherit;
font-size: 0.85rem;
white-space: nowrap;
}
.type-tabs button.active {
background: var(--accent-soft);
border-color: transparent;
color: var(--accent);
font-weight: 700;
}
.type-row {
display: flex;
align-items: center;
gap: 0.65rem;
min-width: 0;
}
.type-row label {
color: var(--muted);
font-size: 0.9rem;
flex: 0 0 auto;
}
.type-row select {
flex: 1;
min-width: 0;
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.65rem 0.85rem;
background: #fff;
font: inherit;
}
.card-head {
display: grid;
gap: 0.35rem;
min-width: 0;
}
.type-badge {
justify-self: start;
font-size: 0.72rem;
font-weight: 700;
border-radius: 999px;
padding: 0.12rem 0.5rem;
}
.type-general { background: #eef2f0; color: #4b5c54; }
.type-call_result { background: #e8f2fb; color: #1d4f91; }
.type-meeting { background: #efe9fb; color: #5b3d9a; }
.type-important { background: #e7f6ef; color: #0f6b5c; }
.type-warning { background: #fff4e5; color: #9a6700; }
.type-next_action { background: #fde8e8; color: #b42318; }
.search input,
.composer input,
.composer textarea,
.extras input,
.extras select {
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.75rem 0.9rem;
background: #fff;
font: inherit;
width: 100%;
}
.composer {
background: var(--surface);
border: 1px solid var(--line);
border-radius: calc(var(--radius) + 2px);
box-shadow: var(--shadow);
padding: 1rem;
display: grid;
gap: 0.7rem;
}
.title-input {
font-weight: 700;
}
.composer-actions,
.right,
.left-actions {
display: flex;
gap: 0.6rem;
align-items: center;
flex-wrap: wrap;
}
.composer-actions {
justify-content: space-between;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
.pending-files,
.attachments {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.35rem;
}
.pending-files li,
.attachments li {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
align-items: center;
font-size: 0.85rem;
background: rgba(15, 107, 92, 0.06);
border-radius: 10px;
padding: 0.35rem 0.55rem;
}
.pending-files button,
.att-remove {
border: 0;
background: transparent;
color: #b42318;
cursor: pointer;
font: inherit;
padding: 0;
}
.att-name {
border: 0;
background: transparent;
color: var(--accent);
cursor: pointer;
font: inherit;
padding: 0;
text-align: start;
text-decoration: underline;
text-underline-offset: 2px;
}
.att-size {
color: var(--muted);
margin-inline-start: auto;
}
.attach-label {
cursor: pointer;
color: var(--muted);
font: inherit;
}
.linkish {
border: 0;
background: transparent;
color: var(--accent);
cursor: pointer;
font: inherit;
}
.extras {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
.block h2 {
margin: 0 0 0.75rem;
font-size: 1rem;
color: var(--muted);
}
.cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 0.85rem;
}
.card {
border: 1px solid var(--line);
border-radius: 14px;
padding: 0.9rem;
display: grid;
gap: 0.55rem;
background: #fff;
}
.card header {
display: flex;
justify-content: space-between;
gap: 0.5rem;
align-items: start;
}
.card header button,
.card footer button {
border: 0;
background: transparent;
cursor: pointer;
color: var(--muted);
font: inherit;
padding: 0;
}
.card footer {
display: flex;
flex-wrap: wrap;
gap: 0.65rem;
align-items: center;
border-top: 1px solid color-mix(in srgb, var(--line) 70%, transparent);
padding-top: 0.55rem;
}
.body {
margin: 0;
white-space: pre-wrap;
line-height: 1.6;
}
.tags {
margin: 0;
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.tags span {
background: rgba(15, 107, 92, 0.08);
color: var(--accent);
border-radius: 999px;
padding: 0.15rem 0.5rem;
font-size: 0.8rem;
}
.meta {
margin: 0;
font-size: 0.85rem;
}
.muted-title {
color: var(--muted);
font-weight: 600;
}
.swatches {
margin-inline-start: auto;
display: inline-flex;
gap: 0.3rem;
}
.swatch {
width: 14px;
height: 14px;
border-radius: 50%;
border: 1px solid var(--line) !important;
padding: 0 !important;
}
.tone-default { background: #fff; }
.tone-mint { background: #e7f6ef; }
.tone-sand { background: #f7f1e4; }
.tone-sky { background: #e8f2fb; }
.tone-rose { background: #f9ecec; }
.empty {
margin: 0;
padding: 1.2rem;
border: 1px dashed var(--line);
border-radius: var(--radius);
background: var(--surface);
}
@media (max-width: 700px) {
h1 {
font-size: 1.45rem;
}
.page-head {
flex-direction: column;
align-items: stretch;
}
.search {
width: 100%;
flex: 0 0 auto;
}
.search input {
height: 2.75rem;
max-height: 2.75rem;
padding: 0 0.9rem;
}
.type-tabs {
flex-wrap: nowrap;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
padding-bottom: 0.15rem;
}
.extras {
grid-template-columns: 1fr;
}
.composer {
padding: 0.9rem;
}
.cards {
grid-template-columns: 1fr;
}
}
</style>