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; contacts: Ref; calendarEvents: Ref; }) { 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([]); 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(); 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, }; }