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:
@@ -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 } : {}),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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) &&
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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] | انبار و مانده داخلی | وقتی تهحساب نیست: موجودی کالا، ورود/اصلاح، خروج با صدور فاکتور، مانده شخص |
|
||||||
|
|||||||
Reference in New Issue
Block a user