Files
clientsflow/frontend/app/composables/useDeals.ts

389 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
};
}