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