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>
This commit is contained in:
@@ -5,10 +5,10 @@ import axios from 'axios';
|
||||
import type {
|
||||
ActivityDto,
|
||||
ActivityStageDto,
|
||||
ActivityType,
|
||||
PaginatedResponse,
|
||||
} from '@mento/shared';
|
||||
import { api } from '@/lib/api';
|
||||
import { activityTypeView } from '@/lib/activity-types';
|
||||
import { calendarDaysBetween, formatClockFa, formatJalaliDay, startOfLocalDay, toFaDigits } from '@/lib/jalali';
|
||||
import PipelineBoard from '@/components/pipeline/PipelineBoard.vue';
|
||||
import DealCardOverlay from '@/components/pipeline/DealCardOverlay.vue';
|
||||
@@ -59,13 +59,6 @@ const moving = ref(false);
|
||||
const stageBusy = ref(false);
|
||||
const localOrder = ref<string[] | null>(null);
|
||||
|
||||
const typeLabel: Record<ActivityType, string> = {
|
||||
call: 'تماس',
|
||||
meeting: 'جلسه',
|
||||
task: 'وظیفه',
|
||||
note: 'پیام',
|
||||
};
|
||||
|
||||
const KIND_BADGE: Record<ActivityStageDto['kind'], string | undefined> = {
|
||||
open: undefined,
|
||||
done: 'انجامشده',
|
||||
@@ -109,7 +102,7 @@ function toCard(item: ActivityDto, stageId: string): PipelineDeal {
|
||||
return {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
description: [typeLabel[item.type], party].filter(Boolean).join(' · '),
|
||||
description: [activityTypeView(item).name, party].filter(Boolean).join(' · '),
|
||||
amount: null,
|
||||
date: dateText(item),
|
||||
initials: initialsFromTitle(party || item.title),
|
||||
|
||||
@@ -0,0 +1,674 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import type {
|
||||
ActivityChecklistItem,
|
||||
ActivityFieldDef,
|
||||
ActivityFieldValue,
|
||||
ActivityFieldValues,
|
||||
ActivityParticipantsValue,
|
||||
OrgUserDto,
|
||||
PaginatedResponse,
|
||||
PersonDto,
|
||||
} from '@mento/shared';
|
||||
import { api } from '@/lib/api';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||
import UserMultiSelect from '@/components/UserMultiSelect.vue';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
fields: ActivityFieldDef[];
|
||||
users: OrgUserDto[];
|
||||
}>();
|
||||
|
||||
const model = defineModel<ActivityFieldValues>({ required: true });
|
||||
|
||||
function get<T extends ActivityFieldValue>(key: string, fallback: T): T {
|
||||
const v = model.value[key];
|
||||
return (v ?? fallback) as T;
|
||||
}
|
||||
|
||||
function set(key: string, value: ActivityFieldValue) {
|
||||
model.value = { ...model.value, [key]: value };
|
||||
}
|
||||
|
||||
function str(key: string): string {
|
||||
const v = model.value[key];
|
||||
return typeof v === 'string' || typeof v === 'number' ? String(v) : '';
|
||||
}
|
||||
|
||||
function multi(key: string): string[] {
|
||||
const v = model.value[key];
|
||||
return Array.isArray(v) && v.every((x) => typeof x === 'string') ? (v as string[]) : [];
|
||||
}
|
||||
|
||||
function toggleMulti(key: string, opt: string) {
|
||||
const list = multi(key);
|
||||
set(key, list.includes(opt) ? list.filter((x) => x !== opt) : [...list, opt]);
|
||||
}
|
||||
|
||||
/* ---------- چکلیست ---------- */
|
||||
const checklistDraft = ref<Record<string, string>>({});
|
||||
|
||||
function checklist(key: string): ActivityChecklistItem[] {
|
||||
const v = model.value[key];
|
||||
return Array.isArray(v) ? (v as ActivityChecklistItem[]).filter((x) => x && typeof x === 'object') : [];
|
||||
}
|
||||
|
||||
function addChecklistItem(key: string) {
|
||||
const text = (checklistDraft.value[key] ?? '').trim();
|
||||
if (!text) return;
|
||||
const id = `c_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
set(key, [...checklist(key), { id, text, done: false }]);
|
||||
checklistDraft.value = { ...checklistDraft.value, [key]: '' };
|
||||
}
|
||||
|
||||
function updateChecklistItem(key: string, id: string, patch: Partial<ActivityChecklistItem>) {
|
||||
set(
|
||||
key,
|
||||
checklist(key).map((it) => (it.id === id ? { ...it, ...patch } : it)),
|
||||
);
|
||||
}
|
||||
|
||||
function removeChecklistItem(key: string, id: string) {
|
||||
set(
|
||||
key,
|
||||
checklist(key).filter((it) => it.id !== id),
|
||||
);
|
||||
}
|
||||
|
||||
function moveChecklistItem(key: string, index: number, dir: -1 | 1) {
|
||||
const list = [...checklist(key)];
|
||||
const to = index + dir;
|
||||
if (to < 0 || to >= list.length) return;
|
||||
const [it] = list.splice(index, 1);
|
||||
list.splice(to, 0, it!);
|
||||
set(key, list);
|
||||
}
|
||||
|
||||
/* ---------- شرکتکنندگان ---------- */
|
||||
function participants(key: string): ActivityParticipantsValue {
|
||||
const v = model.value[key] as ActivityParticipantsValue | null | undefined;
|
||||
return {
|
||||
userIds: v?.userIds ?? [],
|
||||
people: v?.people ?? [],
|
||||
guests: v?.guests ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
function patchParticipants(key: string, patch: Partial<ActivityParticipantsValue>) {
|
||||
set(key, { ...participants(key), ...patch });
|
||||
}
|
||||
|
||||
const guestDraft = ref<Record<string, string>>({});
|
||||
function addGuest(key: string) {
|
||||
const name = (guestDraft.value[key] ?? '').trim();
|
||||
if (!name) return;
|
||||
const p = participants(key);
|
||||
if (!p.guests.includes(name)) patchParticipants(key, { guests: [...p.guests, name] });
|
||||
guestDraft.value = { ...guestDraft.value, [key]: '' };
|
||||
}
|
||||
|
||||
const personQuery = ref<Record<string, string>>({});
|
||||
const personResults = ref<Record<string, PersonDto[]>>({});
|
||||
const personOpenKey = ref('');
|
||||
let personTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let personSeq = 0;
|
||||
|
||||
function searchPeople(key: string) {
|
||||
personOpenKey.value = key;
|
||||
if (personTimer) clearTimeout(personTimer);
|
||||
const q = (personQuery.value[key] ?? '').trim();
|
||||
if (!q) {
|
||||
personResults.value = { ...personResults.value, [key]: [] };
|
||||
return;
|
||||
}
|
||||
const seq = ++personSeq;
|
||||
personTimer = setTimeout(async () => {
|
||||
try {
|
||||
const { data } = await api.get<PaginatedResponse<PersonDto>>('/people', {
|
||||
params: { q, limit: 8 },
|
||||
});
|
||||
if (seq === personSeq) personResults.value = { ...personResults.value, [key]: data.items };
|
||||
} catch {
|
||||
if (seq === personSeq) personResults.value = { ...personResults.value, [key]: [] };
|
||||
}
|
||||
}, 280);
|
||||
}
|
||||
|
||||
function addPerson(key: string, p: PersonDto) {
|
||||
const cur = participants(key);
|
||||
if (!cur.people.some((x) => x.id === p.id)) {
|
||||
patchParticipants(key, { people: [...cur.people, { id: p.id, name: p.fullName }] });
|
||||
}
|
||||
personQuery.value = { ...personQuery.value, [key]: '' };
|
||||
personResults.value = { ...personResults.value, [key]: [] };
|
||||
personOpenKey.value = '';
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.fields.map((f) => f.key).join('|'),
|
||||
() => {
|
||||
checklistDraft.value = {};
|
||||
guestDraft.value = {};
|
||||
personQuery.value = {};
|
||||
personResults.value = {};
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fields-grid">
|
||||
<div
|
||||
v-for="f in fields"
|
||||
:key="f.key"
|
||||
class="cf"
|
||||
:class="{ wide: ['textarea', 'checklist', 'participants', 'multiselect'].includes(f.type) }"
|
||||
>
|
||||
<label :for="`cf-${f.key}`">
|
||||
{{ f.label }}
|
||||
<i v-if="f.required">*</i>
|
||||
</label>
|
||||
|
||||
<input
|
||||
v-if="f.type === 'text' || f.type === 'url'"
|
||||
:id="`cf-${f.key}`"
|
||||
:type="f.type === 'url' ? 'url' : 'text'"
|
||||
:dir="f.type === 'url' ? 'ltr' : undefined"
|
||||
:value="str(f.key)"
|
||||
:placeholder="f.placeholder || ''"
|
||||
maxlength="1000"
|
||||
@input="set(f.key, ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
|
||||
<textarea
|
||||
v-else-if="f.type === 'textarea'"
|
||||
:id="`cf-${f.key}`"
|
||||
:value="str(f.key)"
|
||||
:placeholder="f.placeholder || ''"
|
||||
rows="3"
|
||||
@input="set(f.key, ($event.target as HTMLTextAreaElement).value)"
|
||||
/>
|
||||
|
||||
<input
|
||||
v-else-if="f.type === 'number'"
|
||||
:id="`cf-${f.key}`"
|
||||
type="number"
|
||||
inputmode="decimal"
|
||||
dir="ltr"
|
||||
:value="str(f.key)"
|
||||
:placeholder="f.placeholder || ''"
|
||||
@input="
|
||||
set(
|
||||
f.key,
|
||||
($event.target as HTMLInputElement).value === ''
|
||||
? null
|
||||
: Number(($event.target as HTMLInputElement).value),
|
||||
)
|
||||
"
|
||||
/>
|
||||
|
||||
<JalaliDateInput
|
||||
v-else-if="f.type === 'date'"
|
||||
:id="`cf-${f.key}`"
|
||||
:model-value="str(f.key)"
|
||||
@update:model-value="(v: string) => set(f.key, v || null)"
|
||||
/>
|
||||
|
||||
<select
|
||||
v-else-if="f.type === 'select'"
|
||||
:id="`cf-${f.key}`"
|
||||
:value="str(f.key)"
|
||||
@change="set(f.key, ($event.target as HTMLSelectElement).value || null)"
|
||||
>
|
||||
<option value="">— انتخاب کنید —</option>
|
||||
<option v-for="opt in f.options ?? []" :key="opt" :value="opt">{{ opt }}</option>
|
||||
</select>
|
||||
|
||||
<div v-else-if="f.type === 'multiselect'" class="chips">
|
||||
<button
|
||||
v-for="opt in f.options ?? []"
|
||||
:key="opt"
|
||||
type="button"
|
||||
class="chip-opt"
|
||||
:class="{ on: multi(f.key).includes(opt) }"
|
||||
@click="toggleMulti(f.key, opt)"
|
||||
>
|
||||
<UiIcon v-if="multi(f.key).includes(opt)" name="check-plain" :size="12" />
|
||||
{{ opt }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label v-else-if="f.type === 'checkbox'" class="switch">
|
||||
<input
|
||||
:id="`cf-${f.key}`"
|
||||
type="checkbox"
|
||||
:checked="get<boolean>(f.key, false) === true"
|
||||
@change="set(f.key, ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
<i />
|
||||
<span>{{ get<boolean>(f.key, false) === true ? 'بله' : 'خیر' }}</span>
|
||||
</label>
|
||||
|
||||
<div v-else-if="f.type === 'checklist'" class="checklist">
|
||||
<p v-if="checklist(f.key).length" class="progress">
|
||||
{{ toFaDigits(checklist(f.key).filter((x) => x.done).length) }} از
|
||||
{{ toFaDigits(checklist(f.key).length) }} انجام شده
|
||||
</p>
|
||||
<ul>
|
||||
<li v-for="(it, idx) in checklist(f.key)" :key="it.id" :class="{ done: it.done }">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="it.done"
|
||||
aria-label="انجام شد"
|
||||
@change="updateChecklistItem(f.key, it.id, { done: ($event.target as HTMLInputElement).checked })"
|
||||
/>
|
||||
<input
|
||||
class="item-text"
|
||||
:value="it.text"
|
||||
maxlength="300"
|
||||
@input="updateChecklistItem(f.key, it.id, { text: ($event.target as HTMLInputElement).value })"
|
||||
/>
|
||||
<button type="button" class="mini" :disabled="idx === 0" aria-label="بالا" @click="moveChecklistItem(f.key, idx, -1)">▲</button>
|
||||
<button
|
||||
type="button"
|
||||
class="mini"
|
||||
:disabled="idx === checklist(f.key).length - 1"
|
||||
aria-label="پایین"
|
||||
@click="moveChecklistItem(f.key, idx, 1)"
|
||||
>
|
||||
▼
|
||||
</button>
|
||||
<button type="button" class="mini danger" aria-label="حذف" @click="removeChecklistItem(f.key, it.id)">
|
||||
<UiIcon name="x" :size="13" />
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="add-row">
|
||||
<input
|
||||
v-model="checklistDraft[f.key]"
|
||||
maxlength="300"
|
||||
:placeholder="f.placeholder || 'مورد جدید را بنویسید و Enter بزنید'"
|
||||
@keydown.enter.prevent="addChecklistItem(f.key)"
|
||||
/>
|
||||
<button type="button" class="add-btn" @click="addChecklistItem(f.key)">
|
||||
<UiIcon name="plus" :size="14" />
|
||||
افزودن
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="f.type === 'participants'" class="participants">
|
||||
<div class="p-block">
|
||||
<small>همکاران (فعالیت در لیست آنها هم نمایش داده میشود)</small>
|
||||
<UserMultiSelect
|
||||
:model-value="participants(f.key).userIds"
|
||||
:users="users"
|
||||
add-label="افزودن همکار"
|
||||
empty-label="همکاری انتخاب نشده"
|
||||
@update:model-value="(ids: string[]) => patchParticipants(f.key, { userIds: ids })"
|
||||
/>
|
||||
</div>
|
||||
<div class="p-block">
|
||||
<small>اشخاص / مشتریان</small>
|
||||
<div class="tags">
|
||||
<span v-for="p in participants(f.key).people" :key="p.id" class="tag">
|
||||
<UiIcon name="user" :size="12" />
|
||||
{{ p.name }}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="حذف"
|
||||
@click="patchParticipants(f.key, { people: participants(f.key).people.filter((x) => x.id !== p.id) })"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="person-search">
|
||||
<input
|
||||
v-model="personQuery[f.key]"
|
||||
type="search"
|
||||
placeholder="جستجوی نام یا موبایل شخص…"
|
||||
@input="searchPeople(f.key)"
|
||||
@focus="personOpenKey = f.key"
|
||||
/>
|
||||
<ul v-if="personOpenKey === f.key && (personResults[f.key]?.length ?? 0) > 0" class="results">
|
||||
<li v-for="p in personResults[f.key]" :key="p.id">
|
||||
<button type="button" @click="addPerson(f.key, p)">
|
||||
<b>{{ p.fullName }}</b>
|
||||
<small v-if="p.company?.name">{{ p.company.name }}</small>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-block">
|
||||
<small>مهمانان دیگر (نام آزاد)</small>
|
||||
<div class="tags">
|
||||
<span v-for="g in participants(f.key).guests" :key="g" class="tag guest">
|
||||
{{ g }}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="حذف"
|
||||
@click="patchParticipants(f.key, { guests: participants(f.key).guests.filter((x) => x !== g) })"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="add-row">
|
||||
<input
|
||||
v-model="guestDraft[f.key]"
|
||||
maxlength="120"
|
||||
placeholder="نام مهمان و Enter"
|
||||
@keydown.enter.prevent="addGuest(f.key)"
|
||||
/>
|
||||
<button type="button" class="add-btn" @click="addGuest(f.key)">
|
||||
<UiIcon name="plus" :size="14" />
|
||||
افزودن
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fields-grid {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.cf {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cf > label {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted, #64748b);
|
||||
font-size: var(--fs-xs, 0.78rem);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cf > label i {
|
||||
color: var(--color-danger, #dc2626);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.cf input:not([type='checkbox']),
|
||||
.cf select,
|
||||
.cf textarea {
|
||||
width: 100%;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: var(--radius);
|
||||
min-height: var(--control-h);
|
||||
padding: 0 0.85rem;
|
||||
font: inherit;
|
||||
background: #fff;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.cf textarea {
|
||||
min-height: 5rem;
|
||||
padding: 0.6rem 0.85rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.chips,
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.chip-opt {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #fff;
|
||||
border-radius: 999px;
|
||||
padding: 0.3rem 0.75rem;
|
||||
font: inherit;
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chip-opt.on {
|
||||
border-color: #5eead4;
|
||||
background: #f0fdfa;
|
||||
color: #0f766e;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.switch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.switch input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.switch i {
|
||||
width: 44px;
|
||||
height: 26px;
|
||||
border-radius: 999px;
|
||||
background: #e2e8f0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.switch i::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset-inline-start: 3px;
|
||||
top: 3px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
transition: 0.18s;
|
||||
}
|
||||
|
||||
.switch input:checked + i {
|
||||
background: #14b8a6;
|
||||
}
|
||||
|
||||
.switch input:checked + i::after {
|
||||
inset-inline-start: 21px;
|
||||
}
|
||||
|
||||
.checklist ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.checklist li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.checklist li input[type='checkbox'] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: #14b8a6;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.checklist .item-text {
|
||||
flex: 1;
|
||||
min-height: 2.2rem !important;
|
||||
}
|
||||
|
||||
.checklist li.done .item-text {
|
||||
text-decoration: line-through;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.progress {
|
||||
margin: 0;
|
||||
font-size: 0.78rem;
|
||||
color: #0f766e;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mini {
|
||||
width: 1.9rem;
|
||||
height: 1.9rem;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 0.65rem;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mini:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.mini.danger {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.add-row {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.add-row input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
border: 1px dashed #5eead4;
|
||||
background: #f0fdfa;
|
||||
color: #0f766e;
|
||||
border-radius: var(--radius);
|
||||
padding: 0 0.8rem;
|
||||
font: inherit;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.participants {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px dashed #e2e8f0;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.p-block {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.p-block > small {
|
||||
color: #94a3b8;
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
background: #f0fdfa;
|
||||
color: #0f766e;
|
||||
border-radius: 999px;
|
||||
padding: 0.2rem 0.6rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tag.guest {
|
||||
background: #f1f5f9;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.tag button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
line-height: 1;
|
||||
padding: 0 0.1rem;
|
||||
}
|
||||
|
||||
.person-search {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.results {
|
||||
position: absolute;
|
||||
inset-inline: 0;
|
||||
top: calc(100% + 4px);
|
||||
z-index: 20;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0.3rem;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.14);
|
||||
max-height: 14rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.results button {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
gap: 0.1rem;
|
||||
text-align: start;
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 0.45rem 0.55rem;
|
||||
border-radius: 8px;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.results button:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.results b {
|
||||
font-size: 0.86rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.results small {
|
||||
color: #94a3b8;
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
.fields-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.cf.wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,263 @@
|
||||
<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>
|
||||
@@ -0,0 +1,821 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import axios from 'axios';
|
||||
import type {
|
||||
ActivityFieldDef,
|
||||
ActivityFieldType,
|
||||
ActivityStageColor,
|
||||
ActivityType,
|
||||
ActivityTypeDefDto,
|
||||
ActivityTypeIcon,
|
||||
} from '@mento/shared';
|
||||
import {
|
||||
ACTIVITY_FIELD_TYPE_LABELS,
|
||||
ACTIVITY_FIELD_TYPES,
|
||||
ACTIVITY_FIELD_TYPES_WITH_OPTIONS,
|
||||
ACTIVITY_STAGE_COLOR_LABELS,
|
||||
ACTIVITY_STAGE_COLORS,
|
||||
ACTIVITY_TYPE_ICONS,
|
||||
ACTIVITY_TYPE_LABELS,
|
||||
} from '@mento/shared';
|
||||
import { api } from '@/lib/api';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import {
|
||||
ACTIVITY_TYPE_ICON_LABELS,
|
||||
newFieldKey,
|
||||
typeColorStyle,
|
||||
useActivityTypes,
|
||||
} from '@/lib/activity-types';
|
||||
import { UiButton, UiModal } from '@/components/ui';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
|
||||
const props = defineProps<{ open: boolean }>();
|
||||
const emit = defineEmits<{ close: []; changed: [] }>();
|
||||
|
||||
const BASE_TYPES: ActivityType[] = ['call', 'meeting', 'task', 'note'];
|
||||
|
||||
type FieldDraft = ActivityFieldDef & { optionsText: string };
|
||||
type Draft = {
|
||||
id: string | null;
|
||||
name: string;
|
||||
baseType: ActivityType;
|
||||
icon: ActivityTypeIcon;
|
||||
color: ActivityStageColor;
|
||||
isActive: boolean;
|
||||
isSystem: boolean;
|
||||
fields: FieldDraft[];
|
||||
};
|
||||
|
||||
const { types, load } = useActivityTypes();
|
||||
const selectedId = ref<string | 'new' | null>(null);
|
||||
const draft = ref<Draft | null>(null);
|
||||
const savedSnapshot = ref('');
|
||||
const busy = ref('');
|
||||
const error = ref('');
|
||||
const notice = ref('');
|
||||
|
||||
function toDraft(t: ActivityTypeDefDto | null): Draft {
|
||||
if (!t) {
|
||||
return {
|
||||
id: null,
|
||||
name: '',
|
||||
baseType: 'task',
|
||||
icon: 'check',
|
||||
color: 'blue',
|
||||
isActive: true,
|
||||
isSystem: false,
|
||||
fields: [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
baseType: t.baseType,
|
||||
icon: t.icon,
|
||||
color: t.color,
|
||||
isActive: t.isActive,
|
||||
isSystem: t.isSystem,
|
||||
fields: t.fields.map((f) => ({ ...f, optionsText: (f.options ?? []).join('\n') })),
|
||||
};
|
||||
}
|
||||
|
||||
function snapshot(d: Draft | null) {
|
||||
return JSON.stringify(d);
|
||||
}
|
||||
|
||||
const dirty = computed(() => snapshot(draft.value) !== savedSnapshot.value);
|
||||
|
||||
function select(id: string | 'new') {
|
||||
if (dirty.value && draft.value && !window.confirm('تغییرات ذخیره نشده از بین میرود. ادامه میدهید؟')) {
|
||||
return;
|
||||
}
|
||||
selectedId.value = id;
|
||||
draft.value = toDraft(id === 'new' ? null : (types.value.find((t) => t.id === id) ?? null));
|
||||
savedSnapshot.value = snapshot(draft.value);
|
||||
error.value = '';
|
||||
notice.value = '';
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
async (open) => {
|
||||
if (!open) return;
|
||||
error.value = '';
|
||||
notice.value = '';
|
||||
await load(true);
|
||||
const first = types.value[0];
|
||||
selectedId.value = null;
|
||||
draft.value = null;
|
||||
if (first) select(first.id);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function apiMessage(e: unknown, fallback: string) {
|
||||
if (axios.isAxiosError(e)) {
|
||||
const msg = (e.response?.data as { message?: string | string[] } | undefined)?.message;
|
||||
if (Array.isArray(msg)) return msg[0] ?? fallback;
|
||||
if (typeof msg === 'string' && msg) return msg;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function addField() {
|
||||
if (!draft.value) return;
|
||||
draft.value.fields.push({
|
||||
key: newFieldKey(),
|
||||
label: '',
|
||||
type: 'text',
|
||||
required: false,
|
||||
placeholder: '',
|
||||
optionsText: '',
|
||||
});
|
||||
}
|
||||
|
||||
function moveField(index: number, dir: -1 | 1) {
|
||||
const list = draft.value?.fields;
|
||||
if (!list) return;
|
||||
const to = index + dir;
|
||||
if (to < 0 || to >= list.length) return;
|
||||
const [f] = list.splice(index, 1);
|
||||
list.splice(to, 0, f!);
|
||||
}
|
||||
|
||||
function removeField(index: number) {
|
||||
const f = draft.value?.fields[index];
|
||||
if (!f) return;
|
||||
if (
|
||||
f.label.trim() &&
|
||||
!window.confirm(
|
||||
`فیلد «${f.label}» حذف شود؟ مقادیر ثبتشدهٔ آن در فعالیتهای قبلی دیگر نمایش داده نمیشود.`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
draft.value!.fields.splice(index, 1);
|
||||
}
|
||||
|
||||
function needsOptions(type: ActivityFieldType) {
|
||||
return ACTIVITY_FIELD_TYPES_WITH_OPTIONS.includes(type);
|
||||
}
|
||||
|
||||
function payloadFields(d: Draft): ActivityFieldDef[] {
|
||||
return d.fields.map((f) => {
|
||||
const out: ActivityFieldDef = { key: f.key, label: f.label.trim(), type: f.type };
|
||||
if (needsOptions(f.type)) {
|
||||
out.options = f.optionsText
|
||||
.split(/\n|،|,/)
|
||||
.map((o) => o.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
if (f.required) out.required = true;
|
||||
if (f.placeholder?.trim()) out.placeholder = f.placeholder.trim();
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
function validate(d: Draft): string {
|
||||
if (!d.name.trim()) return 'نام نوع فعالیت را بنویسید.';
|
||||
const empty = d.fields.findIndex((f) => !f.label.trim());
|
||||
if (empty >= 0) return `عنوان فیلد ${toFaDigits(empty + 1)} را بنویسید.`;
|
||||
const noOpts = d.fields.find(
|
||||
(f) => needsOptions(f.type) && !f.optionsText.split(/\n|،|,/).some((o) => o.trim()),
|
||||
);
|
||||
if (noOpts) return `برای فیلد «${noOpts.label}» حداقل یک گزینه بنویسید.`;
|
||||
return '';
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const d = draft.value;
|
||||
if (!d) return;
|
||||
const msg = validate(d);
|
||||
if (msg) {
|
||||
error.value = msg;
|
||||
return;
|
||||
}
|
||||
busy.value = 'save';
|
||||
error.value = '';
|
||||
notice.value = '';
|
||||
const body = {
|
||||
name: d.name.trim(),
|
||||
icon: d.icon,
|
||||
color: d.color,
|
||||
fields: payloadFields(d),
|
||||
...(d.isSystem ? {} : { baseType: d.baseType, ...(d.id ? { isActive: d.isActive } : {}) }),
|
||||
};
|
||||
try {
|
||||
if (d.id) {
|
||||
const prev = types.value.find((t) => t.id === d.id);
|
||||
if (
|
||||
prev &&
|
||||
!d.isSystem &&
|
||||
prev.baseType !== d.baseType &&
|
||||
!window.confirm(
|
||||
'با تغییر نوع پایه، فعالیتهای قبلی این نوع هم در گزارشها با نوع پایهٔ جدید شمرده میشوند. ادامه میدهید؟',
|
||||
)
|
||||
) {
|
||||
busy.value = '';
|
||||
return;
|
||||
}
|
||||
await api.patch(`/activities/types/${d.id}`, body);
|
||||
} else {
|
||||
const { data } = await api.post<ActivityTypeDefDto>('/activities/types', body);
|
||||
d.id = data.id;
|
||||
}
|
||||
await load(true);
|
||||
const savedId = d.id!;
|
||||
selectedId.value = savedId;
|
||||
draft.value = toDraft(types.value.find((t) => t.id === savedId) ?? null);
|
||||
savedSnapshot.value = snapshot(draft.value);
|
||||
notice.value = 'ذخیره شد.';
|
||||
emit('changed');
|
||||
} catch (e) {
|
||||
error.value = apiMessage(e, 'ذخیرهٔ نوع فعالیت ناموفق بود.');
|
||||
} finally {
|
||||
busy.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
const d = draft.value;
|
||||
if (!d?.id || d.isSystem) return;
|
||||
if (
|
||||
!window.confirm(
|
||||
`نوع «${d.name}» حذف شود؟ فعالیتهای این نوع به نوع اصلی «${ACTIVITY_TYPE_LABELS[d.baseType]}» برمیگردند. برای نگه داشتن سوابق، میتوانید بهجای حذف آن را غیرفعال کنید.`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
busy.value = 'remove';
|
||||
error.value = '';
|
||||
try {
|
||||
await api.delete(`/activities/types/${d.id}`);
|
||||
await load(true);
|
||||
savedSnapshot.value = snapshot(null);
|
||||
draft.value = null;
|
||||
const first = types.value[0];
|
||||
if (first) select(first.id);
|
||||
emit('changed');
|
||||
} catch (e) {
|
||||
error.value = apiMessage(e, 'حذف نوع فعالیت ناموفق بود.');
|
||||
} finally {
|
||||
busy.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function moveType(index: number, dir: -1 | 1) {
|
||||
const ids = types.value.map((t) => t.id);
|
||||
const to = index + dir;
|
||||
if (to < 0 || to >= ids.length) return;
|
||||
const [moved] = ids.splice(index, 1);
|
||||
ids.splice(to, 0, moved!);
|
||||
busy.value = 'order';
|
||||
error.value = '';
|
||||
try {
|
||||
await api.patch('/activities/types/reorder', { typeIds: ids });
|
||||
await load(true);
|
||||
emit('changed');
|
||||
} catch (e) {
|
||||
error.value = apiMessage(e, 'جابهجایی ناموفق بود.');
|
||||
} finally {
|
||||
busy.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (dirty.value && draft.value && !window.confirm('تغییرات ذخیره نشده از بین میرود. بستن؟')) return;
|
||||
emit('close');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiModal :open="open" title="انواع فعالیت و فیلدهای اختصاصی" @close="close">
|
||||
<div class="mgr">
|
||||
<p class="hint">
|
||||
برای هر نوع فعالیت فیلدهای مخصوص خودش را تعریف کنید؛ مثلاً «موضوع جلسه» و «شرکتکنندگان» برای جلسه
|
||||
یا «لیست کارها» برای وظیفه. «نوع پایه» مشخص میکند فعالیت در گزارشها جزو تماس، جلسه، وظیفه یا پیام
|
||||
شمرده شود.
|
||||
</p>
|
||||
|
||||
<div class="layout">
|
||||
<aside class="list">
|
||||
<div
|
||||
v-for="(t, index) in types"
|
||||
:key="t.id"
|
||||
class="type-row"
|
||||
:class="{ on: selectedId === t.id, off: !t.isActive }"
|
||||
>
|
||||
<button type="button" class="pick" @click="select(t.id)">
|
||||
<span class="ico" :style="typeColorStyle(t.color)">
|
||||
<UiIcon :name="t.icon" :size="14" />
|
||||
</span>
|
||||
<span class="txt">
|
||||
<b>{{ t.name }}</b>
|
||||
<small>
|
||||
{{ ACTIVITY_TYPE_LABELS[t.baseType] }} · {{ toFaDigits(t.fields.length) }} فیلد
|
||||
<template v-if="!t.isActive"> · غیرفعال</template>
|
||||
</small>
|
||||
</span>
|
||||
</button>
|
||||
<div class="order">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="index === 0 || Boolean(busy)"
|
||||
aria-label="انتقال به بالا"
|
||||
@click="moveType(index, -1)"
|
||||
>
|
||||
▲
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="index === types.length - 1 || Boolean(busy)"
|
||||
aria-label="انتقال به پایین"
|
||||
@click="moveType(index, 1)"
|
||||
>
|
||||
▼
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="add-type" :class="{ on: selectedId === 'new' }" @click="select('new')">
|
||||
<UiIcon name="plus" :size="15" />
|
||||
نوع فعالیت جدید
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<section v-if="draft" class="editor">
|
||||
<div class="grid">
|
||||
<label class="f">
|
||||
<span>نام نوع</span>
|
||||
<input v-model="draft.name" maxlength="60" placeholder="مثلاً: بازدید حضوری" />
|
||||
</label>
|
||||
<label class="f">
|
||||
<span>نوع پایه (برای گزارشها)</span>
|
||||
<select v-model="draft.baseType" :disabled="draft.isSystem">
|
||||
<option v-for="b in BASE_TYPES" :key="b" :value="b">{{ ACTIVITY_TYPE_LABELS[b] }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="f">
|
||||
<span>رنگ</span>
|
||||
<select v-model="draft.color">
|
||||
<option v-for="c in ACTIVITY_STAGE_COLORS" :key="c" :value="c">
|
||||
{{ ACTIVITY_STAGE_COLOR_LABELS[c] }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-if="!draft.isSystem && draft.id" class="f toggle">
|
||||
<input v-model="draft.isActive" type="checkbox" />
|
||||
<span>فعال (در فرم فعالیت قابل انتخاب باشد)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="f">
|
||||
<span>آیکون</span>
|
||||
<div class="icons">
|
||||
<button
|
||||
v-for="ic in ACTIVITY_TYPE_ICONS"
|
||||
:key="ic"
|
||||
type="button"
|
||||
:class="{ on: draft.icon === ic }"
|
||||
:style="draft.icon === ic ? typeColorStyle(draft.color) : undefined"
|
||||
:title="ACTIVITY_TYPE_ICON_LABELS[ic]"
|
||||
:aria-label="ACTIVITY_TYPE_ICON_LABELS[ic]"
|
||||
@click="draft.icon = ic"
|
||||
>
|
||||
<UiIcon :name="ic" :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="draft.isSystem" class="sys-note">
|
||||
این نوع اصلی است: حذف و غیرفعال نمیشود، ولی نام، ظاهر و فیلدهایش قابل تغییر است.
|
||||
</p>
|
||||
|
||||
<div class="fields-head">
|
||||
<strong>فیلدهای اختصاصی</strong>
|
||||
<small>{{ toFaDigits(draft.fields.length) }} فیلد</small>
|
||||
</div>
|
||||
<p v-if="!draft.fields.length" class="empty">
|
||||
هنوز فیلدی تعریف نشده؛ فعالیتهای این نوع فقط فیلدهای عمومی (عنوان، زمان، مسئول، مشتری…) را دارند.
|
||||
</p>
|
||||
|
||||
<div v-for="(f, index) in draft.fields" :key="f.key" class="field-card">
|
||||
<div class="field-top">
|
||||
<span class="num">{{ toFaDigits(index + 1) }}</span>
|
||||
<input v-model="f.label" class="label-input" maxlength="80" placeholder="عنوان فیلد (مثلاً: موضوع جلسه)" />
|
||||
<select v-model="f.type" aria-label="نوع فیلد">
|
||||
<option v-for="ft in ACTIVITY_FIELD_TYPES" :key="ft" :value="ft">
|
||||
{{ ACTIVITY_FIELD_TYPE_LABELS[ft] }}
|
||||
</option>
|
||||
</select>
|
||||
<div class="field-actions">
|
||||
<button type="button" :disabled="index === 0" aria-label="بالا" @click="moveField(index, -1)">▲</button>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="index === draft.fields.length - 1"
|
||||
aria-label="پایین"
|
||||
@click="moveField(index, 1)"
|
||||
>
|
||||
▼
|
||||
</button>
|
||||
<button type="button" class="danger" aria-label="حذف فیلد" @click="removeField(index)">
|
||||
<UiIcon name="trash" :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-extra">
|
||||
<textarea
|
||||
v-if="needsOptions(f.type)"
|
||||
v-model="f.optionsText"
|
||||
rows="3"
|
||||
placeholder="گزینهها — هر گزینه در یک خط"
|
||||
/>
|
||||
<input
|
||||
v-if="!['checkbox', 'participants', 'select', 'multiselect', 'date'].includes(f.type)"
|
||||
v-model="f.placeholder"
|
||||
maxlength="200"
|
||||
placeholder="متن راهنما داخل فیلد (اختیاری)"
|
||||
/>
|
||||
<label v-if="f.type !== 'checkbox'" class="req">
|
||||
<input v-model="f.required" type="checkbox" />
|
||||
اجباری
|
||||
</label>
|
||||
<small v-if="f.type === 'participants'" class="tip">
|
||||
همکارانی که بهعنوان شرکتکننده انتخاب شوند، این فعالیت را در لیست خودشان هم میبینند و اعلان
|
||||
دریافت میکنند.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="add-field" @click="addField">
|
||||
<UiIcon name="plus" :size="15" />
|
||||
افزودن فیلد
|
||||
</button>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-else-if="notice" class="notice">{{ notice }}</p>
|
||||
|
||||
<div class="actions">
|
||||
<UiButton
|
||||
variant="primary"
|
||||
type="button"
|
||||
:disabled="!dirty || Boolean(busy)"
|
||||
:loading="busy === 'save'"
|
||||
@click="save"
|
||||
>
|
||||
{{ draft.id ? 'ذخیرهٔ تغییرات' : 'ساخت نوع فعالیت' }}
|
||||
</UiButton>
|
||||
<UiButton
|
||||
v-if="draft.id && !draft.isSystem"
|
||||
variant="ghost"
|
||||
type="button"
|
||||
:disabled="Boolean(busy)"
|
||||
:loading="busy === 'remove'"
|
||||
@click="remove"
|
||||
>
|
||||
<UiIcon name="trash" :size="15" />
|
||||
حذف نوع
|
||||
</UiButton>
|
||||
</div>
|
||||
</section>
|
||||
<p v-else class="empty">یک نوع فعالیت را انتخاب کنید.</p>
|
||||
</div>
|
||||
</div>
|
||||
</UiModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mgr {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
color: #64748b;
|
||||
font-size: 0.84rem;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.type-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.type-row.on {
|
||||
border-color: #5eead4;
|
||||
background: #f0fdfa;
|
||||
}
|
||||
|
||||
.type-row.off {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.pick {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 0.5rem 0.6rem;
|
||||
font: inherit;
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ico {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 999px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.txt {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.txt b {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.txt small {
|
||||
color: #94a3b8;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.order {
|
||||
display: grid;
|
||||
padding-inline-end: 0.3rem;
|
||||
}
|
||||
|
||||
.order button,
|
||||
.field-actions button {
|
||||
border: 0;
|
||||
background: none;
|
||||
color: #94a3b8;
|
||||
font-size: 0.62rem;
|
||||
cursor: pointer;
|
||||
padding: 0.1rem 0.3rem;
|
||||
}
|
||||
|
||||
.order button:disabled,
|
||||
.field-actions button:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.add-type,
|
||||
.add-field {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
min-height: var(--control-h);
|
||||
border: 1px dashed #5eead4;
|
||||
background: #f0fdfa;
|
||||
color: #0f766e;
|
||||
border-radius: var(--radius);
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
font-size: 0.86rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.add-type.on {
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.editor {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
align-content: start;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.f {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.f > span {
|
||||
color: #64748b;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.f.toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
align-self: end;
|
||||
min-height: var(--control-h);
|
||||
}
|
||||
|
||||
.f.toggle span {
|
||||
color: #334155;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
input:not([type='checkbox']),
|
||||
select,
|
||||
textarea {
|
||||
min-height: var(--control-h);
|
||||
padding: 0 0.65rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
font-size: 0.86rem;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
textarea {
|
||||
padding: 0.5rem 0.65rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
select:disabled {
|
||||
background: #f8fafc;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
input[type='checkbox'] {
|
||||
accent-color: #14b8a6;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.icons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.icons button {
|
||||
width: 2.2rem;
|
||||
height: 2.2rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.icons button.on {
|
||||
border-color: currentColor;
|
||||
}
|
||||
|
||||
.sys-note {
|
||||
margin: 0;
|
||||
padding: 0.5rem 0.7rem;
|
||||
border-radius: var(--radius);
|
||||
background: #f8fafc;
|
||||
color: #475569;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.fields-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
padding-top: 0.6rem;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.fields-head small {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin: 0;
|
||||
color: #94a3b8;
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.field-card {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
padding: 0.6rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: #fcfdfe;
|
||||
}
|
||||
|
||||
.field-top {
|
||||
display: grid;
|
||||
grid-template-columns: 1.4rem minmax(0, 1fr) 10rem auto;
|
||||
gap: 0.45rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.num {
|
||||
color: #94a3b8;
|
||||
font-size: 0.8rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.field-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.field-actions .danger {
|
||||
color: #dc2626;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.field-extra {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
padding-inline-start: 1.85rem;
|
||||
}
|
||||
|
||||
.req {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.82rem;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.tip {
|
||||
color: #0f766e;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.notice {
|
||||
margin: 0;
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
padding: 0.6rem 0 0.2rem;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
@media (min-width: 760px) {
|
||||
.layout {
|
||||
grid-template-columns: 14rem minmax(0, 1fr);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.field-top {
|
||||
grid-template-columns: 1.4rem minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.field-top select {
|
||||
grid-column: 2 / -1;
|
||||
grid-row: 2;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user