Files
clientsflow/frontend/app/composables/useDeals.ts
Ruslan Bakiev d892d0c604 refactor: distribute types from crm-types.ts to owning composables
Each composable now owns its types and exports them. Other composables
import types from the owning composable. Deleted centralized crm-types.ts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 15:21:30 +07:00

227 lines
7.2 KiB
TypeScript

import { ref, computed, watch, type ComputedRef, type Ref } from "vue";
import { useQuery } from "@vue/apollo-composable";
import { DealsQueryDocument } 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;
nextStep: string;
summary: string;
currentStepId: string;
steps: DealStep[];
};
function safeTrim(value: unknown) { return String(value ?? "").trim(); }
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 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;
},
);
return {
deals,
selectedDealId,
selectedDealStepsExpanded,
selectedWorkspaceDeal,
selectedWorkspaceDealDueDate,
selectedWorkspaceDealSubtitle,
selectedWorkspaceDealSteps,
formatDealHeadline,
getDealCurrentStep,
getDealCurrentStepLabel,
getDealCurrentStepMeta,
formatDealDeadline,
isDealStepDone,
formatDealStepMeta,
refetchDeals,
};
}