Also fix activity leave-without-save confirm, kanban due-date labels with tone-colored peek chevron, and rename the types manager button. Co-authored-by: Cursor <cursoragent@cursor.com>
322 lines
8.3 KiB
Vue
322 lines
8.3 KiB
Vue
<script setup lang="ts">
|
|
import { computed, inject, onMounted, ref, watch } 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 RoutineTaskRow, { type RoutineTaskRowAction } from '@/components/routines/RoutineTaskRow.vue';
|
|
import RoutineTaskModals from '@/components/routines/RoutineTaskModals.vue';
|
|
import { UiBadge, UiButton, UiEmptyState, UiSelect } from '@/components/ui';
|
|
import { api } from '@/lib/api';
|
|
import { toFaDigits } from '@/lib/jalali';
|
|
import { useActivityPriorities } from '@/lib/activity-priorities';
|
|
import {
|
|
ENTITY_TYPE_LABELS,
|
|
PRIORITY_VARIANTS,
|
|
ROUTINES_CTX,
|
|
apiError,
|
|
entityRoute,
|
|
formatDayNumeric,
|
|
formatDelay,
|
|
formatTime,
|
|
hasExplicitTime,
|
|
} from '@/lib/routines';
|
|
import { useRoutineTaskActions } from '@/lib/useRoutineTaskActions';
|
|
|
|
const shell = inject(ROUTINES_CTX)!;
|
|
const activityPriorities = useActivityPriorities();
|
|
void activityPriorities.load();
|
|
|
|
const items = ref<RoutineTaskDto[]>([]);
|
|
const loading = ref(true);
|
|
const error = ref('');
|
|
const who = ref('');
|
|
|
|
const isManager = computed(() => !!shell.context.value?.isManager);
|
|
const users = computed(() => shell.context.value?.users ?? []);
|
|
|
|
async function load(silent = false) {
|
|
if (!silent) loading.value = true;
|
|
error.value = '';
|
|
try {
|
|
const { data } = await api.get<RoutineTaskDto[]>('/routine-tasks/overdue', {
|
|
params: who.value ? { userId: who.value } : {},
|
|
});
|
|
items.value = data;
|
|
} catch (e) {
|
|
error.value = apiError(e, 'بارگذاری کارهای عقبافتاده ممکن نشد.');
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
onMounted(() => load());
|
|
watch(who, () => load());
|
|
watch(shell.refreshTick, () => load(true));
|
|
|
|
const actions = useRoutineTaskActions((updated) => {
|
|
if (updated.status === 'overdue') {
|
|
const i = items.value.findIndex((t) => t.id === updated.id);
|
|
if (i >= 0) items.value[i] = updated;
|
|
} else {
|
|
items.value = items.value.filter((t) => t.id !== updated.id);
|
|
}
|
|
shell.notifyChanged();
|
|
});
|
|
|
|
function onAction(kind: RoutineTaskRowAction, task: RoutineTaskDto) {
|
|
if (kind === 'complete-note') actions.openComplete(task);
|
|
else if (kind === 'reschedule') actions.openReschedule(task);
|
|
else if (kind === 'move-today') void actions.moveToday(task);
|
|
else if (kind === 'skip') actions.openSkip(task);
|
|
else if (kind === 'cancel') actions.openCancel(task);
|
|
}
|
|
|
|
const showAssignee = computed(() => isManager.value && who.value !== '');
|
|
</script>
|
|
|
|
<template>
|
|
<div class="overdue">
|
|
<div class="toolbar">
|
|
<p class="lead">
|
|
<UiIcon name="warning" :size="16" />
|
|
<template v-if="items.length">{{ toFaDigits(items.length) }} کار از مهلتش گذشته است.</template>
|
|
<template v-else>کار عقبافتادهای ندارید.</template>
|
|
</p>
|
|
<UiSelect v-if="isManager" v-model="who" size="sm" class="who">
|
|
<option value="">کارهای خودم</option>
|
|
<option value="all">همه اعضای تیم</option>
|
|
<option v-for="u in users" :key="u.id" :value="u.id">{{ u.fullName }}</option>
|
|
</UiSelect>
|
|
</div>
|
|
|
|
<p v-if="error" class="error">{{ error }}</p>
|
|
<p v-if="actions.error.value" class="error">{{ actions.error.value }}</p>
|
|
|
|
<div v-if="loading" class="state">در حال بارگذاری…</div>
|
|
|
|
<UiEmptyState
|
|
v-else-if="!items.length"
|
|
icon="check"
|
|
title="همه چیز بهروز است"
|
|
description="هیچ کاری از مهلتش عقب نیفتاده."
|
|
/>
|
|
|
|
<template v-else>
|
|
<!-- دسکتاپ: جدول -->
|
|
<div class="crm-table-container desk">
|
|
<table class="crm-table">
|
|
<thead>
|
|
<tr>
|
|
<th>کار</th>
|
|
<th>مهلت</th>
|
|
<th>تأخیر</th>
|
|
<th v-if="showAssignee">مسئول</th>
|
|
<th>اولویت</th>
|
|
<th>پرونده</th>
|
|
<th />
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="t in items" :key="t.id" class="clickable" @click="shell.openTask(t.id)">
|
|
<td class="title-cell">
|
|
<strong>{{ t.title }}</strong>
|
|
<small v-if="t.category" class="cat"><i :style="{ background: t.category.color }" />{{ t.category.name }}</small>
|
|
</td>
|
|
<td class="nowrap">
|
|
{{ formatDayNumeric(t.scheduledFor) }}
|
|
<small v-if="hasExplicitTime(t)" class="muted">· {{ formatTime(t.dueAt) }}</small>
|
|
</td>
|
|
<td class="nowrap late">{{ formatDelay(t.dueAt) }}</td>
|
|
<td v-if="showAssignee">
|
|
<span v-if="t.assignee" class="who-cell">
|
|
<UserAvatar :name="t.assignee.fullName" :user-id="t.assignee.id" :size="24" />
|
|
{{ t.assignee.fullName }}
|
|
</span>
|
|
</td>
|
|
<td>
|
|
<UiBadge size="sm" :variant="PRIORITY_VARIANTS[t.priority]">{{ activityPriorities.labelOf(t.priority) }}</UiBadge>
|
|
</td>
|
|
<td>
|
|
<RouterLink
|
|
v-for="ent in t.entities"
|
|
:key="ent.id"
|
|
:to="entityRoute(ent)"
|
|
class="ent"
|
|
@click.stop
|
|
>
|
|
{{ ENTITY_TYPE_LABELS[ent.type] }}: {{ ent.label }}
|
|
</RouterLink>
|
|
<span v-if="!t.entities.length" class="muted">—</span>
|
|
</td>
|
|
<td class="actions-cell" @click.stop>
|
|
<div class="crm-table-actions">
|
|
<UiButton
|
|
size="sm"
|
|
variant="success"
|
|
:loading="actions.busyId.value === t.id"
|
|
@click="actions.toggle(t)"
|
|
>
|
|
انجام شد
|
|
</UiButton>
|
|
<UiButton size="sm" variant="ghost" @click="actions.moveToday(t)">به امروز</UiButton>
|
|
<UiButton size="sm" variant="ghost" @click="actions.openReschedule(t)">تغییر زمان</UiButton>
|
|
<UiButton size="sm" variant="ghost" class="danger-text" @click="actions.openCancel(t)">لغو</UiButton>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- موبایل: کارت -->
|
|
<div class="mobile rows">
|
|
<RoutineTaskRow
|
|
v-for="t in items"
|
|
:key="t.id"
|
|
:task="t"
|
|
:busy="actions.busyId.value === t.id"
|
|
show-date
|
|
show-delay
|
|
:show-assignee="showAssignee"
|
|
@toggle="actions.toggle"
|
|
@open="shell.openTask(t.id)"
|
|
@action="onAction"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<RoutineTaskModals :actions="actions" />
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.overdue {
|
|
display: grid;
|
|
gap: var(--sp-4);
|
|
}
|
|
|
|
.toolbar {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
flex-wrap: wrap;
|
|
gap: var(--sp-3);
|
|
}
|
|
|
|
.lead {
|
|
margin: 0;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
color: var(--ink-secondary);
|
|
font-size: var(--fs-sm);
|
|
}
|
|
|
|
.lead :deep(svg) {
|
|
color: var(--danger);
|
|
}
|
|
|
|
.who {
|
|
width: 220px;
|
|
}
|
|
|
|
.state {
|
|
padding: var(--sp-8);
|
|
text-align: center;
|
|
color: var(--muted);
|
|
}
|
|
|
|
.clickable {
|
|
cursor: pointer;
|
|
}
|
|
|
|
.title-cell {
|
|
display: grid;
|
|
gap: 2px;
|
|
min-width: 200px;
|
|
}
|
|
|
|
.title-cell strong {
|
|
font-weight: var(--fw-semibold);
|
|
}
|
|
|
|
.cat {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 5px;
|
|
color: var(--muted);
|
|
}
|
|
|
|
.cat i {
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
}
|
|
|
|
.nowrap {
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.late {
|
|
color: var(--danger);
|
|
font-weight: var(--fw-semibold);
|
|
}
|
|
|
|
.muted {
|
|
color: var(--muted);
|
|
}
|
|
|
|
.who-cell {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.ent {
|
|
display: block;
|
|
color: var(--accent-deep);
|
|
font-size: var(--fs-xs);
|
|
white-space: nowrap;
|
|
max-width: 200px;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.ent:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.danger-text {
|
|
color: var(--danger) !important;
|
|
}
|
|
|
|
.rows {
|
|
display: grid;
|
|
gap: var(--sp-2);
|
|
}
|
|
|
|
.mobile {
|
|
display: none;
|
|
}
|
|
|
|
.error {
|
|
margin: 0;
|
|
}
|
|
|
|
@media (max-width: 900px) {
|
|
.desk {
|
|
display: none;
|
|
}
|
|
|
|
.mobile {
|
|
display: grid;
|
|
}
|
|
|
|
.who {
|
|
width: 100%;
|
|
}
|
|
}
|
|
</style>
|