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>
227 lines
7.2 KiB
TypeScript
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,
|
|
};
|
|
}
|