Add deal co-assignees and saved filters on people/pipeline lists.
Deals keep a primary owner plus shared assignees with matching visibility; Contacts and Pipeline can save and reuse filter presets.
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "_DealAssignees" (
|
||||
"A" TEXT NOT NULL,
|
||||
"B" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "_DealAssignees_AB_pkey" PRIMARY KEY ("A","B")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "_DealAssignees_B_index" ON "_DealAssignees"("B");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_DealAssignees" ADD CONSTRAINT "_DealAssignees_A_fkey" FOREIGN KEY ("A") REFERENCES "Deal"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_DealAssignees" ADD CONSTRAINT "_DealAssignees_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -224,6 +224,7 @@ model User {
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
ownedDeals Deal[] @relation("DealOwner")
|
||||
assignedDeals Deal[] @relation("DealAssignees")
|
||||
ownedQuotes Quote[] @relation("QuoteOwner")
|
||||
ownedInvoices Invoice[] @relation("InvoiceOwner")
|
||||
ownedPeople Person[] @relation("PersonOwner")
|
||||
@@ -509,6 +510,7 @@ model Deal {
|
||||
company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull)
|
||||
ownerId String?
|
||||
owner User? @relation("DealOwner", fields: [ownerId], references: [id], onDelete: SetNull)
|
||||
assignees User[] @relation("DealAssignees")
|
||||
position Int @default(0)
|
||||
activities Activity[]
|
||||
smartNotes Note[]
|
||||
|
||||
@@ -16,7 +16,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { AutomationsService } from '../automations/automations.service';
|
||||
import { MessageAutomationsService } from '../message-automations/message-automations.service';
|
||||
import { type Actor, assigneeWhere, ownerWhere } from '../common/record-scope';
|
||||
import { type Actor, assigneeWhere, dealAccessWhere, ownerWhere } from '../common/record-scope';
|
||||
import { normalizeTags } from '../common/tags';
|
||||
|
||||
type ActivityActor = Pick<Actor, 'userId' | 'role'>;
|
||||
@@ -460,7 +460,7 @@ export class ActivitiesService {
|
||||
}
|
||||
if (dealId) {
|
||||
const deal = await this.prisma.deal.findFirst({
|
||||
where: { id: dealId, organizationId, ...scope },
|
||||
where: { id: dealId, organizationId, ...dealAccessWhere(actor) },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!deal) throw new BadRequestException('معامله انتخابشده معتبر نیست');
|
||||
|
||||
@@ -48,6 +48,20 @@ export function ownerWhere(
|
||||
return { ownerId: { in: ids } };
|
||||
}
|
||||
|
||||
/** معامله: مالک اصلی یا یکی از مسئولان مشترک در محدوده دید */
|
||||
export function dealAccessWhere(
|
||||
actor: Pick<Actor, 'userId' | 'role' | 'dataVisibility' | 'visibleUserIds'>,
|
||||
): Prisma.DealWhereInput {
|
||||
const ids = scopedOwnerIds(actor);
|
||||
if (!ids) return {};
|
||||
return {
|
||||
OR: [
|
||||
{ ownerId: { in: ids } },
|
||||
{ assignees: { some: { id: { in: ids } } } },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** فعالیتها بر اساس مسئول */
|
||||
export function assigneeWhere(
|
||||
actor: Pick<Actor, 'userId' | 'role' | 'dataVisibility' | 'visibleUserIds'>,
|
||||
@@ -80,7 +94,14 @@ export function noteAccessWhere(
|
||||
{
|
||||
OR: [
|
||||
{ authorId: { in: ids } },
|
||||
{ deal: { ownerId: { in: ids } } },
|
||||
{
|
||||
deal: {
|
||||
OR: [
|
||||
{ ownerId: { in: ids } },
|
||||
{ assignees: { some: { id: { in: ids } } } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ person: { ownerId: { in: ids } } },
|
||||
{ company: { ownerId: { in: ids } } },
|
||||
],
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
type Actor,
|
||||
noteAccessWhere,
|
||||
ownerWhere,
|
||||
dealAccessWhere,
|
||||
} from '../common/record-scope';
|
||||
|
||||
type NoteActor = Pick<
|
||||
@@ -579,7 +580,7 @@ export class NotesService {
|
||||
}
|
||||
if (dealId) {
|
||||
const deal = await this.prisma.deal.findFirst({
|
||||
where: { id: dealId, organizationId, ...scope },
|
||||
where: { id: dealId, organizationId, ...dealAccessWhere(actor) },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!deal) throw new BadRequestException('معامله انتخابشده معتبر نیست');
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { DealStatus, ImportContactsResult } from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { ownerWhere, resolveOwnerId } from '../common/record-scope';
|
||||
import { dealAccessWhere, resolveOwnerId } from '../common/record-scope';
|
||||
import { toJsonCustomFields } from '../common/custom-fields';
|
||||
import { normalizePhone } from '../contacts/normalize';
|
||||
import {
|
||||
@@ -102,7 +102,7 @@ export class DealsExcelService {
|
||||
|
||||
async export(actor: Actor): Promise<Buffer> {
|
||||
const rows = await this.prisma.deal.findMany({
|
||||
where: { organizationId: actor.organizationId, ...ownerWhere(actor) },
|
||||
where: { organizationId: actor.organizationId, ...dealAccessWhere(actor) },
|
||||
include: {
|
||||
person: {
|
||||
select: { id: true, firstName: true, lastName: true, mobile: true },
|
||||
|
||||
@@ -21,6 +21,7 @@ import { WebhooksService } from '../public-api/webhooks.service';
|
||||
import {
|
||||
type Actor,
|
||||
canReassignOwner,
|
||||
dealAccessWhere,
|
||||
ownerWhere,
|
||||
resolveOwnerId,
|
||||
} from '../common/record-scope';
|
||||
@@ -29,10 +30,14 @@ const dealInclude = {
|
||||
person: { select: { id: true, firstName: true, lastName: true } },
|
||||
company: { select: { id: true, name: true } },
|
||||
owner: { select: { id: true, fullName: true } },
|
||||
assignees: { select: { id: true, fullName: true } },
|
||||
lineItems: { orderBy: { position: 'asc' as const } },
|
||||
} satisfies Prisma.DealInclude;
|
||||
|
||||
type DealActor = Pick<Actor, 'userId' | 'role'>;
|
||||
type DealActor = Pick<
|
||||
Actor,
|
||||
'userId' | 'role' | 'dataVisibility' | 'visibleUserIds'
|
||||
>;
|
||||
|
||||
@Injectable()
|
||||
export class DealsService {
|
||||
@@ -93,6 +98,10 @@ export class DealsService {
|
||||
if (dto.lineItems?.length) {
|
||||
await this.assertProducts(organizationId, dto.lineItems);
|
||||
}
|
||||
const assigneeIds = await this.normalizeAssigneeIds(
|
||||
organizationId,
|
||||
dto.assigneeIds,
|
||||
);
|
||||
|
||||
const maxPos = await this.prisma.deal.aggregate({
|
||||
where: { organizationId, stageId: stage.id },
|
||||
@@ -133,6 +142,9 @@ export class DealsService {
|
||||
companyId: emptyToNull(dto.companyId ?? null),
|
||||
ownerId: resolveOwnerId(actor, dto.ownerId),
|
||||
position: (maxPos._max.position ?? -1) + 1,
|
||||
...(assigneeIds.length
|
||||
? { assignees: { connect: assigneeIds.map((id) => ({ id })) } }
|
||||
: {}),
|
||||
...(dto.lineItems?.length
|
||||
? {
|
||||
lineItems: {
|
||||
@@ -219,6 +231,15 @@ export class DealsService {
|
||||
data.ownerId = resolveOwnerId(actor, dto.ownerId);
|
||||
}
|
||||
|
||||
let assigneeConnect: { id: string }[] | undefined;
|
||||
if (dto.assigneeIds !== undefined) {
|
||||
const assigneeIds = await this.normalizeAssigneeIds(
|
||||
organizationId,
|
||||
dto.assigneeIds,
|
||||
);
|
||||
assigneeConnect = assigneeIds.map((uid) => ({ id: uid }));
|
||||
}
|
||||
|
||||
if (dto.lineItems !== undefined) {
|
||||
const total = sumLineItems(dto.lineItems);
|
||||
data.amount =
|
||||
@@ -248,7 +269,15 @@ export class DealsService {
|
||||
});
|
||||
}
|
||||
}
|
||||
await tx.deal.update({ where: { id }, data });
|
||||
await tx.deal.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...data,
|
||||
...(assigneeConnect
|
||||
? { assignees: { set: assigneeConnect } }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const updated = await this.get(organizationId, id, actor);
|
||||
@@ -271,7 +300,7 @@ export class DealsService {
|
||||
dto: MoveDealRequest,
|
||||
): Promise<DealDto> {
|
||||
const deal = await this.prisma.deal.findFirst({
|
||||
where: { id, organizationId, ...ownerWhere(actor) },
|
||||
where: { id, organizationId, ...dealAccessWhere(actor) },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
@@ -377,7 +406,7 @@ export class DealsService {
|
||||
actor: DealActor,
|
||||
) {
|
||||
const found = await this.prisma.deal.findFirst({
|
||||
where: { id, organizationId, ...ownerWhere(actor) },
|
||||
where: { id, organizationId, ...dealAccessWhere(actor) },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!found) throw new NotFoundException('معامله پیدا نشد');
|
||||
@@ -389,13 +418,30 @@ export class DealsService {
|
||||
actor: DealActor,
|
||||
) {
|
||||
const row = await this.prisma.deal.findFirst({
|
||||
where: { id, organizationId, ...ownerWhere(actor) },
|
||||
where: { id, organizationId, ...dealAccessWhere(actor) },
|
||||
include: dealInclude,
|
||||
});
|
||||
if (!row) throw new NotFoundException('معامله پیدا نشد');
|
||||
return row;
|
||||
}
|
||||
|
||||
private async normalizeAssigneeIds(
|
||||
organizationId: string,
|
||||
raw?: string[] | null,
|
||||
): Promise<string[]> {
|
||||
const ids = [
|
||||
...new Set((raw ?? []).map((id) => id.trim()).filter(Boolean)),
|
||||
];
|
||||
if (!ids.length) return [];
|
||||
const count = await this.prisma.user.count({
|
||||
where: { organizationId, id: { in: ids }, isActive: true },
|
||||
});
|
||||
if (count !== ids.length) {
|
||||
throw new BadRequestException('یکی از مسئولان انتخابشده معتبر نیست');
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
private async assertProducts(
|
||||
organizationId: string,
|
||||
items: DealLineItemInput[],
|
||||
|
||||
@@ -138,6 +138,11 @@ export class CreateDealDto {
|
||||
@IsString()
|
||||
ownerId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
assigneeIds?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
customFields?: Record<string, unknown>;
|
||||
@@ -216,6 +221,11 @@ export class UpdateDealDto {
|
||||
@IsString()
|
||||
ownerId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
assigneeIds?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
customFields?: Record<string, unknown>;
|
||||
|
||||
@@ -13,6 +13,7 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { Roles } from '../auth/roles.decorator';
|
||||
import { RolesGuard } from '../auth/roles.guard';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import {
|
||||
CreatePipelineDto,
|
||||
CreatePipelineStageDto,
|
||||
@@ -33,13 +34,10 @@ export class PipelinesController {
|
||||
|
||||
@Get('board')
|
||||
board(
|
||||
@CurrentUser() user: { organizationId: string; userId: string; role: string },
|
||||
@CurrentUser() user: Actor,
|
||||
@Query('pipelineId') pipelineId?: string,
|
||||
) {
|
||||
return this.pipelinesService.getBoard(user.organizationId, pipelineId, {
|
||||
userId: user.userId,
|
||||
role: user.role,
|
||||
});
|
||||
return this.pipelinesService.getBoard(user.organizationId, pipelineId, user);
|
||||
}
|
||||
|
||||
@Post()
|
||||
|
||||
@@ -12,7 +12,7 @@ import type {
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { asCustomFields } from '../common/custom-fields';
|
||||
import { type Actor, ownerWhere } from '../common/record-scope';
|
||||
import { type Actor, dealAccessWhere } from '../common/record-scope';
|
||||
import { decimalToBase, loadOrgRates } from '../common/currency';
|
||||
|
||||
const DEFAULT_STAGES = [
|
||||
@@ -46,6 +46,7 @@ type DealRow = {
|
||||
person: { id: string; firstName: string; lastName: string } | null;
|
||||
company: { id: string; name: string } | null;
|
||||
owner: { id: string; fullName: string } | null;
|
||||
assignees?: Array<{ id: string; fullName: string }>;
|
||||
lineItems?: Array<{
|
||||
id: string;
|
||||
productId: string | null;
|
||||
@@ -236,7 +237,10 @@ export class PipelinesService {
|
||||
async getBoard(
|
||||
organizationId: string,
|
||||
pipelineId?: string,
|
||||
actor?: Pick<Actor, 'userId' | 'role'>,
|
||||
actor?: Pick<
|
||||
Actor,
|
||||
'userId' | 'role' | 'dataVisibility' | 'visibleUserIds'
|
||||
>,
|
||||
): Promise<PipelineBoardDto> {
|
||||
const pipeline = pipelineId
|
||||
? await this.prisma.pipeline.findFirst({
|
||||
@@ -261,12 +265,13 @@ export class PipelinesService {
|
||||
where: {
|
||||
organizationId,
|
||||
pipelineId: resolved.id,
|
||||
...(actor ? ownerWhere(actor) : {}),
|
||||
...(actor ? dealAccessWhere(actor) : {}),
|
||||
},
|
||||
include: {
|
||||
person: { select: { id: true, firstName: true, lastName: true } },
|
||||
company: { select: { id: true, name: true } },
|
||||
owner: { select: { id: true, fullName: true } },
|
||||
assignees: { select: { id: true, fullName: true } },
|
||||
},
|
||||
orderBy: [{ position: 'asc' }, { updatedAt: 'desc' }],
|
||||
});
|
||||
@@ -332,6 +337,7 @@ export class PipelinesService {
|
||||
: null,
|
||||
company: row.company,
|
||||
owner: row.owner,
|
||||
assignees: row.assignees ?? [],
|
||||
lineItems: (row.lineItems ?? []).map((item) => {
|
||||
const quantity = Number(item.quantity);
|
||||
const unitPrice = Number(item.unitPrice);
|
||||
|
||||
@@ -16,6 +16,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
type Actor,
|
||||
canReassignOwner,
|
||||
dealAccessWhere,
|
||||
ownerWhere,
|
||||
resolveOwnerId,
|
||||
} from '../common/record-scope';
|
||||
@@ -506,7 +507,7 @@ export class ProjectBoardsService {
|
||||
}
|
||||
if (dealId) {
|
||||
const d = await this.prisma.deal.findFirst({
|
||||
where: { id: dealId, organizationId, ...ownerWhere(actor) },
|
||||
where: { id: dealId, organizationId, ...dealAccessWhere(actor) },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!d) throw new BadRequestException('معامله معتبر نیست');
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { api } from '@/lib/api';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import type { SavedViewDto, SavedViewScope } from '@mento/shared';
|
||||
|
||||
const props = defineProps<{
|
||||
scope: SavedViewScope;
|
||||
/** فیلتر فعلی صفحه برای ذخیره */
|
||||
currentFilters: Record<string, unknown>;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
apply: [filters: Record<string, unknown>];
|
||||
}>();
|
||||
|
||||
const views = ref<SavedViewDto[]>([]);
|
||||
const activeId = ref('');
|
||||
const loading = ref(false);
|
||||
const busy = ref(false);
|
||||
const error = ref('');
|
||||
const saveOpen = ref(false);
|
||||
const saveName = ref('');
|
||||
const saveShared = ref(false);
|
||||
|
||||
const activeView = computed(
|
||||
() => views.value.find((v) => v.id === activeId.value) ?? null,
|
||||
);
|
||||
|
||||
async function loadViews() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const { data } = await api.get<SavedViewDto[]>('/saved-views', {
|
||||
params: { scope: props.scope },
|
||||
});
|
||||
views.value = data;
|
||||
} catch {
|
||||
views.value = [];
|
||||
error.value = 'بارگذاری فیلترهای ذخیرهشده ناموفق بود.';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onPick(id: string) {
|
||||
activeId.value = id;
|
||||
if (!id) return;
|
||||
const view = views.value.find((v) => v.id === id);
|
||||
if (view) emit('apply', view.filters ?? {});
|
||||
}
|
||||
|
||||
function openSave() {
|
||||
saveName.value = activeView.value?.name ?? '';
|
||||
saveShared.value = activeView.value?.isShared ?? false;
|
||||
saveOpen.value = true;
|
||||
error.value = '';
|
||||
}
|
||||
|
||||
async function saveView() {
|
||||
const name = saveName.value.trim();
|
||||
if (name.length < 2 || busy.value) return;
|
||||
busy.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const body = {
|
||||
name,
|
||||
scope: props.scope,
|
||||
filters: props.currentFilters,
|
||||
isShared: saveShared.value,
|
||||
};
|
||||
if (activeId.value) {
|
||||
const { data } = await api.patch<SavedViewDto>(
|
||||
`/saved-views/${activeId.value}`,
|
||||
body,
|
||||
);
|
||||
const idx = views.value.findIndex((v) => v.id === data.id);
|
||||
if (idx >= 0) views.value[idx] = data;
|
||||
else views.value.unshift(data);
|
||||
activeId.value = data.id;
|
||||
} else {
|
||||
const { data } = await api.post<SavedViewDto>('/saved-views', body);
|
||||
views.value.unshift(data);
|
||||
activeId.value = data.id;
|
||||
}
|
||||
saveOpen.value = false;
|
||||
} catch {
|
||||
error.value = 'ذخیره فیلتر ناموفق بود.';
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeView() {
|
||||
if (!activeId.value || busy.value) return;
|
||||
if (!confirm('این فیلتر ذخیرهشده حذف شود؟')) return;
|
||||
busy.value = true;
|
||||
try {
|
||||
await api.delete(`/saved-views/${activeId.value}`);
|
||||
views.value = views.value.filter((v) => v.id !== activeId.value);
|
||||
activeId.value = '';
|
||||
} catch {
|
||||
error.value = 'حذف فیلتر ناموفق بود.';
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.scope,
|
||||
() => {
|
||||
activeId.value = '';
|
||||
void loadViews();
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
void loadViews();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="saved-filters" dir="rtl">
|
||||
<label class="pick">
|
||||
<span class="lbl">فیلتر ذخیرهشده</span>
|
||||
<select
|
||||
:value="activeId"
|
||||
:disabled="loading"
|
||||
@change="onPick(($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option value="">— انتخاب —</option>
|
||||
<option v-for="v in views" :key="v.id" :value="v.id">
|
||||
{{ v.name }}{{ v.isShared ? ' (سازمانی)' : '' }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" class="btn" title="ذخیره فیلتر فعلی" @click="openSave">
|
||||
<UiIcon name="flag" :size="14" />
|
||||
ذخیره
|
||||
</button>
|
||||
<button
|
||||
v-if="activeId"
|
||||
type="button"
|
||||
class="btn danger"
|
||||
title="حذف فیلتر"
|
||||
:disabled="busy"
|
||||
@click="removeView"
|
||||
>
|
||||
حذف
|
||||
</button>
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
|
||||
<div v-if="saveOpen" class="sheet">
|
||||
<div class="sheet-backdrop" @click="saveOpen = false" />
|
||||
<div class="sheet-panel">
|
||||
<h3>{{ activeId ? 'بهروزرسانی فیلتر' : 'ذخیره فیلتر' }}</h3>
|
||||
<label>
|
||||
نام
|
||||
<input v-model="saveName" type="text" maxlength="120" placeholder="مثلاً سرنخهای این هفته" />
|
||||
</label>
|
||||
<label class="check">
|
||||
<input v-model="saveShared" type="checkbox" />
|
||||
اشتراک با سازمان
|
||||
</label>
|
||||
<div class="actions">
|
||||
<button type="button" class="btn primary" :disabled="busy" @click="saveView">
|
||||
{{ busy ? '…' : 'ذخیره' }}
|
||||
</button>
|
||||
<button type="button" class="btn" @click="saveOpen = false">لغو</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.saved-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 0.45rem;
|
||||
margin-top: 0.55rem;
|
||||
}
|
||||
.pick {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
min-width: 11rem;
|
||||
}
|
||||
.lbl {
|
||||
font-size: 0.72rem;
|
||||
color: #64748b;
|
||||
}
|
||||
select,
|
||||
input[type='text'] {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 0.4rem 0.55rem;
|
||||
background: #fff;
|
||||
font-size: 0.85rem;
|
||||
color: #0f172a;
|
||||
}
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #fff;
|
||||
color: #334155;
|
||||
border-radius: 8px;
|
||||
padding: 0.4rem 0.65rem;
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn.primary {
|
||||
background: #0d9488;
|
||||
border-color: #0d9488;
|
||||
color: #fff;
|
||||
}
|
||||
.btn.danger {
|
||||
color: #b91c1c;
|
||||
}
|
||||
.err {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
color: #b91c1c;
|
||||
}
|
||||
.sheet {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
.sheet-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.4);
|
||||
}
|
||||
.sheet-panel {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: min(100%, 360px);
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.18);
|
||||
}
|
||||
.sheet-panel h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.sheet-panel label {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.85rem;
|
||||
color: #334155;
|
||||
}
|
||||
.check {
|
||||
display: flex !important;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
</style>
|
||||
@@ -32,6 +32,12 @@ const money = computed(() =>
|
||||
formatCurrencyParts(props.deal.amount ?? 0, props.deal.currencyUnit ?? 'تومان'),
|
||||
);
|
||||
const colors = computed(() => PIPELINE_ACCENT[props.accent]);
|
||||
const assigneeTitle = computed(() =>
|
||||
(props.deal.assigneeNames ?? []).filter(Boolean).join('، ') || 'مسئول',
|
||||
);
|
||||
const extraAssignees = computed(() =>
|
||||
Math.max(0, (props.deal.assigneeNames?.length ?? 0) - 1),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -68,7 +74,11 @@ const colors = computed(() => PIPELINE_ACCENT[props.accent]);
|
||||
alt=""
|
||||
width="32"
|
||||
height="32"
|
||||
:title="assigneeTitle"
|
||||
/>
|
||||
<span v-if="extraAssignees > 0" class="more-assignees" :title="assigneeTitle">
|
||||
+{{ extraAssignees }}
|
||||
</span>
|
||||
<span class="date">
|
||||
{{ deal.date }}
|
||||
<UiIcon name="calendar" :size="13" />
|
||||
@@ -155,6 +165,25 @@ const colors = computed(() => PIPELINE_ACCENT[props.accent]);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.more-assignees {
|
||||
width: auto;
|
||||
min-width: 1.15rem;
|
||||
height: 1.15rem;
|
||||
padding: 0 0.22rem;
|
||||
margin-inline-start: -1.55rem;
|
||||
margin-top: 1.1rem;
|
||||
border-radius: 999px;
|
||||
background: #0f766e;
|
||||
color: #fff;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
z-index: 1;
|
||||
justify-self: end;
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
.titles {
|
||||
min-width: 0;
|
||||
text-align: right;
|
||||
|
||||
@@ -10,6 +10,8 @@ export type PipelineDeal = {
|
||||
/** شناسه مرحله فعلی — هنگام جابهجایی بهروز میشود */
|
||||
stageId?: string;
|
||||
currencyUnit?: string;
|
||||
/** نام مسئولان برای نمایش روی کارت */
|
||||
assigneeNames?: string[];
|
||||
};
|
||||
|
||||
export type PipelineUiCopy = {
|
||||
@@ -136,6 +138,8 @@ export function mapBoardToStages(
|
||||
createdAt: string;
|
||||
person: { fullName: string } | null;
|
||||
company: { name: string } | null;
|
||||
owner?: { id: string; fullName: string } | null;
|
||||
assignees?: Array<{ id: string; fullName: string }>;
|
||||
}>;
|
||||
}>;
|
||||
},
|
||||
@@ -164,6 +168,12 @@ export function mapBoardToStages(
|
||||
deal.person?.fullName ||
|
||||
'';
|
||||
const dateIso = deal.expectedCloseAt || deal.createdAt;
|
||||
const assigneeNames = [
|
||||
...(deal.owner?.fullName ? [deal.owner.fullName] : []),
|
||||
...(deal.assignees ?? [])
|
||||
.map((a) => a.fullName)
|
||||
.filter((n) => n && n !== deal.owner?.fullName),
|
||||
];
|
||||
return {
|
||||
id: deal.id,
|
||||
title: deal.title,
|
||||
@@ -175,6 +185,7 @@ export function mapBoardToStages(
|
||||
),
|
||||
stageId: deal.stageId,
|
||||
currencyUnit: currencyUnitLabel(deal.currency),
|
||||
assigneeNames,
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import PersonFormView from '@/views/PersonFormView.vue';
|
||||
import CompanyFormView from '@/views/CompanyFormView.vue';
|
||||
import SavedFiltersBar from '@/components/SavedFiltersBar.vue';
|
||||
import type {
|
||||
CompanyDto,
|
||||
ImportContactsResult,
|
||||
@@ -65,6 +66,30 @@ const total = computed(() =>
|
||||
);
|
||||
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE)));
|
||||
|
||||
const peopleSavedFilters = computed(() => ({
|
||||
q: q.value.trim() || undefined,
|
||||
sort: sort.value === 'name' ? undefined : sort.value,
|
||||
mood: moodFilter.value === 'all' ? undefined : moodFilter.value,
|
||||
}));
|
||||
|
||||
function applyPeopleSavedFilters(filters: Record<string, unknown>) {
|
||||
const nextQ = typeof filters.q === 'string' ? filters.q : '';
|
||||
q.value = nextQ;
|
||||
const nextSort = filters.sort === 'newest' ? 'newest' : 'name';
|
||||
const nextMood =
|
||||
typeof filters.mood === 'string' &&
|
||||
PERSON_MOODS.includes(filters.mood as PersonMood)
|
||||
? (filters.mood as PersonMood)
|
||||
: 'all';
|
||||
patchQuery({
|
||||
q: nextQ.trim() || undefined,
|
||||
sort: nextSort === 'name' ? undefined : nextSort,
|
||||
mood: nextMood === 'all' ? undefined : nextMood,
|
||||
page: undefined,
|
||||
tab: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function moodOf(person: PersonDto): PersonMood {
|
||||
return person.mood || 'newcomer';
|
||||
}
|
||||
@@ -360,6 +385,13 @@ onMounted(load);
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SavedFiltersBar
|
||||
v-if="tab === 'people'"
|
||||
scope="people"
|
||||
:current-filters="peopleSavedFilters"
|
||||
@apply="applyPeopleSavedFilters"
|
||||
/>
|
||||
|
||||
<p v-if="importMsg" class="ok">{{ importMsg }}</p>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
|
||||
|
||||
@@ -93,6 +93,7 @@ const companyId = ref('');
|
||||
const stageId = ref('');
|
||||
const pipelineId = ref('');
|
||||
const ownerId = ref('');
|
||||
const selectedAssigneeIds = ref<string[]>([]);
|
||||
const lostReason = ref('');
|
||||
const status = ref('');
|
||||
const customFields = ref<Record<string, unknown>>({});
|
||||
@@ -247,6 +248,7 @@ async function load() {
|
||||
contactKind.value = data.person?.id || !data.company?.id ? 'person' : 'company';
|
||||
stageId.value = data.stageId;
|
||||
ownerId.value = data.owner?.id ?? auth.user?.id ?? '';
|
||||
selectedAssigneeIds.value = (data.assignees ?? []).map((a) => a.id);
|
||||
lostReason.value = data.lostReason ?? '';
|
||||
status.value = data.status;
|
||||
customFields.value = { ...(data.customFields ?? {}) };
|
||||
@@ -264,6 +266,7 @@ async function load() {
|
||||
}
|
||||
} else {
|
||||
ownerId.value = auth.user?.id ?? users.value[0]?.id ?? '';
|
||||
selectedAssigneeIds.value = [];
|
||||
if (typeof route.query.personId === 'string' && route.query.personId) {
|
||||
personId.value = route.query.personId;
|
||||
contactKind.value = 'person';
|
||||
@@ -306,6 +309,7 @@ function payload(): CreateDealRequest {
|
||||
personId: personId.value || null,
|
||||
companyId: companyId.value || null,
|
||||
ownerId: ownerId.value || null,
|
||||
assigneeIds: selectedAssigneeIds.value,
|
||||
pipelineId: pipelineId.value || undefined,
|
||||
stageId: stageId.value || undefined,
|
||||
customFields: customFields.value,
|
||||
@@ -623,6 +627,20 @@ onMounted(load);
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="deal-assignees">مسئولان مشترک</label>
|
||||
<select
|
||||
id="deal-assignees"
|
||||
v-model="selectedAssigneeIds"
|
||||
multiple
|
||||
class="multi"
|
||||
>
|
||||
<option v-for="u in users" :key="u.id" :value="u.id">
|
||||
{{ u.fullName }}
|
||||
</option>
|
||||
</select>
|
||||
<small class="hint">علاوه بر مسئول اصلی؛ با Ctrl چند نفر را انتخاب کنید.</small>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1322,6 +1340,20 @@ h1 {
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
select.multi {
|
||||
min-height: 110px;
|
||||
padding: 0.4rem;
|
||||
width: 100%;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: #64748b;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.note-card.off,
|
||||
.timeline.hide {
|
||||
display: none;
|
||||
|
||||
@@ -21,6 +21,7 @@ import UiButton from '@/components/ui/UiButton.vue';
|
||||
import UiInput from '@/components/ui/UiInput.vue';
|
||||
import UiModal from '@/components/ui/UiModal.vue';
|
||||
import DealFormView from '@/views/DealFormView.vue';
|
||||
import SavedFiltersBar from '@/components/SavedFiltersBar.vue';
|
||||
import type { ImportContactsResult, PipelineBoardDto, PipelineDto } from '@mento/shared';
|
||||
import { brand } from '@mento/shared';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
@@ -84,6 +85,34 @@ const visibleStages = computed(() =>
|
||||
}),
|
||||
);
|
||||
|
||||
const dealsSavedFilters = computed(() => ({
|
||||
q: search.value.trim() || undefined,
|
||||
amountMin: amountMin.value || undefined,
|
||||
amountMax: amountMax.value || undefined,
|
||||
pipelineId: selectedPipelineId.value || undefined,
|
||||
viewMode: viewMode.value,
|
||||
}));
|
||||
|
||||
function applyDealsSavedFilters(filters: Record<string, unknown>) {
|
||||
search.value = typeof filters.q === 'string' ? filters.q : '';
|
||||
amountMin.value =
|
||||
filters.amountMin == null || filters.amountMin === ''
|
||||
? ''
|
||||
: String(filters.amountMin);
|
||||
amountMax.value =
|
||||
filters.amountMax == null || filters.amountMax === ''
|
||||
? ''
|
||||
: String(filters.amountMax);
|
||||
if (filters.viewMode === 'list' || filters.viewMode === 'kanban') {
|
||||
viewMode.value = filters.viewMode;
|
||||
}
|
||||
const nextPipeline =
|
||||
typeof filters.pipelineId === 'string' ? filters.pipelineId : '';
|
||||
if (nextPipeline && nextPipeline !== selectedPipelineId.value) {
|
||||
selectedPipelineId.value = nextPipeline;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDealDate(iso: string) {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
@@ -480,6 +509,12 @@ defineExpose({ updateDealStage: applyUpdateDealStage });
|
||||
</template>
|
||||
</PipelineToolbar>
|
||||
|
||||
<SavedFiltersBar
|
||||
scope="deals"
|
||||
:current-filters="dealsSavedFilters"
|
||||
@apply="applyDealsSavedFilters"
|
||||
/>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-if="importMsg" class="ok">{{ importMsg }}</p>
|
||||
<p v-else-if="loading && !stages.length" class="muted">در حال بارگذاری کاریز…</p>
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
| تاریخ | مورد |
|
||||
|---|---|
|
||||
| ۱۴۰۵/۷/۵ | چند مسئول روی معاملهٔ کاریز + ذخیره فیلتر در اشخاص و کاریز |
|
||||
| ۱۴۰۵/۷/۵ | صیقل Smart Notes: یادداشت شخصی/تیمی + تاریخچه ویرایش و بازگردانی |
|
||||
| ۱۴۰۵/۶/۲۰ | پورسانت کارشناس (درصد پیشفرض + تغییر روی فاکتور) و دفتر چک وصل به پرداخت |
|
||||
| ۱۴۰۵/۶/۱۹ | انبار داخلی + مانده طرفحساب وقتی تهحساب وصل نیست؛ خروج کالا با صدور فاکتور |
|
||||
|
||||
@@ -774,6 +774,8 @@ export interface DealDto {
|
||||
person: PersonSummaryDto | null;
|
||||
company: CompanySummaryDto | null;
|
||||
owner: UserSummaryDto | null;
|
||||
/** مسئولان مشترک معامله (علاوه بر مالک اصلی) */
|
||||
assignees: UserSummaryDto[];
|
||||
lineItems: DealLineItemDto[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -820,6 +822,7 @@ export interface CreateDealRequest {
|
||||
personId?: string | null;
|
||||
companyId?: string | null;
|
||||
ownerId?: string | null;
|
||||
assigneeIds?: string[];
|
||||
customFields?: Record<string, unknown>;
|
||||
lineItems?: DealLineItemInput[];
|
||||
}
|
||||
@@ -838,10 +841,27 @@ export interface UpdateDealRequest {
|
||||
personId?: string | null;
|
||||
companyId?: string | null;
|
||||
ownerId?: string | null;
|
||||
assigneeIds?: string[];
|
||||
customFields?: Record<string, unknown>;
|
||||
lineItems?: DealLineItemInput[];
|
||||
}
|
||||
|
||||
/** فیلتر ذخیرهشدهٔ لیست اشخاص */
|
||||
export interface PeopleViewFilters {
|
||||
q?: string;
|
||||
sort?: string;
|
||||
mood?: string;
|
||||
page?: number;
|
||||
}
|
||||
|
||||
/** فیلتر ذخیرهشدهٔ کاریز / معاملات */
|
||||
export interface DealsViewFilters {
|
||||
q?: string;
|
||||
amountMin?: string;
|
||||
amountMax?: string;
|
||||
pipelineId?: string;
|
||||
viewMode?: string;
|
||||
}
|
||||
export interface MoveDealRequest {
|
||||
stageId: string;
|
||||
position?: number;
|
||||
|
||||
Reference in New Issue
Block a user