Files
CRM_Montzar/apps/web/src/components/activities/ActivityFieldsView.vue
T
MahdiandCursor 13668c4ba4 feat(activities): split customer column, wider search, custom activity types with type-specific fields
- Table: separate assignee and customer columns; search by assignee and activity type
- ActivityTypeDef model with per-type field definitions (text, select, checklist, participants, ...)
- Default types incl. meeting subject/participants and task checklist; admins manage types
- Participants see activities in their lists and get notified

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 12:29:23 +03:30

264 lines
6.0 KiB
Vue

<script setup lang="ts">
import { computed } from 'vue';
import { RouterLink } from 'vue-router';
import type {
ActivityChecklistItem,
ActivityFieldDef,
ActivityFieldValues,
ActivityParticipantsValue,
UserSummaryDto,
} from '@mento/shared';
import { formatJalaliDay, toFaDigits } from '@/lib/jalali';
import UiIcon from '@/components/UiIcon.vue';
const props = defineProps<{
fields: ActivityFieldDef[];
values: ActivityFieldValues;
participantUsers?: UserSummaryDto[];
/** تیک چک‌لیست قابل تغییر باشد */
editable?: boolean;
busy?: boolean;
}>();
const emit = defineEmits<{
toggleItem: [key: string, itemId: string, done: boolean];
}>();
const userName = computed(
() => new Map((props.participantUsers ?? []).map((u) => [u.id, u.fullName])),
);
const filled = computed(() =>
props.fields.filter((f) => {
const v = props.values[f.key];
if (v == null || v === '') return false;
if (Array.isArray(v)) return v.length > 0;
return true;
}),
);
function text(f: ActivityFieldDef): string {
const v = props.values[f.key];
if (f.type === 'checkbox') return v === true ? 'بله' : 'خیر';
if (f.type === 'date' && typeof v === 'string') {
const d = new Date(v);
return Number.isNaN(d.getTime()) ? v : formatJalaliDay(d);
}
if (f.type === 'number' && typeof v === 'number') return toFaDigits(v.toLocaleString('en-US'));
if (Array.isArray(v)) return (v as string[]).join('، ');
return typeof v === 'string' ? v : '';
}
function checklist(key: string): ActivityChecklistItem[] {
const v = props.values[key];
return Array.isArray(v) ? (v as ActivityChecklistItem[]) : [];
}
function participants(key: string): ActivityParticipantsValue {
const v = props.values[key] as ActivityParticipantsValue | undefined;
return { userIds: v?.userIds ?? [], people: v?.people ?? [], guests: v?.guests ?? [] };
}
function safeUrl(raw: string): string | null {
try {
const u = new URL(/^https?:\/\//i.test(raw) ? raw : `https://${raw}`);
return u.protocol === 'http:' || u.protocol === 'https:' ? u.href : null;
} catch {
return null;
}
}
</script>
<template>
<div v-if="filled.length" class="fv">
<div
v-for="f in filled"
:key="f.key"
class="row"
:class="{ block: ['textarea', 'checklist', 'participants'].includes(f.type) }"
>
<span class="label">{{ f.label }}</span>
<div v-if="f.type === 'checklist'" class="checklist">
<p class="progress">
{{ toFaDigits(checklist(f.key).filter((x) => x.done).length) }} از
{{ toFaDigits(checklist(f.key).length) }} انجام شده
<i
class="bar"
:style="{
'--p': `${(checklist(f.key).filter((x) => x.done).length / Math.max(1, checklist(f.key).length)) * 100}%`,
}"
/>
</p>
<label v-for="it in checklist(f.key)" :key="it.id" class="item" :class="{ done: it.done }">
<input
type="checkbox"
:checked="it.done"
:disabled="!editable || busy"
@change="emit('toggleItem', f.key, it.id, ($event.target as HTMLInputElement).checked)"
/>
<span>{{ it.text }}</span>
</label>
</div>
<div v-else-if="f.type === 'participants'" class="people">
<span v-for="uid in participants(f.key).userIds" :key="uid" class="tag user">
<UiIcon name="user" :size="12" />
{{ userName.get(uid) || 'همکار' }}
</span>
<RouterLink
v-for="p in participants(f.key).people"
:key="p.id"
class="tag person"
:to="{ name: 'person-detail', params: { id: p.id } }"
>
<UiIcon name="users" :size="12" />
{{ p.name }}
</RouterLink>
<span v-for="g in participants(f.key).guests" :key="g" class="tag guest">{{ g }}</span>
</div>
<a
v-else-if="f.type === 'url' && safeUrl(text(f))"
class="value link"
:href="safeUrl(text(f))!"
target="_blank"
rel="noopener noreferrer"
dir="ltr"
>
{{ text(f) }}
</a>
<p v-else class="value" :class="{ multiline: f.type === 'textarea' }">{{ text(f) }}</p>
</div>
</div>
<p v-else class="empty">اطلاعاتی برای این نوع فعالیت ثبت نشده است.</p>
</template>
<style scoped>
.fv {
display: grid;
}
.row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.8rem;
padding: 0.72rem 0;
border-top: 1px solid #f1f5f9;
}
.row.block {
display: grid;
gap: 0.45rem;
justify-content: stretch;
}
.label {
color: #64748b;
font-size: 0.86rem;
}
.value {
margin: 0;
font-weight: 700;
text-align: start;
word-break: break-word;
}
.value.multiline {
font-weight: 500;
white-space: pre-wrap;
line-height: 1.9;
color: #334155;
}
.value.link {
color: #0f766e;
text-decoration: none;
}
.checklist {
display: grid;
gap: 0.35rem;
}
.progress {
margin: 0 0 0.2rem;
display: grid;
gap: 0.3rem;
font-size: 0.78rem;
color: #0f766e;
font-weight: 600;
}
.bar {
display: block;
height: 6px;
border-radius: 999px;
background: linear-gradient(90deg, #14b8a6 var(--p), #e2e8f0 var(--p));
}
.item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.55rem;
border-radius: 8px;
background: #f8fafc;
cursor: pointer;
font-size: 0.9rem;
}
.item input {
width: 18px;
height: 18px;
accent-color: #14b8a6;
flex-shrink: 0;
}
.item.done span {
text-decoration: line-through;
color: #94a3b8;
}
.people {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.tag {
display: inline-flex;
align-items: center;
gap: 0.25rem;
border-radius: 999px;
padding: 0.22rem 0.65rem;
font-size: 0.8rem;
font-weight: 600;
text-decoration: none;
}
.tag.user {
background: #f0fdfa;
color: #0f766e;
}
.tag.person {
background: #eff6ff;
color: #1d4ed8;
}
.tag.guest {
background: #f1f5f9;
color: #334155;
}
.empty {
margin: 0.4rem 0 0;
color: #94a3b8;
font-size: 0.86rem;
}
</style>