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:
2026-09-28 12:28:14 +03:30
parent 8effd4cbc6
commit 32e27d45f7
13 changed files with 303 additions and 50 deletions
@@ -62,6 +62,7 @@ export class ActivitiesController {
from: query.from, from: query.from,
to: query.to, to: query.to,
}, },
query.desk,
); );
} }
@@ -128,6 +128,7 @@ export class ActivitiesService {
from?: string; from?: string;
to?: string; to?: string;
}, },
desk?: 'mine' | 'team',
): Promise<PaginatedResponse<ActivityDto>> { ): Promise<PaginatedResponse<ActivityDto>> {
const where = this.buildWhere( const where = this.buildWhere(
organizationId, organizationId,
@@ -136,6 +137,7 @@ export class ActivitiesService {
q, q,
entity, entity,
range, range,
desk,
); );
const take = Math.min(Math.max(limit, 1), 200); const take = Math.min(Math.max(limit, 1), 200);
const [rows, total] = await this.prisma.$transaction([ const [rows, total] = await this.prisma.$transaction([
@@ -324,11 +326,14 @@ export class ActivitiesService {
from?: string; from?: string;
to?: string; to?: string;
}, },
desk?: 'mine' | 'team',
): Prisma.ActivityWhereInput { ): Prisma.ActivityWhereInput {
const { start, end } = dayBounds(); const { start, end } = dayBounds();
const scope =
desk === 'mine' ? { assigneeId: actor.userId } : assigneeWhere(actor);
const base: Prisma.ActivityWhereInput = { const base: Prisma.ActivityWhereInput = {
organizationId, organizationId,
...assigneeWhere(actor), ...scope,
...(entity?.personId ? { personId: entity.personId } : {}), ...(entity?.personId ? { personId: entity.personId } : {}),
...(entity?.companyId ? { companyId: entity.companyId } : {}), ...(entity?.companyId ? { companyId: entity.companyId } : {}),
...(entity?.dealId ? { dealId: entity.dealId } : {}), ...(entity?.dealId ? { dealId: entity.dealId } : {}),
+4
View File
@@ -28,6 +28,10 @@ export class ListActivitiesQueryDto {
@IsString() @IsString()
q?: string; q?: string;
@IsOptional()
@IsIn(['mine', 'team'])
desk?: 'mine' | 'team';
@IsOptional() @IsOptional()
@IsString() @IsString()
personId?: string; personId?: string;
+14
View File
@@ -62,6 +62,20 @@ export function dealAccessWhere(
}; };
} }
/** کارت پروژه: مالک یا یکی از مسئولان در محدوده دید */
export function projectCardAccessWhere(
actor: Pick<Actor, 'userId' | 'role' | 'dataVisibility' | 'visibleUserIds'>,
): Prisma.ProjectCardWhereInput {
const ids = scopedOwnerIds(actor);
if (!ids) return {};
return {
OR: [
{ ownerId: { in: ids } },
{ assignees: { some: { id: { in: ids } } } },
],
};
}
/** فعالیت‌ها بر اساس مسئول */ /** فعالیت‌ها بر اساس مسئول */
export function assigneeWhere( export function assigneeWhere(
actor: Pick<Actor, 'userId' | 'role' | 'dataVisibility' | 'visibleUserIds'>, actor: Pick<Actor, 'userId' | 'role' | 'dataVisibility' | 'visibleUserIds'>,
@@ -18,6 +18,7 @@ import {
canReassignOwner, canReassignOwner,
dealAccessWhere, dealAccessWhere,
ownerWhere, ownerWhere,
projectCardAccessWhere,
resolveOwnerId, resolveOwnerId,
} from '../common/record-scope'; } from '../common/record-scope';
import { normalizeTags } from '../common/tags'; import { normalizeTags } from '../common/tags';
@@ -32,7 +33,10 @@ const cardInclude = {
assignees: { select: { id: true, fullName: true } }, assignees: { select: { id: true, fullName: true } },
} satisfies Prisma.ProjectCardInclude; } satisfies Prisma.ProjectCardInclude;
type CardActor = Pick<Actor, 'userId' | 'role'>; type CardActor = Pick<
Actor,
'userId' | 'role' | 'dataVisibility' | 'visibleUserIds'
>;
@Injectable() @Injectable()
export class ProjectBoardsService { export class ProjectBoardsService {
@@ -113,7 +117,7 @@ export class ProjectBoardsService {
where: { where: {
organizationId, organizationId,
boardId: resolved.id, boardId: resolved.id,
...(actor ? ownerWhere(actor) : {}), ...(actor ? projectCardAccessWhere(actor) : {}),
}, },
include: cardInclude, include: cardInclude,
orderBy: [{ position: 'asc' }, { updatedAt: 'desc' }], orderBy: [{ position: 'asc' }, { updatedAt: 'desc' }],
@@ -143,7 +147,7 @@ export class ProjectBoardsService {
actor: CardActor, actor: CardActor,
): Promise<ProjectCardDto> { ): Promise<ProjectCardDto> {
const row = await this.prisma.projectCard.findFirst({ const row = await this.prisma.projectCard.findFirst({
where: { id, organizationId, ...ownerWhere(actor) }, where: { id, organizationId, ...projectCardAccessWhere(actor) },
include: cardInclude, include: cardInclude,
}); });
if (!row) throw new NotFoundException('کارت پیدا نشد'); if (!row) throw new NotFoundException('کارت پیدا نشد');
@@ -225,7 +229,7 @@ export class ProjectBoardsService {
dto: UpdateProjectCardRequest, dto: UpdateProjectCardRequest,
): Promise<ProjectCardDto> { ): Promise<ProjectCardDto> {
const existing = await this.prisma.projectCard.findFirst({ const existing = await this.prisma.projectCard.findFirst({
where: { id, organizationId, ...ownerWhere(actor) }, where: { id, organizationId, ...projectCardAccessWhere(actor) },
}); });
if (!existing) throw new NotFoundException('کارت پیدا نشد'); if (!existing) throw new NotFoundException('کارت پیدا نشد');
@@ -279,7 +283,7 @@ export class ProjectBoardsService {
dto: MoveProjectCardRequest, dto: MoveProjectCardRequest,
): Promise<ProjectCardDto> { ): Promise<ProjectCardDto> {
const card = await this.prisma.projectCard.findFirst({ const card = await this.prisma.projectCard.findFirst({
where: { id, organizationId, ...ownerWhere(actor) }, where: { id, organizationId, ...projectCardAccessWhere(actor) },
select: { id: true, boardId: true }, select: { id: true, boardId: true },
}); });
if (!card) throw new NotFoundException('کارت پیدا نشد'); if (!card) throw new NotFoundException('کارت پیدا نشد');
@@ -312,7 +316,7 @@ export class ProjectBoardsService {
actor: CardActor, actor: CardActor,
): Promise<void> { ): Promise<void> {
const found = await this.prisma.projectCard.findFirst({ const found = await this.prisma.projectCard.findFirst({
where: { id, organizationId, ...ownerWhere(actor) }, where: { id, organizationId, ...projectCardAccessWhere(actor) },
select: { id: true }, select: { id: true },
}); });
if (!found) throw new NotFoundException('کارت پیدا نشد'); if (!found) throw new NotFoundException('کارت پیدا نشد');
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import type { ImportContactsResult } from '@mento/shared'; import type { ImportContactsResult } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import type { Actor } from '../common/record-scope'; import type { Actor } from '../common/record-scope';
import { ownerWhere } from '../common/record-scope'; import { projectCardAccessWhere } from '../common/record-scope';
import { normalizePhone } from '../contacts/normalize'; import { normalizePhone } from '../contacts/normalize';
import { import {
excelFieldsFromColumns, excelFieldsFromColumns,
@@ -68,7 +68,7 @@ export class ProjectCardsExcelService {
async export(actor: Actor): Promise<Buffer> { async export(actor: Actor): Promise<Buffer> {
const rows = await this.prisma.projectCard.findMany({ const rows = await this.prisma.projectCard.findMany({
where: { organizationId: actor.organizationId, ...ownerWhere(actor) }, where: { organizationId: actor.organizationId, ...projectCardAccessWhere(actor) },
include: { include: {
person: { person: {
select: { id: true, firstName: true, lastName: true, mobile: true }, select: { id: true, firstName: true, lastName: true, mobile: true },
+3 -3
View File
@@ -512,13 +512,13 @@ onUnmounted(() => {
<button <button
class="nav-item nav-fold-btn" class="nav-item nav-fold-btn"
type="button" type="button"
title="فعالیت‌ها" title="کارها"
:class="{ 'router-link-active': activitiesActive || openHubKey === 'activities', 'hub-on': openHubKey === 'activities' }" :class="{ 'router-link-active': activitiesActive || openHubKey === 'activities', 'hub-on': openHubKey === 'activities' }"
:aria-expanded="openHubKey === 'activities'" :aria-expanded="openHubKey === 'activities'"
@click="onHubClick('activities')" @click="onHubClick('activities')"
> >
<UiIcon name="check" :size="collapsed ? 20 : 18" /> <UiIcon name="check" :size="collapsed ? 20 : 18" />
<span>فعالیت‌ها</span> <span>کارها</span>
<span v-if="activityBadge" class="nav-badge">{{ toFaDigits(activityBadge) }}</span> <span v-if="activityBadge" class="nav-badge">{{ toFaDigits(activityBadge) }}</span>
<i class="chev" aria-hidden="true" /> <i class="chev" aria-hidden="true" />
</button> </button>
@@ -822,7 +822,7 @@ onUnmounted(() => {
<UiIcon name="check" :size="20" /> <UiIcon name="check" :size="20" />
<span v-if="activityBadge" class="tab-badge">{{ toFaDigits(activityBadge) }}</span> <span v-if="activityBadge" class="tab-badge">{{ toFaDigits(activityBadge) }}</span>
</span> </span>
<span>فعالیت‌ها</span> <span>کارها</span>
</button> </button>
<button <button
class="tab" class="tab"
+8 -8
View File
@@ -211,27 +211,27 @@ export const NAV_HUBS: NavHubDef[] = [
}, },
{ {
key: 'activities', key: 'activities',
title: 'فعالیت‌ها', title: 'کارها و فعالیت‌ها',
desc: 'کارها، تماس‌ها و برنامه‌ریزی روزانه', desc: 'میز کار شخصی، ارجاع و پیگیری روزانه',
icon: 'check', icon: 'check',
tint: 'sky', tint: 'sky',
shortcuts: [ shortcuts: [
{ to: '/activities/new', title: 'فعالیت جدید', icon: 'plus', module: 'activities' }, { to: '/activities/new', title: 'کار جدید', icon: 'plus', module: 'activities' },
{ to: '/activities/calendar', title: 'تقویم', icon: 'calendar', module: 'activities' }, { to: '/activities/calendar', title: 'تقویم', icon: 'calendar', module: 'activities' },
], ],
cards: [ cards: [
{ {
to: '/activities', to: '/activities',
title: 'فعالیت‌های من', title: 'میز کار من',
desc: 'کارها، تماس‌ها و پیگیری‌های جاری', desc: 'وظایف، تماس‌ها و پیگیری‌های جاری',
icon: 'check', icon: 'check',
tint: 'mint', tint: 'mint',
module: 'activities', module: 'activities',
}, },
{ {
to: '/activities/calendar', to: '/activities/calendar',
title: 'تقویم فعالیت‌ها', title: 'تقویم کارها',
desc: 'نمای روز، هفته و زمان‌بندی کارها', desc: 'نمای روز، هفته و زمان‌بندی',
icon: 'calendar', icon: 'calendar',
tint: 'violet', tint: 'violet',
module: 'activities', module: 'activities',
@@ -330,7 +330,7 @@ export const NAV_HUBS: NavHubDef[] = [
{ {
to: '/projects', to: '/projects',
title: 'کاریز پروژه', title: 'کاریز پروژه',
desc: 'کارت‌ها و مراحل اجرای کار', desc: 'میز کار تیمی: صف، انجام، بازبینی و ارجاع کارت',
icon: 'flag', icon: 'flag',
tint: 'violet', tint: 'violet',
module: 'project_boards', module: 'project_boards',
+41 -16
View File
@@ -12,6 +12,8 @@ export type PipelineDeal = {
currencyUnit?: string; currencyUnit?: string;
/** نام مسئولان برای نمایش روی کارت */ /** نام مسئولان برای نمایش روی کارت */
assigneeNames?: string[]; assigneeNames?: string[];
ownerId?: string | null;
assigneeIds?: string[];
}; };
export type PipelineUiCopy = { export type PipelineUiCopy = {
@@ -206,6 +208,8 @@ export function mapProjectBoardToStages(
person: { fullName: string } | null; person: { fullName: string } | null;
company: { name: string } | null; company: { name: string } | null;
deal: { title: string } | null; deal: { title: string } | null;
owner?: { id: string; fullName: string } | null;
assignees?: Array<{ id: string; fullName: string }>;
}>; }>;
}>; }>;
}, },
@@ -215,22 +219,33 @@ export function mapProjectBoardToStages(
id: stage.id, id: stage.id,
title: stage.name, title: stage.name,
color: ACCENT_CYCLE[index % ACCENT_CYCLE.length]!, color: ACCENT_CYCLE[index % ACCENT_CYCLE.length]!,
deals: stage.cards.map((card) => ({ deals: stage.cards.map((card) => {
id: card.id, const assigneeNames = [
title: card.title, ...(card.owner?.fullName ? [card.owner.fullName] : []),
description: ...(card.assignees ?? [])
card.company?.name || .map((a) => a.fullName)
card.person?.fullName || .filter((n) => n && n !== card.owner?.fullName),
card.deal?.title || ];
card.notes?.trim() || return {
'', id: card.id,
amount: null, title: card.title,
date: formatDate(card.createdAt), description:
initials: initialsFromTitle( card.company?.name ||
card.company?.name || card.person?.fullName || card.title, card.person?.fullName ||
), card.deal?.title ||
stageId: card.stageId, card.notes?.trim() ||
})), '',
amount: null,
date: formatDate(card.createdAt),
initials: initialsFromTitle(
card.company?.name || card.person?.fullName || card.title,
),
stageId: card.stageId,
assigneeNames,
ownerId: card.owner?.id ?? null,
assigneeIds: (card.assignees ?? []).map((a) => a.id),
};
}),
})); }));
} }
@@ -292,6 +307,8 @@ export type PipelineBoardFilter = {
query?: string; query?: string;
amountMin?: number | null; amountMin?: number | null;
amountMax?: number | null; amountMax?: number | null;
/** اگر ست باشد فقط کارت‌های مربوط به این کاربر (مالک یا مسئول) */
mineUserId?: string | null;
}; };
export function filterStagesByQuery( export function filterStagesByQuery(
@@ -308,14 +325,22 @@ export function filterStages(
const q = filter.query?.trim().toLowerCase() ?? ''; const q = filter.query?.trim().toLowerCase() ?? '';
const min = filter.amountMin; const min = filter.amountMin;
const max = filter.amountMax; const max = filter.amountMax;
const mineUserId = filter.mineUserId?.trim() || null;
const hasFilter = const hasFilter =
Boolean(q) || Boolean(q) ||
Boolean(mineUserId) ||
(min != null && !Number.isNaN(min)) || (min != null && !Number.isNaN(min)) ||
(max != null && !Number.isNaN(max)); (max != null && !Number.isNaN(max));
if (!hasFilter) return stages; if (!hasFilter) return stages;
return stages.map((stage) => ({ return stages.map((stage) => ({
...stage, ...stage,
deals: stage.deals.filter((d) => { deals: stage.deals.filter((d) => {
if (mineUserId) {
const isMine =
d.ownerId === mineUserId ||
(d.assigneeIds ?? []).includes(mineUserId);
if (!isMine) return false;
}
if ( if (
q && q &&
!d.title.toLowerCase().includes(q) && !d.title.toLowerCase().includes(q) &&
+75 -5
View File
@@ -28,9 +28,12 @@ import type {
PaginatedResponse, PaginatedResponse,
} from '@mento/shared'; } from '@mento/shared';
import { brand } from '@mento/shared'; import { brand } from '@mento/shared';
import { useAuthStore } from '@/stores/auth';
import { canViewAllRecords } from '@/lib/roles';
type TypeFilter = 'all' | ActivityType; type TypeFilter = 'all' | ActivityType;
type UiStatus = 'done' | 'progress' | 'planned' | 'overdue' | 'canceled'; type UiStatus = 'done' | 'progress' | 'planned' | 'overdue' | 'canceled';
type DeskScope = 'mine' | 'team';
const TABS: Array<{ key: ActivityFilter; label: string }> = [ const TABS: Array<{ key: ActivityFilter; label: string }> = [
{ key: 'today', label: 'امروز' }, { key: 'today', label: 'امروز' },
@@ -40,6 +43,11 @@ const TABS: Array<{ key: ActivityFilter; label: string }> = [
{ key: 'all', label: 'همه' }, { 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 }> = [ const TYPE_FILTERS: Array<{ key: TypeFilter; label: string }> = [
{ key: 'all', label: 'همه انواع' }, { key: 'all', label: 'همه انواع' },
{ key: 'call', label: 'تماس' }, { key: 'call', label: 'تماس' },
@@ -52,6 +60,8 @@ const PAGE_SIZE = 6;
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const auth = useAuthStore();
const showDeskTabs = computed(() => canViewAllRecords(auth.user?.role));
const filter = computed<ActivityFilter>(() => { const filter = computed<ActivityFilter>(() => {
const value = typeof route.query.filter === 'string' ? route.query.filter : 'today'; const value = typeof route.query.filter === 'string' ? route.query.filter : 'today';
@@ -61,6 +71,11 @@ const filter = computed<ActivityFilter>(() => {
return 'today'; return 'today';
}); });
const desk = computed<DeskScope>(() => {
if (!showDeskTabs.value) return 'mine';
return route.query.desk === 'team' ? 'team' : 'mine';
});
const page = computed(() => { const page = computed(() => {
const raw = Number(route.query.page); const raw = Number(route.query.page);
return Number.isFinite(raw) && raw > 0 ? raw : 1; return Number.isFinite(raw) && raw > 0 ? raw : 1;
@@ -178,10 +193,11 @@ function subtitle(item: ActivityDto) {
async function loadCounts() { async function loadCounts() {
const keys: ActivityFilter[] = ['today', 'overdue', 'upcoming', 'done', 'all']; const keys: ActivityFilter[] = ['today', 'overdue', 'upcoming', 'done', 'all'];
const deskParam = showDeskTabs.value ? desk.value : undefined;
const results = await Promise.all( const results = await Promise.all(
keys.map((key) => keys.map((key) =>
api.get<PaginatedResponse<ActivityDto>>('/activities', { 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, q: q.value || undefined,
page: page.value, page: page.value,
limit: PAGE_SIZE, limit: PAGE_SIZE,
desk: showDeskTabs.value ? desk.value : undefined,
}, },
}); });
data.value = res; 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) { function setFilter(next: ActivityFilter) {
filterOpen.value = false; filterOpen.value = false;
void router.replace({ void router.replace({
@@ -318,10 +346,11 @@ function onImportSettled() {
} }
watch( 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 : ''; q.value = typeof route.query.q === 'string' ? route.query.q : '';
void load(); void load();
void loadCounts();
}, },
); );
@@ -348,12 +377,12 @@ onUnmounted(() => {
<nav class="crumbs" aria-label="مسیر"> <nav class="crumbs" aria-label="مسیر">
<RouterLink :to="{ name: 'home' }">خانه</RouterLink> <RouterLink :to="{ name: 'home' }">خانه</RouterLink>
<span>‹</span> <span>‹</span>
<span>فعالیت‌ها</span> <span>میز کار</span>
</nav> </nav>
<UiPageHeader <UiPageHeader
title="فعالیت‌ها" title="میز کار و فعالیت‌ها"
lead="تماس، جلسه و وظیفه — امروز و عقب‌افتاده‌ها یکجا." lead="کارهای امروز، ارجاع به همکار و پیگیری وضعیت."
> >
<template #actions> <template #actions>
<UiButton variant="ghost" type="button" :disabled="busy" @click.stop="exportExcel"> <UiButton variant="ghost" type="button" :disabled="busy" @click.stop="exportExcel">
@@ -387,6 +416,20 @@ onUnmounted(() => {
</template> </template>
</UiPageHeader> </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="فیلتر فعالیت"> <div class="tabs" role="tablist" aria-label="فیلتر فعالیت">
<button <button
v-for="tab in TABS" v-for="tab in TABS"
@@ -763,6 +806,33 @@ h1 {
width: min(100%, 320px); 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 { .tabs {
display: flex; display: flex;
gap: 0.2rem; gap: 0.2rem;
+97 -5
View File
@@ -11,11 +11,15 @@ import UiIcon from '@/components/UiIcon.vue';
import UserAvatar from '@/components/UserAvatar.vue'; import UserAvatar from '@/components/UserAvatar.vue';
import ActivityCommentSheet from '@/components/activities/ActivityCommentSheet.vue'; import ActivityCommentSheet from '@/components/activities/ActivityCommentSheet.vue';
import ActivityPostponeSheet from '@/components/activities/ActivityPostponeSheet.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 route = useRoute();
const router = useRouter(); const router = useRouter();
const auth = useAuthStore();
const id = computed(() => (typeof route.params.id === 'string' ? route.params.id : '')); const id = computed(() => (typeof route.params.id === 'string' ? route.params.id : ''));
const canReassign = computed(() => canReassignRecords(auth.user?.role));
const loading = ref(true); const loading = ref(true);
const saving = ref(false); const saving = ref(false);
@@ -25,6 +29,8 @@ const moreOpen = ref(false);
const commentOpen = ref(false); const commentOpen = ref(false);
const postponeOpen = ref(false); const postponeOpen = ref(false);
const noteOpen = ref(true); const noteOpen = ref(true);
const users = ref<OrgUserDto[]>([]);
const assignUserId = ref('');
const typeLabel: Record<ActivityType, string> = { const typeLabel: Record<ActivityType, string> = {
call: 'تماس', call: 'تماس',
@@ -89,6 +95,7 @@ async function load() {
try { try {
const { data } = await api.get<ActivityDto>(`/activities/${id.value}`); const { data } = await api.get<ActivityDto>(`/activities/${id.value}`);
activity.value = data; activity.value = data;
assignUserId.value = data.assignee?.id ?? '';
} catch { } catch {
error.value = 'بارگذاری جزئیات فعالیت ناموفق بود.'; error.value = 'بارگذاری جزئیات فعالیت ناموفق بود.';
activity.value = null; 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() { async function complete() {
if (!id.value || isDone.value) return; if (!id.value || isDone.value) return;
saving.value = true; 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) { async function postponeTo(iso: string) {
if (!id.value || !activity.value || isDone.value) return; if (!id.value || !activity.value || isDone.value) return;
saving.value = true; saving.value = true;
@@ -160,7 +207,10 @@ watch(id, () => {
postponeOpen.value = false; postponeOpen.value = false;
void load(); void load();
}); });
onMounted(load); onMounted(() => {
void load();
void loadUsers();
});
</script> </script>
<template> <template>
@@ -224,8 +274,23 @@ onMounted(load);
مسئول مسئول
</dt> </dt>
<dd class="person"> <dd class="person">
<UserAvatar v-if="activity.assignee" :name="activity.assignee.fullName" :size="28" /> <template v-if="canReassign">
{{ activity.assignee?.fullName || '—' }} <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> </dd>
</div> </div>
<div> <div>
@@ -295,7 +360,23 @@ onMounted(load);
</template> </template>
<footer v-if="activity && !commentOpen && !postponeOpen" class="dock"> <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" /> <UiIcon name="check" :size="16" />
انجام شد انجام شد
</button> </button>
@@ -508,6 +589,17 @@ dd {
gap: 0.45rem; 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, .flag.low,
.priority.low { .priority.low {
color: #64748b; color: #64748b;
+39 -2
View File
@@ -44,6 +44,7 @@ const boards = ref<ProjectBoardDto[]>([]);
const currentBoardId = ref(''); const currentBoardId = ref('');
const viewMode = ref<PipelineViewMode>('kanban'); const viewMode = ref<PipelineViewMode>('kanban');
const search = ref(''); const search = ref('');
const mineOnly = ref(route.query.mine === '1');
const showCreate = ref(false); const showCreate = ref(false);
const newName = ref(''); const newName = ref('');
const cardModal = ref<{ cardId?: string; stageId?: string } | null>(null); const cardModal = ref<{ cardId?: string; stageId?: string } | null>(null);
@@ -80,9 +81,21 @@ const selectedBoardId = computed({
}); });
const visibleStages = 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) { function formatCardDate(iso: string) {
const d = new Date(iso); const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso; if (Number.isNaN(d.getTime())) return iso;
@@ -445,7 +458,7 @@ onBeforeUnmount(() => {
<template> <template>
<section class="page" dir="rtl"> <section class="page" dir="rtl">
<PipelineToolbar <PipelineToolbar
pipeline-name="کاریز پروژه" pipeline-name="کاریز پروژه — کارهای تیمی"
icon="grid" icon="grid"
create-option-label="برد جدید…" create-option-label="برد جدید…"
:pipeline-options="boardOptions" :pipeline-options="boardOptions"
@@ -457,6 +470,10 @@ onBeforeUnmount(() => {
@create="showCreate = true" @create="showCreate = true"
> >
<template #excel> <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 class="btn-ghost-sm" type="button" :disabled="loading || busy" @click="exportExcel">
خروجی اکسل خروجی اکسل
</button> </button>
@@ -619,6 +636,26 @@ onBeforeUnmount(() => {
container-name: pipeline; 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 { .btn-ghost-sm {
border: 1px solid #e2e8f0; border: 1px solid #e2e8f0;
background: #fff; background: #fff;
+3 -2
View File
@@ -14,6 +14,7 @@
| تاریخ | مورد | | تاریخ | مورد |
|---|---| |---|---|
| ۱۴۰۵/۷/۶ | میز کار (MVP): فیلتر میز من/تیم روی فعالیت‌ها، فقط‌کارهای‌من روی کاریز پروژه، ارجاع مسئول، دسترسی مسئولان کارت |
| ۱۴۰۵/۷/۵ | ماژول مدیریت لیدها: ایمپورت اکسل، تخصیص، Kanban، تبدیل به مشتری، جلوگیری از تکراری | | ۱۴۰۵/۷/۵ | ماژول مدیریت لیدها: ایمپورت اکسل، تخصیص، Kanban، تبدیل به مشتری، جلوگیری از تکراری |
| ۱۴۰۵/۷/۵ | نرخ تبدیل گزارشات (سبک دیدار): ورودی→موفق + محور زمان/کاربر/منبع | | ۱۴۰۵/۷/۵ | نرخ تبدیل گزارشات (سبک دیدار): ورودی→موفق + محور زمان/کاربر/منبع |
| ۱۴۰۵/۷/۵ | خلاصه خودکار تماس VoIP (ضبط/متن → STT → AI → یادداشت call_result) | | ۱۴۰۵/۷/۵ | خلاصه خودکار تماس VoIP (ضبط/متن → STT → AI → یادداشت call_result) |
@@ -46,7 +47,7 @@
| [x] | چند کاریز | انتخاب + ساخت | | [x] | چند کاریز | انتخاب + ساخت |
| [x] | غنی‌سازی معامله | منبع، شکست، برچسب، راکد/فاسد | | [x] | غنی‌سازی معامله | منبع، شکست، برچسب، راکد/فاسد |
| [x] | محصول/خدمت روی معامله | کاتالوگ + ردیف + جمع | | [x] | محصول/خدمت روی معامله | کاتالوگ + ردیف + جمع |
| [x] | فعالیت‌ها | لیست/جزئیات/فرم؛ فیلتر امروز و عقب‌افتاده | | [x] | فعالیت‌ها | لیست/جزئیات/فرم؛ فیلتر امروز و عقب‌افتاده؛ میز من / همه تیم برای مدیر |
| [x] | یادآور فعالیت | اعلان داخل‌برنامه و/یا SMS | | [x] | یادآور فعالیت | اعلان داخل‌برنامه و/یا SMS |
| [x] | تقویم فعالیت‌ها | نمای ماهانه شمسی | | [x] | تقویم فعالیت‌ها | نمای ماهانه شمسی |
| [x] | تاریخ/زمان جلالی | ورودی شمسی + صفحهٔ ساعت دایره‌ای | | [x] | تاریخ/زمان جلالی | ورودی شمسی + صفحهٔ ساعت دایره‌ای |
@@ -103,7 +104,7 @@
| [x] | تیکتینگ | گفتگو، ارجاع، وضعیت/اولویت، ACL | | [x] | تیکتینگ | گفتگو، ارجاع، وضعیت/اولویت، ACL |
| [x] | پرتال مشتری OTP | `/p/:slug`؛ FAQ؛ تیکت؛ CSAT؛ SMS | | [x] | پرتال مشتری OTP | `/p/:slug`؛ FAQ؛ تیکت؛ CSAT؛ SMS |
| [x] | SLA + FAQ + CSAT | | | [x] | SLA + FAQ + CSAT | |
| [x] | کاریز پروژه | صف/انجام/بازبینی/تمام | | [x] | کاریز پروژه | صف/انجام/بازبینی/تمام؛ فیلتر فقط کارهای من؛ دسترسی مسئولان کارت |
| [x] | باشگاه مشتریان | امتیاز و سطوح | | [x] | باشگاه مشتریان | امتیاز و سطوح |
| [x] | اتصال ته‌حساب | چند اتصال DB، لینک شخص، مانده، اسناد، ثبت سند (طلا/جواهر) | | [x] | اتصال ته‌حساب | چند اتصال DB، لینک شخص، مانده، اسناد، ثبت سند (طلا/جواهر) |
| [x] | انبار و مانده داخلی | وقتی ته‌حساب نیست: موجودی کالا، ورود/اصلاح، خروج با صدور فاکتور، مانده شخص | | [x] | انبار و مانده داخلی | وقتی ته‌حساب نیست: موجودی کالا، ورود/اصلاح، خروج با صدور فاکتور، مانده شخص |