Files
CRM_Montzar/apps/web/src/components/routines/RoutineTaskRow.vue
T

441 lines
10 KiB
Vue
Raw Normal View History

<script setup lang="ts">
import { computed, ref } from 'vue';
import { RouterLink } from 'vue-router';
import type { RoutineTaskDto } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import UserAvatar from '@/components/UserAvatar.vue';
import { UiBadge, UiDropdownMenu } from '@/components/ui';
import {
ENTITY_TYPE_LABELS,
PRIORITY_LABELS,
PRIORITY_VARIANTS,
ROUTINE_TASK_SOURCE_LABELS,
ROUTINE_TASK_STATUS_LABELS,
STATUS_VARIANTS,
entityRoute,
formatDayNumeric,
formatDelay,
formatTime,
hasExplicitTime,
isOpenStatus,
} from '@/lib/routines';
export type RoutineTaskRowAction =
| 'start'
| 'complete-note'
| 'reschedule'
| 'move-today'
| 'skip'
| 'cancel';
const props = withDefaults(
defineProps<{
task: RoutineTaskDto;
busy?: boolean;
showAssignee?: boolean;
showDate?: boolean;
showDelay?: boolean;
}>(),
{ busy: false, showAssignee: false, showDate: false, showDelay: false },
);
const emit = defineEmits<{
toggle: [task: RoutineTaskDto];
open: [task: RoutineTaskDto];
action: [kind: RoutineTaskRowAction, task: RoutineTaskDto];
}>();
const menuOpen = ref(false);
const done = computed(() => props.task.status === 'completed');
const closed = computed(() => props.task.status === 'skipped' || props.task.status === 'cancelled');
const open = computed(() => isOpenStatus(props.task.status));
const showTime = computed(() => hasExplicitTime(props.task));
const checkLabel = computed(() =>
done.value ? `برگرداندن «${props.task.title}» به حالت انجام‌نشده` : `علامت‌زدن «${props.task.title}» به‌عنوان انجام‌شده`,
);
function act(kind: RoutineTaskRowAction) {
emit('action', kind, props.task);
}
</script>
<template>
<article
class="rt-row"
:class="[`st-${task.status}`, { done, closed, busy }]"
@click="emit('open', task)"
>
<button
type="button"
class="rt-check"
:class="{ on: done }"
:disabled="busy || closed"
:aria-pressed="done"
:aria-label="checkLabel"
@click.stop="emit('toggle', task)"
>
<span class="box">
<UiIcon v-if="done" name="check-plain" :size="16" />
<span v-else-if="busy" class="spin" aria-hidden="true" />
</span>
</button>
<div class="rt-main">
<div class="rt-title-line">
<h3 class="rt-title">{{ task.title }}</h3>
<UiIcon v-if="task.isMandatory" class="mand" name="flag" :size="13" title="اجباری" />
</div>
<p v-if="task.description" class="rt-desc">{{ task.description }}</p>
<div class="rt-meta">
<span v-if="showDate" class="meta-item">
<UiIcon name="calendar" :size="13" />
{{ formatDayNumeric(task.scheduledFor) }}
</span>
<span v-if="showTime" class="meta-item">
<UiIcon name="clock" :size="13" />
{{ formatTime(task.dueAt) }}
</span>
<span v-if="showDelay && task.status === 'overdue'" class="meta-item late">
<UiIcon name="warning" :size="13" />
{{ formatDelay(task.dueAt) }} تأخیر
</span>
<span v-if="done && task.completedAt" class="meta-item ok">
<UiIcon name="check" :size="13" />
{{ formatTime(task.completedAt) }}
</span>
<span v-if="task.category" class="cat-chip">
<i :style="{ background: task.category.color }" />
{{ task.category.name }}
</span>
<UiBadge size="sm" :variant="PRIORITY_VARIANTS[task.priority]">
{{ PRIORITY_LABELS[task.priority] }}
</UiBadge>
<UiBadge v-if="task.status !== 'todo'" size="sm" :variant="STATUS_VARIANTS[task.status]">
{{ ROUTINE_TASK_STATUS_LABELS[task.status] }}
</UiBadge>
<span class="src-chip" :title="`منبع: ${ROUTINE_TASK_SOURCE_LABELS[task.source]}`">
<UiIcon :name="task.source === 'routine' ? 'refresh' : 'pin'" :size="12" />
{{ ROUTINE_TASK_SOURCE_LABELS[task.source] }}
</span>
<RouterLink
v-for="ent in task.entities"
:key="`${ent.type}-${ent.id}`"
class="ent-link"
:to="entityRoute(ent)"
@click.stop
>
<span class="ent-type">{{ ENTITY_TYPE_LABELS[ent.type] }}</span>
{{ ent.label }}
</RouterLink>
</div>
</div>
<div class="rt-side" @click.stop>
<span v-if="showAssignee && task.assignee" class="assignee" :title="task.assignee.fullName">
<UserAvatar :name="task.assignee.fullName" :user-id="task.assignee.id" :size="26" />
<span class="assignee-name">{{ task.assignee.fullName }}</span>
</span>
<UiDropdownMenu v-model:open="menuOpen" align="end">
<template #trigger="{ toggle }">
<button type="button" class="rt-more" aria-label="عملیات بیشتر" @click="toggle">
<UiIcon name="dots" :size="18" />
</button>
</template>
<button type="button" @click="emit('open', task)">جزئیات</button>
<button v-if="task.status === 'todo'" type="button" @click="act('start')">شروع کار</button>
<button v-if="open || done" type="button" @click="act('complete-note')">
{{ done ? 'ویرایش گزارش نتیجه' : 'انجام با ثبت گزارش' }}
</button>
<button v-if="open" type="button" @click="act('reschedule')">تغییر زمان</button>
<button v-if="task.status === 'overdue'" type="button" @click="act('move-today')">انتقال به امروز</button>
<button v-if="open && task.rules.allowSkip" type="button" @click="act('skip')">رد کردن</button>
<button v-if="open" type="button" class="danger" @click="act('cancel')">لغو کار</button>
</UiDropdownMenu>
</div>
</article>
</template>
<style scoped>
.rt-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: start;
gap: var(--sp-3);
padding: var(--sp-3) var(--sp-4);
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
cursor: pointer;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast), opacity var(--transition-fast);
}
.rt-row:hover {
border-color: var(--line-strong);
box-shadow: var(--shadow-xs);
}
.rt-row.st-overdue {
border-inline-start: 3px solid var(--danger);
}
.rt-row.done {
background: color-mix(in srgb, var(--success-soft) 28%, var(--surface));
}
.rt-row.closed {
opacity: 0.62;
}
.rt-check {
width: 40px;
height: 40px;
margin: -6px;
padding: 0;
border: 0;
background: transparent;
display: grid;
place-items: center;
cursor: pointer;
border-radius: var(--radius);
}
.rt-check:disabled {
cursor: not-allowed;
}
.rt-check:focus-visible {
outline: 2px solid var(--accent);
}
.rt-check .box {
width: 24px;
height: 24px;
border-radius: 7px;
border: 2px solid var(--line-strong);
background: var(--surface);
display: grid;
place-items: center;
color: #fff;
transition: background var(--transition-fast), border-color var(--transition-fast), transform 0.12s ease;
}
.rt-check:not(:disabled):hover .box {
border-color: var(--accent);
}
.rt-check:active .box {
transform: scale(0.92);
}
.rt-check.on .box {
background: var(--success);
border-color: var(--success);
}
.st-overdue .rt-check .box {
border-color: color-mix(in srgb, var(--danger) 55%, white);
}
.spin {
width: 12px;
height: 12px;
border: 2px solid var(--line);
border-top-color: var(--accent);
border-radius: 50%;
animation: rt-spin 0.7s linear infinite;
}
@keyframes rt-spin {
to {
transform: rotate(360deg);
}
}
.rt-main {
min-width: 0;
display: grid;
gap: 4px;
}
.rt-title-line {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.rt-title {
margin: 0;
font-size: var(--fs-base);
font-weight: var(--fw-semibold);
color: var(--ink);
line-height: 1.5;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.done .rt-title {
color: var(--muted);
text-decoration: line-through;
text-decoration-color: color-mix(in srgb, var(--muted) 60%, transparent);
}
.mand {
color: var(--warning-deep);
flex-shrink: 0;
}
.rt-desc {
margin: 0;
font-size: var(--fs-sm);
color: var(--muted);
line-height: 1.6;
display: -webkit-box;
-webkit-line-clamp: 1;
line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.rt-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px 10px;
margin-top: 2px;
font-size: var(--fs-xs);
color: var(--muted);
}
.meta-item {
display: inline-flex;
align-items: center;
gap: 4px;
white-space: nowrap;
}
.meta-item.late {
color: var(--danger);
font-weight: var(--fw-semibold);
}
.meta-item.ok {
color: var(--success-deep);
}
.cat-chip,
.src-chip {
display: inline-flex;
align-items: center;
gap: 5px;
white-space: nowrap;
}
.cat-chip i {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.src-chip {
color: var(--muted-light);
}
.ent-link {
display: inline-flex;
align-items: center;
gap: 5px;
max-width: 220px;
padding: 1px 8px;
border-radius: var(--radius-full);
background: var(--accent-subtle);
color: var(--accent-deep);
font-weight: var(--fw-medium);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ent-link:hover {
background: var(--accent-soft);
}
.ent-type {
color: var(--muted);
font-weight: var(--fw-normal);
}
.rt-side {
display: flex;
align-items: center;
gap: var(--sp-2);
}
.assignee {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: var(--fs-xs);
color: var(--ink-secondary);
max-width: 150px;
}
.assignee-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rt-more {
width: 32px;
height: 32px;
border: 0;
border-radius: var(--radius);
background: transparent;
color: var(--muted);
display: grid;
place-items: center;
cursor: pointer;
}
.rt-more:hover {
background: var(--surface-subtle);
color: var(--ink);
}
:global(.ui-dd-panel button.danger) {
color: var(--danger);
}
@media (max-width: 900px) {
.rt-row {
padding: var(--sp-3);
gap: var(--sp-2);
}
.rt-check {
width: 48px;
height: 48px;
margin: -8px -6px;
}
.rt-check .box {
width: 28px;
height: 28px;
border-radius: 8px;
}
.rt-title {
white-space: normal;
}
.assignee-name {
display: none;
}
}
</style>