From b8ad4037b640bad37c5ef45523a670a7c0a98611 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Thu, 10 Sep 2026 01:22:24 +0330 Subject: [PATCH] Unify the web UI on shared controls and let pipeline columns be managed on the board. Co-authored-by: Cursor --- apps/api/src/pipelines/dto.ts | 21 ++ .../api/src/pipelines/pipelines.controller.ts | 68 +++- apps/api/src/pipelines/pipelines.service.ts | 138 +++++++- apps/web/package.json | 2 + apps/web/src/components/QuickCreate.vue | 109 +++--- .../components/dashboard/HomeSalesTrend.vue | 171 ++++----- .../components/dashboard/HomeSourcesChart.vue | 72 ++-- .../src/components/pipeline/AddDealButton.vue | 44 +-- apps/web/src/components/pipeline/DealCard.vue | 171 ++++----- .../src/components/pipeline/PipelineBoard.vue | 168 ++++++++- .../components/pipeline/PipelineColumn.vue | 279 +++++++++++++-- .../src/components/pipeline/PipelineList.vue | 2 +- .../components/pipeline/PipelineToolbar.vue | 268 ++++---------- apps/web/src/components/ui/UiButton.vue | 76 ++++ apps/web/src/components/ui/UiCard.vue | 14 + apps/web/src/components/ui/UiChart.vue | 54 +++ apps/web/src/components/ui/UiFilterBar.vue | 61 ++++ apps/web/src/components/ui/UiInput.vue | 51 +++ apps/web/src/components/ui/UiModal.vue | 125 +++++++ apps/web/src/components/ui/UiSearch.vue | 62 ++++ apps/web/src/components/ui/UiSegmented.vue | 65 ++++ apps/web/src/components/ui/UiSelect.vue | 54 +++ apps/web/src/layouts/AppLayout.vue | 26 +- apps/web/src/lib/pipeline-board.ts | 56 ++- apps/web/src/styles/main.css | 35 +- apps/web/src/views/ActivitiesView.vue | 53 ++- apps/web/src/views/ActivityFormView.vue | 74 +++- apps/web/src/views/CompanyFormView.vue | 51 ++- apps/web/src/views/ContactsView.vue | 36 +- apps/web/src/views/DealFormView.vue | 74 +++- apps/web/src/views/InvoiceFormView.vue | 72 +++- apps/web/src/views/InvoicesView.vue | 53 ++- apps/web/src/views/LeadFormPublicView.vue | 2 +- apps/web/src/views/PersonFormView.vue | 51 ++- apps/web/src/views/PipelineBoardView.vue | 335 +++++++++++++----- apps/web/src/views/ProductFormView.vue | 44 ++- apps/web/src/views/ProductsSettingsView.vue | 23 +- apps/web/src/views/SalesForecastView.vue | 4 +- package-lock.json | 46 ++- packages/shared/src/index.ts | 12 + 40 files changed, 2334 insertions(+), 788 deletions(-) create mode 100644 apps/web/src/components/ui/UiButton.vue create mode 100644 apps/web/src/components/ui/UiCard.vue create mode 100644 apps/web/src/components/ui/UiChart.vue create mode 100644 apps/web/src/components/ui/UiFilterBar.vue create mode 100644 apps/web/src/components/ui/UiInput.vue create mode 100644 apps/web/src/components/ui/UiModal.vue create mode 100644 apps/web/src/components/ui/UiSearch.vue create mode 100644 apps/web/src/components/ui/UiSegmented.vue create mode 100644 apps/web/src/components/ui/UiSelect.vue diff --git a/apps/api/src/pipelines/dto.ts b/apps/api/src/pipelines/dto.ts index a8d54b1..3b73fe8 100644 --- a/apps/api/src/pipelines/dto.ts +++ b/apps/api/src/pipelines/dto.ts @@ -21,6 +21,27 @@ export class CreatePipelineDto { name!: string; } +export class CreatePipelineStageDto { + @IsString() + @MinLength(1) + @MaxLength(80) + name!: string; +} + +export class UpdatePipelineStageDto { + @IsOptional() + @IsString() + @MinLength(1) + @MaxLength(80) + name?: string; +} + +export class ReorderPipelineStagesDto { + @IsArray() + @IsString({ each: true }) + stageIds!: string[]; +} + export class DealLineItemInputDto { @IsOptional() @ValidateIf((_, v) => v !== '' && v != null) diff --git a/apps/api/src/pipelines/pipelines.controller.ts b/apps/api/src/pipelines/pipelines.controller.ts index 8791467..8096c78 100644 --- a/apps/api/src/pipelines/pipelines.controller.ts +++ b/apps/api/src/pipelines/pipelines.controller.ts @@ -1,7 +1,10 @@ import { Body, Controller, + Delete, Get, + Param, + Patch, Post, Query, UseGuards, @@ -10,7 +13,12 @@ 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 { CreatePipelineDto } from './dto'; +import { + CreatePipelineDto, + CreatePipelineStageDto, + ReorderPipelineStagesDto, + UpdatePipelineStageDto, +} from './dto'; import { PipelinesService } from './pipelines.service'; @Controller('pipelines') @@ -42,4 +50,62 @@ export class PipelinesController { ) { return this.pipelinesService.create(user.organizationId, dto.name); } + + @Post(':pipelineId/stages') + @Roles('owner', 'admin') + createStage( + @CurrentUser() user: { organizationId: string }, + @Param('pipelineId') pipelineId: string, + @Body() dto: CreatePipelineStageDto, + ) { + return this.pipelinesService.createStage( + user.organizationId, + pipelineId, + dto.name, + ); + } + + @Patch(':pipelineId/stages/order') + @Roles('owner', 'admin') + reorderStages( + @CurrentUser() user: { organizationId: string }, + @Param('pipelineId') pipelineId: string, + @Body() dto: ReorderPipelineStagesDto, + ) { + return this.pipelinesService.reorderStages( + user.organizationId, + pipelineId, + dto.stageIds, + ); + } + + @Patch(':pipelineId/stages/:stageId') + @Roles('owner', 'admin') + updateStage( + @CurrentUser() user: { organizationId: string }, + @Param('pipelineId') pipelineId: string, + @Param('stageId') stageId: string, + @Body() dto: UpdatePipelineStageDto, + ) { + return this.pipelinesService.updateStage( + user.organizationId, + pipelineId, + stageId, + dto.name, + ); + } + + @Delete(':pipelineId/stages/:stageId') + @Roles('owner', 'admin') + deleteStage( + @CurrentUser() user: { organizationId: string }, + @Param('pipelineId') pipelineId: string, + @Param('stageId') stageId: string, + ) { + return this.pipelinesService.deleteStage( + user.organizationId, + pipelineId, + stageId, + ); + } } diff --git a/apps/api/src/pipelines/pipelines.service.ts b/apps/api/src/pipelines/pipelines.service.ts index 95631ef..8275eb1 100644 --- a/apps/api/src/pipelines/pipelines.service.ts +++ b/apps/api/src/pipelines/pipelines.service.ts @@ -1,4 +1,8 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; import type { DealDto, PipelineBoardDto, @@ -114,6 +118,121 @@ export class PipelinesService { return this.toPipelineDto(created); } + async createStage( + organizationId: string, + pipelineId: string, + name: string, + ): Promise { + const pipeline = await this.getOwnedPipeline(organizationId, pipelineId); + const trimmed = name.trim(); + if (!trimmed) throw new BadRequestException('نام ستون لازم است'); + + const firstClosed = pipeline.stages.findIndex((s) => s.isWon || s.isLost); + const insertAt = firstClosed === -1 ? pipeline.stages.length : firstClosed; + + await this.prisma.$transaction(async (tx) => { + await tx.pipelineStage.updateMany({ + where: { pipelineId: pipeline.id, position: { gte: insertAt } }, + data: { position: { increment: 1 } }, + }); + await tx.pipelineStage.create({ + data: { + pipelineId: pipeline.id, + name: trimmed, + position: insertAt, + isWon: false, + isLost: false, + }, + }); + }); + + return this.getOwnedPipelineDto(organizationId, pipeline.id); + } + + async updateStage( + organizationId: string, + pipelineId: string, + stageId: string, + name?: string, + ): Promise { + const pipeline = await this.getOwnedPipeline(organizationId, pipelineId); + const stage = pipeline.stages.find((s) => s.id === stageId); + if (!stage) throw new NotFoundException('ستون پیدا نشد'); + + const trimmed = name?.trim() ?? ''; + if (!trimmed) throw new BadRequestException('نام ستون لازم است'); + + await this.prisma.pipelineStage.update({ + where: { id: stage.id }, + data: { name: trimmed }, + }); + + return this.getOwnedPipelineDto(organizationId, pipeline.id); + } + + async deleteStage( + organizationId: string, + pipelineId: string, + stageId: string, + ): Promise { + const pipeline = await this.getOwnedPipeline(organizationId, pipelineId); + const stage = pipeline.stages.find((s) => s.id === stageId); + if (!stage) throw new NotFoundException('ستون پیدا نشد'); + if (pipeline.stages.length <= 1) { + throw new BadRequestException('حداقل یک ستون باید باقی بماند'); + } + + const dealCount = await this.prisma.deal.count({ + where: { organizationId, stageId: stage.id }, + }); + if (dealCount > 0) { + throw new BadRequestException( + 'ابتدا معاملات این ستون را به ستون دیگری منتقل کنید', + ); + } + + const remaining = pipeline.stages.filter((s) => s.id !== stage.id); + await this.prisma.$transaction([ + this.prisma.pipelineStage.delete({ where: { id: stage.id } }), + ...remaining.map((s, index) => + this.prisma.pipelineStage.update({ + where: { id: s.id }, + data: { position: index }, + }), + ), + ]); + + return this.getOwnedPipelineDto(organizationId, pipeline.id); + } + + async reorderStages( + organizationId: string, + pipelineId: string, + stageIds: string[], + ): Promise { + const pipeline = await this.getOwnedPipeline(organizationId, pipelineId); + const currentIds = pipeline.stages.map((s) => s.id); + const unique = new Set(stageIds); + if ( + stageIds.length !== currentIds.length || + unique.size !== currentIds.length || + currentIds.some((id) => !unique.has(id)) + ) { + throw new BadRequestException('ترتیب ستون‌ها نامعتبر است'); + } + + await this.prisma.$transaction( + stageIds.map((id, index) => + this.prisma.pipelineStage.update({ + where: { id }, + data: { position: index }, + }), + ), + ); + + return this.getOwnedPipelineDto(organizationId, pipeline.id); + } + async getBoard( organizationId: string, pipelineId?: string, @@ -232,6 +351,23 @@ export class PipelinesService { }; } + private async getOwnedPipeline(organizationId: string, pipelineId: string) { + const pipeline = await this.prisma.pipeline.findFirst({ + where: { id: pipelineId, organizationId }, + include: { stages: { orderBy: { position: 'asc' } } }, + }); + if (!pipeline) throw new NotFoundException('کاریز پیدا نشد'); + return pipeline; + } + + private async getOwnedPipelineDto( + organizationId: string, + pipelineId: string, + ): Promise { + const pipeline = await this.getOwnedPipeline(organizationId, pipelineId); + return this.toPipelineDto(pipeline); + } + private toPipelineDto(row: { id: string; name: string; diff --git a/apps/web/package.json b/apps/web/package.json index df7fcd2..433f79d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,8 +12,10 @@ "dependencies": { "@mento/shared": "*", "axios": "^1.8.4", + "echarts": "^6.1.0", "pinia": "^3.0.1", "vue": "^3.5.13", + "vue-echarts": "^8.3.0", "vue-router": "^4.5.0" }, "devDependencies": { diff --git a/apps/web/src/components/QuickCreate.vue b/apps/web/src/components/QuickCreate.vue index e814da0..bc2d1c7 100644 --- a/apps/web/src/components/QuickCreate.vue +++ b/apps/web/src/components/QuickCreate.vue @@ -4,7 +4,7 @@ import { useRoute, useRouter } from 'vue-router'; import type { RouteLocationRaw } from 'vue-router'; import UiIcon from '@/components/UiIcon.vue'; -const FAB_SIZE = 56; +const FAB_SIZE = 36; const FAB_MARGIN = 20; const POS_KEY = 'mento.quickCreateFab'; @@ -164,13 +164,18 @@ const fabStyle = computed(() => ({ })); const panelStyle = computed(() => { - const gap = 18; + const gap = 12; + const edge = 16; const width = Math.min(390, viewport.value.w - 32); let left = fabPos.value.left; - left = Math.min(left, viewport.value.w - width - 16); - left = Math.max(16, left); - const spaceAbove = fabPos.value.top; - const placeAbove = spaceAbove > 360; + left = Math.min(left, viewport.value.w - width - edge); + left = Math.max(edge, left); + + const spaceAbove = fabPos.value.top - edge; + const spaceBelow = viewport.value.h - (fabPos.value.top + FAB_SIZE) - edge; + const placeAbove = spaceAbove >= spaceBelow; + const maxHeight = Math.max(0, (placeAbove ? spaceAbove : spaceBelow) - gap); + if (placeAbove) { return { left: `${left}px`, @@ -178,6 +183,7 @@ const panelStyle = computed(() => { bottom: `${viewport.value.h - fabPos.value.top + gap}px`, top: 'auto', width: `${width}px`, + maxHeight: `${maxHeight}px`, }; } return { @@ -186,6 +192,7 @@ const panelStyle = computed(() => { top: `${fabPos.value.top + FAB_SIZE + gap}px`, bottom: 'auto', width: `${width}px`, + maxHeight: `${maxHeight}px`, }; }); @@ -257,21 +264,23 @@ onBeforeUnmount(() => { :style="panelStyle" >

ایجاد سریع

- +
+ +
@@ -315,29 +324,43 @@ onBeforeUnmount(() => { position: fixed; z-index: 92; width: 390px; + max-height: min(540px, calc(100dvh - 80px)); box-sizing: border-box; + display: flex; + flex-direction: column; background: #fff; border-radius: 28px; - padding: 20px; + padding: 16px 16px 12px; box-shadow: 0 18px 48px rgba(15, 23, 42, 0.18); + overflow: hidden; } .panel h2 { - margin: 0 0 16px; + flex-shrink: 0; + margin: 0 0 12px; text-align: right; - font-size: 22px; + font-size: 20px; font-weight: 800; color: #111827; } +.items { + min-height: 0; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 8px; + padding-left: 2px; + overscroll-behavior: contain; +} + .item { width: 100%; - height: 82px; - margin-bottom: 10px; + min-height: 68px; display: flex; align-items: center; gap: 14px; - padding: 0 14px; + padding: 10px 14px; border: 1px solid #eef1f2; border-radius: 16px; background: #fff; @@ -347,11 +370,7 @@ onBeforeUnmount(() => { cursor: pointer; } -.item:last-child { - margin-bottom: 0; -} - -.ico { +.item-ico { width: 52px; height: 52px; border-radius: 14px; @@ -361,23 +380,23 @@ onBeforeUnmount(() => { flex-shrink: 0; } -.ico.person { +.item-ico.person { background: #0d9488; } -.ico.company { +.item-ico.company { background: #22d3ee; } -.ico.deal { +.item-ico.deal { background: #7c3aed; } -.ico.activity { +.item-ico.activity { background: #f97316; } -.ico.note { +.item-ico.note { background: #2563eb; } @@ -403,12 +422,14 @@ onBeforeUnmount(() => { .fab { position: fixed; z-index: 93; - width: 54px; - height: 54px; + box-sizing: border-box; + width: 36px; + height: 36px; padding: 0; margin: 0; border: 0; - border-radius: 16px; + border-radius: 10px; + overflow: hidden; background: linear-gradient(145deg, #0f766e 0%, #14b8a6 100%); color: #fff; display: grid; @@ -437,7 +458,11 @@ onBeforeUnmount(() => { .fab :deep(.ico) { display: block; - stroke-width: 1.55; + width: 28px; + height: 28px; + max-width: 28px; + max-height: 28px; + stroke-width: 1.7; filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.15)); } diff --git a/apps/web/src/components/dashboard/HomeSalesTrend.vue b/apps/web/src/components/dashboard/HomeSalesTrend.vue index ad0406f..2c8a8cc 100644 --- a/apps/web/src/components/dashboard/HomeSalesTrend.vue +++ b/apps/web/src/components/dashboard/HomeSalesTrend.vue @@ -1,6 +1,7 @@ diff --git a/apps/web/src/components/dashboard/HomeSourcesChart.vue b/apps/web/src/components/dashboard/HomeSourcesChart.vue index aec7ba3..721786b 100644 --- a/apps/web/src/components/dashboard/HomeSourcesChart.vue +++ b/apps/web/src/components/dashboard/HomeSourcesChart.vue @@ -2,6 +2,7 @@ import { computed } from 'vue'; import { toFaDigits } from '@/lib/jalali'; import type { HomeDashboardSourceDto } from '@mento/shared'; +import UiChart, { type UiChartOption } from '@/components/ui/UiChart.vue'; const props = defineProps<{ sources: HomeDashboardSourceDto[]; @@ -18,17 +19,31 @@ const demoSources: HomeDashboardSourceDto[] = [ const colors = ['#2563eb', '#14b8a6', '#22c55e', '#fb923c', '#94a3b8']; const rows = computed(() => (props.sources.length ? props.sources : demoSources)); - -const segments = computed(() => { - let offset = 0; - return rows.value.map((s, i) => { - const seg = { ...s, color: colors[i % colors.length], offset }; - offset += s.percent; - return seg; - }); -}); - const total = computed(() => rows.value.reduce((a, s) => a + s.count, 0)); + +const option = computed(() => ({ + animationDuration: 400, + color: colors, + tooltip: { + trigger: 'item', + textStyle: { fontFamily: 'IRANSans, Tahoma, sans-serif' }, + formatter: (p) => { + const item = Array.isArray(p) ? p[0] : p; + return `${item.name}
${toFaDigits(Number(item.value ?? 0))} — ${toFaDigits(item.percent ?? 0)}٪`; + }, + }, + series: [ + { + type: 'pie', + radius: ['58%', '82%'], + center: ['50%', '50%'], + avoidLabelOverlap: true, + label: { show: false }, + labelLine: { show: false }, + data: rows.value.map((s) => ({ name: s.label, value: s.count })), + }, + ], +})); diff --git a/apps/web/src/components/pipeline/PipelineColumn.vue b/apps/web/src/components/pipeline/PipelineColumn.vue index 034f1c9..63fdafc 100644 --- a/apps/web/src/components/pipeline/PipelineColumn.vue +++ b/apps/web/src/components/pipeline/PipelineColumn.vue @@ -1,14 +1,18 @@ @@ -120,19 +102,19 @@ function onPick(value: string) { } .brand-ico { - width: 2.15rem; - height: 2.15rem; - border-radius: 10px; + width: var(--control-h); + height: var(--control-h); + border-radius: var(--radius); display: grid; place-items: center; - background: #0f766e; + background: var(--accent-deep); color: #fff; flex: 0 0 auto; } h1 { margin: 0; - font-size: 1.2rem; + font-size: 1.15rem; font-weight: 800; color: var(--ink); overflow: hidden; @@ -140,127 +122,25 @@ h1 { white-space: nowrap; } -.view-toggle { - display: inline-flex; - background: #e8eef1; - border-radius: 12px; - padding: 0.18rem; - gap: 0.12rem; - flex: 0 0 auto; -} - -.seg { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 0.35rem; - border: 0; - background: transparent; - color: #7b8794; - font-weight: 700; - font-size: 0.82rem; - padding: 0.42rem 0.7rem; - border-radius: 10px; - cursor: pointer; - white-space: nowrap; -} - -.seg.on { - background: #fff; - color: var(--accent); - box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06); -} - -.seg:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 1px; -} - .pipeline-pick { - position: relative; - display: inline-flex; - align-items: center; - min-width: 8.5rem; flex: 0 1 13rem; + min-width: 8.5rem; } -.pipeline-pick select { - appearance: none; - border: 1px solid var(--line); - background: #fff; - border-radius: 12px; - padding: 0.52rem 2rem 0.52rem 0.85rem; - font: inherit; - font-weight: 600; - color: var(--ink); - width: 100%; +.filters { + flex: 1 1 12rem; min-width: 0; - cursor: pointer; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; } -.pipeline-pick .chev { - position: absolute; - left: 0.65rem; - pointer-events: none; +.field { + display: grid; + gap: 0.3rem; +} + +.field span { + font-size: 0.8rem; color: var(--muted); - transform: rotate(-90deg); -} - -.search { - display: flex; - align-items: center; - gap: 0.45rem; - flex: 1 1 8rem; - min-width: 6.5rem; - background: #fff; - border: 1px solid var(--line); - border-radius: 12px; - padding: 0.45rem 0.75rem; - color: var(--muted); -} - -.search input { - border: 0; - outline: none; - background: transparent; - width: 100%; - min-width: 0; - color: var(--ink); - font: inherit; -} - -.filter-btn { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 0.35rem; - border: 0; - border-radius: 12px; - padding: 0.52rem 0.95rem; - background: var(--accent); - color: #fff; font-weight: 700; - cursor: pointer; - white-space: nowrap; - flex: 0 0 auto; -} - -.filter-btn:focus-visible, -.pipeline-pick select:focus-visible, -.search:focus-within { - outline: 2px solid var(--accent-soft); - outline-offset: 1px; -} - -.sr { - position: absolute; - width: 1px; - height: 1px; - overflow: hidden; - clip: rect(0 0 0 0); } @container pipeline (max-width: 720px) { @@ -272,37 +152,9 @@ h1 { width: 100%; } - .view-toggle { - width: 100%; - } - - .seg { - flex: 1 1 0; - } - - .pipeline-pick { - flex: 1 1 auto; - min-width: 0; - } - - .search { - flex: 1 1 12rem; - } -} - -@container pipeline (max-width: 520px) { - .seg-label { - display: none; - } - - .filter-btn, - .pipeline-pick { - width: 100%; + .pipeline-pick, + .filters { flex: 1 1 100%; } - - .filter-btn { - justify-content: center; - } } diff --git a/apps/web/src/components/ui/UiButton.vue b/apps/web/src/components/ui/UiButton.vue new file mode 100644 index 0000000..8a90b31 --- /dev/null +++ b/apps/web/src/components/ui/UiButton.vue @@ -0,0 +1,76 @@ + + + + + diff --git a/apps/web/src/components/ui/UiCard.vue b/apps/web/src/components/ui/UiCard.vue new file mode 100644 index 0000000..0423d0b --- /dev/null +++ b/apps/web/src/components/ui/UiCard.vue @@ -0,0 +1,14 @@ + + + diff --git a/apps/web/src/components/ui/UiChart.vue b/apps/web/src/components/ui/UiChart.vue new file mode 100644 index 0000000..2fc080a --- /dev/null +++ b/apps/web/src/components/ui/UiChart.vue @@ -0,0 +1,54 @@ + + + + + diff --git a/apps/web/src/components/ui/UiFilterBar.vue b/apps/web/src/components/ui/UiFilterBar.vue new file mode 100644 index 0000000..35c7223 --- /dev/null +++ b/apps/web/src/components/ui/UiFilterBar.vue @@ -0,0 +1,61 @@ + + + + + diff --git a/apps/web/src/components/ui/UiInput.vue b/apps/web/src/components/ui/UiInput.vue new file mode 100644 index 0000000..b2e05a6 --- /dev/null +++ b/apps/web/src/components/ui/UiInput.vue @@ -0,0 +1,51 @@ + + + + + diff --git a/apps/web/src/components/ui/UiModal.vue b/apps/web/src/components/ui/UiModal.vue new file mode 100644 index 0000000..447bbb1 --- /dev/null +++ b/apps/web/src/components/ui/UiModal.vue @@ -0,0 +1,125 @@ + + + + + diff --git a/apps/web/src/components/ui/UiSearch.vue b/apps/web/src/components/ui/UiSearch.vue new file mode 100644 index 0000000..2307cf1 --- /dev/null +++ b/apps/web/src/components/ui/UiSearch.vue @@ -0,0 +1,62 @@ + + + + + diff --git a/apps/web/src/components/ui/UiSegmented.vue b/apps/web/src/components/ui/UiSegmented.vue new file mode 100644 index 0000000..2d7d987 --- /dev/null +++ b/apps/web/src/components/ui/UiSegmented.vue @@ -0,0 +1,65 @@ + + + + + diff --git a/apps/web/src/components/ui/UiSelect.vue b/apps/web/src/components/ui/UiSelect.vue new file mode 100644 index 0000000..6d54614 --- /dev/null +++ b/apps/web/src/components/ui/UiSelect.vue @@ -0,0 +1,54 @@ + + + + + diff --git a/apps/web/src/layouts/AppLayout.vue b/apps/web/src/layouts/AppLayout.vue index 68b7009..7442dad 100644 --- a/apps/web/src/layouts/AppLayout.vue +++ b/apps/web/src/layouts/AppLayout.vue @@ -525,7 +525,6 @@ onUnmounted(() => { type="search" placeholder="جستجو در مشتریان، معاملات و..." /> - ⌘K
@@ -594,7 +593,7 @@ onUnmounted(() => { grid-template-columns: calc(var(--sidebar-card) + var(--sidebar-gutter-start) + var(--sidebar-gutter-end)) minmax(0, 1fr); min-height: 100vh; min-height: 100dvh; - background: #eef2f6; + background: var(--bg); gap: 0; transition: grid-template-columns 0.2s ease; } @@ -1247,8 +1246,9 @@ onUnmounted(() => { align-items: center; gap: 0.7rem; background: #fff; - border-radius: 999px; - padding: 0.7rem 1.05rem; + border-radius: var(--radius); + min-height: var(--control-h); + padding: 0 0.95rem; border: 1px solid transparent; box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04); box-sizing: border-box; @@ -1265,7 +1265,7 @@ onUnmounted(() => { position: sticky; top: 0; z-index: 20; - background: rgba(238, 242, 246, 0.92); + background: color-mix(in srgb, var(--bg) 92%, transparent); backdrop-filter: blur(10px); } @@ -1275,7 +1275,7 @@ onUnmounted(() => { gap: 0.5rem; flex-shrink: 0; /* مرکز عمودی نسبت به ارتفاع جستجو (~49px) */ - min-height: 49px; + min-height: var(--control-h); } .global-search:focus-within { @@ -1302,16 +1302,6 @@ onUnmounted(() => { color: #94a3b8; } -.global-search kbd { - flex-shrink: 0; - font-size: 0.72rem; - color: #94a3b8; - background: #f8fafc; - border: 1px solid #e2e8f0; - border-radius: 8px; - padding: 0.18rem 0.45rem; -} - .round-btn { position: relative; width: 42px; @@ -1470,10 +1460,6 @@ onUnmounted(() => { .desktop-topbar { padding-inline: 1rem; } - - .global-search kbd { - display: none; - } } .burger { diff --git a/apps/web/src/lib/pipeline-board.ts b/apps/web/src/lib/pipeline-board.ts index b609ac4..15fe2ec 100644 --- a/apps/web/src/lib/pipeline-board.ts +++ b/apps/web/src/lib/pipeline-board.ts @@ -206,22 +206,64 @@ export function updateDealStage( }); } +export type PipelineBoardFilter = { + query?: string; + amountMin?: number | null; + amountMax?: number | null; +}; + export function filterStagesByQuery( stages: PipelineStage[], query: string, ): PipelineStage[] { - const q = query.trim().toLowerCase(); - if (!q) return stages; + return filterStages(stages, { query }); +} + +export function filterStages( + stages: PipelineStage[], + filter: PipelineBoardFilter, +): PipelineStage[] { + const q = filter.query?.trim().toLowerCase() ?? ''; + const min = filter.amountMin; + const max = filter.amountMax; + const hasFilter = + Boolean(q) || + (min != null && !Number.isNaN(min)) || + (max != null && !Number.isNaN(max)); + if (!hasFilter) return stages; return stages.map((stage) => ({ ...stage, - deals: stage.deals.filter( - (d) => - d.title.toLowerCase().includes(q) || - d.description.toLowerCase().includes(q), - ), + deals: stage.deals.filter((d) => { + if ( + q && + !d.title.toLowerCase().includes(q) && + !d.description.toLowerCase().includes(q) + ) { + return false; + } + if (min != null && !Number.isNaN(min) && d.amount < min) return false; + if (max != null && !Number.isNaN(max) && d.amount > max) return false; + return true; + }), })); } export function toFaDealCount(n: number): string { return `${new Intl.NumberFormat('fa-IR').format(n)} معامله`; } + +/** جابه‌جایی یک ستون به ایندکس جدید */ +export function moveStageToIndex( + stages: PipelineStage[], + stageId: string, + toIndex: number, +): PipelineStage[] { + const from = stages.findIndex((s) => s.id === stageId); + if (from < 0) return stages; + const next = [...stages]; + const [item] = next.splice(from, 1); + if (!item) return stages; + const clamped = Math.max(0, Math.min(toIndex, next.length)); + next.splice(clamped, 0, item); + return next; +} diff --git a/apps/web/src/styles/main.css b/apps/web/src/styles/main.css index 6eb1f7d..0383aa0 100644 --- a/apps/web/src/styles/main.css +++ b/apps/web/src/styles/main.css @@ -1,16 +1,19 @@ @import "./fonts.css"; :root { - --bg: #f4f7f8; + --bg: #f8fafc; --surface: #ffffff; --ink: #0f172a; --muted: #64748b; --line: #e2e8f0; - --accent: #0d9488; + --accent: #14b8a6; + --accent-deep: #0f766e; --accent-soft: #ccfbf1; --danger: #b42318; --shadow: 0 8px 24px rgba(15, 23, 42, 0.06); - --radius: 18px; + --radius: 10px; + --control-h: 36px; + --pad-modal: 24px; --font: "IRANSans", Tahoma, sans-serif; } @@ -60,12 +63,17 @@ textarea { .btn { border: 0; - border-radius: 12px; - padding: 0.75rem 1.1rem; + border-radius: var(--radius); + min-height: var(--control-h); + padding: 0 1rem; cursor: pointer; background: var(--accent); color: white; font-weight: 600; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.35rem; } .btn:disabled { @@ -97,15 +105,24 @@ textarea { } .field input, -.field select, -.field textarea { +.field select { border: 1px solid var(--line); - border-radius: 12px; - padding: 0.8rem 0.95rem; + border-radius: var(--radius); + min-height: var(--control-h); + padding: 0 0.85rem; background: #fff; width: 100%; } +.field textarea { + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 0.7rem 0.85rem; + background: #fff; + width: 100%; + min-height: 88px; +} + .field input:focus, .field select:focus, .field textarea:focus { diff --git a/apps/web/src/views/ActivitiesView.vue b/apps/web/src/views/ActivitiesView.vue index 62ddb64..29797ef 100644 --- a/apps/web/src/views/ActivitiesView.vue +++ b/apps/web/src/views/ActivitiesView.vue @@ -10,7 +10,10 @@ import { startOfLocalDay, toFaDigits, } from '@/lib/jalali'; +import UiButton from '@/components/ui/UiButton.vue'; +import UiModal from '@/components/ui/UiModal.vue'; import UiIcon from '@/components/UiIcon.vue'; +import ActivityFormView from '@/views/ActivityFormView.vue'; import type { ActivityDto, ActivityFilter, @@ -62,6 +65,10 @@ const q = ref(typeof route.query.q === 'string' ? route.query.q : ''); const typeFilter = ref('all'); const filterOpen = ref(false); const ideasOpen = ref(false); +const activityModal = ref<{ + type?: ActivityType; + title?: string; +} | null>(null); const menuId = ref(null); const counts = ref>({ today: 0, @@ -282,10 +289,10 @@ onMounted(() => {

تماس، جلسه و وظیفه — امروز و عقب‌افتاده‌ها یکجا.

- - + + + فعالیت جدید - + تقویم @@ -360,24 +367,23 @@ onMounted(() => { : 'فیلتر یا جستجو را عوض کنید، یا یک فعالیت جدید بسازید.' }}

- - + + {{ trulyEmpty ? 'اولین فعالیتت رو ثبت کن' : 'فعالیت جدید' }} - +
- + + +
@@ -553,6 +559,21 @@ onMounted(() => {

نمایش {{ toFaDigits(PAGE_SIZE) }} در هر صفحه

+ + + + @@ -792,7 +813,13 @@ h1 { width: min(100%, 320px); } -.idea-list a { +.idea-list a, +.idea-list button { + border: 0; + width: 100%; + text-align: right; + font: inherit; + cursor: pointer; background: #f8fafc; border-radius: 10px; padding: 0.65rem 0.8rem; diff --git a/apps/web/src/views/ActivityFormView.vue b/apps/web/src/views/ActivityFormView.vue index 797e814..ee5897b 100644 --- a/apps/web/src/views/ActivityFormView.vue +++ b/apps/web/src/views/ActivityFormView.vue @@ -29,11 +29,28 @@ import type { PipelineBoardDto, } from '@mento/shared'; +const props = withDefaults( + defineProps<{ + embedded?: boolean; + entityId?: string; + initialPersonId?: string; + initialType?: ActivityType; + initialTitle?: string; + }>(), + { embedded: false }, +); +const emit = defineEmits<{ saved: []; cancel: [] }>(); + const route = useRoute(); const router = useRouter(); const auth = useAuthStore(); -const id = computed(() => (typeof route.params.id === 'string' ? route.params.id : '')); -const isNew = computed(() => route.name === 'activity-new'); +const id = computed(() => { + if (props.embedded) return props.entityId ?? ''; + return typeof route.params.id === 'string' ? route.params.id : ''; +}); +const isNew = computed(() => + props.embedded ? !props.entityId : route.name === 'activity-new', +); const loading = ref(false); const saving = ref(false); @@ -190,18 +207,21 @@ async function load() { isDone.value = data.status === 'done'; } else { resetForm(); - if (typeof route.query.personId === 'string' && route.query.personId) { - personId.value = route.query.personId; - } + const incomingPerson = + props.initialPersonId || + (typeof route.query.personId === 'string' ? route.query.personId : ''); + if (incomingPerson) personId.value = incomingPerson; + const incomingType = props.initialType || route.query.type; if ( - typeof route.query.type === 'string' && - typeOptions.some((o) => o.value === route.query.type) + typeof incomingType === 'string' && + typeOptions.some((o) => o.value === incomingType) ) { - type.value = route.query.type as ActivityType; - } - if (typeof route.query.title === 'string' && route.query.title) { - title.value = route.query.title; + type.value = incomingType as ActivityType; } + const incomingTitle = + props.initialTitle || + (typeof route.query.title === 'string' ? route.query.title : ''); + if (incomingTitle) title.value = incomingTitle; if (typeof route.query.dueAt === 'string' && route.query.dueAt) { splitDue(new Date(route.query.dueAt).toISOString()); } @@ -271,6 +291,10 @@ async function submit(next: 'detail' | 'new' = 'detail') { status: isDone.value ? 'done' : 'open', }); } + if (props.embedded) { + emit('saved'); + return; + } if (next === 'new') { if (!isNew.value) { await router.push({ name: 'activity-new' }); @@ -299,6 +323,10 @@ function bumpDuration(delta: number) { } function cancel() { + if (props.embedded) { + emit('cancel'); + return; + } if (!isNew.value && id.value) { void router.push({ name: 'activity-detail', params: { id: id.value } }); return; @@ -310,8 +338,14 @@ watch([id, isNew], () => void load(), { immediate: true });