254 lines
7.0 KiB
Vue
254 lines
7.0 KiB
Vue
<script setup lang="ts">
|
||||
|
|
import { computed, inject, reactive, ref, watch } from 'vue';
|
|||
|
|
import type {
|
|||
|
|
ActivityPriority,
|
|||
|
|
CreateRoutineTaskRequest,
|
|||
|
|
RoutineContextDto,
|
|||
|
|
RoutineEntityRefDto,
|
|||
|
|
RoutineTaskDto,
|
|||
|
|
RoutineTaskSource,
|
|||
|
|
} from '@mento/shared';
|
|||
|
|
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
|||
|
|
import EntityLinkPicker, { type PickedEntity } from '@/components/routines/EntityLinkPicker.vue';
|
|||
|
|
import { UiButton, UiField, UiInput, UiModal, UiSelect, UiTextarea } from '@/components/ui';
|
|||
|
|
import { api } from '@/lib/api';
|
|||
|
|
import {
|
|||
|
|
ENTITY_TYPE_LABELS,
|
|||
|
|
PRIORITY_LABELS,
|
|||
|
|
ROUTINES_CTX,
|
|||
|
|
apiError,
|
|||
|
|
localInputToIso,
|
|||
|
|
todayKey,
|
|||
|
|
} from '@/lib/routines';
|
|||
|
|
import { useAuthStore } from '@/stores/auth';
|
|||
|
|
|
|||
|
|
const props = withDefaults(
|
|||
|
|
defineProps<{
|
|||
|
|
open: boolean;
|
|||
|
|
/** پروندهای که Task از صفحه آن ساخته میشود */
|
|||
|
|
preset?: RoutineEntityRefDto | null;
|
|||
|
|
defaultDate?: string | null;
|
|||
|
|
defaultAssigneeId?: string | null;
|
|||
|
|
}>(),
|
|||
|
|
{ preset: null, defaultDate: null, defaultAssigneeId: null },
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const emit = defineEmits<{ close: []; created: [task: RoutineTaskDto] }>();
|
|||
|
|
|
|||
|
|
const auth = useAuthStore();
|
|||
|
|
const shell = inject(ROUTINES_CTX, null);
|
|||
|
|
const localCtx = ref<RoutineContextDto | null>(null);
|
|||
|
|
const ctx = computed(() => shell?.context.value ?? localCtx.value);
|
|||
|
|
|
|||
|
|
const form = reactive({
|
|||
|
|
title: '',
|
|||
|
|
description: '',
|
|||
|
|
date: todayKey(),
|
|||
|
|
time: '',
|
|||
|
|
priority: 'medium' as ActivityPriority,
|
|||
|
|
categoryId: '',
|
|||
|
|
assigneeId: '',
|
|||
|
|
isMandatory: false,
|
|||
|
|
entity: null as PickedEntity | null,
|
|||
|
|
});
|
|||
|
|
const saving = ref(false);
|
|||
|
|
const error = ref('');
|
|||
|
|
|
|||
|
|
const priorities = Object.entries(PRIORITY_LABELS) as Array<[ActivityPriority, string]>;
|
|||
|
|
|
|||
|
|
async function ensureContext() {
|
|||
|
|
if (ctx.value) return;
|
|||
|
|
try {
|
|||
|
|
const { data } = await api.get<RoutineContextDto>('/routines/context');
|
|||
|
|
localCtx.value = data;
|
|||
|
|
} catch {
|
|||
|
|
/* بدون زمینه هم فرم کار میکند */
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
watch(
|
|||
|
|
() => props.open,
|
|||
|
|
(open) => {
|
|||
|
|
if (!open) return;
|
|||
|
|
Object.assign(form, {
|
|||
|
|
title: '',
|
|||
|
|
description: '',
|
|||
|
|
date: props.defaultDate || todayKey(),
|
|||
|
|
time: '',
|
|||
|
|
priority: 'medium',
|
|||
|
|
categoryId: '',
|
|||
|
|
assigneeId: props.defaultAssigneeId || auth.user?.id || '',
|
|||
|
|
isMandatory: false,
|
|||
|
|
entity: null,
|
|||
|
|
});
|
|||
|
|
error.value = '';
|
|||
|
|
void ensureContext();
|
|||
|
|
},
|
|||
|
|
{ immediate: true },
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const assignees = computed(() => {
|
|||
|
|
const c = ctx.value;
|
|||
|
|
if (!c?.isManager) return [];
|
|||
|
|
return c.users;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
function sourceFor(type: RoutineEntityRefDto['type'] | undefined): RoutineTaskSource {
|
|||
|
|
if (type === 'lead') return 'lead';
|
|||
|
|
if (type === 'person' || type === 'company') return 'customer';
|
|||
|
|
if (type === 'deal') return 'deal';
|
|||
|
|
if (type === 'activity') return 'follow_up';
|
|||
|
|
return 'manual';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function submit() {
|
|||
|
|
error.value = '';
|
|||
|
|
if (form.title.trim().length < 2) {
|
|||
|
|
error.value = 'عنوان کار را وارد کنید.';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
const link = props.preset ?? form.entity;
|
|||
|
|
const body: CreateRoutineTaskRequest = {
|
|||
|
|
title: form.title.trim(),
|
|||
|
|
description: form.description.trim() || null,
|
|||
|
|
priority: form.priority,
|
|||
|
|
categoryId: form.categoryId || null,
|
|||
|
|
assigneeId: form.assigneeId || null,
|
|||
|
|
isMandatory: form.isMandatory,
|
|||
|
|
source: sourceFor(link?.type),
|
|||
|
|
scheduledFor: form.date || todayKey(),
|
|||
|
|
dueAt: form.time ? localInputToIso(form.date || todayKey(), form.time) : null,
|
|||
|
|
};
|
|||
|
|
if (link) (body as unknown as Record<string, unknown>)[`${link.type}Id`] = link.id;
|
|||
|
|
saving.value = true;
|
|||
|
|
try {
|
|||
|
|
const { data } = await api.post<RoutineTaskDto>('/routine-tasks', body);
|
|||
|
|
emit('created', data);
|
|||
|
|
shell?.notifyChanged();
|
|||
|
|
emit('close');
|
|||
|
|
} catch (e) {
|
|||
|
|
error.value = apiError(e, 'ثبت کار ممکن نشد.');
|
|||
|
|
} finally {
|
|||
|
|
saving.value = false;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<template>
|
|||
|
|
<UiModal :open="open" title="کار جدید" @close="emit('close')">
|
|||
|
|
<form class="qt-form" @submit.prevent="submit">
|
|||
|
|
<UiField label="عنوان" required>
|
|||
|
|
<UiInput v-model="form.title" placeholder="مثلاً: تماس با مشتری برای پیگیری پیشفاکتور" :maxlength="200" />
|
|||
|
|
</UiField>
|
|||
|
|
|
|||
|
|
<UiField label="توضیحات">
|
|||
|
|
<UiTextarea v-model="form.description" :rows="2" :maxlength="2000" />
|
|||
|
|
</UiField>
|
|||
|
|
|
|||
|
|
<div class="grid-2">
|
|||
|
|
<UiField label="تاریخ" required>
|
|||
|
|
<JalaliDateInput v-model="form.date" mode="date" :clearable="false" />
|
|||
|
|
</UiField>
|
|||
|
|
<UiField label="ساعت" hint="خالی = تا پایان روز">
|
|||
|
|
<JalaliDateInput v-model="form.time" mode="time" />
|
|||
|
|
</UiField>
|
|||
|
|
<UiField label="اولویت">
|
|||
|
|
<UiSelect v-model="form.priority">
|
|||
|
|
<option v-for="[k, label] in priorities" :key="k" :value="k">{{ label }}</option>
|
|||
|
|
</UiSelect>
|
|||
|
|
</UiField>
|
|||
|
|
<UiField label="دستهبندی">
|
|||
|
|
<UiSelect v-model="form.categoryId">
|
|||
|
|
<option value="">بدون دسته</option>
|
|||
|
|
<option v-for="c in ctx?.categories ?? []" :key="c.id" :value="c.id">{{ c.name }}</option>
|
|||
|
|
</UiSelect>
|
|||
|
|
</UiField>
|
|||
|
|
<UiField v-if="assignees.length > 1" label="مسئول" class="span-2">
|
|||
|
|
<UiSelect v-model="form.assigneeId">
|
|||
|
|
<option v-for="u in assignees" :key="u.id" :value="u.id">
|
|||
|
|
{{ u.fullName }}{{ u.id === auth.user?.id ? ' (خودم)' : '' }}
|
|||
|
|
</option>
|
|||
|
|
</UiSelect>
|
|||
|
|
</UiField>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<UiField label="اتصال به پرونده">
|
|||
|
|
<div v-if="preset" class="preset">
|
|||
|
|
<span>{{ ENTITY_TYPE_LABELS[preset.type] }}</span>
|
|||
|
|
<strong>{{ preset.label }}</strong>
|
|||
|
|
</div>
|
|||
|
|
<EntityLinkPicker v-else v-model="form.entity" />
|
|||
|
|
</UiField>
|
|||
|
|
|
|||
|
|
<label class="check">
|
|||
|
|
<input v-model="form.isMandatory" type="checkbox" />
|
|||
|
|
<span>کار اجباری (در امتیاز روزانه و Streak حساب میشود)</span>
|
|||
|
|
</label>
|
|||
|
|
|
|||
|
|
<p v-if="error" class="error">{{ error }}</p>
|
|||
|
|
</form>
|
|||
|
|
|
|||
|
|
<template #footer>
|
|||
|
|
<UiButton :loading="saving" @click="submit">ثبت کار</UiButton>
|
|||
|
|
<UiButton variant="ghost" @click="emit('close')">انصراف</UiButton>
|
|||
|
|
</template>
|
|||
|
|
</UiModal>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<style scoped>
|
|||
|
|
.qt-form {
|
|||
|
|
display: grid;
|
|||
|
|
gap: var(--sp-4);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.grid-2 {
|
|||
|
|
display: grid;
|
|||
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|||
|
|
gap: var(--sp-3);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.span-2 {
|
|||
|
|
grid-column: 1 / -1;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.preset {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: var(--sp-2);
|
|||
|
|
padding: 8px 12px;
|
|||
|
|
border-radius: var(--radius);
|
|||
|
|
background: var(--accent-subtle);
|
|||
|
|
color: var(--accent-deep);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.preset span {
|
|||
|
|
font-size: var(--fs-xs);
|
|||
|
|
color: var(--muted);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.check {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: var(--sp-2);
|
|||
|
|
font-size: var(--fs-sm);
|
|||
|
|
color: var(--ink-secondary);
|
|||
|
|
cursor: pointer;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.check input {
|
|||
|
|
width: 18px;
|
|||
|
|
height: 18px;
|
|||
|
|
accent-color: var(--accent);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.error {
|
|||
|
|
margin: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@media (max-width: 600px) {
|
|||
|
|
.grid-2 {
|
|||
|
|
grid-template-columns: 1fr;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
</style>
|