From cd70c57a3b6aab93610404012bd6ce8f52830c76 Mon Sep 17 00:00:00 2001 From: Ruslan Bakiev Date: Thu, 19 Feb 2026 12:58:24 +0700 Subject: [PATCH] Rename compose file to docker-compose.yml --- Frontend/app.vue | 87 +++++++++++++++++++++- Frontend/prisma/seed.mjs | 59 +++++++++++++-- Frontend/server/agent/langgraphCrmAgent.ts | 52 ++++++++++++- compose.yaml => docker-compose.yml | 0 4 files changed, 185 insertions(+), 13 deletions(-) rename compose.yaml => docker-compose.yml (100%) diff --git a/Frontend/app.vue b/Frontend/app.vue index 7124321..ae11d13 100644 --- a/Frontend/app.vue +++ b/Frontend/app.vue @@ -1487,6 +1487,7 @@ const peopleSortOptions: Array<{ value: PeopleSortMode; label: string }> = [ { value: "country", label: "Country" }, ]; const selectedDealId = ref(deals.value[0]?.id ?? ""); +const selectedDealStepsExpanded = ref(false); const commThreads = computed(() => { const sorted = [...commItems.value].sort((a, b) => a.at.localeCompare(b.at)); @@ -1803,6 +1804,22 @@ function formatDealHeadline(deal: Deal) { return `${title} за ${amountRaw}`; } +function getDealCurrentStep(deal: Deal) { + if (!deal.steps?.length) return null; + if (deal.currentStepId) { + const explicit = deal.steps.find((step) => step.id === deal.currentStepId); + if (explicit) return explicit; + } + const inProgress = deal.steps.find((step) => step.status === "in_progress"); + if (inProgress) return inProgress; + const nextTodo = deal.steps.find((step) => step.status !== "done"); + return nextTodo ?? deal.steps[deal.steps.length - 1]; +} + +function getDealCurrentStepLabel(deal: Deal) { + return getDealCurrentStep(deal)?.title?.trim() || deal.nextStep.trim() || deal.stage.trim() || "Без шага"; +} + function parseDateFromText(input: string) { const text = input.trim(); if (!text) return null; @@ -1847,11 +1864,33 @@ function formatDealDeadline(dueDate: Date) { return `через ${dayDiff} ${pluralizeRuDays(dayDiff)}`; } +function isDealStepDone(step: DealStep) { + return step.status === "done"; +} + +function formatDealStepMeta(step: DealStep) { + if (step.status === "done") return "выполнено"; + if (step.status === "blocked") return "заблокировано"; + if (!step.dueAt) { + if (step.status === "in_progress") return "в работе"; + return "без дедлайна"; + } + const parsed = new Date(step.dueAt); + if (Number.isNaN(parsed.getTime())) return "без дедлайна"; + return formatDealDeadline(parsed); +} + const selectedWorkspaceDealDueDate = computed(() => { const deal = selectedWorkspaceDeal.value; if (!deal) return null; - const fromNextStep = parseDateFromText(deal.nextStep); + const currentStep = getDealCurrentStep(deal); + if (currentStep?.dueAt) { + const parsed = new Date(currentStep.dueAt); + if (!Number.isNaN(parsed.getTime())) return parsed; + } + + const fromNextStep = parseDateFromText(currentStep?.title || deal.nextStep); if (fromNextStep) return fromNextStep; const now = Date.now(); @@ -1870,12 +1909,25 @@ const selectedWorkspaceDealDueDate = computed(() => { const selectedWorkspaceDealSubtitle = computed(() => { const deal = selectedWorkspaceDeal.value; if (!deal) return ""; - const stepLabel = deal.nextStep.trim() || deal.stage.trim() || "Без шага"; + const stepLabel = getDealCurrentStepLabel(deal); const dueDate = selectedWorkspaceDealDueDate.value; if (!dueDate) return `${stepLabel} · без дедлайна`; return `${stepLabel} · ${formatDealDeadline(dueDate)}`; }); +const selectedWorkspaceDealSteps = computed(() => { + const deal = selectedWorkspaceDeal.value; + if (!deal?.steps?.length) return []; + return [...deal.steps].sort((a, b) => a.order - b.order); +}); + +watch( + () => selectedWorkspaceDeal.value?.id ?? "", + () => { + selectedDealStepsExpanded.value = false; + }, +); + async function transcribeCallItem(item: CommItem) { const itemId = item.id; if (callTranscriptLoading.value[itemId]) return; @@ -1964,6 +2016,7 @@ function pushPilotNote(text: string) { function openCommunicationThread(contact: string) { selectedTab.value = "communications"; peopleLeftMode.value = "contacts"; + selectedDealStepsExpanded.value = false; const linkedContact = contacts.value.find((item) => item.name === contact); if (linkedContact) { selectedContactId.value = linkedContact.id; @@ -1980,6 +2033,7 @@ function openCommunicationThread(contact: string) { function openDealThread(deal: Deal) { selectedDealId.value = deal.id; + selectedDealStepsExpanded.value = false; openCommunicationThread(deal.contact); } @@ -2955,7 +3009,7 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected") {{ deal.amount }}

{{ deal.company }} · {{ deal.stage }}

-

{{ deal.nextStep }}

+

{{ getDealCurrentStepLabel(deal) }}

@@ -3421,6 +3475,33 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")

{{ selectedWorkspaceDealSubtitle }}

+ +
+
+ +
+

+ {{ step.title }} +

+

{{ formatDealStepMeta(step) }}

+
+
+
diff --git a/Frontend/prisma/seed.mjs b/Frontend/prisma/seed.mjs index 1f13c10..b786ca2 100644 --- a/Frontend/prisma/seed.mjs +++ b/Frontend/prisma/seed.mjs @@ -249,23 +249,68 @@ async function main() { }); const stages = ["Лид", "Уточнение", "Подбор решения", "Коммерческое предложение", "Переговоры", "Пилот", "Проверка договора"]; - await prisma.deal.createMany({ - data: contacts.map((c, idx) => ({ + for (const [idx, c] of contacts.entries()) { + const nextStepText = + idx % 4 === 0 + ? "Отправить предложение по пилоту и зафиксировать список задач интеграции." + : "Провести воркшоп по решению и согласовать сроки с коммерческим владельцем."; + + const deal = await prisma.deal.create({ + data: { teamId: team.id, contactId: c.id, title: `${c.company ?? "Клиент"}: интеграция Odoo + AI`, stage: stages[idx % stages.length], amount: 18000 + (idx % 8) * 7000, - nextStep: - idx % 4 === 0 - ? "Отправить предложение по пилоту и зафиксировать список задач интеграции." - : "Провести воркшоп по решению и согласовать сроки с коммерческим владельцем.", + nextStep: nextStepText, summary: "Потенциальная сделка на поэтапное внедрение Odoo с AI-копилотами для операций, продаж и планирования. " + "Коммерческая модель: уточнение + пилот + тиражирование.", - })), + }, + select: { id: true }, }); + const dueBase = atOffset((idx % 5) + 1, 11 + (idx % 4), 0); + const steps = [ + { + dealId: deal.id, + title: "Собрать уточняющие требования", + description: "Подтвердить модули Odoo, владельцев данных и критерии успеха.", + status: "done", + order: 1, + completedAt: atOffset(-2 - (idx % 3), 16, 0), + dueAt: atOffset(-1, 12, 0), + }, + { + dealId: deal.id, + title: "Провести воркшоп по решению", + description: "Согласовать границы интеграции и план пилота.", + status: idx % 3 === 0 ? "in_progress" : "todo", + order: 2, + dueAt: dueBase, + }, + { + dealId: deal.id, + title: "Согласовать и отправить договор", + description: "Выслать договор и зафиксировать дату подписи.", + status: "todo", + order: 3, + dueAt: atOffset((idx % 5) + 6, 15, 0), + }, + ]; + + await prisma.dealStep.createMany({ data: steps }); + const current = await prisma.dealStep.findFirst({ + where: { dealId: deal.id, status: { not: "done" } }, + orderBy: [{ order: "asc" }, { createdAt: "asc" }], + select: { id: true }, + }); + await prisma.deal.update({ + where: { id: deal.id }, + data: { currentStepId: current?.id ?? null }, + }); + } + await prisma.contactPin.createMany({ data: contacts.map((c, idx) => ({ teamId: team.id, diff --git a/Frontend/server/agent/langgraphCrmAgent.ts b/Frontend/server/agent/langgraphCrmAgent.ts index 97a59b7..bb588f7 100644 --- a/Frontend/server/agent/langgraphCrmAgent.ts +++ b/Frontend/server/agent/langgraphCrmAgent.ts @@ -200,7 +200,20 @@ async function buildCrmSnapshot(input: SnapshotOptions) { take: 4, }, deals: { - select: { id: true, title: true, stage: true, amount: true, updatedAt: true, nextStep: true, summary: true }, + select: { + id: true, + title: true, + stage: true, + amount: true, + updatedAt: true, + nextStep: true, + summary: true, + currentStepId: true, + steps: { + select: { id: true, title: true, status: true, dueAt: true, order: true, completedAt: true }, + orderBy: [{ order: "asc" }, { createdAt: "asc" }], + }, + }, orderBy: { updatedAt: "desc" }, take: 3, }, @@ -221,7 +234,10 @@ async function buildCrmSnapshot(input: SnapshotOptions) { where: { teamId: input.teamId }, orderBy: { updatedAt: "desc" }, take: 20, - include: { contact: { select: { name: true, company: true } } }, + include: { + contact: { select: { name: true, company: true } }, + steps: { select: { id: true, title: true, status: true, dueAt: true, order: true, completedAt: true }, orderBy: [{ order: "asc" }, { createdAt: "asc" }] }, + }, }), prisma.workspaceDocument.findMany({ where: { teamId: input.teamId }, @@ -270,6 +286,15 @@ async function buildCrmSnapshot(input: SnapshotOptions) { amount: d.amount, nextStep: d.nextStep, summary: d.summary, + currentStepId: d.currentStepId, + steps: d.steps.map((s) => ({ + id: s.id, + title: s.title, + status: s.status, + dueAt: s.dueAt ? iso(s.dueAt) : null, + order: s.order, + completedAt: s.completedAt ? iso(s.completedAt) : null, + })), updatedAt: iso(d.updatedAt), contact: { name: d.contact.name, @@ -310,6 +335,15 @@ async function buildCrmSnapshot(input: SnapshotOptions) { amount: d.amount, nextStep: d.nextStep, summary: d.summary, + currentStepId: d.currentStepId, + steps: d.steps.map((s) => ({ + id: s.id, + title: s.title, + status: s.status, + dueAt: s.dueAt ? iso(s.dueAt) : null, + order: s.order, + completedAt: s.completedAt ? iso(s.completedAt) : null, + })), updatedAt: iso(d.updatedAt), })), pins: c.pins.map((p) => ({ @@ -621,7 +655,10 @@ export async function runLangGraphCrmAgentFor(input: { where: { teamId: input.teamId, ...(raw.stage ? { stage: raw.stage } : {}) }, orderBy: { updatedAt: "desc" }, take: Math.max(1, Math.min(raw.limit ?? 20, 100)), - include: { contact: { select: { name: true, company: true } } }, + include: { + contact: { select: { name: true, company: true } }, + steps: { select: { id: true, title: true, status: true, dueAt: true, order: true, completedAt: true }, orderBy: [{ order: "asc" }, { createdAt: "asc" }] }, + }, }); return JSON.stringify( items.map((d) => ({ @@ -631,6 +668,15 @@ export async function runLangGraphCrmAgentFor(input: { amount: d.amount, nextStep: d.nextStep, summary: d.summary, + currentStepId: d.currentStepId, + steps: d.steps.map((s) => ({ + id: s.id, + title: s.title, + status: s.status, + dueAt: s.dueAt ? s.dueAt.toISOString() : null, + order: s.order, + completedAt: s.completedAt ? s.completedAt.toISOString() : null, + })), contact: d.contact.name, company: d.contact.company, })), diff --git a/compose.yaml b/docker-compose.yml similarity index 100% rename from compose.yaml rename to docker-compose.yml