refactor: decompose CrmWorkspaceApp.vue into 15 composables
Split the 6000+ line monolithic component into modular composables: - crm-types.ts: shared types and utility functions - useAuth, useContacts, useContactInboxes, useCalendar, useDeals, useDocuments, useFeed, useTimeline, usePilotChat, useCallAudio, usePins, useChangeReview, useCrmRealtime, useWorkspaceRouting CrmWorkspaceApp.vue is now a thin orchestrator (~2500 lines) that wires composables together with glue code, keeping template and styles intact. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
200
frontend/app/composables/useDeals.ts
Normal file
200
frontend/app/composables/useDeals.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { ref, computed, watch, type ComputedRef, type Ref } from "vue";
|
||||
import { useQuery } from "@vue/apollo-composable";
|
||||
import { DealsQueryDocument } from "~~/graphql/generated";
|
||||
import type { Deal, DealStep, CalendarEvent, Contact } from "~/composables/crm-types";
|
||||
import { safeTrim, formatDay } from "~/composables/crm-types";
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user