feat(crm): add deal create/update controls with status and payment

This commit is contained in:
Ruslan Bakiev
2026-02-27 09:44:15 +07:00
parent 881a8c6d39
commit 12af9979ab
13 changed files with 907 additions and 94 deletions

View File

@@ -1,6 +1,6 @@
import { ref, computed, watch, type ComputedRef, type Ref } from "vue";
import { useQuery } from "@vue/apollo-composable";
import { DealsQueryDocument } from "~~/graphql/generated";
import { useQuery, useMutation } from "@vue/apollo-composable";
import { CreateDealMutationDocument, DealsQueryDocument, UpdateDealMutationDocument } from "~~/graphql/generated";
import type { Contact } from "~/composables/useContacts";
import type { CalendarEvent } from "~/composables/useCalendar";
@@ -22,6 +22,7 @@ export type Deal = {
title: string;
stage: string;
amount: string;
paidAmount: string;
nextStep: string;
summary: string;
currentStepId: string;
@@ -29,6 +30,26 @@ export type Deal = {
};
function safeTrim(value: unknown) { return String(value ?? "").trim(); }
const DEFAULT_DEAL_STAGES = ["Новый", "Квалификация", "Переговоры", "Согласование", "Выиграно", "Проиграно"];
function parseMoneyInput(value: unknown, fieldLabel: "Сумма" | "Оплачено") {
const normalized = safeTrim(value).replace(/\s+/g, "").replace(",", ".");
if (!normalized) return null;
if (!/^\d+(\.\d+)?$/.test(normalized)) {
throw new Error(`${fieldLabel} должно быть числом`);
}
const num = Number(normalized);
if (!Number.isFinite(num)) {
throw new Error(`${fieldLabel} заполнено некорректно`);
}
if (!Number.isInteger(num)) {
throw new Error(`${fieldLabel} должно быть целым числом`);
}
if (num < 0) {
throw new Error(`${fieldLabel} не может быть отрицательным`);
}
return num;
}
export function useDeals(opts: {
apolloAuthReady: ComputedRef<boolean>;
@@ -40,6 +61,12 @@ export function useDeals(opts: {
null,
{ enabled: opts.apolloAuthReady },
);
const { mutate: doUpdateDeal, loading: dealUpdateLoading } = useMutation(UpdateDealMutationDocument, {
refetchQueries: [{ query: DealsQueryDocument }],
});
const { mutate: doCreateDeal, loading: dealCreateLoading } = useMutation(CreateDealMutationDocument, {
refetchQueries: [{ query: DealsQueryDocument }],
});
const deals = ref<Deal[]>([]);
const selectedDealId = ref(deals.value[0]?.id ?? "");
@@ -206,6 +233,136 @@ export function useDeals(opts: {
},
);
const dealStageOptions = computed(() => {
const unique = new Set<string>();
for (const stage of DEFAULT_DEAL_STAGES) unique.add(stage);
for (const deal of deals.value) {
const stage = safeTrim(deal.stage);
if (stage) unique.add(stage);
}
return [...unique];
});
async function updateDealDetails(input: {
dealId: string;
stage: string;
amount: string;
paidAmount: string;
}) {
const dealId = safeTrim(input.dealId);
if (!dealId) throw new Error("Не выбрана сделка");
const current = deals.value.find((deal) => deal.id === dealId);
if (!current) throw new Error("Сделка не найдена");
const stage = safeTrim(input.stage);
if (!stage) throw new Error("Статус обязателен");
const amount = parseMoneyInput(input.amount, "Сумма");
const paidAmount = parseMoneyInput(input.paidAmount, "Оплачено");
if (amount === null && paidAmount !== null) {
throw new Error("Нельзя указать 'Оплачено' без поля 'Сумма'");
}
if (amount !== null && paidAmount !== null && paidAmount > amount) {
throw new Error("'Оплачено' не может быть больше 'Сумма'");
}
const payload: {
id: string;
stage?: string;
amount?: number | null;
paidAmount?: number | null;
} = { id: dealId };
let changed = false;
if (stage !== current.stage) {
payload.stage = stage;
changed = true;
}
if (amount !== parseMoneyInput(current.amount, "Сумма")) {
payload.amount = amount;
changed = true;
}
if (paidAmount !== parseMoneyInput(current.paidAmount, "Оплачено")) {
payload.paidAmount = paidAmount;
changed = true;
}
if (!changed) return false;
const res = await doUpdateDeal({ input: payload });
const updated = res?.data?.updateDeal as Deal | undefined;
if (updated) {
deals.value = deals.value.map((deal) => (deal.id === updated.id ? updated : deal));
} else {
await refetchDeals();
}
return true;
}
async function createDealForContact(input: {
contactId: string;
title: string;
stage: string;
amount: string;
paidAmount: string;
nextStep?: string;
summary?: string;
}) {
const contactId = safeTrim(input.contactId);
if (!contactId) throw new Error("Не выбран контакт");
const title = safeTrim(input.title);
if (!title) throw new Error("Название сделки обязательно");
const stage = safeTrim(input.stage) || DEFAULT_DEAL_STAGES[0]!;
if (!stage) throw new Error("Статус обязателен");
const amount = parseMoneyInput(input.amount, "Сумма");
const paidAmount = parseMoneyInput(input.paidAmount, "Оплачено");
if (amount === null && paidAmount !== null) {
throw new Error("Нельзя указать 'Оплачено' без поля 'Сумма'");
}
if (amount !== null && paidAmount !== null && paidAmount > amount) {
throw new Error("'Оплачено' не может быть больше 'Сумма'");
}
const payload: {
contactId: string;
title: string;
stage: string;
amount?: number | null;
paidAmount?: number | null;
nextStep?: string;
summary?: string;
} = {
contactId,
title,
stage,
};
if (input.amount.trim()) payload.amount = amount;
if (input.paidAmount.trim()) payload.paidAmount = paidAmount;
const nextStep = safeTrim(input.nextStep);
if (nextStep) payload.nextStep = nextStep;
const summary = safeTrim(input.summary);
if (summary) payload.summary = summary;
const res = await doCreateDeal({ input: payload });
const created = res?.data?.createDeal as Deal | undefined;
if (created) {
deals.value = [created, ...deals.value.filter((deal) => deal.id !== created.id)];
selectedDealId.value = created.id;
selectedDealStepsExpanded.value = false;
return created;
}
await refetchDeals();
const refreshed = deals.value.find((deal) => deal.title === title && deal.stage === stage && deal.contact);
if (refreshed) {
selectedDealId.value = refreshed.id;
selectedDealStepsExpanded.value = false;
}
return refreshed ?? null;
}
return {
deals,
selectedDealId,
@@ -221,6 +378,11 @@ export function useDeals(opts: {
formatDealDeadline,
isDealStepDone,
formatDealStepMeta,
dealStageOptions,
createDealForContact,
dealCreateLoading,
updateDealDetails,
dealUpdateLoading,
refetchDeals,
};
}