feat(crm): add deal create/update controls with status and payment
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user