Add desk-style task workflow on Activities and Project boards.
Managers can switch my desk vs team, reassign activities, and filter project cards to their work; assignees can see and move cards.
This commit is contained in:
@@ -28,9 +28,12 @@ import type {
|
||||
PaginatedResponse,
|
||||
} from '@mento/shared';
|
||||
import { brand } from '@mento/shared';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { canViewAllRecords } from '@/lib/roles';
|
||||
|
||||
type TypeFilter = 'all' | ActivityType;
|
||||
type UiStatus = 'done' | 'progress' | 'planned' | 'overdue' | 'canceled';
|
||||
type DeskScope = 'mine' | 'team';
|
||||
|
||||
const TABS: Array<{ key: ActivityFilter; label: string }> = [
|
||||
{ key: 'today', label: 'امروز' },
|
||||
@@ -40,6 +43,11 @@ const TABS: Array<{ key: ActivityFilter; label: string }> = [
|
||||
{ key: 'all', label: 'همه' },
|
||||
];
|
||||
|
||||
const DESK_TABS: Array<{ key: DeskScope; label: string }> = [
|
||||
{ key: 'mine', label: 'میز من' },
|
||||
{ key: 'team', label: 'همه تیم' },
|
||||
];
|
||||
|
||||
const TYPE_FILTERS: Array<{ key: TypeFilter; label: string }> = [
|
||||
{ key: 'all', label: 'همه انواع' },
|
||||
{ key: 'call', label: 'تماس' },
|
||||
@@ -52,6 +60,8 @@ const PAGE_SIZE = 6;
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
const showDeskTabs = computed(() => canViewAllRecords(auth.user?.role));
|
||||
|
||||
const filter = computed<ActivityFilter>(() => {
|
||||
const value = typeof route.query.filter === 'string' ? route.query.filter : 'today';
|
||||
@@ -61,6 +71,11 @@ const filter = computed<ActivityFilter>(() => {
|
||||
return 'today';
|
||||
});
|
||||
|
||||
const desk = computed<DeskScope>(() => {
|
||||
if (!showDeskTabs.value) return 'mine';
|
||||
return route.query.desk === 'team' ? 'team' : 'mine';
|
||||
});
|
||||
|
||||
const page = computed(() => {
|
||||
const raw = Number(route.query.page);
|
||||
return Number.isFinite(raw) && raw > 0 ? raw : 1;
|
||||
@@ -178,10 +193,11 @@ function subtitle(item: ActivityDto) {
|
||||
|
||||
async function loadCounts() {
|
||||
const keys: ActivityFilter[] = ['today', 'overdue', 'upcoming', 'done', 'all'];
|
||||
const deskParam = showDeskTabs.value ? desk.value : undefined;
|
||||
const results = await Promise.all(
|
||||
keys.map((key) =>
|
||||
api.get<PaginatedResponse<ActivityDto>>('/activities', {
|
||||
params: { filter: key, limit: 1 },
|
||||
params: { filter: key, limit: 1, desk: deskParam },
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -203,6 +219,7 @@ async function load() {
|
||||
q: q.value || undefined,
|
||||
page: page.value,
|
||||
limit: PAGE_SIZE,
|
||||
desk: showDeskTabs.value ? desk.value : undefined,
|
||||
},
|
||||
});
|
||||
data.value = res;
|
||||
@@ -217,6 +234,17 @@ async function load() {
|
||||
}
|
||||
}
|
||||
|
||||
function setDesk(next: DeskScope) {
|
||||
void router.replace({
|
||||
name: 'activities',
|
||||
query: {
|
||||
...route.query,
|
||||
desk: next === 'mine' ? undefined : next,
|
||||
page: undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function setFilter(next: ActivityFilter) {
|
||||
filterOpen.value = false;
|
||||
void router.replace({
|
||||
@@ -318,10 +346,11 @@ function onImportSettled() {
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [route.query.filter, route.query.q, route.query.page],
|
||||
() => [route.query.filter, route.query.q, route.query.page, route.query.desk],
|
||||
() => {
|
||||
q.value = typeof route.query.q === 'string' ? route.query.q : '';
|
||||
void load();
|
||||
void loadCounts();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -348,12 +377,12 @@ onUnmounted(() => {
|
||||
<nav class="crumbs" aria-label="مسیر">
|
||||
<RouterLink :to="{ name: 'home' }">خانه</RouterLink>
|
||||
<span>‹</span>
|
||||
<span>فعالیتها</span>
|
||||
<span>میز کار</span>
|
||||
</nav>
|
||||
|
||||
<UiPageHeader
|
||||
title="فعالیتها"
|
||||
lead="تماس، جلسه و وظیفه — امروز و عقبافتادهها یکجا."
|
||||
title="میز کار و فعالیتها"
|
||||
lead="کارهای امروز، ارجاع به همکار و پیگیری وضعیت."
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton variant="ghost" type="button" :disabled="busy" @click.stop="exportExcel">
|
||||
@@ -387,6 +416,20 @@ onUnmounted(() => {
|
||||
</template>
|
||||
</UiPageHeader>
|
||||
|
||||
<div v-if="showDeskTabs" class="desk-tabs" role="tablist" aria-label="محدوده میز کار">
|
||||
<button
|
||||
v-for="tab in DESK_TABS"
|
||||
:key="tab.key"
|
||||
type="button"
|
||||
role="tab"
|
||||
:class="{ on: desk === tab.key }"
|
||||
:aria-selected="desk === tab.key"
|
||||
@click="setDesk(tab.key)"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="tabs" role="tablist" aria-label="فیلتر فعالیت">
|
||||
<button
|
||||
v-for="tab in TABS"
|
||||
@@ -763,6 +806,33 @@ h1 {
|
||||
width: min(100%, 320px);
|
||||
}
|
||||
|
||||
.desk-tabs {
|
||||
display: inline-flex;
|
||||
gap: 0.25rem;
|
||||
padding: 0.2rem;
|
||||
border-radius: 12px;
|
||||
background: #f1f5f9;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.desk-tabs button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
font: inherit;
|
||||
font-size: 0.86rem;
|
||||
font-weight: 700;
|
||||
padding: 0.45rem 0.85rem;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.desk-tabs button.on {
|
||||
background: #fff;
|
||||
color: #0f766e;
|
||||
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.2rem;
|
||||
|
||||
@@ -11,11 +11,15 @@ import UiIcon from '@/components/UiIcon.vue';
|
||||
import UserAvatar from '@/components/UserAvatar.vue';
|
||||
import ActivityCommentSheet from '@/components/activities/ActivityCommentSheet.vue';
|
||||
import ActivityPostponeSheet from '@/components/activities/ActivityPostponeSheet.vue';
|
||||
import type { ActivityDto, ActivityPriority, ActivityType } from '@mento/shared';
|
||||
import type { ActivityDto, ActivityPriority, ActivityType, OrgUserDto } from '@mento/shared';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { canReassignRecords } from '@/lib/roles';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
const id = computed(() => (typeof route.params.id === 'string' ? route.params.id : ''));
|
||||
const canReassign = computed(() => canReassignRecords(auth.user?.role));
|
||||
|
||||
const loading = ref(true);
|
||||
const saving = ref(false);
|
||||
@@ -25,6 +29,8 @@ const moreOpen = ref(false);
|
||||
const commentOpen = ref(false);
|
||||
const postponeOpen = ref(false);
|
||||
const noteOpen = ref(true);
|
||||
const users = ref<OrgUserDto[]>([]);
|
||||
const assignUserId = ref('');
|
||||
|
||||
const typeLabel: Record<ActivityType, string> = {
|
||||
call: 'تماس',
|
||||
@@ -89,6 +95,7 @@ async function load() {
|
||||
try {
|
||||
const { data } = await api.get<ActivityDto>(`/activities/${id.value}`);
|
||||
activity.value = data;
|
||||
assignUserId.value = data.assignee?.id ?? '';
|
||||
} catch {
|
||||
error.value = 'بارگذاری جزئیات فعالیت ناموفق بود.';
|
||||
activity.value = null;
|
||||
@@ -97,6 +104,16 @@ async function load() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
if (!canReassign.value) return;
|
||||
try {
|
||||
const { data } = await api.get<OrgUserDto[]>('/users');
|
||||
users.value = data;
|
||||
} catch {
|
||||
users.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function complete() {
|
||||
if (!id.value || isDone.value) return;
|
||||
saving.value = true;
|
||||
@@ -111,6 +128,36 @@ async function complete() {
|
||||
}
|
||||
}
|
||||
|
||||
async function reopen() {
|
||||
if (!id.value || !activity.value || activity.value.status === 'open') return;
|
||||
saving.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
await api.patch(`/activities/${id.value}`, { status: 'open' });
|
||||
await load();
|
||||
} catch {
|
||||
error.value = 'بازگشایی کار ناموفق بود.';
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function reassign() {
|
||||
if (!id.value || !canReassign.value) return;
|
||||
saving.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
await api.patch(`/activities/${id.value}`, {
|
||||
assigneeId: assignUserId.value || null,
|
||||
});
|
||||
await load();
|
||||
} catch {
|
||||
error.value = 'ارجاع کار ناموفق بود.';
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function postponeTo(iso: string) {
|
||||
if (!id.value || !activity.value || isDone.value) return;
|
||||
saving.value = true;
|
||||
@@ -160,7 +207,10 @@ watch(id, () => {
|
||||
postponeOpen.value = false;
|
||||
void load();
|
||||
});
|
||||
onMounted(load);
|
||||
onMounted(() => {
|
||||
void load();
|
||||
void loadUsers();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -224,8 +274,23 @@ onMounted(load);
|
||||
مسئول
|
||||
</dt>
|
||||
<dd class="person">
|
||||
<UserAvatar v-if="activity.assignee" :name="activity.assignee.fullName" :size="28" />
|
||||
{{ activity.assignee?.fullName || '—' }}
|
||||
<template v-if="canReassign">
|
||||
<select
|
||||
v-model="assignUserId"
|
||||
class="assign-select"
|
||||
:disabled="saving"
|
||||
@change="reassign"
|
||||
>
|
||||
<option value="">بدون مسئول</option>
|
||||
<option v-for="u in users" :key="u.id" :value="u.id">
|
||||
{{ u.fullName }}
|
||||
</option>
|
||||
</select>
|
||||
</template>
|
||||
<template v-else>
|
||||
<UserAvatar v-if="activity.assignee" :name="activity.assignee.fullName" :size="28" />
|
||||
{{ activity.assignee?.fullName || '—' }}
|
||||
</template>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
@@ -295,7 +360,23 @@ onMounted(load);
|
||||
</template>
|
||||
|
||||
<footer v-if="activity && !commentOpen && !postponeOpen" class="dock">
|
||||
<button class="act primary" type="button" :disabled="saving || isDone" @click="complete">
|
||||
<button
|
||||
v-if="isDone || activity.status === 'canceled'"
|
||||
class="act primary"
|
||||
type="button"
|
||||
:disabled="saving"
|
||||
@click="reopen"
|
||||
>
|
||||
<UiIcon name="clock" :size="16" />
|
||||
بازگشایی
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="act primary"
|
||||
type="button"
|
||||
:disabled="saving || isDone"
|
||||
@click="complete"
|
||||
>
|
||||
<UiIcon name="check" :size="16" />
|
||||
انجام شد
|
||||
</button>
|
||||
@@ -508,6 +589,17 @@ dd {
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.assign-select {
|
||||
min-height: 2.2rem;
|
||||
min-width: 10rem;
|
||||
max-width: 100%;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
padding: 0 0.55rem;
|
||||
font: inherit;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.flag.low,
|
||||
.priority.low {
|
||||
color: #64748b;
|
||||
|
||||
@@ -44,6 +44,7 @@ const boards = ref<ProjectBoardDto[]>([]);
|
||||
const currentBoardId = ref('');
|
||||
const viewMode = ref<PipelineViewMode>('kanban');
|
||||
const search = ref('');
|
||||
const mineOnly = ref(route.query.mine === '1');
|
||||
const showCreate = ref(false);
|
||||
const newName = ref('');
|
||||
const cardModal = ref<{ cardId?: string; stageId?: string } | null>(null);
|
||||
@@ -80,9 +81,21 @@ const selectedBoardId = computed({
|
||||
});
|
||||
|
||||
const visibleStages = computed(() =>
|
||||
filterStages(stages.value, { query: search.value }),
|
||||
filterStages(stages.value, {
|
||||
query: search.value,
|
||||
mineUserId: mineOnly.value ? auth.user?.id ?? null : null,
|
||||
}),
|
||||
);
|
||||
|
||||
watch(mineOnly, (on) => {
|
||||
const current = route.query.mine === '1';
|
||||
if (on === current) return;
|
||||
const query = { ...route.query } as Record<string, string | undefined>;
|
||||
if (on) query.mine = '1';
|
||||
else delete query.mine;
|
||||
void router.replace({ name: 'projects', query });
|
||||
});
|
||||
|
||||
function formatCardDate(iso: string) {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
@@ -445,7 +458,7 @@ onBeforeUnmount(() => {
|
||||
<template>
|
||||
<section class="page" dir="rtl">
|
||||
<PipelineToolbar
|
||||
pipeline-name="کاریز پروژه"
|
||||
pipeline-name="کاریز پروژه — کارهای تیمی"
|
||||
icon="grid"
|
||||
create-option-label="برد جدید…"
|
||||
:pipeline-options="boardOptions"
|
||||
@@ -457,6 +470,10 @@ onBeforeUnmount(() => {
|
||||
@create="showCreate = true"
|
||||
>
|
||||
<template #excel>
|
||||
<label class="mine-toggle">
|
||||
<input v-model="mineOnly" type="checkbox" />
|
||||
<span>فقط کارهای من</span>
|
||||
</label>
|
||||
<button class="btn-ghost-sm" type="button" :disabled="loading || busy" @click="exportExcel">
|
||||
خروجی اکسل
|
||||
</button>
|
||||
@@ -619,6 +636,26 @@ onBeforeUnmount(() => {
|
||||
container-name: pipeline;
|
||||
}
|
||||
|
||||
.mine-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin-inline-end: 0.35rem;
|
||||
padding: 0.4rem 0.7rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
color: #0f172a;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.mine-toggle input {
|
||||
accent-color: #0f766e;
|
||||
}
|
||||
|
||||
.btn-ghost-sm {
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #fff;
|
||||
|
||||
Reference in New Issue
Block a user