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>
This commit is contained in:
@@ -33,29 +33,15 @@ import { useChangeReview } from "~~/app/composables/useChangeReview";
|
|||||||
import { useCrmRealtime } from "~~/app/composables/useCrmRealtime";
|
import { useCrmRealtime } from "~~/app/composables/useCrmRealtime";
|
||||||
import { useWorkspaceRouting } from "~~/app/composables/useWorkspaceRouting";
|
import { useWorkspaceRouting } from "~~/app/composables/useWorkspaceRouting";
|
||||||
|
|
||||||
// Types
|
// Types from composables
|
||||||
|
import type { Contact, CommItem } from "~~/app/composables/useContacts";
|
||||||
|
import type { ContactInbox } from "~~/app/composables/useContactInboxes";
|
||||||
import type {
|
import type {
|
||||||
TabId,
|
|
||||||
CalendarView,
|
CalendarView,
|
||||||
PeopleLeftMode,
|
|
||||||
PeopleSortMode,
|
|
||||||
PeopleVisibilityMode,
|
|
||||||
FeedCard,
|
|
||||||
Contact,
|
|
||||||
CalendarEvent,
|
CalendarEvent,
|
||||||
EventLifecyclePhase,
|
EventLifecyclePhase,
|
||||||
CommItem,
|
} from "~~/app/composables/useCalendar";
|
||||||
ContactInbox,
|
|
||||||
Deal,
|
|
||||||
DealStep,
|
|
||||||
WorkspaceDocument,
|
|
||||||
PilotMessage,
|
|
||||||
PilotChangeItem,
|
|
||||||
ContextScope,
|
|
||||||
} from "~~/app/composables/crm-types";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
safeTrim,
|
|
||||||
dayKey,
|
dayKey,
|
||||||
formatDay,
|
formatDay,
|
||||||
formatTime,
|
formatTime,
|
||||||
@@ -65,7 +51,22 @@ import {
|
|||||||
isEventFinalStatus,
|
isEventFinalStatus,
|
||||||
eventRelativeLabel,
|
eventRelativeLabel,
|
||||||
eventPhaseToneClass,
|
eventPhaseToneClass,
|
||||||
} from "~~/app/composables/crm-types";
|
} from "~~/app/composables/useCalendar";
|
||||||
|
import type { Deal, DealStep } from "~~/app/composables/useDeals";
|
||||||
|
import type { WorkspaceDocument } from "~~/app/composables/useDocuments";
|
||||||
|
import type { FeedCard } from "~~/app/composables/useFeed";
|
||||||
|
import type {
|
||||||
|
PilotMessage,
|
||||||
|
PilotChangeItem,
|
||||||
|
ContextScope,
|
||||||
|
} from "~~/app/composables/usePilotChat";
|
||||||
|
import type { TabId, PeopleLeftMode } from "~~/app/composables/useWorkspaceRouting";
|
||||||
|
|
||||||
|
function safeTrim(value: unknown) { return String(value ?? "").trim(); }
|
||||||
|
|
||||||
|
type PeopleSortMode = "name" | "lastContact";
|
||||||
|
type PeopleVisibilityMode = "all" | "hidden";
|
||||||
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 1. Auth
|
// 1. Auth
|
||||||
|
|||||||
@@ -1,373 +0,0 @@
|
|||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Shared CRM types & utility functions
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export type TabId = "communications" | "documents";
|
|
||||||
export type CalendarView = "day" | "week" | "month" | "year" | "agenda";
|
|
||||||
export type SortMode = "name" | "lastContact";
|
|
||||||
export type PeopleLeftMode = "contacts" | "calendar";
|
|
||||||
export type PeopleSortMode = "name" | "lastContact";
|
|
||||||
export type PeopleVisibilityMode = "all" | "hidden";
|
|
||||||
export type DocumentSortMode = "updatedAt" | "title" | "owner";
|
|
||||||
|
|
||||||
export type FeedCard = {
|
|
||||||
id: string;
|
|
||||||
at: string;
|
|
||||||
contact: string;
|
|
||||||
text: string;
|
|
||||||
proposal: {
|
|
||||||
title: string;
|
|
||||||
details: string[];
|
|
||||||
key: "create_followup" | "open_comm" | "call" | "draft_message" | "run_summary" | "prepare_question";
|
|
||||||
};
|
|
||||||
decision: "pending" | "accepted" | "rejected";
|
|
||||||
decisionNote?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Contact = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
avatar: string;
|
|
||||||
channels: string[];
|
|
||||||
lastContactAt: string;
|
|
||||||
description: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CalendarEvent = {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
start: string;
|
|
||||||
end: string;
|
|
||||||
contact: string;
|
|
||||||
note: string;
|
|
||||||
isArchived: boolean;
|
|
||||||
createdAt: string;
|
|
||||||
archiveNote: string;
|
|
||||||
archivedAt: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type EventLifecyclePhase = "scheduled" | "due_soon" | "awaiting_outcome" | "closed";
|
|
||||||
|
|
||||||
export type CommItem = {
|
|
||||||
id: string;
|
|
||||||
at: string;
|
|
||||||
contact: string;
|
|
||||||
contactInboxId: string;
|
|
||||||
sourceExternalId: string;
|
|
||||||
sourceTitle: string;
|
|
||||||
channel: "Telegram" | "WhatsApp" | "Instagram" | "Phone" | "Email";
|
|
||||||
kind: "message" | "call";
|
|
||||||
direction: "in" | "out";
|
|
||||||
text: string;
|
|
||||||
audioUrl?: string;
|
|
||||||
duration?: string;
|
|
||||||
waveform?: number[];
|
|
||||||
transcript?: string[];
|
|
||||||
deliveryStatus?: "PENDING" | "SENT" | "DELIVERED" | "READ" | "FAILED" | string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ContactInbox = {
|
|
||||||
id: string;
|
|
||||||
contactId: string;
|
|
||||||
contactName: string;
|
|
||||||
channel: CommItem["channel"];
|
|
||||||
sourceExternalId: string;
|
|
||||||
title: string;
|
|
||||||
isHidden: boolean;
|
|
||||||
lastMessageAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CommPin = {
|
|
||||||
id: string;
|
|
||||||
contact: string;
|
|
||||||
text: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Deal = {
|
|
||||||
id: string;
|
|
||||||
contact: string;
|
|
||||||
title: string;
|
|
||||||
stage: string;
|
|
||||||
amount: string;
|
|
||||||
nextStep: string;
|
|
||||||
summary: string;
|
|
||||||
currentStepId: string;
|
|
||||||
steps: DealStep[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type DealStep = {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
status: "todo" | "in_progress" | "done" | "blocked" | string;
|
|
||||||
dueAt: string;
|
|
||||||
order: number;
|
|
||||||
completedAt: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WorkspaceDocument = {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
type: "Regulation" | "Playbook" | "Policy" | "Template";
|
|
||||||
owner: string;
|
|
||||||
scope: string;
|
|
||||||
updatedAt: string;
|
|
||||||
summary: string;
|
|
||||||
body: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ClientTimelineItem = {
|
|
||||||
id: string;
|
|
||||||
contactId: string;
|
|
||||||
contentType: "message" | "calendar_event" | "document" | "recommendation" | string;
|
|
||||||
contentId: string;
|
|
||||||
datetime: string;
|
|
||||||
message?: CommItem | null;
|
|
||||||
calendarEvent?: CalendarEvent | null;
|
|
||||||
recommendation?: FeedCard | null;
|
|
||||||
document?: WorkspaceDocument | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PilotMessage = {
|
|
||||||
id: string;
|
|
||||||
role: "user" | "assistant" | "system";
|
|
||||||
text: string;
|
|
||||||
messageKind?: string | null;
|
|
||||||
requestId?: string | null;
|
|
||||||
eventType?: string | null;
|
|
||||||
phase?: string | null;
|
|
||||||
transient?: boolean | null;
|
|
||||||
thinking?: string[] | null;
|
|
||||||
tools?: string[] | null;
|
|
||||||
toolRuns?: Array<{
|
|
||||||
name: string;
|
|
||||||
status: "ok" | "error";
|
|
||||||
input: string;
|
|
||||||
output: string;
|
|
||||||
at: string;
|
|
||||||
}> | null;
|
|
||||||
changeSetId?: string | null;
|
|
||||||
changeStatus?: "pending" | "confirmed" | "rolled_back" | null;
|
|
||||||
changeSummary?: string | null;
|
|
||||||
changeItems?: PilotChangeItem[] | null;
|
|
||||||
createdAt?: string;
|
|
||||||
_live?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PilotChangeItem = {
|
|
||||||
id: string;
|
|
||||||
entity: string;
|
|
||||||
entityId?: string | null;
|
|
||||||
action: string;
|
|
||||||
title: string;
|
|
||||||
before: string;
|
|
||||||
after: string;
|
|
||||||
rolledBack?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ContextScope = "summary" | "deal" | "message" | "calendar";
|
|
||||||
|
|
||||||
export type PilotContextPayload = {
|
|
||||||
scopes: ContextScope[];
|
|
||||||
summary?: {
|
|
||||||
contactId: string;
|
|
||||||
name: string;
|
|
||||||
};
|
|
||||||
deal?: {
|
|
||||||
dealId: string;
|
|
||||||
title: string;
|
|
||||||
contact: string;
|
|
||||||
};
|
|
||||||
message?: {
|
|
||||||
contactId?: string;
|
|
||||||
contact?: string;
|
|
||||||
intent: "add_message_or_reminder";
|
|
||||||
};
|
|
||||||
calendar?: {
|
|
||||||
view: CalendarView;
|
|
||||||
period: string;
|
|
||||||
selectedDateKey: string;
|
|
||||||
focusedEventId?: string;
|
|
||||||
eventIds: string[];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ChatConversation = {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
lastMessageAt?: string | null;
|
|
||||||
lastMessageText?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Utility functions
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export function safeTrim(value: unknown) {
|
|
||||||
return String(value ?? "").trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function dayKey(date: Date) {
|
|
||||||
const y = date.getFullYear();
|
|
||||||
const m = String(date.getMonth() + 1).padStart(2, "0");
|
|
||||||
const d = String(date.getDate()).padStart(2, "0");
|
|
||||||
return `${y}-${m}-${d}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatDay(iso: string) {
|
|
||||||
return new Intl.DateTimeFormat("en-GB", {
|
|
||||||
day: "2-digit",
|
|
||||||
month: "short",
|
|
||||||
year: "numeric",
|
|
||||||
}).format(new Date(iso));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatTime(iso: string) {
|
|
||||||
return new Intl.DateTimeFormat("en-GB", {
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
}).format(new Date(iso));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatThreadTime(iso: string) {
|
|
||||||
return new Intl.DateTimeFormat("en-GB", {
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
hour12: false,
|
|
||||||
})
|
|
||||||
.format(new Date(iso))
|
|
||||||
.replace(":", ".");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatStamp(iso: string) {
|
|
||||||
return new Intl.DateTimeFormat("en-GB", {
|
|
||||||
day: "2-digit",
|
|
||||||
month: "short",
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
}).format(new Date(iso));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function atOffset(days: number, hour: number, minute: number) {
|
|
||||||
const d = new Date();
|
|
||||||
d.setDate(d.getDate() + days);
|
|
||||||
d.setHours(hour, minute, 0, 0);
|
|
||||||
return d.toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function inMinutes(minutes: number) {
|
|
||||||
const d = new Date();
|
|
||||||
d.setMinutes(d.getMinutes() + minutes, 0, 0);
|
|
||||||
return d.toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function endAfter(startIso: string, minutes: number) {
|
|
||||||
const d = new Date(startIso);
|
|
||||||
d.setMinutes(d.getMinutes() + minutes);
|
|
||||||
return d.toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isEventFinalStatus(isArchived: boolean) {
|
|
||||||
return Boolean(isArchived);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function eventPreDueAt(event: CalendarEvent) {
|
|
||||||
return new Date(new Date(event.start).getTime() - 30 * 60 * 1000).toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function eventDueAt(event: CalendarEvent) {
|
|
||||||
return event.start;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function eventLifecyclePhase(event: CalendarEvent, nowMs: number): EventLifecyclePhase {
|
|
||||||
if (event.isArchived) return "closed";
|
|
||||||
const dueMs = new Date(eventDueAt(event)).getTime();
|
|
||||||
const preDueMs = new Date(eventPreDueAt(event)).getTime();
|
|
||||||
if (nowMs >= dueMs) return "awaiting_outcome";
|
|
||||||
if (nowMs >= preDueMs) return "due_soon";
|
|
||||||
return "scheduled";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function eventTimelineAt(event: CalendarEvent, phase: EventLifecyclePhase) {
|
|
||||||
if (phase === "scheduled") return event.createdAt || event.start;
|
|
||||||
if (phase === "due_soon") return eventPreDueAt(event);
|
|
||||||
return eventDueAt(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function eventRelativeLabel(event: CalendarEvent, nowMs: number) {
|
|
||||||
if (event.isArchived) return "Archived";
|
|
||||||
const diffMs = new Date(event.start).getTime() - nowMs;
|
|
||||||
const minuteMs = 60 * 1000;
|
|
||||||
const hourMs = 60 * minuteMs;
|
|
||||||
const dayMs = 24 * hourMs;
|
|
||||||
const abs = Math.abs(diffMs);
|
|
||||||
|
|
||||||
if (diffMs >= 0) {
|
|
||||||
if (abs >= dayMs) {
|
|
||||||
const days = Math.round(abs / dayMs);
|
|
||||||
return `Event in ${days} day${days === 1 ? "" : "s"}`;
|
|
||||||
}
|
|
||||||
if (abs >= hourMs) {
|
|
||||||
const hours = Math.round(abs / hourMs);
|
|
||||||
return `Event in ${hours} hour${hours === 1 ? "" : "s"}`;
|
|
||||||
}
|
|
||||||
const minutes = Math.max(1, Math.round(abs / minuteMs));
|
|
||||||
return `Event in ${minutes} minute${minutes === 1 ? "" : "s"}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (abs >= dayMs) {
|
|
||||||
const days = Math.round(abs / dayMs);
|
|
||||||
return `Overdue by ${days} day${days === 1 ? "" : "s"}`;
|
|
||||||
}
|
|
||||||
if (abs >= hourMs) {
|
|
||||||
const hours = Math.round(abs / hourMs);
|
|
||||||
return `Overdue by ${hours} hour${hours === 1 ? "" : "s"}`;
|
|
||||||
}
|
|
||||||
const minutes = Math.max(1, Math.round(abs / minuteMs));
|
|
||||||
return `Overdue by ${minutes} minute${minutes === 1 ? "" : "s"}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function eventPhaseToneClass(phase: EventLifecyclePhase) {
|
|
||||||
if (phase === "awaiting_outcome") return "border-warning/50 bg-warning/10";
|
|
||||||
if (phase === "due_soon") return "border-info/50 bg-info/10";
|
|
||||||
if (phase === "closed") return "border-success/40 bg-success/10";
|
|
||||||
return "border-base-300 bg-base-100";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toInputDate(date: Date) {
|
|
||||||
const y = date.getFullYear();
|
|
||||||
const m = String(date.getMonth() + 1).padStart(2, "0");
|
|
||||||
const d = String(date.getDate()).padStart(2, "0");
|
|
||||||
return `${y}-${m}-${d}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toInputTime(date: Date) {
|
|
||||||
const hh = String(date.getHours()).padStart(2, "0");
|
|
||||||
const mm = String(date.getMinutes()).padStart(2, "0");
|
|
||||||
return `${hh}:${mm}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function roundToNextQuarter(date = new Date()) {
|
|
||||||
const d = new Date(date);
|
|
||||||
d.setSeconds(0, 0);
|
|
||||||
const minutes = d.getMinutes();
|
|
||||||
const rounded = Math.ceil(minutes / 15) * 15;
|
|
||||||
if (rounded >= 60) {
|
|
||||||
d.setHours(d.getHours() + 1, 0, 0, 0);
|
|
||||||
} else {
|
|
||||||
d.setMinutes(rounded, 0, 0);
|
|
||||||
}
|
|
||||||
return d;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function roundToPrevQuarter(date = new Date()) {
|
|
||||||
const d = new Date(date);
|
|
||||||
d.setSeconds(0, 0);
|
|
||||||
const minutes = d.getMinutes();
|
|
||||||
const rounded = Math.floor(minutes / 15) * 15;
|
|
||||||
d.setMinutes(rounded, 0, 0);
|
|
||||||
return d;
|
|
||||||
}
|
|
||||||
@@ -5,21 +5,189 @@ import {
|
|||||||
CreateCalendarEventMutationDocument,
|
CreateCalendarEventMutationDocument,
|
||||||
ArchiveCalendarEventMutationDocument,
|
ArchiveCalendarEventMutationDocument,
|
||||||
} from "~~/graphql/generated";
|
} from "~~/graphql/generated";
|
||||||
import type { CalendarEvent, CalendarView } from "~/composables/crm-types";
|
|
||||||
import {
|
|
||||||
dayKey,
|
|
||||||
formatDay,
|
|
||||||
formatTime,
|
|
||||||
toInputDate,
|
|
||||||
toInputTime,
|
|
||||||
roundToNextQuarter,
|
|
||||||
roundToPrevQuarter,
|
|
||||||
isEventFinalStatus,
|
|
||||||
} from "~/composables/crm-types";
|
|
||||||
|
|
||||||
type CalendarHierarchyView = "year" | "month" | "week" | "day";
|
type CalendarHierarchyView = "year" | "month" | "week" | "day";
|
||||||
type CalendarRect = { left: number; top: number; width: number; height: number };
|
type CalendarRect = { left: number; top: number; width: number; height: number };
|
||||||
|
|
||||||
|
|
||||||
|
export type CalendarView = "day" | "week" | "month" | "year" | "agenda";
|
||||||
|
|
||||||
|
export type CalendarEvent = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
contact: string;
|
||||||
|
note: string;
|
||||||
|
isArchived: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
archiveNote: string;
|
||||||
|
archivedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EventLifecyclePhase = "scheduled" | "due_soon" | "awaiting_outcome" | "closed";
|
||||||
|
|
||||||
|
export function dayKey(date: Date) {
|
||||||
|
const y = date.getFullYear();
|
||||||
|
const m = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
|
const d = String(date.getDate()).padStart(2, "0");
|
||||||
|
return `${y}-${m}-${d}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDay(iso: string) {
|
||||||
|
return new Intl.DateTimeFormat("en-GB", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
}).format(new Date(iso));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTime(iso: string) {
|
||||||
|
return new Intl.DateTimeFormat("en-GB", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
}).format(new Date(iso));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatThreadTime(iso: string) {
|
||||||
|
return new Intl.DateTimeFormat("en-GB", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
})
|
||||||
|
.format(new Date(iso))
|
||||||
|
.replace(":", ".");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatStamp(iso: string) {
|
||||||
|
return new Intl.DateTimeFormat("en-GB", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
}).format(new Date(iso));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toInputDate(date: Date) {
|
||||||
|
const y = date.getFullYear();
|
||||||
|
const m = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
|
const d = String(date.getDate()).padStart(2, "0");
|
||||||
|
return `${y}-${m}-${d}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toInputTime(date: Date) {
|
||||||
|
const hh = String(date.getHours()).padStart(2, "0");
|
||||||
|
const mm = String(date.getMinutes()).padStart(2, "0");
|
||||||
|
return `${hh}:${mm}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function roundToNextQuarter(date = new Date()) {
|
||||||
|
const d = new Date(date);
|
||||||
|
d.setSeconds(0, 0);
|
||||||
|
const minutes = d.getMinutes();
|
||||||
|
const rounded = Math.ceil(minutes / 15) * 15;
|
||||||
|
if (rounded >= 60) {
|
||||||
|
d.setHours(d.getHours() + 1, 0, 0, 0);
|
||||||
|
} else {
|
||||||
|
d.setMinutes(rounded, 0, 0);
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function roundToPrevQuarter(date = new Date()) {
|
||||||
|
const d = new Date(date);
|
||||||
|
d.setSeconds(0, 0);
|
||||||
|
const minutes = d.getMinutes();
|
||||||
|
const rounded = Math.floor(minutes / 15) * 15;
|
||||||
|
d.setMinutes(rounded, 0, 0);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function atOffset(days: number, hour: number, minute: number) {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() + days);
|
||||||
|
d.setHours(hour, minute, 0, 0);
|
||||||
|
return d.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inMinutes(minutes: number) {
|
||||||
|
const d = new Date();
|
||||||
|
d.setMinutes(d.getMinutes() + minutes, 0, 0);
|
||||||
|
return d.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function endAfter(startIso: string, minutes: number) {
|
||||||
|
const d = new Date(startIso);
|
||||||
|
d.setMinutes(d.getMinutes() + minutes);
|
||||||
|
return d.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isEventFinalStatus(isArchived: boolean) {
|
||||||
|
return Boolean(isArchived);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function eventPreDueAt(event: CalendarEvent) {
|
||||||
|
return new Date(new Date(event.start).getTime() - 30 * 60 * 1000).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function eventDueAt(event: CalendarEvent) {
|
||||||
|
return event.start;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function eventLifecyclePhase(event: CalendarEvent, nowMs: number): EventLifecyclePhase {
|
||||||
|
if (event.isArchived) return "closed";
|
||||||
|
const dueMs = new Date(eventDueAt(event)).getTime();
|
||||||
|
const preDueMs = new Date(eventPreDueAt(event)).getTime();
|
||||||
|
if (nowMs >= dueMs) return "awaiting_outcome";
|
||||||
|
if (nowMs >= preDueMs) return "due_soon";
|
||||||
|
return "scheduled";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function eventTimelineAt(event: CalendarEvent, phase: EventLifecyclePhase) {
|
||||||
|
if (phase === "scheduled") return event.createdAt || event.start;
|
||||||
|
if (phase === "due_soon") return eventPreDueAt(event);
|
||||||
|
return eventDueAt(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function eventRelativeLabel(event: CalendarEvent, nowMs: number) {
|
||||||
|
if (event.isArchived) return "Archived";
|
||||||
|
const diffMs = new Date(event.start).getTime() - nowMs;
|
||||||
|
const minuteMs = 60 * 1000;
|
||||||
|
const hourMs = 60 * minuteMs;
|
||||||
|
const dayMs = 24 * hourMs;
|
||||||
|
const abs = Math.abs(diffMs);
|
||||||
|
|
||||||
|
if (diffMs >= 0) {
|
||||||
|
if (abs >= dayMs) {
|
||||||
|
const days = Math.round(abs / dayMs);
|
||||||
|
return `Event in ${days} day${days === 1 ? "" : "s"}`;
|
||||||
|
}
|
||||||
|
if (abs >= hourMs) {
|
||||||
|
const hours = Math.round(abs / hourMs);
|
||||||
|
return `Event in ${hours} hour${hours === 1 ? "" : "s"}`;
|
||||||
|
}
|
||||||
|
const minutes = Math.max(1, Math.round(abs / minuteMs));
|
||||||
|
return `Event in ${minutes} minute${minutes === 1 ? "" : "s"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (abs >= dayMs) {
|
||||||
|
const days = Math.round(abs / dayMs);
|
||||||
|
return `Overdue by ${days} day${days === 1 ? "" : "s"}`;
|
||||||
|
}
|
||||||
|
if (abs >= hourMs) {
|
||||||
|
const hours = Math.round(abs / hourMs);
|
||||||
|
return `Overdue by ${hours} hour${hours === 1 ? "" : "s"}`;
|
||||||
|
}
|
||||||
|
const minutes = Math.max(1, Math.round(abs / minuteMs));
|
||||||
|
return `Overdue by ${minutes} minute${minutes === 1 ? "" : "s"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function eventPhaseToneClass(phase: EventLifecyclePhase) {
|
||||||
|
if (phase === "awaiting_outcome") return "border-warning/50 bg-warning/10";
|
||||||
|
if (phase === "due_soon") return "border-info/50 bg-info/10";
|
||||||
|
if (phase === "closed") return "border-success/40 bg-success/10";
|
||||||
|
return "border-base-300 bg-base-100";
|
||||||
|
}
|
||||||
|
|
||||||
export function useCalendar(opts: { apolloAuthReady: ComputedRef<boolean> }) {
|
export function useCalendar(opts: { apolloAuthReady: ComputedRef<boolean> }) {
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Apollo query & mutation
|
// Apollo query & mutation
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import {
|
|||||||
} from "~~/graphql/generated";
|
} from "~~/graphql/generated";
|
||||||
import { useMutation } from "@vue/apollo-composable";
|
import { useMutation } from "@vue/apollo-composable";
|
||||||
import { isVoiceCaptureSupported, transcribeAudioBlob } from "~/composables/useVoiceTranscription";
|
import { isVoiceCaptureSupported, transcribeAudioBlob } from "~/composables/useVoiceTranscription";
|
||||||
import type { CommItem } from "~/composables/crm-types";
|
|
||||||
|
import type { CommItem } from "~/composables/useContacts";
|
||||||
|
|
||||||
export function useCallAudio() {
|
export function useCallAudio() {
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ import {
|
|||||||
PinsQueryDocument,
|
PinsQueryDocument,
|
||||||
DocumentsQueryDocument,
|
DocumentsQueryDocument,
|
||||||
} from "~~/graphql/generated";
|
} from "~~/graphql/generated";
|
||||||
import type { PilotMessage, PilotChangeItem } from "~/composables/crm-types";
|
|
||||||
|
import type { PilotMessage, PilotChangeItem } from "~/composables/usePilotChat";
|
||||||
|
|
||||||
export function useChangeReview(opts: {
|
export function useChangeReview(opts: {
|
||||||
pilotMessages: Ref<PilotMessage[]>;
|
pilotMessages: Ref<PilotMessage[]>;
|
||||||
|
|||||||
@@ -4,7 +4,20 @@ import {
|
|||||||
ContactInboxesQueryDocument,
|
ContactInboxesQueryDocument,
|
||||||
SetContactInboxHiddenDocument,
|
SetContactInboxHiddenDocument,
|
||||||
} from "~~/graphql/generated";
|
} from "~~/graphql/generated";
|
||||||
import type { ContactInbox } from "~/composables/crm-types";
|
|
||||||
|
import type { CommItem } from "~/composables/useContacts";
|
||||||
|
|
||||||
|
export type ContactInbox = {
|
||||||
|
id: string;
|
||||||
|
contactId: string;
|
||||||
|
contactName: string;
|
||||||
|
channel: CommItem["channel"];
|
||||||
|
sourceExternalId: string;
|
||||||
|
title: string;
|
||||||
|
isHidden: boolean;
|
||||||
|
lastMessageAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
export function useContactInboxes(opts: { apolloAuthReady: ComputedRef<boolean> }) {
|
export function useContactInboxes(opts: { apolloAuthReady: ComputedRef<boolean> }) {
|
||||||
const { result: contactInboxesResult, refetch: refetchContactInboxes } = useQuery(
|
const { result: contactInboxesResult, refetch: refetchContactInboxes } = useQuery(
|
||||||
|
|||||||
@@ -4,7 +4,35 @@ import {
|
|||||||
ContactsQueryDocument,
|
ContactsQueryDocument,
|
||||||
CommunicationsQueryDocument,
|
CommunicationsQueryDocument,
|
||||||
} from "~~/graphql/generated";
|
} from "~~/graphql/generated";
|
||||||
import type { Contact, CommItem, SortMode } from "~/composables/crm-types";
|
|
||||||
|
export type Contact = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
avatar: string;
|
||||||
|
channels: string[];
|
||||||
|
lastContactAt: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CommItem = {
|
||||||
|
id: string;
|
||||||
|
at: string;
|
||||||
|
contact: string;
|
||||||
|
contactInboxId: string;
|
||||||
|
sourceExternalId: string;
|
||||||
|
sourceTitle: string;
|
||||||
|
channel: "Telegram" | "WhatsApp" | "Instagram" | "Phone" | "Email";
|
||||||
|
kind: "message" | "call";
|
||||||
|
direction: "in" | "out";
|
||||||
|
text: string;
|
||||||
|
audioUrl?: string;
|
||||||
|
duration?: string;
|
||||||
|
waveform?: number[];
|
||||||
|
transcript?: string[];
|
||||||
|
deliveryStatus?: "PENDING" | "SENT" | "DELIVERED" | "READ" | "FAILED" | string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SortMode = "name" | "lastContact";
|
||||||
|
|
||||||
export function useContacts(opts: { apolloAuthReady: ComputedRef<boolean> }) {
|
export function useContacts(opts: { apolloAuthReady: ComputedRef<boolean> }) {
|
||||||
const { result: contactsResult, refetch: refetchContacts } = useQuery(
|
const { result: contactsResult, refetch: refetchContacts } = useQuery(
|
||||||
|
|||||||
@@ -1,8 +1,34 @@
|
|||||||
import { ref, computed, watch, type ComputedRef, type Ref } from "vue";
|
import { ref, computed, watch, type ComputedRef, type Ref } from "vue";
|
||||||
import { useQuery } from "@vue/apollo-composable";
|
import { useQuery } from "@vue/apollo-composable";
|
||||||
import { DealsQueryDocument } from "~~/graphql/generated";
|
import { DealsQueryDocument } from "~~/graphql/generated";
|
||||||
import type { Deal, DealStep, CalendarEvent, Contact } from "~/composables/crm-types";
|
|
||||||
import { safeTrim, formatDay } from "~/composables/crm-types";
|
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: {
|
export function useDeals(opts: {
|
||||||
apolloAuthReady: ComputedRef<boolean>;
|
apolloAuthReady: ComputedRef<boolean>;
|
||||||
|
|||||||
@@ -5,10 +5,24 @@ import {
|
|||||||
CreateWorkspaceDocumentDocument,
|
CreateWorkspaceDocumentDocument,
|
||||||
DeleteWorkspaceDocumentDocument,
|
DeleteWorkspaceDocumentDocument,
|
||||||
} from "~~/graphql/generated";
|
} from "~~/graphql/generated";
|
||||||
import type { WorkspaceDocument, DocumentSortMode, ClientTimelineItem } from "~/composables/crm-types";
|
|
||||||
import { safeTrim } from "~/composables/crm-types";
|
|
||||||
import { formatDocumentScope } from "~/composables/useWorkspaceDocuments";
|
import { formatDocumentScope } from "~/composables/useWorkspaceDocuments";
|
||||||
|
|
||||||
|
|
||||||
|
export type DocumentSortMode = "updatedAt" | "title" | "owner";
|
||||||
|
|
||||||
|
export type WorkspaceDocument = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
type: "Regulation" | "Playbook" | "Policy" | "Template";
|
||||||
|
owner: string;
|
||||||
|
scope: string;
|
||||||
|
updatedAt: string;
|
||||||
|
summary: string;
|
||||||
|
body: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function safeTrim(value: unknown) { return String(value ?? "").trim(); }
|
||||||
|
|
||||||
export function useDocuments(opts: { apolloAuthReady: ComputedRef<boolean> }) {
|
export function useDocuments(opts: { apolloAuthReady: ComputedRef<boolean> }) {
|
||||||
const { result: documentsResult, refetch: refetchDocuments } = useQuery(
|
const { result: documentsResult, refetch: refetchDocuments } = useQuery(
|
||||||
DocumentsQueryDocument,
|
DocumentsQueryDocument,
|
||||||
|
|||||||
@@ -12,8 +12,23 @@ import {
|
|||||||
ChatMessagesQueryDocument,
|
ChatMessagesQueryDocument,
|
||||||
ChatConversationsQueryDocument,
|
ChatConversationsQueryDocument,
|
||||||
} from "~~/graphql/generated";
|
} from "~~/graphql/generated";
|
||||||
import type { FeedCard, CalendarEvent } from "~/composables/crm-types";
|
|
||||||
import { dayKey, formatDay, formatTime } from "~/composables/crm-types";
|
import type { CalendarEvent } from "~/composables/useCalendar";
|
||||||
|
import { dayKey, formatDay, formatTime } from "~/composables/useCalendar";
|
||||||
|
|
||||||
|
export type FeedCard = {
|
||||||
|
id: string;
|
||||||
|
at: string;
|
||||||
|
contact: string;
|
||||||
|
text: string;
|
||||||
|
proposal: {
|
||||||
|
title: string;
|
||||||
|
details: string[];
|
||||||
|
key: "create_followup" | "open_comm" | "call" | "draft_message" | "run_summary" | "prepare_question";
|
||||||
|
};
|
||||||
|
decision: "pending" | "accepted" | "rejected";
|
||||||
|
decisionNote?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export function useFeed(opts: {
|
export function useFeed(opts: {
|
||||||
apolloAuthReady: ComputedRef<boolean>;
|
apolloAuthReady: ComputedRef<boolean>;
|
||||||
|
|||||||
@@ -12,17 +12,85 @@ import {
|
|||||||
import { Chat as AiChat } from "@ai-sdk/vue";
|
import { Chat as AiChat } from "@ai-sdk/vue";
|
||||||
import { DefaultChatTransport, isTextUIPart, type UIMessage } from "ai";
|
import { DefaultChatTransport, isTextUIPart, type UIMessage } from "ai";
|
||||||
import { isVoiceCaptureSupported, transcribeAudioBlob } from "~/composables/useVoiceTranscription";
|
import { isVoiceCaptureSupported, transcribeAudioBlob } from "~/composables/useVoiceTranscription";
|
||||||
import type {
|
|
||||||
PilotMessage,
|
import type { Contact } from "~/composables/useContacts";
|
||||||
ChatConversation,
|
import type { CalendarView, CalendarEvent } from "~/composables/useCalendar";
|
||||||
ContextScope,
|
import type { Deal } from "~/composables/useDeals";
|
||||||
PilotContextPayload,
|
|
||||||
CalendarView,
|
export type PilotChangeItem = {
|
||||||
Contact,
|
id: string;
|
||||||
CalendarEvent,
|
entity: string;
|
||||||
Deal,
|
entityId?: string | null;
|
||||||
} from "~/composables/crm-types";
|
action: string;
|
||||||
import { safeTrim } from "~/composables/crm-types";
|
title: string;
|
||||||
|
before: string;
|
||||||
|
after: string;
|
||||||
|
rolledBack?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PilotMessage = {
|
||||||
|
id: string;
|
||||||
|
role: "user" | "assistant" | "system";
|
||||||
|
text: string;
|
||||||
|
messageKind?: string | null;
|
||||||
|
requestId?: string | null;
|
||||||
|
eventType?: string | null;
|
||||||
|
phase?: string | null;
|
||||||
|
transient?: boolean | null;
|
||||||
|
thinking?: string[] | null;
|
||||||
|
tools?: string[] | null;
|
||||||
|
toolRuns?: Array<{
|
||||||
|
name: string;
|
||||||
|
status: "ok" | "error";
|
||||||
|
input: string;
|
||||||
|
output: string;
|
||||||
|
at: string;
|
||||||
|
}> | null;
|
||||||
|
changeSetId?: string | null;
|
||||||
|
changeStatus?: "pending" | "confirmed" | "rolled_back" | null;
|
||||||
|
changeSummary?: string | null;
|
||||||
|
changeItems?: PilotChangeItem[] | null;
|
||||||
|
createdAt?: string;
|
||||||
|
_live?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ContextScope = "summary" | "deal" | "message" | "calendar";
|
||||||
|
|
||||||
|
export type PilotContextPayload = {
|
||||||
|
scopes: ContextScope[];
|
||||||
|
summary?: {
|
||||||
|
contactId: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
deal?: {
|
||||||
|
dealId: string;
|
||||||
|
title: string;
|
||||||
|
contact: string;
|
||||||
|
};
|
||||||
|
message?: {
|
||||||
|
contactId?: string;
|
||||||
|
contact?: string;
|
||||||
|
intent: "add_message_or_reminder";
|
||||||
|
};
|
||||||
|
calendar?: {
|
||||||
|
view: CalendarView;
|
||||||
|
period: string;
|
||||||
|
selectedDateKey: string;
|
||||||
|
focusedEventId?: string;
|
||||||
|
eventIds: string[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ChatConversation = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
lastMessageAt?: string | null;
|
||||||
|
lastMessageText?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function safeTrim(value: unknown) { return String(value ?? "").trim(); }
|
||||||
|
|
||||||
export function usePilotChat(opts: {
|
export function usePilotChat(opts: {
|
||||||
apolloAuthReady: ComputedRef<boolean>;
|
apolloAuthReady: ComputedRef<boolean>;
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import { ref, computed, watch, type ComputedRef, type Ref } from "vue";
|
import { ref, computed, watch, type ComputedRef, type Ref } from "vue";
|
||||||
import { useQuery, useMutation } from "@vue/apollo-composable";
|
import { useQuery, useMutation } from "@vue/apollo-composable";
|
||||||
import { PinsQueryDocument, ToggleContactPinMutationDocument } from "~~/graphql/generated";
|
import { PinsQueryDocument, ToggleContactPinMutationDocument } from "~~/graphql/generated";
|
||||||
import type { CommPin, CalendarEvent, CommItem, ClientTimelineItem } from "~/composables/crm-types";
|
|
||||||
import {
|
import type { CommItem } from "~/composables/useContacts";
|
||||||
formatDay,
|
import type { CalendarEvent, EventLifecyclePhase } from "~/composables/useCalendar";
|
||||||
isEventFinalStatus,
|
import { formatDay, isEventFinalStatus } from "~/composables/useCalendar";
|
||||||
eventLifecyclePhase,
|
|
||||||
} from "~/composables/crm-types";
|
export type CommPin = {
|
||||||
import type { EventLifecyclePhase } from "~/composables/crm-types";
|
id: string;
|
||||||
|
contact: string;
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
export function usePins(opts: {
|
export function usePins(opts: {
|
||||||
apolloAuthReady: ComputedRef<boolean>;
|
apolloAuthReady: ComputedRef<boolean>;
|
||||||
|
|||||||
@@ -1,7 +1,23 @@
|
|||||||
import { ref, computed, watch, type ComputedRef } from "vue";
|
import { ref, computed, watch, type ComputedRef } from "vue";
|
||||||
import { useQuery } from "@vue/apollo-composable";
|
import { useQuery } from "@vue/apollo-composable";
|
||||||
import { GetClientTimelineQueryDocument } from "~~/graphql/generated";
|
import { GetClientTimelineQueryDocument } from "~~/graphql/generated";
|
||||||
import type { ClientTimelineItem } from "~/composables/crm-types";
|
|
||||||
|
import type { CommItem } from "~/composables/useContacts";
|
||||||
|
import type { CalendarEvent } from "~/composables/useCalendar";
|
||||||
|
import type { FeedCard } from "~/composables/useFeed";
|
||||||
|
import type { WorkspaceDocument } from "~/composables/useDocuments";
|
||||||
|
|
||||||
|
export type ClientTimelineItem = {
|
||||||
|
id: string;
|
||||||
|
contactId: string;
|
||||||
|
contentType: "message" | "calendar_event" | "document" | "recommendation" | string;
|
||||||
|
contentId: string;
|
||||||
|
datetime: string;
|
||||||
|
message?: CommItem | null;
|
||||||
|
calendarEvent?: CalendarEvent | null;
|
||||||
|
recommendation?: FeedCard | null;
|
||||||
|
document?: WorkspaceDocument | null;
|
||||||
|
};
|
||||||
|
|
||||||
export function useTimeline(opts: { apolloAuthReady: ComputedRef<boolean> }) {
|
export function useTimeline(opts: { apolloAuthReady: ComputedRef<boolean> }) {
|
||||||
const timelineContactId = ref("");
|
const timelineContactId = ref("");
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
import { ref, type Ref, type ComputedRef } from "vue";
|
import { ref, type Ref, type ComputedRef } from "vue";
|
||||||
import type { TabId, CalendarView, PeopleLeftMode, CalendarEvent, PilotChangeItem } from "~/composables/crm-types";
|
|
||||||
import { safeTrim, dayKey } from "~/composables/crm-types";
|
import type { CommItem } from "~/composables/useContacts";
|
||||||
|
import type { CalendarView, CalendarEvent } from "~/composables/useCalendar";
|
||||||
|
import { dayKey } from "~/composables/useCalendar";
|
||||||
|
import type { PilotChangeItem } from "~/composables/usePilotChat";
|
||||||
|
|
||||||
|
export type TabId = "communications" | "documents";
|
||||||
|
|
||||||
|
export type PeopleLeftMode = "contacts" | "calendar";
|
||||||
|
|
||||||
|
function safeTrim(value: unknown) { return String(value ?? "").trim(); }
|
||||||
|
|
||||||
export function useWorkspaceRouting(opts: {
|
export function useWorkspaceRouting(opts: {
|
||||||
selectedTab: Ref<TabId>;
|
selectedTab: Ref<TabId>;
|
||||||
|
|||||||
Reference in New Issue
Block a user