Files
CRM_Montzar/apps/web/src/views/routines/RoutinesOverdueView.vue
T
MahdiandCursor 7a55b84b81 Add org-scoped activity priority labels/colors and apply them across forms, boards, and routines.
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>
2026-11-12 21:39:15 +03:30

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>