- 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>
264 lines
6.0 KiB
Vue
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>
|