feat(forms): merge notes tab into main info for activity and deal forms; show activity hashtags
- Activity form: notes/tags card inline, live hashtag preview, focus notes when required - Hashtags shown in activity detail (clickable search) and list rows; search accepts #tag - Deal form: remove mobile tabs Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -628,7 +628,7 @@ export class ActivitiesService {
|
||||
const search = buildSearchWhere<Prisma.ActivityWhereInput>(q, (t) => [
|
||||
{ title: ci(t) },
|
||||
{ description: ci(t) },
|
||||
{ tags: { has: t } },
|
||||
{ tags: { has: t.replace(/^#/, '') } },
|
||||
{
|
||||
person: {
|
||||
OR: [
|
||||
|
||||
@@ -983,6 +983,10 @@ onUnmounted(() => {
|
||||
{{ item.title }}
|
||||
</RouterLink>
|
||||
<p v-if="subtitle(item)" class="sub">{{ subtitle(item) }}</p>
|
||||
<p v-if="item.tags?.length" class="row-tags">
|
||||
<span v-for="t in item.tags.slice(0, 3)" :key="t">#{{ t }}</span>
|
||||
<span v-if="item.tags.length > 3">+{{ toFaDigits(item.tags.length - 3) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="colOn('who')" class="who">
|
||||
@@ -1727,6 +1731,22 @@ time.note {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.row-tags {
|
||||
margin: 0.2rem 0 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.row-tags span {
|
||||
background: #f0fdfa;
|
||||
color: #0f766e;
|
||||
border-radius: 999px;
|
||||
padding: 0.05rem 0.45rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.customer {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
|
||||
@@ -316,6 +316,16 @@ onMounted(() => {
|
||||
<UiIcon name="building" :size="14" />
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
<div v-if="activity.tags?.length" class="tags">
|
||||
<RouterLink
|
||||
v-for="t in activity.tags"
|
||||
:key="t"
|
||||
class="tag"
|
||||
:to="{ name: 'activities', query: { q: t, filter: 'all' } }"
|
||||
>
|
||||
#{{ t }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
<span class="type-ico" :style="typeColorStyle(typeView!.color)">
|
||||
<UiIcon :name="typeView!.icon" :size="22" />
|
||||
@@ -612,6 +622,23 @@ h1 {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.tag {
|
||||
background: #f0fdfa;
|
||||
color: #0f766e;
|
||||
border-radius: 999px;
|
||||
padding: 0.15rem 0.55rem;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.type-ico.sm {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
|
||||
@@ -71,7 +71,6 @@ const isNew = computed(() =>
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const error = ref('');
|
||||
const tab = ref<'main' | 'notes'>('main');
|
||||
const people = ref<PersonDto[]>([]);
|
||||
const deals = ref<DealDto[]>([]);
|
||||
const users = ref<OrgUserDto[]>([]);
|
||||
@@ -298,7 +297,6 @@ function resetForm() {
|
||||
reminderSentAt.value = null;
|
||||
reminderNotifyApp.value = true;
|
||||
reminderNotifySms.value = false;
|
||||
tab.value = 'main';
|
||||
error.value = '';
|
||||
}
|
||||
|
||||
@@ -310,8 +308,18 @@ watch(personId, () => {
|
||||
}
|
||||
});
|
||||
|
||||
const noteCard = ref<HTMLElement | null>(null);
|
||||
const tagPreview = computed(() => parseTags(tagsRaw.value).map((t) => t.replace(/^#/, '')));
|
||||
|
||||
function focusNote() {
|
||||
void nextTick(() => {
|
||||
noteCard.value?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
noteCard.value?.querySelector<HTMLTextAreaElement>('textarea')?.focus({ preventScroll: true });
|
||||
});
|
||||
}
|
||||
|
||||
watch(needsNote, (on) => {
|
||||
if (on) tab.value = 'notes';
|
||||
if (on && !loading.value) focusNote();
|
||||
});
|
||||
|
||||
async function load() {
|
||||
@@ -470,8 +478,7 @@ async function submit(next: 'detail' | 'new' = 'detail') {
|
||||
const message = validate();
|
||||
if (message) {
|
||||
error.value = message;
|
||||
if (needsNote.value && !description.value.trim()) tab.value = 'notes';
|
||||
else tab.value = 'main';
|
||||
if (needsNote.value && !description.value.trim()) focusNote();
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
@@ -579,16 +586,11 @@ watch([id, isNew], () => void load(), { immediate: true });
|
||||
</template>
|
||||
</UiPageHeader>
|
||||
|
||||
<nav class="tabs">
|
||||
<button type="button" :class="{ on: tab === 'main' }" @click="tab = 'main'">اطلاعات اصلی</button>
|
||||
<button type="button" :class="{ on: tab === 'notes' }" @click="tab = 'notes'">توضیحات و یادداشت</button>
|
||||
</nav>
|
||||
|
||||
<p v-if="loading" class="muted">در حال بارگذاری…</p>
|
||||
|
||||
<div v-else class="layout">
|
||||
<form class="form" @submit.prevent="submit('detail')">
|
||||
<div v-show="tab === 'main'" class="stack">
|
||||
<div class="stack">
|
||||
<div class="card pair">
|
||||
<div class="field">
|
||||
<label>نوع فعالیت <i>*</i></label>
|
||||
@@ -865,9 +867,12 @@ watch([id, isNew], () => void load(), { immediate: true });
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="tab === 'notes'" class="card note-card">
|
||||
<div ref="noteCard" class="card note-card">
|
||||
<div class="note-head">
|
||||
<label for="description">توضیحات و یادداشت</label>
|
||||
<label for="description">
|
||||
توضیحات و یادداشت
|
||||
<i v-if="needsNote" class="req-star">*</i>
|
||||
</label>
|
||||
<VoiceToTextButton
|
||||
@transcript="(text) => (description = appendSpokenText(description, text))"
|
||||
/>
|
||||
@@ -875,7 +880,7 @@ watch([id, isNew], () => void load(), { immediate: true });
|
||||
<UiTextarea
|
||||
id="description"
|
||||
v-model="description"
|
||||
:rows="8"
|
||||
:rows="4"
|
||||
placeholder="جزئیات تماس، نتیجه جلسه یا یادداشت مرتبط را بنویسید…"
|
||||
/>
|
||||
<label for="activity-tags" class="tags-label">هشتگ / برچسب</label>
|
||||
@@ -884,6 +889,13 @@ watch([id, isNew], () => void load(), { immediate: true });
|
||||
v-model="tagsRaw"
|
||||
placeholder="پیگیری، فوری (با کاما یا #)"
|
||||
/>
|
||||
<div v-if="tagPreview.length" class="tag-preview">
|
||||
<span v-for="t in tagPreview" :key="t" class="tag-chip">#{{ t }}</span>
|
||||
</div>
|
||||
<small class="hint">
|
||||
چند برچسب را با کاما (،) یا # جدا کنید؛ برچسبها در جزئیات و لیست فعالیتها نمایش داده میشوند و با
|
||||
جستجوی همان کلمه پیدا میشوند.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
@@ -1018,26 +1030,24 @@ watch([id, isNew], () => void load(), { immediate: true });
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
.tag-preview {
|
||||
display: flex;
|
||||
gap: var(--sp-4);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.tabs button {
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: var(--sp-2) 0;
|
||||
font: inherit;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
.tag-chip {
|
||||
background: #f0fdfa;
|
||||
color: #0f766e;
|
||||
border-radius: 999px;
|
||||
padding: 0.15rem 0.55rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tabs .on {
|
||||
color: var(--color-primary);
|
||||
font-weight: 700;
|
||||
box-shadow: inset 0 -2px 0 var(--color-primary);
|
||||
.req-star {
|
||||
color: var(--color-danger);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.layout {
|
||||
@@ -1690,7 +1700,7 @@ watch([id, isNew], () => void load(), { immediate: true });
|
||||
}
|
||||
|
||||
.note-card textarea {
|
||||
min-height: 12rem;
|
||||
min-height: 7rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
|
||||
@@ -68,7 +68,6 @@ const pageTitle = computed(() => (isNew.value ? 'ایجاد معامله جدی
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const error = ref('');
|
||||
const tab = ref<'main' | 'notes'>('main');
|
||||
const showOthers = ref(true);
|
||||
const productsOpen = ref(false);
|
||||
const openMenu = ref<MenuKey>('');
|
||||
@@ -373,7 +372,6 @@ async function submit() {
|
||||
const message = validate();
|
||||
if (message) {
|
||||
error.value = message;
|
||||
tab.value = 'main';
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
@@ -473,15 +471,10 @@ onMounted(load);
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<nav class="tabs">
|
||||
<button type="button" :class="{ on: tab === 'main' }" @click="tab = 'main'">اطلاعات اصلی</button>
|
||||
<button type="button" :class="{ on: tab === 'notes' }" @click="tab = 'notes'">توضیحات و یادداشت</button>
|
||||
</nav>
|
||||
|
||||
<p v-if="loading" class="muted">در حال بارگذاری…</p>
|
||||
|
||||
<form v-else class="form" @submit.prevent="submit">
|
||||
<div v-show="tab === 'main'" class="stack">
|
||||
<div class="stack">
|
||||
<section class="card main-card">
|
||||
<h2 class="sec">
|
||||
<i class="dot" />
|
||||
@@ -804,7 +797,7 @@ onMounted(load);
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="card note-card" :class="{ off: tab !== 'notes' }">
|
||||
<div class="card note-card">
|
||||
<label for="notes">توضیحات و یادداشت</label>
|
||||
<textarea
|
||||
id="notes"
|
||||
@@ -817,14 +810,12 @@ onMounted(load);
|
||||
<RelatedTasksPanel
|
||||
v-if="!isNew && id"
|
||||
class="timeline"
|
||||
:class="{ hide: tab !== 'main' }"
|
||||
:entity="{ type: 'deal', id, label: title }"
|
||||
/>
|
||||
|
||||
<EntityTimeline
|
||||
v-if="!isNew && id"
|
||||
class="timeline"
|
||||
:class="{ hide: tab !== 'main' }"
|
||||
entity-type="deal"
|
||||
:entity-id="id"
|
||||
/>
|
||||
@@ -944,27 +935,6 @@ h1 {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 1.2rem;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.tabs button {
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 0.55rem 0;
|
||||
font: inherit;
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tabs .on {
|
||||
color: #0f766e;
|
||||
font-weight: 600;
|
||||
box-shadow: inset 0 -2px 0 #14b8a6;
|
||||
}
|
||||
|
||||
.form,
|
||||
.stack {
|
||||
display: grid;
|
||||
@@ -1397,13 +1367,8 @@ h1 {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.note-card.off,
|
||||
.timeline.hide {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.note-card textarea {
|
||||
min-height: 12rem;
|
||||
min-height: 7rem;
|
||||
width: 100%;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: var(--radius);
|
||||
@@ -1492,18 +1457,12 @@ h1 {
|
||||
}
|
||||
|
||||
.mobile-head,
|
||||
.tabs,
|
||||
.kind-field,
|
||||
.follow-up,
|
||||
.mobile-save {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.note-card.off,
|
||||
.timeline.hide {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.sec {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user