441 lines
10 KiB
Vue
441 lines
10 KiB
Vue
<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>
|