389 lines
13 KiB
TypeScript
389 lines
13 KiB
TypeScript
import { ref, computed, watch, type ComputedRef, type Ref } from "vue";
|
||
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";
|
||
import { formatDay } from "~/composables/useCalendar";
|
||
|
||
export type DealStep = {
|
||
id: string;
|
||
title: string;
|
||
description: string;
|
||
status: "todo" | "in_progress" | "done" | "blocked" | string;
|
||
dueAt: string;
|
||
order: number;
|
||
completedAt: string;
|
||
};
|
||
|
||
export type Deal = {
|
||
id: string;
|
||
contact: string;
|
||
title: string;
|
||
stage: string;
|
||
amount: string;
|
||
paidAmount: string;
|
||
nextStep: string;
|
||
summary: string;
|
||
currentStepId: string;
|
||
steps: DealStep[];
|
||
};
|
||
|
||
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>;
|
||
contacts: Ref<Contact[]>;
|
||
calendarEvents: Ref<CalendarEvent[]>;
|
||
}) {
|
||
const { result: dealsResult, refetch: refetchDeals } = useQuery(
|
||
DealsQueryDocument,
|
||
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 ?? "");
|
||
const selectedDealStepsExpanded = ref(false);
|
||
|
||
watch(() => dealsResult.value?.deals, (v) => {
|
||
if (v) deals.value = v as Deal[];
|
||
}, { immediate: true });
|
||
|
||
const sortedEvents = computed(() => [...opts.calendarEvents.value].sort((a, b) => a.start.localeCompare(b.start)));
|
||
|
||
const selectedWorkspaceDeal = computed(() => {
|
||
const explicit = deals.value.find((deal) => deal.id === selectedDealId.value);
|
||
if (explicit) return explicit;
|
||
|
||
const contactName = opts.contacts.value[0]?.name;
|
||
if (contactName) {
|
||
const linked = deals.value.find((deal) => deal.contact === contactName);
|
||
if (linked) return linked;
|
||
}
|
||
return null;
|
||
});
|
||
|
||
function formatDealHeadline(deal: Deal) {
|
||
const title = safeTrim(deal.title);
|
||
const amountRaw = safeTrim(deal.amount);
|
||
if (!amountRaw) return title;
|
||
|
||
const normalized = amountRaw.replace(/\s+/g, "").replace(",", ".");
|
||
if (/^\d+(\.\d+)?$/.test(normalized)) {
|
||
return `${title} за ${new Intl.NumberFormat("ru-RU").format(Number(normalized))} $`;
|
||
}
|
||
|
||
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 safeTrim(getDealCurrentStep(deal)?.title) || safeTrim(deal.nextStep) || safeTrim(deal.stage) || "Без шага";
|
||
}
|
||
|
||
function parseDateFromText(input: string) {
|
||
const text = input.trim();
|
||
if (!text) return null;
|
||
|
||
const isoMatch = text.match(/\b(\d{4})-(\d{2})-(\d{2})\b/);
|
||
if (isoMatch) {
|
||
const [, y, m, d] = isoMatch;
|
||
const parsed = new Date(Number(y), Number(m) - 1, Number(d));
|
||
if (!Number.isNaN(parsed.getTime())) return parsed;
|
||
}
|
||
|
||
const ruMatch = text.match(/\b(\d{1,2})[./](\d{1,2})[./](\d{4})\b/);
|
||
if (ruMatch) {
|
||
const [, d, m, y] = ruMatch;
|
||
const parsed = new Date(Number(y), Number(m) - 1, Number(d));
|
||
if (!Number.isNaN(parsed.getTime())) return parsed;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function pluralizeRuDays(days: number) {
|
||
const mod10 = days % 10;
|
||
const mod100 = days % 100;
|
||
if (mod10 === 1 && mod100 !== 11) return "день";
|
||
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return "дня";
|
||
return "дней";
|
||
}
|
||
|
||
function formatDealDeadline(dueDate: Date) {
|
||
const today = new Date();
|
||
const startOfToday = new Date(today.getFullYear(), today.getMonth(), today.getDate());
|
||
const startOfDue = new Date(dueDate.getFullYear(), dueDate.getMonth(), dueDate.getDate());
|
||
const dayDiff = Math.round((startOfDue.getTime() - startOfToday.getTime()) / 86_400_000);
|
||
|
||
if (dayDiff < 0) {
|
||
const overdue = Math.abs(dayDiff);
|
||
return `просрочено на ${overdue} ${pluralizeRuDays(overdue)}`;
|
||
}
|
||
if (dayDiff === 0) return "сегодня";
|
||
if (dayDiff === 1) return "завтра";
|
||
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);
|
||
}
|
||
|
||
function getDealCurrentStepMeta(deal: Deal) {
|
||
const step = getDealCurrentStep(deal);
|
||
if (!step) return "";
|
||
return formatDealStepMeta(step);
|
||
}
|
||
|
||
const selectedWorkspaceDealDueDate = computed(() => {
|
||
const deal = selectedWorkspaceDeal.value;
|
||
if (!deal) return null;
|
||
|
||
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();
|
||
const contactEvents = sortedEvents.value
|
||
.filter((event) => event.contact === deal.contact)
|
||
.map((event) => new Date(event.start))
|
||
.filter((date) => !Number.isNaN(date.getTime()))
|
||
.sort((a, b) => a.getTime() - b.getTime());
|
||
|
||
const nextUpcoming = contactEvents.find((date) => date.getTime() >= now);
|
||
if (nextUpcoming) return nextUpcoming;
|
||
|
||
return contactEvents.length ? contactEvents[contactEvents.length - 1] : null;
|
||
});
|
||
|
||
const selectedWorkspaceDealSubtitle = computed(() => {
|
||
const deal = selectedWorkspaceDeal.value;
|
||
if (!deal) return "";
|
||
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;
|
||
},
|
||
);
|
||
|
||
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,
|
||
selectedDealStepsExpanded,
|
||
selectedWorkspaceDeal,
|
||
selectedWorkspaceDealDueDate,
|
||
selectedWorkspaceDealSubtitle,
|
||
selectedWorkspaceDealSteps,
|
||
formatDealHeadline,
|
||
getDealCurrentStep,
|
||
getDealCurrentStepLabel,
|
||
getDealCurrentStepMeta,
|
||
formatDealDeadline,
|
||
isDealStepDone,
|
||
formatDealStepMeta,
|
||
dealStageOptions,
|
||
createDealForContact,
|
||
dealCreateLoading,
|
||
updateDealDetails,
|
||
dealUpdateLoading,
|
||
refetchDeals,
|
||
};
|
||
}
|