Rename compose file to docker-compose.yml

This commit is contained in:
Ruslan Bakiev
2026-02-19 12:58:24 +07:00
parent 3ac487c25b
commit cd70c57a3b
4 changed files with 185 additions and 13 deletions

View File

@@ -1487,6 +1487,7 @@ const peopleSortOptions: Array<{ value: PeopleSortMode; label: string }> = [
{ value: "country", label: "Country" }, { value: "country", label: "Country" },
]; ];
const selectedDealId = ref(deals.value[0]?.id ?? ""); const selectedDealId = ref(deals.value[0]?.id ?? "");
const selectedDealStepsExpanded = ref(false);
const commThreads = computed(() => { const commThreads = computed(() => {
const sorted = [...commItems.value].sort((a, b) => a.at.localeCompare(b.at)); const sorted = [...commItems.value].sort((a, b) => a.at.localeCompare(b.at));
@@ -1803,6 +1804,22 @@ function formatDealHeadline(deal: Deal) {
return `${title} за ${amountRaw}`; 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) { function parseDateFromText(input: string) {
const text = input.trim(); const text = input.trim();
if (!text) return null; if (!text) return null;
@@ -1847,11 +1864,33 @@ function formatDealDeadline(dueDate: Date) {
return `через ${dayDiff} ${pluralizeRuDays(dayDiff)}`; 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 selectedWorkspaceDealDueDate = computed(() => {
const deal = selectedWorkspaceDeal.value; const deal = selectedWorkspaceDeal.value;
if (!deal) return null; 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; if (fromNextStep) return fromNextStep;
const now = Date.now(); const now = Date.now();
@@ -1870,12 +1909,25 @@ const selectedWorkspaceDealDueDate = computed(() => {
const selectedWorkspaceDealSubtitle = computed(() => { const selectedWorkspaceDealSubtitle = computed(() => {
const deal = selectedWorkspaceDeal.value; const deal = selectedWorkspaceDeal.value;
if (!deal) return ""; if (!deal) return "";
const stepLabel = deal.nextStep.trim() || deal.stage.trim() || "Без шага"; const stepLabel = getDealCurrentStepLabel(deal);
const dueDate = selectedWorkspaceDealDueDate.value; const dueDate = selectedWorkspaceDealDueDate.value;
if (!dueDate) return `${stepLabel} · без дедлайна`; if (!dueDate) return `${stepLabel} · без дедлайна`;
return `${stepLabel} · ${formatDealDeadline(dueDate)}`; 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) { async function transcribeCallItem(item: CommItem) {
const itemId = item.id; const itemId = item.id;
if (callTranscriptLoading.value[itemId]) return; if (callTranscriptLoading.value[itemId]) return;
@@ -1964,6 +2016,7 @@ function pushPilotNote(text: string) {
function openCommunicationThread(contact: string) { function openCommunicationThread(contact: string) {
selectedTab.value = "communications"; selectedTab.value = "communications";
peopleLeftMode.value = "contacts"; peopleLeftMode.value = "contacts";
selectedDealStepsExpanded.value = false;
const linkedContact = contacts.value.find((item) => item.name === contact); const linkedContact = contacts.value.find((item) => item.name === contact);
if (linkedContact) { if (linkedContact) {
selectedContactId.value = linkedContact.id; selectedContactId.value = linkedContact.id;
@@ -1980,6 +2033,7 @@ function openCommunicationThread(contact: string) {
function openDealThread(deal: Deal) { function openDealThread(deal: Deal) {
selectedDealId.value = deal.id; selectedDealId.value = deal.id;
selectedDealStepsExpanded.value = false;
openCommunicationThread(deal.contact); openCommunicationThread(deal.contact);
} }
@@ -2955,7 +3009,7 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
<span class="shrink-0 text-[10px] text-base-content/55">{{ deal.amount }}</span> <span class="shrink-0 text-[10px] text-base-content/55">{{ deal.amount }}</span>
</div> </div>
<p class="mt-0.5 truncate text-[11px] text-base-content/75">{{ deal.company }} · {{ deal.stage }}</p> <p class="mt-0.5 truncate text-[11px] text-base-content/75">{{ deal.company }} · {{ deal.stage }}</p>
<p class="mt-0.5 truncate text-[11px] text-base-content/60">{{ deal.nextStep }}</p> <p class="mt-0.5 truncate text-[11px] text-base-content/60">{{ getDealCurrentStepLabel(deal) }}</p>
</button> </button>
<p v-if="peopleListMode === 'contacts' && peopleContactList.length === 0" class="px-1 py-2 text-xs text-base-content/55"> <p v-if="peopleListMode === 'contacts' && peopleContactList.length === 0" class="px-1 py-2 text-xs text-base-content/55">
@@ -3421,6 +3475,33 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
<p class="mt-1 text-[11px] text-base-content/75"> <p class="mt-1 text-[11px] text-base-content/75">
{{ selectedWorkspaceDealSubtitle }} {{ selectedWorkspaceDealSubtitle }}
</p> </p>
<button
v-if="selectedWorkspaceDealSteps.length"
class="mt-2 text-[11px] font-medium text-primary hover:underline"
@click="selectedDealStepsExpanded = !selectedDealStepsExpanded"
>
{{ selectedDealStepsExpanded ? "Скрыть шаги" : `Показать шаги (${selectedWorkspaceDealSteps.length})` }}
</button>
<div v-if="selectedDealStepsExpanded && selectedWorkspaceDealSteps.length" class="mt-2 space-y-1.5">
<div
v-for="step in selectedWorkspaceDealSteps"
:key="step.id"
class="flex items-start gap-2 rounded-lg border border-base-300/70 bg-base-100/80 px-2 py-1.5"
>
<input
type="checkbox"
class="checkbox checkbox-xs mt-0.5"
:checked="isDealStepDone(step)"
disabled
>
<div class="min-w-0 flex-1">
<p class="truncate text-[11px] font-medium" :class="isDealStepDone(step) ? 'line-through text-base-content/60' : 'text-base-content/90'">
{{ step.title }}
</p>
<p class="mt-0.5 text-[10px] text-base-content/55">{{ formatDealStepMeta(step) }}</p>
</div>
</div>
</div>
</div> </div>
<div> <div>

View File

@@ -249,23 +249,68 @@ async function main() {
}); });
const stages = ["Лид", "Уточнение", "Подбор решения", "Коммерческое предложение", "Переговоры", "Пилот", "Проверка договора"]; const stages = ["Лид", "Уточнение", "Подбор решения", "Коммерческое предложение", "Переговоры", "Пилот", "Проверка договора"];
await prisma.deal.createMany({ for (const [idx, c] of contacts.entries()) {
data: contacts.map((c, idx) => ({ const nextStepText =
idx % 4 === 0
? "Отправить предложение по пилоту и зафиксировать список задач интеграции."
: "Провести воркшоп по решению и согласовать сроки с коммерческим владельцем.";
const deal = await prisma.deal.create({
data: {
teamId: team.id, teamId: team.id,
contactId: c.id, contactId: c.id,
title: `${c.company ?? "Клиент"}: интеграция Odoo + AI`, title: `${c.company ?? "Клиент"}: интеграция Odoo + AI`,
stage: stages[idx % stages.length], stage: stages[idx % stages.length],
amount: 18000 + (idx % 8) * 7000, amount: 18000 + (idx % 8) * 7000,
nextStep: nextStep: nextStepText,
idx % 4 === 0
? "Отправить предложение по пилоту и зафиксировать список задач интеграции."
: "Провести воркшоп по решению и согласовать сроки с коммерческим владельцем.",
summary: summary:
"Потенциальная сделка на поэтапное внедрение Odoo с AI-копилотами для операций, продаж и планирования. " + "Потенциальная сделка на поэтапное внедрение 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({ await prisma.contactPin.createMany({
data: contacts.map((c, idx) => ({ data: contacts.map((c, idx) => ({
teamId: team.id, teamId: team.id,

View File

@@ -200,7 +200,20 @@ async function buildCrmSnapshot(input: SnapshotOptions) {
take: 4, take: 4,
}, },
deals: { 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" }, orderBy: { updatedAt: "desc" },
take: 3, take: 3,
}, },
@@ -221,7 +234,10 @@ async function buildCrmSnapshot(input: SnapshotOptions) {
where: { teamId: input.teamId }, where: { teamId: input.teamId },
orderBy: { updatedAt: "desc" }, orderBy: { updatedAt: "desc" },
take: 20, 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({ prisma.workspaceDocument.findMany({
where: { teamId: input.teamId }, where: { teamId: input.teamId },
@@ -270,6 +286,15 @@ async function buildCrmSnapshot(input: SnapshotOptions) {
amount: d.amount, amount: d.amount,
nextStep: d.nextStep, nextStep: d.nextStep,
summary: d.summary, 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), updatedAt: iso(d.updatedAt),
contact: { contact: {
name: d.contact.name, name: d.contact.name,
@@ -310,6 +335,15 @@ async function buildCrmSnapshot(input: SnapshotOptions) {
amount: d.amount, amount: d.amount,
nextStep: d.nextStep, nextStep: d.nextStep,
summary: d.summary, 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), updatedAt: iso(d.updatedAt),
})), })),
pins: c.pins.map((p) => ({ pins: c.pins.map((p) => ({
@@ -621,7 +655,10 @@ export async function runLangGraphCrmAgentFor(input: {
where: { teamId: input.teamId, ...(raw.stage ? { stage: raw.stage } : {}) }, where: { teamId: input.teamId, ...(raw.stage ? { stage: raw.stage } : {}) },
orderBy: { updatedAt: "desc" }, orderBy: { updatedAt: "desc" },
take: Math.max(1, Math.min(raw.limit ?? 20, 100)), 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( return JSON.stringify(
items.map((d) => ({ items.map((d) => ({
@@ -631,6 +668,15 @@ export async function runLangGraphCrmAgentFor(input: {
amount: d.amount, amount: d.amount,
nextStep: d.nextStep, nextStep: d.nextStep,
summary: d.summary, 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, contact: d.contact.name,
company: d.contact.company, company: d.contact.company,
})), })),