chore(repo): split frontend backend backend_worker into submodules

This commit is contained in:
Ruslan Bakiev
2026-03-09 10:23:40 +07:00
parent e96b57a55f
commit 2ca1e75651
155 changed files with 15 additions and 55944 deletions

12
.gitmodules vendored
View File

@@ -1,3 +1,15 @@
[submodule "instructions"]
path = instructions
url = git@gitea.dsrptlab.com:dsrptlab/instructions.git
[submodule "frontend"]
path = frontend
url = git@gitea.dsrptlab.com:clientflow/frontend.git
branch = main
[submodule "backend"]
path = backend
url = git@gitea.dsrptlab.com:clientflow/backend.git
branch = main
[submodule "backend_worker"]
path = backend_worker
url = git@gitea.dsrptlab.com:clientflow/backend_worker.git
branch = main

1
backend Submodule

Submodule backend added at 42e9dc7bcb

View File

@@ -1,14 +0,0 @@
FROM node:22-alpine
WORKDIR /app
RUN apk add --no-cache curl jq
COPY package*.json ./
COPY prisma ./prisma
RUN npm ci
RUN DATABASE_URL="postgresql://postgres:postgres@127.0.0.1:5432/postgres?schema=public" npx prisma generate
COPY . .
CMD ["sh", "-lc", ". /app/scripts/load-vault-env.sh && npm run start"]

View File

@@ -1,40 +0,0 @@
# backend
Core CRM/omni-домен с единственной Prisma-базой.
## Назначение
- принимает входящие telegram-события через GraphQL mutation `ingestTelegramInbound`;
- создает исходящую задачу через GraphQL mutation `requestTelegramOutbound``telegram_backend`, далее в Hatchet);
- принимает отчет о доставке через GraphQL mutation `reportTelegramOutbound`.
- выполняет sync календарных предзаписей через GraphQL mutation `syncCalendarPredueTimeline`.
## API
- `GET /health`
- `POST /graphql`
## GraphQL auth
Если задан `BACKEND_GRAPHQL_SHARED_SECRET`, запросы на `/graphql` должны содержать заголовок:
- `x-graphql-secret: <BACKEND_GRAPHQL_SHARED_SECRET>`
## Переменные окружения
- `PORT` (default: `8090`)
- `MAX_BODY_SIZE_BYTES` (default: `2097152`)
- `BACKEND_GRAPHQL_SHARED_SECRET` (optional)
- `TELEGRAM_BACKEND_GRAPHQL_URL` (required для `requestTelegramOutbound`)
- `TELEGRAM_BACKEND_GRAPHQL_SHARED_SECRET` (optional)
- `DEFAULT_TEAM_ID` (optional fallback для inbound маршрутизации)
- `TIMELINE_EVENT_PREDUE_MINUTES` (default: `30`)
- `TIMELINE_EVENT_LOOKBACK_MINUTES` (default: `180`)
- `TIMELINE_EVENT_LOOKAHEAD_MINUTES` (default: `1440`)
- `TIMELINE_SCHEDULER_LOCK_KEY` (default: `603001`)
## Prisma policy
- Источник схемы: `frontend/prisma/schema.prisma`.
- Локальная копия в `backend/prisma/schema.prisma` обновляется только через `scripts/prisma-sync.sh`.
- Миграции/`db push` выполняются только в `frontend`.

1025
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,19 +0,0 @@
{
"name": "crm-backend",
"private": true,
"type": "module",
"scripts": {
"start": "tsx src/index.ts",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@types/node": "^22.13.9",
"prisma": "^6.16.1",
"tsx": "^4.20.5",
"typescript": "^5.9.2"
},
"dependencies": {
"@prisma/client": "^6.16.1",
"graphql": "^16.13.1"
}
}

View File

@@ -1,483 +0,0 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum TeamRole {
OWNER
MEMBER
}
enum MessageDirection {
IN
OUT
}
enum MessageChannel {
TELEGRAM
WHATSAPP
INSTAGRAM
PHONE
EMAIL
INTERNAL
}
enum ContactMessageKind {
MESSAGE
CALL
}
enum ChatRole {
USER
ASSISTANT
SYSTEM
}
enum OmniMessageStatus {
PENDING
SENT
FAILED
DELIVERED
READ
}
enum FeedCardDecision {
PENDING
ACCEPTED
REJECTED
}
enum WorkspaceDocumentType {
Regulation
Playbook
Policy
Template
}
enum ClientTimelineContentType {
CALENDAR_EVENT
DOCUMENT
RECOMMENDATION
}
model Team {
id String @id @default(cuid())
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
members TeamMember[]
contacts Contact[]
calendarEvents CalendarEvent[]
deals Deal[]
aiConversations AiConversation[]
aiMessages AiMessage[]
omniThreads OmniThread[]
omniMessages OmniMessage[]
omniIdentities OmniContactIdentity[]
telegramBusinessConnections TelegramBusinessConnection[]
feedCards FeedCard[]
contactPins ContactPin[]
documents WorkspaceDocument[]
clientTimelineEntries ClientTimelineEntry[]
contactInboxes ContactInbox[]
contactInboxPreferences ContactInboxPreference[]
contactThreadReads ContactThreadRead[]
}
model User {
id String @id @default(cuid())
phone String @unique
passwordHash String
email String? @unique
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
memberships TeamMember[]
aiConversations AiConversation[] @relation("ConversationCreator")
aiMessages AiMessage[] @relation("ChatAuthor")
contactInboxPreferences ContactInboxPreference[]
contactThreadReads ContactThreadRead[]
}
model TeamMember {
id String @id @default(cuid())
teamId String
userId String
role TeamRole @default(MEMBER)
createdAt DateTime @default(now())
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([teamId, userId])
@@index([userId])
}
model Contact {
id String @id @default(cuid())
teamId String
name String
avatarUrl String?
email String?
phone String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
note ContactNote?
messages ContactMessage[]
events CalendarEvent[]
deals Deal[]
feedCards FeedCard[]
pins ContactPin[]
omniThreads OmniThread[]
omniMessages OmniMessage[]
omniIdentities OmniContactIdentity[]
contactInboxes ContactInbox[]
clientTimelineEntries ClientTimelineEntry[]
contactThreadReads ContactThreadRead[]
@@index([teamId, updatedAt])
}
model ContactThreadRead {
id String @id @default(cuid())
teamId String
userId String
contactId String
readAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
@@unique([userId, contactId])
@@index([teamId, userId])
}
model ContactNote {
id String @id @default(cuid())
contactId String @unique
content String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
}
model ContactMessage {
id String @id @default(cuid())
contactId String
contactInboxId String?
kind ContactMessageKind @default(MESSAGE)
direction MessageDirection
channel MessageChannel
content String
audioUrl String?
durationSec Int?
waveformJson Json?
transcriptJson Json?
occurredAt DateTime @default(now())
createdAt DateTime @default(now())
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
contactInbox ContactInbox? @relation(fields: [contactInboxId], references: [id], onDelete: SetNull)
@@index([contactId, occurredAt])
@@index([contactInboxId, occurredAt])
}
model ContactInbox {
id String @id @default(cuid())
teamId String
contactId String
channel MessageChannel
sourceExternalId String
title String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
messages ContactMessage[]
preferences ContactInboxPreference[]
@@unique([teamId, channel, sourceExternalId])
@@index([contactId, updatedAt])
@@index([teamId, updatedAt])
}
model ContactInboxPreference {
id String @id @default(cuid())
teamId String
userId String
contactInboxId String
isHidden Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
contactInbox ContactInbox @relation(fields: [contactInboxId], references: [id], onDelete: Cascade)
@@unique([userId, contactInboxId])
@@index([teamId, userId, isHidden])
}
model OmniContactIdentity {
id String @id @default(cuid())
teamId String
contactId String
channel MessageChannel
externalId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
@@unique([teamId, channel, externalId])
@@index([contactId])
@@index([teamId, updatedAt])
}
model OmniThread {
id String @id @default(cuid())
teamId String
contactId String
channel MessageChannel
externalChatId String
businessConnectionId String?
title String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
messages OmniMessage[]
@@unique([teamId, channel, externalChatId, businessConnectionId])
@@index([teamId, updatedAt])
@@index([contactId, updatedAt])
}
model OmniMessage {
id String @id @default(cuid())
teamId String
contactId String
threadId String
direction MessageDirection
channel MessageChannel
status OmniMessageStatus @default(PENDING)
text String
providerMessageId String?
providerUpdateId String?
rawJson Json?
occurredAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
thread OmniThread @relation(fields: [threadId], references: [id], onDelete: Cascade)
@@unique([threadId, providerMessageId])
@@index([teamId, occurredAt])
@@index([threadId, occurredAt])
}
model TelegramBusinessConnection {
id String @id @default(cuid())
teamId String
businessConnectionId String
isEnabled Boolean?
canReply Boolean?
rawJson Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
@@unique([teamId, businessConnectionId])
@@index([teamId, updatedAt])
}
model CalendarEvent {
id String @id @default(cuid())
teamId String
contactId String?
title String
startsAt DateTime
endsAt DateTime?
note String?
isArchived Boolean @default(false)
archiveNote String?
archivedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
contact Contact? @relation(fields: [contactId], references: [id], onDelete: SetNull)
@@index([startsAt])
@@index([contactId, startsAt])
@@index([teamId, startsAt])
}
model Deal {
id String @id @default(cuid())
teamId String
contactId String
title String
stage String
amount Int?
paidAmount Int?
nextStep String?
summary String?
currentStepId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
steps DealStep[]
@@index([teamId, updatedAt])
@@index([contactId, updatedAt])
@@index([currentStepId])
}
model DealStep {
id String @id @default(cuid())
dealId String
title String
description String?
status String @default("todo")
dueAt DateTime?
order Int @default(0)
completedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deal Deal @relation(fields: [dealId], references: [id], onDelete: Cascade)
@@index([dealId, order])
@@index([status, dueAt])
}
model AiConversation {
id String @id @default(cuid())
teamId String
createdByUserId String
title String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
createdByUser User @relation("ConversationCreator", fields: [createdByUserId], references: [id], onDelete: Cascade)
messages AiMessage[]
@@index([teamId, updatedAt])
@@index([createdByUserId])
@@map("ChatConversation")
}
model AiMessage {
id String @id @default(cuid())
teamId String
conversationId String
authorUserId String?
role ChatRole
text String
planJson Json?
createdAt DateTime @default(now())
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
conversation AiConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
authorUser User? @relation("ChatAuthor", fields: [authorUserId], references: [id], onDelete: SetNull)
@@index([createdAt])
@@index([teamId, createdAt])
@@index([conversationId, createdAt])
@@map("ChatMessage")
}
model FeedCard {
id String @id @default(cuid())
teamId String
contactId String?
happenedAt DateTime
text String
proposalJson Json
decision FeedCardDecision @default(PENDING)
decisionNote String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
contact Contact? @relation(fields: [contactId], references: [id], onDelete: SetNull)
@@index([teamId, happenedAt])
@@index([contactId, happenedAt])
}
model ContactPin {
id String @id @default(cuid())
teamId String
contactId String
text String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
@@index([teamId, updatedAt])
@@index([contactId, updatedAt])
}
model WorkspaceDocument {
id String @id @default(cuid())
teamId String
title String
type WorkspaceDocumentType
owner String
scope String
summary String
body String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
@@index([teamId, updatedAt])
}
model ClientTimelineEntry {
id String @id @default(cuid())
teamId String
contactId String
contentType ClientTimelineContentType
contentId String
datetime DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
@@unique([teamId, contentType, contentId])
@@index([teamId, contactId, datetime])
@@index([contactId, datetime])
}

View File

@@ -1,60 +0,0 @@
#!/bin/sh
set -eu
log() {
printf '%s\n' "$*" >&2
}
VAULT_ENABLED="${VAULT_ENABLED:-auto}"
if [ "$VAULT_ENABLED" = "false" ] || [ "$VAULT_ENABLED" = "0" ]; then
exit 0
fi
if [ -z "${VAULT_ADDR:-}" ] || [ -z "${VAULT_TOKEN:-}" ]; then
if [ "$VAULT_ENABLED" = "true" ] || [ "$VAULT_ENABLED" = "1" ]; then
log "Vault bootstrap is required but VAULT_ADDR or VAULT_TOKEN is missing."
exit 1
fi
exit 0
fi
if ! command -v curl >/dev/null 2>&1 || ! command -v jq >/dev/null 2>&1; then
log "Vault bootstrap requires curl and jq."
exit 1
fi
VAULT_KV_MOUNT="${VAULT_KV_MOUNT:-secret}"
load_secret_path() {
path="$1"
source_name="$2"
if [ -z "$path" ]; then
return 0
fi
url="${VAULT_ADDR%/}/v1/${VAULT_KV_MOUNT}/data/${path}"
response="$(curl -fsS -H "X-Vault-Token: $VAULT_TOKEN" "$url")" || {
log "Failed to load Vault path ${VAULT_KV_MOUNT}/${path}."
return 1
}
encoded_items="$(printf '%s' "$response" | jq -r '.data.data // {} | to_entries[]? | @base64')"
if [ -z "$encoded_items" ]; then
return 0
fi
old_ifs="${IFS}"
IFS='
'
for encoded_item in $encoded_items; do
key="$(printf '%s' "$encoded_item" | base64 -d | jq -r '.key')"
value="$(printf '%s' "$encoded_item" | base64 -d | jq -r '.value | tostring')"
export "$key=$value"
done
IFS="${old_ifs}"
log "Loaded Vault ${source_name} secrets from ${VAULT_KV_MOUNT}/${path}."
}
load_secret_path "${VAULT_SHARED_PATH:-}" "shared"
load_secret_path "${VAULT_PROJECT_PATH:-}" "project"

View File

@@ -1,38 +0,0 @@
import { startServer } from "./server";
import { prisma } from "./utils/prisma";
const server = startServer();
async function shutdown(signal: string) {
console.log(`[backend] shutting down by ${signal}`);
try {
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
} catch {
// ignore shutdown errors
}
try {
await prisma.$disconnect();
} catch {
// ignore shutdown errors
}
process.exit(0);
}
process.on("SIGINT", () => {
void shutdown("SIGINT");
});
process.on("SIGTERM", () => {
void shutdown("SIGTERM");
});

View File

@@ -1,265 +0,0 @@
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { buildSchema, graphql } from "graphql";
import {
ingestTelegramInbound,
reportTelegramOutbound,
requestTelegramOutbound,
syncCalendarPredueTimeline,
type TelegramInboundEnvelope,
type TelegramOutboundReport,
type TelegramOutboundRequest,
} from "./service";
const PORT = Number(process.env.PORT || 8090);
const MAX_BODY_SIZE_BYTES = Number(process.env.MAX_BODY_SIZE_BYTES || 2 * 1024 * 1024);
const GRAPHQL_SHARED_SECRET = String(process.env.BACKEND_GRAPHQL_SHARED_SECRET || "").trim();
const schema = buildSchema(`
type Query {
health: Health!
}
type Health {
ok: Boolean!
service: String!
now: String!
}
type MutationResult {
ok: Boolean!
message: String!
runId: String
omniMessageId: String
}
type SchedulerSyncResult {
ok: Boolean!
message: String!
now: String!
scanned: Int!
updated: Int!
skippedBeforeWindow: Int!
skippedLocked: Boolean!
preDueMinutes: Int!
lookbackMinutes: Int!
lookaheadMinutes: Int!
lockKey: Int!
}
input TelegramInboundInput {
version: Int!
idempotencyKey: String!
provider: String!
channel: String!
direction: String!
providerEventId: String!
providerMessageId: String
eventType: String!
occurredAt: String!
receivedAt: String!
payloadRawJson: String!
payloadNormalizedJson: String!
}
input TelegramOutboundReportInput {
omniMessageId: String!
status: String!
providerMessageId: String
error: String
responseJson: String
}
input TelegramOutboundTaskInput {
omniMessageId: String!
chatId: String!
text: String!
businessConnectionId: String
}
type Mutation {
ingestTelegramInbound(input: TelegramInboundInput!): MutationResult!
reportTelegramOutbound(input: TelegramOutboundReportInput!): MutationResult!
requestTelegramOutbound(input: TelegramOutboundTaskInput!): MutationResult!
syncCalendarPredueTimeline: SchedulerSyncResult!
}
`);
function writeJson(res: ServerResponse, statusCode: number, body: unknown) {
res.statusCode = statusCode;
res.setHeader("content-type", "application/json; charset=utf-8");
res.end(JSON.stringify(body));
}
function isGraphqlAuthorized(req: IncomingMessage) {
if (!GRAPHQL_SHARED_SECRET) return true;
const incoming = String(req.headers["x-graphql-secret"] || "").trim();
return incoming !== "" && incoming === GRAPHQL_SHARED_SECRET;
}
async function readJsonBody(req: IncomingMessage): Promise<unknown> {
const chunks: Buffer[] = [];
let total = 0;
for await (const chunk of req) {
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
total += buf.length;
if (total > MAX_BODY_SIZE_BYTES) {
throw new Error(`payload_too_large:${MAX_BODY_SIZE_BYTES}`);
}
chunks.push(buf);
}
const raw = Buffer.concat(chunks).toString("utf8");
if (!raw) return {};
return JSON.parse(raw);
}
function parseJsonField<T>(raw: string, fieldName: string): T {
try {
return JSON.parse(raw) as T;
} catch {
throw new Error(`${fieldName} must be valid JSON string`);
}
}
const root = {
health: () => ({
ok: true,
service: "backend",
now: new Date().toISOString(),
}),
ingestTelegramInbound: async ({ input }: { input: any }) => {
const envelope: TelegramInboundEnvelope = {
version: Number(input.version ?? 1),
idempotencyKey: String(input.idempotencyKey ?? ""),
provider: String(input.provider ?? ""),
channel: String(input.channel ?? ""),
direction: String(input.direction ?? "IN") === "OUT" ? "OUT" : "IN",
providerEventId: String(input.providerEventId ?? ""),
providerMessageId: input.providerMessageId != null ? String(input.providerMessageId) : null,
eventType: String(input.eventType ?? ""),
occurredAt: String(input.occurredAt ?? new Date().toISOString()),
receivedAt: String(input.receivedAt ?? new Date().toISOString()),
payloadRaw: parseJsonField(input.payloadRawJson, "payloadRawJson"),
payloadNormalized: parseJsonField(input.payloadNormalizedJson, "payloadNormalizedJson"),
};
const result = await ingestTelegramInbound(envelope);
return {
ok: result.ok,
message: result.message,
omniMessageId: (result as any).omniMessageId ?? null,
runId: null,
};
},
reportTelegramOutbound: async ({ input }: { input: any }) => {
const payload: TelegramOutboundReport = {
omniMessageId: String(input.omniMessageId ?? ""),
status: String(input.status ?? "FAILED"),
providerMessageId: input.providerMessageId != null ? String(input.providerMessageId) : null,
error: input.error != null ? String(input.error) : null,
responseJson: input.responseJson != null ? String(input.responseJson) : null,
};
const result = await reportTelegramOutbound(payload);
return {
ok: result.ok,
message: result.message,
runId: null,
omniMessageId: null,
};
},
requestTelegramOutbound: async ({ input }: { input: any }) => {
const payload: TelegramOutboundRequest = {
omniMessageId: String(input.omniMessageId ?? ""),
chatId: String(input.chatId ?? ""),
text: String(input.text ?? ""),
businessConnectionId: input.businessConnectionId != null ? String(input.businessConnectionId) : null,
};
const result = await requestTelegramOutbound(payload);
return {
ok: result.ok,
message: result.message,
runId: result.runId ?? null,
omniMessageId: null,
};
},
syncCalendarPredueTimeline: async () => {
const result = await syncCalendarPredueTimeline();
return {
ok: result.ok,
message: result.message,
now: result.now,
scanned: result.scanned,
updated: result.updated,
skippedBeforeWindow: result.skippedBeforeWindow,
skippedLocked: result.skippedLocked,
preDueMinutes: result.preDueMinutes,
lookbackMinutes: result.lookbackMinutes,
lookaheadMinutes: result.lookaheadMinutes,
lockKey: result.lockKey,
};
},
};
export function startServer() {
const server = createServer(async (req, res) => {
if (!req.url || !req.method) {
writeJson(res, 404, { ok: false, error: "not_found" });
return;
}
if (req.url === "/health" && req.method === "GET") {
writeJson(res, 200, {
ok: true,
service: "backend",
now: new Date().toISOString(),
});
return;
}
if (req.url === "/graphql" && req.method === "POST") {
if (!isGraphqlAuthorized(req)) {
writeJson(res, 401, { errors: [{ message: "unauthorized" }] });
return;
}
try {
const body = (await readJsonBody(req)) as {
query?: string;
variables?: Record<string, unknown>;
operationName?: string;
};
const result = await graphql({
schema,
source: String(body.query || ""),
rootValue: root,
variableValues: body.variables || {},
operationName: body.operationName,
});
writeJson(res, 200, result);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const statusCode = message.startsWith("payload_too_large:") ? 413 : 400;
writeJson(res, statusCode, { errors: [{ message }] });
}
return;
}
writeJson(res, 404, { ok: false, error: "not_found" });
});
server.listen(PORT, "0.0.0.0", () => {
console.log(`[backend] listening on :${PORT}`);
});
return server;
}

View File

@@ -1,637 +0,0 @@
type MessageDirection = "IN" | "OUT";
type OmniMessageStatus = "PENDING" | "SENT" | "FAILED" | "DELIVERED" | "READ";
import { prisma } from "./utils/prisma";
export type TelegramInboundEnvelope = {
version: number;
idempotencyKey: string;
provider: string;
channel: string;
direction: "IN" | "OUT";
providerEventId: string;
providerMessageId: string | null;
eventType: string;
occurredAt: string;
receivedAt: string;
payloadRaw: unknown;
payloadNormalized: {
threadExternalId: string | null;
contactExternalId: string | null;
text: string | null;
businessConnectionId: string | null;
[key: string]: unknown;
};
};
export type TelegramOutboundReport = {
omniMessageId: string;
status: string;
providerMessageId?: string | null;
error?: string | null;
responseJson?: string | null;
};
export type TelegramOutboundRequest = {
omniMessageId: string;
chatId: string;
text: string;
businessConnectionId?: string | null;
};
export type CalendarPredueSyncResult = {
ok: boolean;
message: string;
now: string;
scanned: number;
updated: number;
skippedBeforeWindow: number;
skippedLocked: boolean;
preDueMinutes: number;
lookbackMinutes: number;
lookaheadMinutes: number;
lockKey: number;
};
function asString(value: unknown) {
if (typeof value !== "string") return null;
const v = value.trim();
return v || null;
}
function parseDate(value: string) {
const d = new Date(value);
if (Number.isNaN(d.getTime())) return new Date();
return d;
}
function normalizeDirection(value: string): MessageDirection {
return value === "OUT" ? "OUT" : "IN";
}
function readIntEnv(name: string, defaultValue: number) {
const raw = asString(process.env[name]);
if (!raw) return defaultValue;
const parsed = Number.parseInt(raw, 10);
return Number.isFinite(parsed) ? parsed : defaultValue;
}
async function resolveTeamId(envelope: TelegramInboundEnvelope) {
const n = envelope.payloadNormalized;
const bcId = asString(n.businessConnectionId);
if (bcId) {
const linked = await prisma.telegramBusinessConnection.findFirst({
where: { businessConnectionId: bcId },
orderBy: { updatedAt: "desc" },
select: { teamId: true },
});
if (linked?.teamId) return linked.teamId;
}
const externalContactId = asString(n.contactExternalId) ?? asString(n.threadExternalId);
if (externalContactId) {
const linked = await prisma.telegramBusinessConnection.findFirst({
where: { businessConnectionId: `link:${externalContactId}` },
orderBy: { updatedAt: "desc" },
select: { teamId: true },
});
if (linked?.teamId) return linked.teamId;
}
const fallback = asString(process.env.DEFAULT_TEAM_ID);
if (fallback) return fallback;
const firstTeam = await prisma.team.findFirst({
orderBy: { createdAt: "asc" },
select: { id: true },
});
return firstTeam?.id ?? null;
}
async function resolveContact(input: {
teamId: string;
externalContactId: string;
displayName: string;
avatarUrl: string | null;
}) {
const existing = await prisma.omniContactIdentity.findFirst({
where: {
teamId: input.teamId,
channel: "TELEGRAM",
externalId: input.externalContactId,
},
select: { contactId: true },
});
if (existing?.contactId) {
return existing.contactId;
}
const contact = await prisma.contact.create({
data: {
teamId: input.teamId,
name: input.displayName,
avatarUrl: input.avatarUrl,
},
select: { id: true },
});
try {
await prisma.omniContactIdentity.create({
data: {
teamId: input.teamId,
contactId: contact.id,
channel: "TELEGRAM",
externalId: input.externalContactId,
},
});
return contact.id;
} catch {
const concurrent = await prisma.omniContactIdentity.findFirst({
where: {
teamId: input.teamId,
channel: "TELEGRAM",
externalId: input.externalContactId,
},
select: { contactId: true },
});
if (concurrent?.contactId) {
await prisma.contact.delete({ where: { id: contact.id } }).catch(() => undefined);
return concurrent.contactId;
}
throw new Error("failed to create telegram contact identity");
}
}
async function upsertThread(input: {
teamId: string;
contactId: string;
externalChatId: string;
businessConnectionId: string | null;
title: string | null;
}) {
const existing = await prisma.omniThread.findFirst({
where: {
teamId: input.teamId,
channel: "TELEGRAM",
externalChatId: input.externalChatId,
businessConnectionId: input.businessConnectionId,
},
select: { id: true },
});
if (existing) {
await prisma.omniThread.update({
where: { id: existing.id },
data: {
contactId: input.contactId,
...(input.title ? { title: input.title } : {}),
},
select: { id: true },
});
return existing.id;
}
try {
const created = await prisma.omniThread.create({
data: {
teamId: input.teamId,
contactId: input.contactId,
channel: "TELEGRAM",
externalChatId: input.externalChatId,
businessConnectionId: input.businessConnectionId,
title: input.title,
},
select: { id: true },
});
return created.id;
} catch {
const concurrent = await prisma.omniThread.findFirst({
where: {
teamId: input.teamId,
channel: "TELEGRAM",
externalChatId: input.externalChatId,
businessConnectionId: input.businessConnectionId,
},
select: { id: true },
});
if (concurrent?.id) return concurrent.id;
throw new Error("failed to upsert telegram thread");
}
}
async function upsertContactInbox(input: {
teamId: string;
contactId: string;
sourceExternalId: string;
title: string | null;
}) {
const inbox = await prisma.contactInbox.upsert({
where: {
teamId_channel_sourceExternalId: {
teamId: input.teamId,
channel: "TELEGRAM",
sourceExternalId: input.sourceExternalId,
},
},
create: {
teamId: input.teamId,
contactId: input.contactId,
channel: "TELEGRAM",
sourceExternalId: input.sourceExternalId,
title: input.title,
},
update: {
contactId: input.contactId,
...(input.title ? { title: input.title } : {}),
},
select: { id: true },
});
return inbox.id;
}
async function markRead(teamId: string, externalChatId: string) {
const thread = await prisma.omniThread.findFirst({
where: {
teamId,
channel: "TELEGRAM",
externalChatId,
},
select: { contactId: true },
});
if (!thread) return;
const members = await prisma.teamMember.findMany({
where: { teamId },
select: { userId: true },
});
const readAt = new Date();
await Promise.all(
members.map((member: { userId: string }) =>
prisma.contactThreadRead.upsert({
where: {
userId_contactId: {
userId: member.userId,
contactId: thread.contactId,
},
},
create: {
teamId,
userId: member.userId,
contactId: thread.contactId,
readAt,
},
update: { readAt },
}),
),
);
}
export async function ingestTelegramInbound(envelope: TelegramInboundEnvelope) {
if (envelope.channel !== "TELEGRAM") {
return { ok: true, message: "skip_non_telegram" };
}
const teamId = await resolveTeamId(envelope);
if (!teamId) {
throw new Error("team_not_resolved");
}
const n = envelope.payloadNormalized;
const externalChatId = asString(n.threadExternalId) ?? asString(n.contactExternalId);
if (!externalChatId) {
throw new Error("thread_external_id_required");
}
if (envelope.eventType === "read_business_message") {
await markRead(teamId, externalChatId);
return { ok: true, message: "read_marked" };
}
const externalContactId = asString(n.contactExternalId) ?? externalChatId;
const businessConnectionId = asString(n.businessConnectionId);
const text = asString(n.text) ?? "[no text]";
const occurredAt = parseDate(envelope.occurredAt);
const direction = normalizeDirection(envelope.direction);
const contactFirstName = asString(n.contactFirstName);
const contactLastName = asString(n.contactLastName);
const contactUsername = asString(n.contactUsername);
const fallbackName = `Telegram ${externalContactId}`;
const displayName =
[contactFirstName, contactLastName].filter(Boolean).join(" ") ||
(contactUsername ? `@${contactUsername.replace(/^@/, "")}` : null) ||
fallbackName;
const contactId = await resolveContact({
teamId,
externalContactId,
displayName,
avatarUrl: asString(n.contactAvatarUrl),
});
const threadId = await upsertThread({
teamId,
contactId,
externalChatId,
businessConnectionId,
title: asString(n.chatTitle),
});
const contactInboxId = await upsertContactInbox({
teamId,
contactId,
sourceExternalId: externalChatId,
title: asString(n.chatTitle),
});
const rawEnvelope: Record<string, unknown> = {
version: envelope.version,
source: "backend.graphql.ingestTelegramInbound",
provider: envelope.provider,
channel: envelope.channel,
direction,
providerEventId: envelope.providerEventId,
receivedAt: envelope.receivedAt,
occurredAt: occurredAt.toISOString(),
payloadNormalized: n,
payloadRaw: envelope.payloadRaw ?? null,
};
let omniMessageId: string;
if (envelope.providerMessageId) {
const message = await prisma.omniMessage.upsert({
where: {
threadId_providerMessageId: {
threadId,
providerMessageId: envelope.providerMessageId,
},
},
create: {
teamId,
contactId,
threadId,
direction,
channel: "TELEGRAM",
status: "DELIVERED",
text,
providerMessageId: envelope.providerMessageId,
providerUpdateId: envelope.providerEventId,
rawJson: rawEnvelope,
occurredAt,
},
update: {
text,
providerUpdateId: envelope.providerEventId,
rawJson: rawEnvelope,
occurredAt,
},
select: { id: true },
});
omniMessageId = message.id;
} else {
const message = await prisma.omniMessage.create({
data: {
teamId,
contactId,
threadId,
direction,
channel: "TELEGRAM",
status: "DELIVERED",
text,
providerMessageId: null,
providerUpdateId: envelope.providerEventId,
rawJson: rawEnvelope,
occurredAt,
},
select: { id: true },
});
omniMessageId = message.id;
}
await prisma.contactMessage.create({
data: {
contactId,
contactInboxId,
kind: "MESSAGE",
direction,
channel: "TELEGRAM",
content: text,
occurredAt,
},
});
return { ok: true, message: "inbound_ingested", omniMessageId };
}
export async function reportTelegramOutbound(input: TelegramOutboundReport) {
const statusRaw = input.status.trim().toUpperCase();
const status: OmniMessageStatus =
statusRaw === "SENT" ||
statusRaw === "FAILED" ||
statusRaw === "DELIVERED" ||
statusRaw === "READ" ||
statusRaw === "PENDING"
? (statusRaw as OmniMessageStatus)
: "FAILED";
const existing = await prisma.omniMessage.findUnique({
where: { id: input.omniMessageId },
select: { rawJson: true },
});
const raw = (existing?.rawJson && typeof existing.rawJson === "object" && !Array.isArray(existing.rawJson)
? (existing.rawJson as Record<string, unknown>)
: {}) as Record<string, unknown>;
await prisma.omniMessage.update({
where: { id: input.omniMessageId },
data: {
status,
...(input.providerMessageId ? { providerMessageId: input.providerMessageId } : {}),
rawJson: {
...raw,
telegramWorker: {
reportedAt: new Date().toISOString(),
status,
error: input.error ?? null,
response: (() => {
if (!input.responseJson) return null;
try {
return JSON.parse(input.responseJson);
} catch {
return input.responseJson;
}
})(),
},
},
},
});
return { ok: true, message: "outbound_reported" };
}
async function callTelegramBackendGraphql<T>(query: string, variables: Record<string, unknown>) {
const url = asString(process.env.TELEGRAM_BACKEND_GRAPHQL_URL);
if (!url) {
throw new Error("TELEGRAM_BACKEND_GRAPHQL_URL is required");
}
const headers: Record<string, string> = {
"content-type": "application/json",
};
const secret = asString(process.env.TELEGRAM_BACKEND_GRAPHQL_SHARED_SECRET);
if (secret) {
headers["x-graphql-secret"] = secret;
}
const response = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify({ query, variables }),
});
const payload = (await response.json()) as { data?: T; errors?: Array<{ message?: string }> };
if (!response.ok || payload.errors?.length) {
const errorMessage = payload.errors?.map((e) => e.message).filter(Boolean).join("; ") || `HTTP ${response.status}`;
throw new Error(errorMessage);
}
return payload.data as T;
}
export async function requestTelegramOutbound(input: TelegramOutboundRequest) {
type Out = {
enqueueTelegramOutbound: {
ok: boolean;
message: string;
runId?: string | null;
};
};
const query = `mutation Enqueue($input: TelegramOutboundTaskInput!) {
enqueueTelegramOutbound(input: $input) {
ok
message
runId
}
}`;
const data = await callTelegramBackendGraphql<Out>(query, { input });
const result = data.enqueueTelegramOutbound;
if (!result?.ok) {
throw new Error(result?.message || "enqueue failed");
}
return { ok: true, message: "outbound_enqueued", runId: result.runId ?? null };
}
export async function syncCalendarPredueTimeline(): Promise<CalendarPredueSyncResult> {
const preDueMinutes = Math.max(1, readIntEnv("TIMELINE_EVENT_PREDUE_MINUTES", 30));
const lookbackMinutes = Math.max(preDueMinutes, readIntEnv("TIMELINE_EVENT_LOOKBACK_MINUTES", 180));
const lookaheadMinutes = Math.max(preDueMinutes, readIntEnv("TIMELINE_EVENT_LOOKAHEAD_MINUTES", 1440));
const lockKey = readIntEnv("TIMELINE_SCHEDULER_LOCK_KEY", 603001);
const now = new Date();
const rangeStart = new Date(now.getTime() - lookbackMinutes * 60_000);
const rangeEnd = new Date(now.getTime() + lookaheadMinutes * 60_000);
const lockRows = await prisma.$queryRaw<Array<{ locked: boolean }>>`
SELECT pg_try_advisory_lock(${lockKey}) AS locked
`;
const locked = Boolean(lockRows?.[0]?.locked);
if (!locked) {
return {
ok: true,
message: "lock_busy_skip",
now: now.toISOString(),
scanned: 0,
updated: 0,
skippedBeforeWindow: 0,
skippedLocked: true,
preDueMinutes,
lookbackMinutes,
lookaheadMinutes,
lockKey,
};
}
try {
const events = await prisma.calendarEvent.findMany({
where: {
isArchived: false,
contactId: { not: null },
startsAt: {
gte: rangeStart,
lte: rangeEnd,
},
},
orderBy: { startsAt: "asc" },
select: {
id: true,
teamId: true,
contactId: true,
startsAt: true,
},
});
let updated = 0;
let skippedBeforeWindow = 0;
for (const event of events) {
if (!event.contactId) continue;
const preDueAt = new Date(event.startsAt.getTime() - preDueMinutes * 60_000);
if (now < preDueAt) {
skippedBeforeWindow += 1;
continue;
}
await prisma.clientTimelineEntry.upsert({
where: {
teamId_contentType_contentId: {
teamId: event.teamId,
contentType: "CALENDAR_EVENT",
contentId: event.id,
},
},
create: {
teamId: event.teamId,
contactId: event.contactId,
contentType: "CALENDAR_EVENT",
contentId: event.id,
datetime: preDueAt,
},
update: {
contactId: event.contactId,
datetime: preDueAt,
},
});
updated += 1;
}
return {
ok: true,
message: "calendar_predue_synced",
now: now.toISOString(),
scanned: events.length,
updated,
skippedBeforeWindow,
skippedLocked: false,
preDueMinutes,
lookbackMinutes,
lookaheadMinutes,
lockKey,
};
} finally {
await prisma.$queryRaw`SELECT pg_advisory_unlock(${lockKey})`;
}
}

View File

@@ -1,16 +0,0 @@
import { PrismaClient } from "@prisma/client";
declare global {
// eslint-disable-next-line no-var
var __omniChatPrisma: PrismaClient | undefined;
}
export const prisma =
globalThis.__omniChatPrisma ??
new PrismaClient({
log: ["error", "warn"],
});
if (process.env.NODE_ENV !== "production") {
globalThis.__omniChatPrisma = prisma;
}

View File

@@ -1,14 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"types": ["node"],
"resolveJsonModule": true,
"verbatimModuleSyntax": true
},
"include": ["src/**/*.ts"]
}

1
backend_worker Submodule

Submodule backend_worker added at 653617983a

View File

@@ -1,16 +0,0 @@
FROM node:22-alpine
WORKDIR /app
RUN apk add --no-cache curl jq
COPY package*.json ./
RUN npm ci
COPY src ./src
COPY scripts ./scripts
COPY tsconfig.json ./tsconfig.json
ENV NODE_ENV=production
CMD ["sh", "-lc", ". /app/scripts/load-vault-env.sh && npm run start"]

View File

@@ -1,24 +0,0 @@
# backend_worker
Hatchet worker для периодических backend-задач.
## Назначение
- запускает cron workflow `backend-calendar-timeline-scheduler`;
- вызывает `backend` GraphQL mutation `syncCalendarPredueTimeline`;
- заменяет legacy `schedulers/` сервис для предзаписи календарных событий в `ClientTimelineEntry`.
## Переменные окружения
- `BACKEND_GRAPHQL_URL` (required)
- `BACKEND_GRAPHQL_SHARED_SECRET` (optional)
- `BACKEND_TIMELINE_SYNC_CRON` (default: `* * * * *`)
- `HATCHET_CLIENT_TOKEN` (required)
- `HATCHET_CLIENT_TLS_STRATEGY` (optional, например `none` для self-host без TLS)
- `HATCHET_CLIENT_HOST_PORT` (optional, например `hatchet-engine:7070`)
- `HATCHET_CLIENT_API_URL` (optional)
## Скрипты
- `npm run start` — запуск Hatchet worker.
- `npm run typecheck` — проверка TypeScript.

File diff suppressed because it is too large Load Diff

View File

@@ -1,17 +0,0 @@
{
"name": "crm-backend-worker",
"private": true,
"type": "module",
"scripts": {
"start": "tsx src/hatchet/worker.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@hatchet-dev/typescript-sdk": "^1.15.2"
},
"devDependencies": {
"@types/node": "^22.13.9",
"tsx": "^4.20.5",
"typescript": "^5.9.2"
}
}

View File

@@ -1,60 +0,0 @@
#!/bin/sh
set -eu
log() {
printf '%s\n' "$*" >&2
}
VAULT_ENABLED="${VAULT_ENABLED:-auto}"
if [ "$VAULT_ENABLED" = "false" ] || [ "$VAULT_ENABLED" = "0" ]; then
exit 0
fi
if [ -z "${VAULT_ADDR:-}" ] || [ -z "${VAULT_TOKEN:-}" ]; then
if [ "$VAULT_ENABLED" = "true" ] || [ "$VAULT_ENABLED" = "1" ]; then
log "Vault bootstrap is required but VAULT_ADDR or VAULT_TOKEN is missing."
exit 1
fi
exit 0
fi
if ! command -v curl >/dev/null 2>&1 || ! command -v jq >/dev/null 2>&1; then
log "Vault bootstrap requires curl and jq."
exit 1
fi
VAULT_KV_MOUNT="${VAULT_KV_MOUNT:-secret}"
load_secret_path() {
path="$1"
source_name="$2"
if [ -z "$path" ]; then
return 0
fi
url="${VAULT_ADDR%/}/v1/${VAULT_KV_MOUNT}/data/${path}"
response="$(curl -fsS -H "X-Vault-Token: $VAULT_TOKEN" "$url")" || {
log "Failed to load Vault path ${VAULT_KV_MOUNT}/${path}."
return 1
}
encoded_items="$(printf '%s' "$response" | jq -r '.data.data // {} | to_entries[]? | @base64')"
if [ -z "$encoded_items" ]; then
return 0
fi
old_ifs="${IFS}"
IFS='
'
for encoded_item in $encoded_items; do
key="$(printf '%s' "$encoded_item" | base64 -d | jq -r '.key')"
value="$(printf '%s' "$encoded_item" | base64 -d | jq -r '.value | tostring')"
export "$key=$value"
done
IFS="${old_ifs}"
log "Loaded Vault ${source_name} secrets from ${VAULT_KV_MOUNT}/${path}."
}
load_secret_path "${VAULT_SHARED_PATH:-}" "shared"
load_secret_path "${VAULT_PROJECT_PATH:-}" "project"

View File

@@ -1,3 +0,0 @@
import { HatchetClient } from "@hatchet-dev/typescript-sdk/v1";
export const hatchet = HatchetClient.init();

View File

@@ -1,22 +0,0 @@
import { hatchet } from "./client";
import { backendCalendarTimelineScheduler } from "./workflow";
import path from "node:path";
import { fileURLToPath } from "node:url";
async function main() {
const worker = await hatchet.worker("backend-worker", {
workflows: [backendCalendarTimelineScheduler],
});
await worker.start();
}
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isMain) {
main().catch((error) => {
const message = error instanceof Error ? error.stack || error.message : String(error);
console.error(`[backend_worker/hatchet] worker failed: ${message}`);
process.exitCode = 1;
});
}

View File

@@ -1,119 +0,0 @@
import { hatchet } from "./client";
type SyncCalendarPredueResult = {
syncCalendarPredueTimeline: {
ok: boolean;
message: string;
now: string;
scanned: number;
updated: number;
skippedBeforeWindow: number;
skippedLocked: boolean;
preDueMinutes: number;
lookbackMinutes: number;
lookaheadMinutes: number;
lockKey: number;
};
};
type GraphqlResponse<T> = {
data?: T;
errors?: Array<{ message?: string }>;
};
function asString(value: unknown) {
if (typeof value !== "string") return null;
const v = value.trim();
return v || null;
}
function requiredEnv(name: string) {
const value = asString(process.env[name]);
if (!value) {
throw new Error(`${name} is required`);
}
return value;
}
async function callBackendSyncMutation() {
const url = requiredEnv("BACKEND_GRAPHQL_URL");
const secret = asString(process.env.BACKEND_GRAPHQL_SHARED_SECRET);
const headers: Record<string, string> = {
"content-type": "application/json",
};
if (secret) {
headers["x-graphql-secret"] = secret;
}
const query = `mutation SyncCalendarPredueTimeline {
syncCalendarPredueTimeline {
ok
message
now
scanned
updated
skippedBeforeWindow
skippedLocked
preDueMinutes
lookbackMinutes
lookaheadMinutes
lockKey
}
}`;
const response = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify({
operationName: "SyncCalendarPredueTimeline",
query,
variables: {},
}),
});
const payload = (await response.json()) as GraphqlResponse<SyncCalendarPredueResult>;
if (!response.ok || payload.errors?.length) {
const message = payload.errors?.map((error) => error.message).filter(Boolean).join("; ") || `HTTP ${response.status}`;
throw new Error(message);
}
const result = payload.data?.syncCalendarPredueTimeline;
if (!result?.ok) {
throw new Error(result?.message || "syncCalendarPredueTimeline failed");
}
return result;
}
const BACKEND_WORKER_CRON = asString(process.env.BACKEND_TIMELINE_SYNC_CRON) || "* * * * *";
export const backendCalendarTimelineScheduler = hatchet.workflow({
name: "backend-calendar-timeline-scheduler",
on: {
cron: BACKEND_WORKER_CRON,
},
});
backendCalendarTimelineScheduler.task({
name: "sync-calendar-predue-timeline-in-backend",
retries: 6,
backoff: {
factor: 2,
maxSeconds: 60,
},
fn: async (_, ctx) => {
const result = await callBackendSyncMutation();
await ctx.logger.info("backend timeline predue sync completed", {
scanned: result.scanned,
updated: result.updated,
skippedBeforeWindow: result.skippedBeforeWindow,
skippedLocked: result.skippedLocked,
now: result.now,
});
return result;
},
});

View File

@@ -1,14 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"types": ["node"],
"resolveJsonModule": true,
"verbatimModuleSyntax": true
},
"include": ["src/**/*.ts"]
}

1
frontend Submodule

Submodule frontend added at e11185259f

View File

@@ -1,9 +0,0 @@
.git
.gitignore
node_modules
.nuxt
.output
.data
npm-debug.log*
dist
coverage

View File

@@ -1,35 +0,0 @@
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/clientsflow?schema=public"
REDIS_URL="redis://localhost:6379"
# Agent (LangGraph + OpenRouter)
OPENROUTER_API_KEY=""
OPENROUTER_BASE_URL="https://openrouter.ai/api/v1"
OPENROUTER_MODEL="openai/gpt-4o-mini"
# Optional headers for OpenRouter ranking/analytics
OPENROUTER_HTTP_REFERER=""
OPENROUTER_X_TITLE="clientsflow"
# Enable reasoning payload for models that support it: 1 or 0
OPENROUTER_REASONING_ENABLED="0"
# Langfuse local tracing (optional)
LANGFUSE_ENABLED="true"
LANGFUSE_BASE_URL="http://localhost:3001"
LANGFUSE_PUBLIC_KEY="pk-lf-local"
LANGFUSE_SECRET_KEY="sk-lf-local"
# Optional fallback (OpenAI-compatible)
OPENAI_API_KEY=""
OPENAI_MODEL="gpt-4o-mini"
# "langgraph" (default) or "rule"
CF_AGENT_MODE="langgraph"
CF_WHISPER_MODEL="Xenova/whisper-small"
CF_WHISPER_LANGUAGE="ru"
TELEGRAM_BOT_TOKEN=""
TELEGRAM_WEBHOOK_SECRET=""
TELEGRAM_DEFAULT_TEAM_ID="demo-team"
# Frontend GraphQL endpoint for Apollo client runtime
GRAPHQL_HTTP_ENDPOINT="http://localhost:3000/api/graphql"
# Remote GraphQL schema URL for codegen (used by `pnpm codegen` / `npm run codegen`)
GRAPHQL_SCHEMA_URL=""

View File

@@ -1,12 +0,0 @@
import type { StorybookConfig } from "@storybook/vue3-vite";
const config: StorybookConfig = {
stories: ["../components/**/*.stories.@(ts|tsx)"],
addons: ["@storybook/addon-essentials", "@storybook/addon-interactions"],
framework: {
name: "@storybook/vue3-vite",
options: {},
},
};
export default config;

View File

@@ -1,16 +0,0 @@
import type { Preview } from "@storybook/vue3-vite";
import "../assets/css/main.css";
const preview: Preview = {
parameters: {
actions: { argTypesRegex: "^on[A-Z].*" },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
},
};
export default preview;

View File

@@ -1,33 +0,0 @@
FROM node:22-bookworm-slim
WORKDIR /app/frontend
RUN apt-get update -y \
&& apt-get install -y --no-install-recommends openssl ca-certificates curl jq \
&& rm -rf /var/lib/apt/lists/*
COPY package*.json ./
RUN npm ci --ignore-scripts --legacy-peer-deps
RUN set -eux; \
arch="$(dpkg --print-architecture)"; \
if [ "$arch" = "amd64" ]; then \
npm rebuild sharp --platform=linux --arch=x64 || npm install --no-save sharp --platform=linux --arch=x64; \
elif [ "$arch" = "arm64" ]; then \
npm rebuild sharp --platform=linux --arch=arm64 || npm install --no-save sharp --platform=linux --arch=arm64; \
else \
npm rebuild sharp || true; \
fi
COPY . .
# Build server bundle at image build time.
RUN npm run postinstall && npm run build
ENV NODE_ENV=production
ENV NITRO_HOST=0.0.0.0
ENV NITRO_PORT=3000
EXPOSE 3000
# Keep schema in sync, then start Nitro production server.
CMD ["sh", "-lc", ". /app/frontend/scripts/load-vault-env.sh && npx prisma db push && node .output/server/index.mjs"]

View File

@@ -1,5 +0,0 @@
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>

View File

@@ -1,16 +0,0 @@
@import "tailwindcss";
@plugin "daisyui";
:root {
--color-accent: #1e6bff;
}
body {
min-height: 100vh;
background:
radial-gradient(circle at 100% 0%, rgba(30, 107, 255, 0.08), transparent 40%),
radial-gradient(circle at 0% 100%, rgba(30, 107, 255, 0.08), transparent 40%),
#f5f7fb;
color: #111827;
}

View File

@@ -1,19 +0,0 @@
import type { Meta, StoryObj } from "@storybook/vue3";
import ContactCollaborativeEditor from "./ContactCollaborativeEditor.client.vue";
const meta: Meta<typeof ContactCollaborativeEditor> = {
title: "Components/ContactCollaborativeEditor",
component: ContactCollaborativeEditor,
args: {
modelValue: "<p>Client summary draft...</p>",
room: "storybook-contact-editor-room",
placeholder: "Type here...",
plain: false,
},
};
export default meta;
type Story = StoryObj<typeof ContactCollaborativeEditor>;
export const Default: Story = {};

View File

@@ -1,238 +0,0 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from "vue";
import { EditorContent, useEditor } from "@tiptap/vue-3";
import StarterKit from "@tiptap/starter-kit";
import Collaboration from "@tiptap/extension-collaboration";
import CollaborationCursor from "@tiptap/extension-collaboration-cursor";
import Placeholder from "@tiptap/extension-placeholder";
import * as Y from "yjs";
import { WebrtcProvider } from "y-webrtc";
const props = defineProps<{
modelValue: string;
room: string;
placeholder?: string;
plain?: boolean;
}>();
const emit = defineEmits<{
(event: "update:modelValue", value: string): void;
}>();
const ydoc = new Y.Doc();
const provider = new WebrtcProvider(props.room, ydoc);
const isBootstrapped = ref(false);
const awarenessVersion = ref(0);
const userPalette = ["#2563eb", "#0ea5e9", "#14b8a6", "#16a34a", "#eab308", "#f97316", "#ef4444"];
const currentUser = {
name: `You ${Math.floor(Math.random() * 900 + 100)}`,
color: userPalette[Math.floor(Math.random() * userPalette.length)],
};
function escapeHtml(value: string) {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function normalizeInitialContent(value: string) {
const input = value.trim();
if (!input) return "<p></p>";
if (input.includes("<") && input.includes(">")) return value;
const blocks = value
.replaceAll("\r\n", "\n")
.split(/\n\n+/)
.map((block) => `<p>${escapeHtml(block).replaceAll("\n", "<br />")}</p>`);
return blocks.join("");
}
const editor = useEditor({
extensions: [
StarterKit.configure({
history: false,
}),
Placeholder.configure({
placeholder: props.placeholder ?? "Type here...",
includeChildren: true,
}),
Collaboration.configure({
document: ydoc,
field: "contact",
}),
CollaborationCursor.configure({
provider,
user: currentUser,
}),
],
autofocus: true,
editorProps: {
attributes: {
class: "contact-editor-content",
spellcheck: "true",
},
},
onCreate: ({ editor: instance }) => {
if (instance.isEmpty) {
instance.commands.setContent(normalizeInitialContent(props.modelValue), false);
}
isBootstrapped.value = true;
},
onUpdate: ({ editor: instance }) => {
emit("update:modelValue", instance.getHTML());
},
});
watch(
() => props.modelValue,
(incoming) => {
const instance = editor.value;
if (!instance || !isBootstrapped.value) return;
const current = instance.getHTML();
if (incoming === current || !incoming.trim()) return;
if (instance.isEmpty) {
instance.commands.setContent(normalizeInitialContent(incoming), false);
}
},
);
const peerCount = computed(() => {
awarenessVersion.value;
const states = Array.from(provider.awareness.getStates().values());
return states.length;
});
const onAwarenessChange = () => {
awarenessVersion.value += 1;
};
provider.awareness.on("change", onAwarenessChange);
function runCommand(action: () => void) {
const instance = editor.value;
if (!instance) return;
action();
instance.commands.focus();
}
onBeforeUnmount(() => {
provider.awareness.off("change", onAwarenessChange);
editor.value?.destroy();
provider.destroy();
ydoc.destroy();
});
</script>
<template>
<div :class="props.plain ? 'space-y-2' : 'space-y-3'">
<div :class="props.plain ? 'flex flex-wrap items-center justify-between gap-2 bg-transparent p-0' : 'flex flex-wrap items-center justify-between gap-2 rounded-xl border border-base-300 bg-base-100 p-2'">
<div class="flex flex-wrap items-center gap-1">
<button
class="btn btn-xs"
:class="editor?.isActive('bold') ? 'btn-primary' : 'btn-ghost'"
@click="runCommand(() => editor?.chain().focus().toggleBold().run())"
>
B
</button>
<button
class="btn btn-xs"
:class="editor?.isActive('italic') ? 'btn-primary' : 'btn-ghost'"
@click="runCommand(() => editor?.chain().focus().toggleItalic().run())"
>
I
</button>
<button
class="btn btn-xs"
:class="editor?.isActive('bulletList') ? 'btn-primary' : 'btn-ghost'"
@click="runCommand(() => editor?.chain().focus().toggleBulletList().run())"
>
List
</button>
<button
class="btn btn-xs"
:class="editor?.isActive('heading', { level: 2 }) ? 'btn-primary' : 'btn-ghost'"
@click="runCommand(() => editor?.chain().focus().toggleHeading({ level: 2 }).run())"
>
H2
</button>
<button
class="btn btn-xs"
:class="editor?.isActive('blockquote') ? 'btn-primary' : 'btn-ghost'"
@click="runCommand(() => editor?.chain().focus().toggleBlockquote().run())"
>
Quote
</button>
</div>
<p class="px-1 text-xs text-base-content/60">Live: {{ peerCount }}</p>
</div>
<div :class="props.plain ? 'bg-transparent p-0' : 'rounded-xl border border-base-300 bg-base-100 p-2'">
<EditorContent :editor="editor" class="contact-editor min-h-[420px]" />
</div>
</div>
</template>
<style scoped>
.contact-editor :deep(.ProseMirror) {
min-height: 390px;
padding: 0.75rem;
outline: none;
line-height: 1.65;
color: rgba(17, 24, 39, 0.95);
}
.contact-editor :deep(.ProseMirror p) {
margin: 0.45rem 0;
}
.contact-editor :deep(.ProseMirror h1),
.contact-editor :deep(.ProseMirror h2),
.contact-editor :deep(.ProseMirror h3) {
margin: 0.75rem 0 0.45rem;
font-weight: 700;
line-height: 1.3;
}
.contact-editor :deep(.ProseMirror ul),
.contact-editor :deep(.ProseMirror ol) {
margin: 0.45rem 0;
padding-left: 1.25rem;
}
.contact-editor :deep(.ProseMirror blockquote) {
margin: 0.6rem 0;
border-left: 3px solid rgba(30, 107, 255, 0.5);
padding-left: 0.75rem;
color: rgba(55, 65, 81, 0.95);
}
.contact-editor :deep(.ProseMirror .collaboration-cursor__caret) {
margin-left: -1px;
margin-right: -1px;
border-left: 1px solid currentColor;
border-right: 1px solid currentColor;
pointer-events: none;
position: relative;
}
.contact-editor :deep(.ProseMirror .collaboration-cursor__label) {
position: absolute;
top: -1.35em;
left: -1px;
border-radius: 4px;
padding: 0.1rem 0.4rem;
color: #fff;
font-size: 10px;
font-weight: 600;
white-space: nowrap;
line-height: 1.2;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +0,0 @@
<template>
<div class="flex h-full items-center justify-center">
<span class="loading loading-spinner loading-md text-base-content/70" />
</div>
</template>

View File

@@ -1,47 +0,0 @@
<script setup lang="ts">
const props = defineProps<{
phone: string;
password: string;
error: string | null;
busy: boolean;
}>();
const emit = defineEmits<{
(e: "update:phone", value: string): void;
(e: "update:password", value: string): void;
(e: "submit"): void;
}>();
</script>
<template>
<div class="flex h-full items-center justify-center px-3">
<div class="card w-full max-w-sm border border-base-300 bg-base-100 shadow-sm">
<div class="card-body p-5">
<h1 class="text-lg font-semibold">Login</h1>
<p class="mt-1 text-xs text-base-content/65">Sign in with phone and password.</p>
<div class="mt-4 space-y-2">
<input
:value="props.phone"
type="tel"
class="input input-bordered w-full"
placeholder="+1 555 000 0001"
@input="emit('update:phone', ($event.target as HTMLInputElement).value)"
@keyup.enter="emit('submit')"
>
<input
:value="props.password"
type="password"
class="input input-bordered w-full"
placeholder="Password"
@input="emit('update:password', ($event.target as HTMLInputElement).value)"
@keyup.enter="emit('submit')"
>
<p v-if="props.error" class="text-xs text-error">{{ props.error }}</p>
<button class="btn w-full" :disabled="props.busy" @click="emit('submit')">
{{ props.busy ? "Logging in..." : "Login" }}
</button>
</div>
</div>
</div>
</div>
</template>

View File

@@ -1,713 +0,0 @@
<script setup lang="ts">
import type { CalendarEvent } from "~~/app/composables/useCalendar";
type YearMonthItem = {
monthIndex: number;
label: string;
count: number;
first?: CalendarEvent;
};
type MonthCell = {
key: string;
day: number;
inMonth: boolean;
events: CalendarEvent[];
};
type MonthRow = {
key: string;
startKey: string;
weekNumber: number;
cells: MonthCell[];
};
type WeekDay = {
key: string;
label: string;
day: number;
events: CalendarEvent[];
};
defineProps<{
contextPickerEnabled: boolean;
hasContextScope: (scope: "calendar") => boolean;
toggleContextScope: (scope: "calendar") => void;
contextScopeLabel: (scope: "calendar") => string;
setToday: () => void;
calendarPeriodLabel: string;
calendarZoomLevelIndex: number;
onCalendarZoomSliderInput: (event: Event) => void;
focusedCalendarEvent: CalendarEvent | null;
formatDay: (iso: string) => string;
formatTime: (iso: string) => string;
avatarSrcForCalendarEvent: (event: CalendarEvent) => string;
markCalendarAvatarBroken: (event: CalendarEvent) => void;
contactInitials: (contactName: string) => string;
setCalendarContentWrapRef: (element: HTMLDivElement | null) => void;
shiftCalendar: (step: number) => void;
setCalendarContentScrollRef: (element: HTMLDivElement | null) => void;
onCalendarHierarchyWheel: (event: WheelEvent) => void;
setCalendarSceneRef: (element: HTMLDivElement | null) => void;
calendarViewportHeight: number;
normalizedCalendarView: string;
onCalendarSceneMouseLeave: () => void;
calendarView: string;
yearMonths: YearMonthItem[];
calendarCursorMonth: number;
calendarHoveredMonthIndex: number | null;
setCalendarHoveredMonthIndex: (value: number | null) => void;
calendarZoomPrimeToken: string;
calendarPrimeMonthToken: (monthIndex: number) => string;
calendarPrimeStyle: (token: string) => Record<string, string>;
zoomToMonth: (monthIndex: number) => void;
openThreadFromCalendarItem: (event: CalendarEvent) => void;
monthRows: MonthRow[];
calendarHoveredWeekStartKey: string;
setCalendarHoveredWeekStartKey: (value: string) => void;
calendarPrimeWeekToken: (startKey: string) => string;
selectedDateKey: string;
monthCellHasFocusedEvent: (events: CalendarEvent[]) => boolean;
calendarHoveredDayKey: string;
setCalendarHoveredDayKey: (value: string) => void;
pickDate: (key: string) => void;
monthCellEvents: (events: CalendarEvent[]) => CalendarEvent[];
isReviewHighlightedEvent: (eventId: string) => boolean;
weekDays: WeekDay[];
calendarPrimeDayToken: (dayKey: string) => string;
selectedDayEvents: CalendarEvent[];
calendarFlyVisible: boolean;
setCalendarFlyRectRef: (element: HTMLDivElement | null) => void;
calendarFlyLabelVisible: boolean;
setCalendarFlyLabelRef: (element: HTMLDivElement | null) => void;
setCalendarToolbarLabelRef: (element: HTMLDivElement | null) => void;
}>();
</script>
<template>
<section
class="relative flex h-full min-h-0 flex-col gap-3"
:class="[
contextPickerEnabled ? 'context-scope-block context-scope-block-active' : '',
hasContextScope('calendar') ? 'context-scope-block-selected' : '',
]"
@click="toggleContextScope('calendar')"
>
<span
v-if="contextPickerEnabled"
class="context-scope-label"
>{{ contextScopeLabel('calendar') }}</span>
<div class="grid grid-cols-[auto_1fr_auto] items-center gap-2">
<div class="flex items-center gap-1">
<button class="btn btn-xs" @click="setToday">Today</button>
</div>
<div :ref="setCalendarToolbarLabelRef" class="text-center text-sm font-medium">
{{ calendarPeriodLabel }}
</div>
<div class="justify-self-end calendar-zoom-inline" @click.stop>
<input
class="calendar-zoom-slider"
type="range"
min="0"
max="3"
step="1"
:value="calendarZoomLevelIndex"
aria-label="Calendar zoom level"
@input="onCalendarZoomSliderInput"
>
<div class="calendar-zoom-marks" aria-hidden="true">
<span
v-for="index in 4"
:key="`calendar-zoom-mark-${index}`"
class="calendar-zoom-mark"
:class="calendarZoomLevelIndex === index - 1 ? 'calendar-zoom-mark-active' : ''"
/>
</div>
</div>
</div>
<article
v-if="focusedCalendarEvent"
class="rounded-xl border border-success/50 bg-success/10 px-3 py-2"
>
<p class="text-xs font-semibold uppercase tracking-wide text-success/80">Review focus event</p>
<p class="text-sm font-medium text-base-content">{{ focusedCalendarEvent.title }}</p>
<div class="mt-1 flex items-center gap-1.5">
<div class="avatar shrink-0">
<div class="h-5 w-5 rounded-full ring-1 ring-base-300/70">
<img
v-if="avatarSrcForCalendarEvent(focusedCalendarEvent)"
:src="avatarSrcForCalendarEvent(focusedCalendarEvent)"
:alt="focusedCalendarEvent.contact"
@error="markCalendarAvatarBroken(focusedCalendarEvent)"
/>
<span
v-else
class="flex h-full w-full items-center justify-center text-[9px] font-semibold text-base-content/65"
>{{ contactInitials(focusedCalendarEvent.contact) }}</span>
</div>
</div>
<p class="truncate text-xs text-base-content/70">{{ focusedCalendarEvent.contact || "Unknown contact" }}</p>
</div>
<p class="text-xs text-base-content/70">
{{ formatDay(focusedCalendarEvent.start) }} · {{ formatTime(focusedCalendarEvent.start) }} - {{ formatTime(focusedCalendarEvent.end) }}
</p>
<p class="mt-1 text-xs text-base-content/80">{{ focusedCalendarEvent.note || "No note" }}</p>
</article>
<!-- GSAP flying label (title transition overlay) -->
<div
v-show="calendarFlyLabelVisible"
:ref="setCalendarFlyLabelRef"
class="calendar-fly-label-el"
/>
<div :ref="setCalendarContentWrapRef" class="calendar-content-wrap min-h-0 flex-1">
<button
class="calendar-side-nav calendar-side-nav-left"
type="button"
title="Previous period"
@click="shiftCalendar(-1)"
>
<span></span>
</button>
<button
class="calendar-side-nav calendar-side-nav-right"
type="button"
title="Next period"
@click="shiftCalendar(1)"
>
<span></span>
</button>
<!-- GSAP flying rect (zoom transition overlay) -->
<div
v-show="calendarFlyVisible"
:ref="setCalendarFlyRectRef"
class="calendar-fly-rect"
/>
<div
:ref="setCalendarContentScrollRef"
class="calendar-content-scroll min-h-0 h-full overflow-y-auto pr-1"
@wheel.prevent="onCalendarHierarchyWheel"
>
<div
:ref="setCalendarSceneRef"
:class="[
'calendar-scene',
normalizedCalendarView === 'day' ? 'cursor-zoom-out' : 'cursor-zoom-in',
]"
@mouseleave="onCalendarSceneMouseLeave"
>
<div
class="grid grid-cols-1 gap-2 sm:grid-cols-2 xl:grid-cols-3 auto-rows-fr"
:style="calendarViewportHeight > 0 ? { minHeight: `${calendarView === 'year' ? Math.max(420, calendarViewportHeight) : calendarViewportHeight}px` } : undefined"
>
<div
v-for="item in yearMonths"
:key="`year-month-${item.monthIndex}`"
v-show="calendarView === 'year' || item.monthIndex === calendarCursorMonth"
:class="[
calendarView === 'year' ? 'flex flex-col h-full' : 'sm:col-span-2 xl:col-span-3 flex flex-col',
]"
>
<p
v-if="calendarView === 'year'"
class="calendar-card-title"
>{{ item.label }}</p>
<article
class="group relative rounded-xl border border-base-300 p-3 text-left transition calendar-hover-targetable flex-1"
:class="[
calendarView === 'year'
? 'hover:border-primary/50 hover:bg-primary/5 cursor-zoom-in'
: 'cursor-default bg-base-100 flex flex-col',
calendarHoveredMonthIndex === item.monthIndex ? 'calendar-hover-target' : '',
calendarZoomPrimeToken === calendarPrimeMonthToken(item.monthIndex) ? 'calendar-zoom-prime-active' : '',
]"
:style="{
...calendarPrimeStyle(calendarPrimeMonthToken(item.monthIndex)),
...(calendarView !== 'year' && item.monthIndex === calendarCursorMonth && calendarViewportHeight > 0
? { minHeight: `${calendarViewportHeight}px` }
: {}),
}"
:data-calendar-month-index="item.monthIndex"
@mouseenter="setCalendarHoveredMonthIndex(item.monthIndex)"
@click="calendarView === 'year' ? zoomToMonth(item.monthIndex) : undefined"
>
<p v-if="calendarView === 'year'" class="text-xs text-base-content/60">{{ item.count }} events</p>
<button
v-if="calendarView === 'year' && item.first"
class="mt-1 block w-full text-left text-xs text-base-content/70 hover:underline"
@click.stop="openThreadFromCalendarItem(item.first)"
>
{{ formatDay(item.first.start) }} · {{ item.first.title }}
</button>
<div v-if="item.monthIndex === calendarCursorMonth" class="mt-3 calendar-depth-stack">
<div
class="space-y-1 calendar-depth-layer"
data-calendar-layer="month"
:class="calendarView === 'month' || calendarView === 'agenda' ? 'calendar-depth-layer-active' : 'calendar-depth-layer-hidden'"
>
<div class="flex items-center gap-1">
<span class="calendar-week-number" aria-hidden="true"></span>
<div class="grid grid-cols-7 gap-1 text-center text-xs font-semibold text-base-content/60 flex-1">
<span>Sun</span>
<span>Mon</span>
<span>Tue</span>
<span>Wed</span>
<span>Thu</span>
<span>Fri</span>
<span>Sat</span>
</div>
</div>
<div class="flex flex-1 flex-col gap-1">
<div
v-for="row in monthRows"
:key="row.key"
class="group relative flex-1 flex items-stretch gap-1 calendar-hover-targetable"
:class="[
calendarHoveredWeekStartKey === row.startKey ? 'calendar-hover-target' : '',
calendarZoomPrimeToken === calendarPrimeWeekToken(row.startKey) ? 'calendar-zoom-prime-active' : '',
]"
:style="calendarPrimeStyle(calendarPrimeWeekToken(row.startKey))"
:data-calendar-week-start-key="row.startKey"
@mouseenter="setCalendarHoveredWeekStartKey(row.startKey)"
>
<span class="calendar-week-number">{{ row.weekNumber }}</span>
<div class="grid grid-cols-7 gap-1 h-full flex-1">
<button
v-for="cell in row.cells"
:key="cell.key"
class="group relative rounded-lg border p-1 text-left"
:class="[
cell.inMonth ? 'border-base-300 bg-base-100' : 'border-base-200 bg-base-200/40 text-base-content/40',
selectedDateKey === cell.key ? 'border-primary bg-primary/5' : '',
monthCellHasFocusedEvent(cell.events) ? 'border-success/60 bg-success/10' : '',
]"
:data-calendar-day-key="cell.key"
@mouseenter="setCalendarHoveredDayKey(cell.key)"
@click="pickDate(cell.key)"
>
<p class="mb-1 text-xs font-semibold">{{ cell.day }}</p>
<button
v-for="event in monthCellEvents(cell.events)"
:key="event.id"
class="block w-full rounded px-1 text-left text-[10px] text-base-content/70 transition hover:underline"
:class="isReviewHighlightedEvent(event.id) ? 'bg-success/20 text-success-content ring-1 ring-success/40' : ''"
@click.stop="openThreadFromCalendarItem(event)"
>
<span class="flex items-center gap-1">
<span class="avatar shrink-0">
<span class="inline-flex h-3.5 w-3.5 rounded-full ring-1 ring-base-300/70">
<img
v-if="avatarSrcForCalendarEvent(event)"
:src="avatarSrcForCalendarEvent(event)"
:alt="event.contact"
@error="markCalendarAvatarBroken(event)"
/>
<span
v-else
class="flex h-full w-full items-center justify-center text-[7px] font-semibold text-base-content/65"
>{{ contactInitials(event.contact) }}</span>
</span>
</span>
<span class="truncate">{{ formatTime(event.start) }} {{ event.title }}</span>
</span>
</button>
</button>
</div>
</div>
</div>
</div>
<div
class="calendar-week-scroll h-full min-h-0 overflow-x-auto pb-1 calendar-depth-layer"
data-calendar-layer="week"
:class="calendarView === 'week' ? 'calendar-depth-layer-active' : 'calendar-depth-layer-hidden'"
>
<div class="calendar-week-grid">
<div
v-for="day in weekDays"
:key="day.key"
class="flex flex-col min-h-full"
>
<p class="calendar-card-title text-center">{{ day.label }} {{ day.day }}</p>
<article
class="group relative flex flex-1 flex-col rounded-xl border border-base-300 bg-base-100 p-2.5 cursor-zoom-in calendar-hover-targetable"
:class="[
selectedDateKey === day.key ? 'border-primary bg-primary/5' : '',
calendarHoveredDayKey === day.key ? 'calendar-hover-target' : '',
calendarZoomPrimeToken === calendarPrimeDayToken(day.key) ? 'calendar-zoom-prime-active' : '',
]"
:style="calendarPrimeStyle(calendarPrimeDayToken(day.key))"
:data-calendar-day-key="day.key"
@mouseenter="setCalendarHoveredDayKey(day.key)"
@click="pickDate(day.key)"
>
<div class="space-y-1.5">
<button
v-for="event in day.events"
:key="event.id"
class="block w-full rounded-md px-2 py-1.5 text-left text-xs"
:class="isReviewHighlightedEvent(event.id) ? 'bg-success/20 ring-1 ring-success/45' : 'bg-base-200 hover:bg-base-300/80'"
@click.stop="openThreadFromCalendarItem(event)"
>
<div class="flex items-center gap-1.5">
<div class="avatar shrink-0">
<div class="h-5 w-5 rounded-full ring-1 ring-base-300/70">
<img
v-if="avatarSrcForCalendarEvent(event)"
:src="avatarSrcForCalendarEvent(event)"
:alt="event.contact"
@error="markCalendarAvatarBroken(event)"
/>
<span
v-else
class="flex h-full w-full items-center justify-center text-[8px] font-semibold text-base-content/65"
>{{ contactInitials(event.contact) }}</span>
</div>
</div>
<span class="truncate">{{ formatTime(event.start) }} - {{ event.title }}</span>
</div>
<p class="ml-7 mt-0.5 truncate text-[11px] text-base-content/65">{{ event.contact }}</p>
</button>
<p v-if="day.events.length === 0" class="pt-1 text-xs text-base-content/50">No events</p>
</div>
</article>
</div>
</div>
</div>
<div
class="space-y-2 calendar-depth-layer"
data-calendar-layer="day"
:class="calendarView === 'day' ? 'calendar-depth-layer-active' : 'calendar-depth-layer-hidden'"
>
<button
v-for="event in selectedDayEvents"
:key="event.id"
class="block w-full rounded-xl border border-base-300 p-3 text-left transition hover:bg-base-200/60"
:class="isReviewHighlightedEvent(event.id) ? 'border-success/60 bg-success/10' : ''"
@click="openThreadFromCalendarItem(event)"
>
<p class="font-medium">{{ event.title }}</p>
<div class="mt-1 flex items-center gap-1.5">
<div class="avatar shrink-0">
<div class="h-6 w-6 rounded-full ring-1 ring-base-300/70">
<img
v-if="avatarSrcForCalendarEvent(event)"
:src="avatarSrcForCalendarEvent(event)"
:alt="event.contact"
@error="markCalendarAvatarBroken(event)"
/>
<span
v-else
class="flex h-full w-full items-center justify-center text-[9px] font-semibold text-base-content/65"
>{{ contactInitials(event.contact) }}</span>
</div>
</div>
<p class="truncate text-xs text-base-content/60">{{ event.contact }}</p>
</div>
<p class="text-xs text-base-content/60">{{ formatTime(event.start) }} - {{ formatTime(event.end) }}</p>
<p class="mt-1 text-sm text-base-content/80">{{ event.note }}</p>
</button>
<p v-if="selectedDayEvents.length === 0" class="text-sm text-base-content/60">No events on this day.</p>
</div>
</div>
</article>
</div>
</div>
</div>
</div>
</div>
</section>
</template>
<style scoped>
.calendar-content-wrap {
position: relative;
padding-left: 40px;
padding-right: 40px;
}
.calendar-content-scroll {
height: 100%;
overscroll-behavior: contain;
}
.calendar-scene {
min-height: 100%;
min-width: 100%;
transform-origin: center center;
}
.calendar-scene.cursor-zoom-in,
.calendar-scene.cursor-zoom-in * {
cursor: zoom-in;
}
.calendar-scene.cursor-zoom-out,
.calendar-scene.cursor-zoom-out * {
cursor: zoom-out;
}
.calendar-week-grid {
display: grid;
grid-template-columns: repeat(7, minmax(165px, 1fr));
gap: 8px;
min-width: 1180px;
min-height: 100%;
align-items: stretch;
}
.calendar-depth-stack {
position: relative;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.calendar-depth-layer {
transition: opacity 260ms ease, transform 260ms ease;
}
.calendar-depth-layer-active {
opacity: 1;
transform: translateY(0);
position: relative;
z-index: 1;
pointer-events: auto;
flex: 1;
display: flex;
flex-direction: column;
}
.calendar-depth-layer-hidden {
opacity: 0;
transform: translateY(10px);
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
}
.calendar-side-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 4;
width: 28px;
height: 28px;
border: 1px solid color-mix(in oklab, var(--color-base-content) 16%, transparent);
border-radius: 999px;
background: color-mix(in oklab, var(--color-base-100) 88%, transparent);
color: color-mix(in oklab, var(--color-base-content) 86%, transparent);
display: inline-flex;
align-items: center;
justify-content: center;
transition: background-color 120ms ease, border-color 120ms ease, transform 120ms ease;
}
.calendar-side-nav:hover {
border-color: color-mix(in oklab, var(--color-primary) 50%, transparent);
background: color-mix(in oklab, var(--color-primary) 14%, var(--color-base-100));
transform: translateY(-50%) scale(1.03);
}
.calendar-side-nav-left {
left: 4px;
}
.calendar-side-nav-right {
right: 4px;
}
.calendar-card-title {
font-size: 11px;
font-weight: 600;
color: color-mix(in oklab, var(--color-base-content) 55%, transparent);
padding: 0 4px 2px;
user-select: none;
}
.calendar-week-number {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
min-width: 24px;
font-size: 10px;
font-weight: 500;
color: color-mix(in oklab, var(--color-base-content) 40%, transparent);
user-select: none;
}
.calendar-hover-targetable {
transform-origin: center center;
transition: transform 320ms ease, box-shadow 320ms ease, outline-color 320ms ease;
}
.calendar-hover-target {
outline: 2px solid color-mix(in oklab, var(--color-primary) 66%, transparent);
outline-offset: 1px;
box-shadow: 0 0 0 1px color-mix(in oklab, var(--color-primary) 32%, transparent) inset;
}
.calendar-zoom-prime-active {
z-index: 2;
}
.calendar-fly-rect {
position: absolute;
z-index: 20;
pointer-events: none;
will-change: left, top, width, height;
}
.calendar-fly-label-el {
position: absolute;
z-index: 30;
pointer-events: none;
white-space: nowrap;
will-change: left, top, font-size;
}
.calendar-zoom-inline {
position: relative;
display: flex;
align-items: center;
width: 128px;
height: 22px;
padding: 0 10px;
}
.calendar-zoom-slider {
width: 100%;
height: 18px;
margin: 0;
background: transparent;
-webkit-appearance: none;
appearance: none;
cursor: pointer;
}
.calendar-zoom-slider:focus-visible {
outline: none;
}
.calendar-zoom-slider::-webkit-slider-runnable-track {
height: 2px;
border-radius: 999px;
background: color-mix(in oklab, var(--color-base-content) 22%, transparent);
}
.calendar-zoom-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 10px;
height: 10px;
margin-top: -4px;
border-radius: 999px;
background: color-mix(in oklab, var(--color-base-content) 88%, transparent);
border: 0;
}
.calendar-zoom-slider::-moz-range-track {
height: 2px;
border-radius: 999px;
background: color-mix(in oklab, var(--color-base-content) 22%, transparent);
}
.calendar-zoom-slider::-moz-range-progress {
height: 2px;
border-radius: 999px;
background: transparent;
}
.calendar-zoom-slider::-moz-range-thumb {
width: 10px;
height: 10px;
border: 0;
border-radius: 999px;
background: color-mix(in oklab, var(--color-base-content) 88%, transparent);
}
.calendar-zoom-marks {
position: absolute;
inset: 0 10px;
display: flex;
align-items: center;
justify-content: space-between;
pointer-events: none;
}
.calendar-zoom-mark {
width: 4px;
height: 4px;
border-radius: 999px;
background: color-mix(in oklab, var(--color-base-content) 35%, transparent);
}
.calendar-zoom-mark-active {
background: color-mix(in oklab, var(--color-base-content) 85%, transparent);
}
@media (max-width: 960px) {
.calendar-content-wrap {
padding-left: 32px;
padding-right: 32px;
}
.calendar-week-grid {
grid-template-columns: repeat(7, minmax(150px, 1fr));
min-width: 1060px;
}
.calendar-side-nav {
width: 24px;
height: 24px;
}
.calendar-zoom-inline {
width: 108px;
}
}
</style>
<style>
/* Non-scoped: fly-rect inner content is injected via innerHTML */
.calendar-fly-rect .calendar-fly-content {
display: flex;
flex-direction: column;
padding: 12px 16px;
height: 100%;
box-sizing: border-box;
overflow: hidden;
}
.calendar-fly-rect .calendar-fly-skeleton {
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
}
.calendar-fly-rect .calendar-fly-skeleton-line {
height: 8px;
border-radius: 4px;
background: color-mix(in oklab, var(--color-base-content) 10%, transparent);
animation: calendar-fly-skeleton-pulse 0.8s ease-in-out infinite alternate;
}
@keyframes calendar-fly-skeleton-pulse {
from { opacity: 0.3; }
to { opacity: 0.7; }
}
</style>

View File

@@ -1,43 +0,0 @@
<script setup lang="ts">
defineProps<{
isActive: boolean;
isLoading: boolean;
isLoaded: boolean;
showContent: boolean;
pulseScale: number;
}>();
</script>
<template>
<section
class="calendar-lab-rect calendar-lab-day"
:class="isActive ? 'calendar-lab-rect-active' : ''"
:style="{ transform: `scale(${pulseScale})` }"
>
<header class="calendar-lab-header">
<p class="calendar-lab-title">Day</p>
<p class="calendar-lab-subtitle">Timeline events</p>
</header>
<template v-if="showContent">
<p v-if="isLoading" class="calendar-lab-loading">Loading GraphQL day payload</p>
<p v-else-if="isLoaded" class="calendar-lab-meta">Data ready</p>
<div class="calendar-lab-timeline">
<article class="calendar-lab-event">
<span>09:30</span>
<p>Call with client</p>
</article>
<article class="calendar-lab-event">
<span>13:00</span>
<p>Prepare follow-up summary</p>
</article>
<article class="calendar-lab-event">
<span>16:45</span>
<p>Send proposal update</p>
</article>
</div>
</template>
<p v-else class="calendar-lab-hint">Zoom stopped. Day content will render here.</p>
</section>
</template>

View File

@@ -1,41 +0,0 @@
<script setup lang="ts">
defineProps<{
isActive: boolean;
isLoading: boolean;
isLoaded: boolean;
showContent: boolean;
nextLabel?: string;
pulseScale: number;
}>();
</script>
<template>
<section
class="calendar-lab-rect calendar-lab-month"
:class="isActive ? 'calendar-lab-rect-active' : ''"
:style="{ transform: `scale(${pulseScale})` }"
>
<header class="calendar-lab-header">
<p class="calendar-lab-title">Month</p>
<p class="calendar-lab-subtitle">Weeks inside one month</p>
</header>
<template v-if="showContent">
<p v-if="isLoading" class="calendar-lab-loading">Loading GraphQL month payload</p>
<p v-else-if="isLoaded" class="calendar-lab-meta">Data ready</p>
<div class="calendar-lab-grid-month">
<div
v-for="week in 4"
:key="`lab-month-week-${week}`"
class="calendar-lab-row"
>
Week {{ week }}
</div>
</div>
</template>
<p v-else class="calendar-lab-hint">
Zoom into {{ nextLabel ?? "Week" }}
</p>
</section>
</template>

View File

@@ -1,41 +0,0 @@
<script setup lang="ts">
defineProps<{
isActive: boolean;
isLoading: boolean;
isLoaded: boolean;
showContent: boolean;
nextLabel?: string;
pulseScale: number;
}>();
</script>
<template>
<section
class="calendar-lab-rect calendar-lab-week"
:class="isActive ? 'calendar-lab-rect-active' : ''"
:style="{ transform: `scale(${pulseScale})` }"
>
<header class="calendar-lab-header">
<p class="calendar-lab-title">Week</p>
<p class="calendar-lab-subtitle">7 day columns</p>
</header>
<template v-if="showContent">
<p v-if="isLoading" class="calendar-lab-loading">Loading GraphQL week payload</p>
<p v-else-if="isLoaded" class="calendar-lab-meta">Data ready</p>
<div class="calendar-lab-grid-week">
<span
v-for="day in 7"
:key="`lab-week-day-${day}`"
class="calendar-lab-day"
>
D{{ day }}
</span>
</div>
</template>
<p v-else class="calendar-lab-hint">
Zoom into {{ nextLabel ?? "Day" }}
</p>
</section>
</template>

View File

@@ -1,41 +0,0 @@
<script setup lang="ts">
defineProps<{
isActive: boolean;
isLoading: boolean;
isLoaded: boolean;
showContent: boolean;
nextLabel?: string;
pulseScale: number;
}>();
</script>
<template>
<section
class="calendar-lab-rect calendar-lab-year"
:class="isActive ? 'calendar-lab-rect-active' : ''"
:style="{ transform: `scale(${pulseScale})` }"
>
<header class="calendar-lab-header">
<p class="calendar-lab-title">Year</p>
<p class="calendar-lab-subtitle">12 months overview</p>
</header>
<template v-if="showContent">
<p v-if="isLoading" class="calendar-lab-loading">Loading GraphQL year payload</p>
<p v-else-if="isLoaded" class="calendar-lab-meta">Data ready</p>
<div class="calendar-lab-grid-year">
<span
v-for="month in 12"
:key="`lab-year-month-${month}`"
class="calendar-lab-chip"
>
{{ month }}
</span>
</div>
</template>
<p v-else class="calendar-lab-hint">
Zoom into {{ nextLabel ?? "Month" }}
</p>
</section>
</template>

View File

@@ -1,825 +0,0 @@
<script setup lang="ts">
import gsap from "gsap";
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue";
import CrmCalendarLabYearRect from "./CrmCalendarLabYearRect.vue";
import CrmCalendarLabMonthRect from "./CrmCalendarLabMonthRect.vue";
import CrmCalendarLabWeekRect from "./CrmCalendarLabWeekRect.vue";
import CrmCalendarLabDayRect from "./CrmCalendarLabDayRect.vue";
/* ------------------------------------------------------------------ */
/* Types & constants */
/* ------------------------------------------------------------------ */
type Level = "year" | "month" | "week" | "day";
type Direction = "in" | "out";
const LEVELS: Level[] = ["year", "month", "week", "day"];
const LEVEL_LABELS: Record<Level, string> = {
year: "Year",
month: "Month",
week: "Week",
day: "Day",
};
const MONTH_LABELS = [
"Jan", "Feb", "Mar", "Apr",
"May", "Jun", "Jul", "Aug",
"Sep", "Oct", "Nov", "Dec",
];
const DAY_LABELS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
const ZOOM_PRIME_STEPS = 2;
const PRIME_SCALE_MAX = 0.10;
const PRIME_DECAY_MS = 400;
const FLY_DURATION = 0.65;
const FADE_DURATION = 0.18;
const EASE = "power3.inOut";
/* ------------------------------------------------------------------ */
/* Refs */
/* ------------------------------------------------------------------ */
const viewportRef = ref<HTMLDivElement | null>(null);
const flyRectRef = ref<HTMLDivElement | null>(null);
const contentRef = ref<HTMLDivElement | null>(null);
const gridLayerRef = ref<HTMLDivElement | null>(null);
const currentLevel = ref<Level>("year");
const isAnimating = ref(false);
const contentVisible = ref(true);
const flyVisible = ref(false);
const flyLabel = ref("");
const vpWidth = ref(0);
const vpHeight = ref(0);
const selectedMonth = ref(0);
const selectedWeek = ref(0);
const selectedDay = ref(0);
const hoveredMonth = ref(0);
const hoveredWeek = ref(0);
const hoveredDay = ref(0);
const primeCellIndex = ref(-1);
const primeProgress = ref(0);
const wheelPrimeDirection = ref<"" | Direction>("");
const wheelPrimeTicks = ref(0);
let primeTimer: ReturnType<typeof setTimeout> | null = null;
let activeTweens: gsap.core.Tween[] = [];
let sliderTarget = -1;
/* ------------------------------------------------------------------ */
/* Computed */
/* ------------------------------------------------------------------ */
const currentLevelIndex = computed(() => LEVELS.indexOf(currentLevel.value));
const canZoomIn = computed(() => currentLevelIndex.value < LEVELS.length - 1);
const hoveredCellIndex = computed(() => {
switch (currentLevel.value) {
case "year": return hoveredMonth.value;
case "month": return hoveredWeek.value;
case "week": return hoveredDay.value;
default: return 0;
}
});
/* ------------------------------------------------------------------ */
/* Grid definitions */
/* ------------------------------------------------------------------ */
function getChildCount(level: Level): number {
switch (level) {
case "year": return 12;
case "month": return 6;
case "week": return 7;
case "day": return 12;
default: return 0;
}
}
function getGridConfig(level: Level) {
switch (level) {
case "year": return { cols: 4, rows: 3, gap: 10 };
case "month": return { cols: 1, rows: 6, gap: 8 };
case "week": return { cols: 7, rows: 1, gap: 6 };
case "day": return { cols: 1, rows: 12, gap: 5 };
default: return { cols: 1, rows: 1, gap: 0 };
}
}
function getChildLabel(level: Level, index: number): string {
switch (level) {
case "year": return MONTH_LABELS[index] ?? "";
case "month": return `W${index + 1}`;
case "week": return DAY_LABELS[index] ?? "";
case "day": return `${8 + index}:00`;
default: return "";
}
}
function computeGridRects(level: Level, vw: number, vh: number) {
const count = getChildCount(level);
const { cols, rows, gap } = getGridConfig(level);
const pad = 24;
const areaW = vw - pad * 2;
const areaH = vh - pad * 2;
const cellW = (areaW - gap * Math.max(0, cols - 1)) / cols;
const cellH = (areaH - gap * Math.max(0, rows - 1)) / rows;
return Array.from({ length: count }, (_, i) => {
const col = i % cols;
const row = Math.floor(i / cols);
return {
id: `cell-${level}-${i}`,
x: pad + col * (cellW + gap),
y: pad + row * (cellH + gap),
w: cellW,
h: cellH,
label: getChildLabel(level, i),
index: i,
};
});
}
const gridRects = computed(() => {
if (vpWidth.value <= 0 || vpHeight.value <= 0) return [];
return computeGridRects(currentLevel.value, vpWidth.value, vpHeight.value);
});
/* ------------------------------------------------------------------ */
/* GSAP helpers */
/* ------------------------------------------------------------------ */
function tweenTo(target: gsap.TweenTarget, vars: gsap.TweenVars): Promise<void> {
return new Promise((resolve) => {
const t = gsap.to(target, {
...vars,
onComplete: () => {
activeTweens = activeTweens.filter((tw) => tw !== t);
resolve();
},
});
activeTweens.push(t);
});
}
function killAllTweens() {
for (const t of activeTweens) t.kill();
activeTweens = [];
}
/* ------------------------------------------------------------------ */
/* Prime (tension) helpers */
/* ------------------------------------------------------------------ */
function advancePrime(cellIndex: number) {
if (primeTimer) {
clearTimeout(primeTimer);
primeTimer = null;
}
primeCellIndex.value = cellIndex;
primeProgress.value = Math.min(primeProgress.value + 1, ZOOM_PRIME_STEPS);
primeTimer = setTimeout(() => {
primeCellIndex.value = -1;
primeProgress.value = 0;
wheelPrimeDirection.value = "";
wheelPrimeTicks.value = 0;
}, PRIME_DECAY_MS);
}
function resetPrime() {
if (primeTimer) {
clearTimeout(primeTimer);
primeTimer = null;
}
primeCellIndex.value = -1;
primeProgress.value = 0;
}
function getCellPrimeScale(idx: number): number {
if (primeCellIndex.value !== idx || primeProgress.value <= 0) return 1;
return 1 + (primeProgress.value / ZOOM_PRIME_STEPS) * PRIME_SCALE_MAX;
}
/* ------------------------------------------------------------------ */
/* Zoom In */
/* ------------------------------------------------------------------ */
async function zoomIn(overrideIndex?: number) {
if (isAnimating.value) return;
if (currentLevelIndex.value >= LEVELS.length - 1) return;
const hovIdx = overrideIndex ?? hoveredCellIndex.value;
const rects = gridRects.value;
const targetRect = rects[hovIdx];
if (!targetRect) return;
const flyEl = flyRectRef.value;
const contentEl = contentRef.value;
const gridEl = gridLayerRef.value;
if (!flyEl || !contentEl || !gridEl) return;
isAnimating.value = true;
killAllTweens();
resetPrime();
const vw = vpWidth.value;
const vh = vpHeight.value;
const pad = 8;
// 1. Fade out content + grid
await Promise.all([
tweenTo(contentEl, { opacity: 0, duration: FADE_DURATION, ease: "power2.in" }),
tweenTo(gridEl, { opacity: 0, duration: FADE_DURATION, ease: "power2.in" }),
]);
// 2. Position fly rect at source cell, show it
flyLabel.value = targetRect.label;
gsap.set(flyEl, {
left: targetRect.x,
top: targetRect.y,
width: targetRect.w,
height: targetRect.h,
opacity: 1,
borderRadius: 12,
});
flyVisible.value = true;
// 3. Animate fly rect → full viewport (morphing aspect ratio)
await tweenTo(flyEl, {
left: pad,
top: pad,
width: vw - pad * 2,
height: vh - pad * 2,
borderRadius: 14,
duration: FLY_DURATION,
ease: EASE,
});
// 4. Update selection
switch (currentLevel.value) {
case "year":
selectedMonth.value = hovIdx;
selectedWeek.value = 0;
selectedDay.value = 0;
break;
case "month":
selectedWeek.value = hovIdx;
selectedDay.value = 0;
break;
case "week":
selectedDay.value = hovIdx;
break;
}
// 5. Switch level
currentLevel.value = LEVELS[currentLevelIndex.value + 1]!;
// 6. Hide fly rect, prepare content
flyVisible.value = false;
contentVisible.value = true;
gsap.set(contentEl, { opacity: 0 });
gsap.set(gridEl, { opacity: 0 });
await nextTick();
// 7. Fade in new content + grid
await Promise.all([
tweenTo(contentEl, { opacity: 1, duration: 0.25, ease: "power2.out" }),
tweenTo(gridEl, { opacity: 1, duration: 0.25, ease: "power2.out" }),
]);
isAnimating.value = false;
}
/* ------------------------------------------------------------------ */
/* Zoom Out */
/* ------------------------------------------------------------------ */
async function zoomOut() {
if (isAnimating.value) return;
if (currentLevelIndex.value <= 0) return;
const flyEl = flyRectRef.value;
const contentEl = contentRef.value;
const gridEl = gridLayerRef.value;
if (!flyEl || !contentEl || !gridEl) return;
isAnimating.value = true;
killAllTweens();
const vw = vpWidth.value;
const vh = vpHeight.value;
const pad = 8;
const prevIdx = currentLevelIndex.value - 1;
const parentLevel = LEVELS[prevIdx]!;
let fromIdx = 0;
switch (parentLevel) {
case "year": fromIdx = selectedMonth.value; break;
case "month": fromIdx = selectedWeek.value; break;
case "week": fromIdx = selectedDay.value; break;
}
// 1. Fade out current content + grid
await Promise.all([
tweenTo(contentEl, { opacity: 0, duration: FADE_DURATION, ease: "power2.in" }),
tweenTo(gridEl, { opacity: 0, duration: FADE_DURATION, ease: "power2.in" }),
]);
// 2. Position fly rect at full viewport, show it
flyLabel.value = getChildLabel(parentLevel, fromIdx);
gsap.set(flyEl, {
left: pad,
top: pad,
width: vw - pad * 2,
height: vh - pad * 2,
opacity: 1,
borderRadius: 14,
});
flyVisible.value = true;
// 3. Switch to parent level so gridRects recomputes
contentVisible.value = false;
currentLevel.value = parentLevel;
await nextTick();
// 4. Get child rect position in the new grid
const rects = gridRects.value;
const childRect = rects[fromIdx];
if (childRect) {
// 5. Animate fly rect → child cell position (shrink + morph)
await tweenTo(flyEl, {
left: childRect.x,
top: childRect.y,
width: childRect.w,
height: childRect.h,
borderRadius: 12,
duration: FLY_DURATION,
ease: EASE,
});
}
// 6. Hide fly rect, show parent content
flyVisible.value = false;
contentVisible.value = true;
gsap.set(contentEl, { opacity: 0 });
gsap.set(gridEl, { opacity: 0 });
await nextTick();
// 7. Fade in parent content + grid
await Promise.all([
tweenTo(contentEl, { opacity: 1, duration: 0.25, ease: "power2.out" }),
tweenTo(gridEl, { opacity: 1, duration: 0.25, ease: "power2.out" }),
]);
isAnimating.value = false;
}
/* ------------------------------------------------------------------ */
/* Reset to year */
/* ------------------------------------------------------------------ */
async function resetToYear() {
if (isAnimating.value) return;
if (currentLevel.value === "year") return;
const contentEl = contentRef.value;
const gridEl = gridLayerRef.value;
if (!contentEl || !gridEl) return;
isAnimating.value = true;
killAllTweens();
await Promise.all([
tweenTo(contentEl, { opacity: 0, duration: 0.2, ease: "power2.in" }),
tweenTo(gridEl, { opacity: 0, duration: 0.2, ease: "power2.in" }),
]);
currentLevel.value = "year";
contentVisible.value = true;
gsap.set(contentEl, { opacity: 0 });
gsap.set(gridEl, { opacity: 0 });
await nextTick();
await Promise.all([
tweenTo(contentEl, { opacity: 1, duration: 0.3, ease: "power2.out" }),
tweenTo(gridEl, { opacity: 1, duration: 0.3, ease: "power2.out" }),
]);
isAnimating.value = false;
}
/* ------------------------------------------------------------------ */
/* Wheel / interaction */
/* ------------------------------------------------------------------ */
function resetWheelPrime() {
wheelPrimeDirection.value = "";
wheelPrimeTicks.value = 0;
resetPrime();
}
function onWheel(event: WheelEvent) {
event.preventDefault();
if (isAnimating.value) return;
const direction: Direction = event.deltaY < 0 ? "in" : "out";
if (direction === "in" && !canZoomIn.value) return;
if (direction === "out" && currentLevelIndex.value <= 0) return;
if (wheelPrimeDirection.value !== direction) {
wheelPrimeDirection.value = direction;
wheelPrimeTicks.value = 0;
resetPrime();
}
if (wheelPrimeTicks.value < ZOOM_PRIME_STEPS) {
wheelPrimeTicks.value += 1;
if (direction === "in") {
advancePrime(hoveredCellIndex.value);
}
return;
}
resetWheelPrime();
if (direction === "in") {
void zoomIn();
} else {
void zoomOut();
}
}
function onDoubleClick() {
resetWheelPrime();
void resetToYear();
}
/* ------------------------------------------------------------------ */
/* Zoom slider */
/* ------------------------------------------------------------------ */
async function onSliderInput(event: Event) {
const value = Number((event.target as HTMLInputElement)?.value ?? NaN);
if (!Number.isFinite(value)) return;
const targetIndex = Math.max(0, Math.min(3, Math.round(value)));
sliderTarget = targetIndex;
if (isAnimating.value) return;
if (targetIndex === currentLevelIndex.value) return;
for (let i = 0; i < 3; i++) {
if (currentLevelIndex.value === sliderTarget) break;
if (sliderTarget > currentLevelIndex.value) {
await zoomIn(0);
} else {
await zoomOut();
}
}
sliderTarget = -1;
}
/* ------------------------------------------------------------------ */
/* Lifecycle */
/* ------------------------------------------------------------------ */
let resizeObserver: ResizeObserver | null = null;
onMounted(() => {
if (viewportRef.value) {
vpWidth.value = viewportRef.value.clientWidth;
vpHeight.value = viewportRef.value.clientHeight;
}
resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
vpWidth.value = entry.contentRect.width;
vpHeight.value = entry.contentRect.height;
}
});
if (viewportRef.value) {
resizeObserver.observe(viewportRef.value);
}
});
onBeforeUnmount(() => {
if (primeTimer) clearTimeout(primeTimer);
killAllTweens();
resizeObserver?.disconnect();
});
</script>
<template>
<section class="canvas-lab-root">
<header class="canvas-lab-toolbar">
<p class="canvas-lab-level-text">
{{ LEVEL_LABELS[currentLevel] }}
</p>
<div class="canvas-lab-zoom-control" @click.stop>
<input
class="canvas-lab-zoom-slider"
type="range"
min="0"
max="3"
step="1"
:value="currentLevelIndex"
aria-label="Zoom level"
@input="onSliderInput"
>
<div class="canvas-lab-zoom-marks" aria-hidden="true">
<span
v-for="index in 4"
:key="`zoom-mark-${index}`"
class="canvas-lab-zoom-mark"
:class="currentLevelIndex === index - 1 ? 'canvas-lab-zoom-mark-active' : ''"
/>
</div>
</div>
</header>
<div
ref="viewportRef"
class="canvas-lab-viewport"
:class="canZoomIn ? 'cursor-zoom-in' : 'cursor-zoom-out'"
@wheel.prevent="onWheel"
@dblclick="onDoubleClick"
>
<!-- Grid cells (outline rects for current level) -->
<div ref="gridLayerRef" class="canvas-grid-layer">
<div
v-for="(rect, idx) in gridRects"
:key="rect.id"
class="canvas-cell"
:class="[primeCellIndex === idx ? 'canvas-cell-priming' : '']"
:style="{
left: `${rect.x}px`,
top: `${rect.y}px`,
width: `${rect.w}px`,
height: `${rect.h}px`,
transform: primeCellIndex === idx && primeProgress > 0
? `scale(${getCellPrimeScale(idx)})`
: undefined,
}"
@mouseenter="
currentLevel === 'year' ? (hoveredMonth = idx) :
currentLevel === 'month' ? (hoveredWeek = idx) :
currentLevel === 'week' ? (hoveredDay = idx) :
undefined
"
>
<span class="canvas-cell-label">{{ rect.label }}</span>
</div>
</div>
<!-- Flying rect (GSAP-animated during transitions) -->
<div
v-show="flyVisible"
ref="flyRectRef"
class="canvas-fly-rect"
>
<span class="canvas-fly-label">{{ flyLabel }}</span>
</div>
<!-- Content overlay (always 1:1 scale, opacity managed by GSAP) -->
<div
ref="contentRef"
class="canvas-content-layer"
:class="contentVisible ? 'canvas-content-visible' : ''"
>
<CrmCalendarLabYearRect
v-if="currentLevel === 'year'"
:is-active="true"
:is-loading="false"
:is-loaded="true"
:show-content="true"
:pulse-scale="1"
/>
<CrmCalendarLabMonthRect
v-if="currentLevel === 'month'"
:is-active="true"
:is-loading="false"
:is-loaded="true"
:show-content="true"
:pulse-scale="1"
/>
<CrmCalendarLabWeekRect
v-if="currentLevel === 'week'"
:is-active="true"
:is-loading="false"
:is-loaded="true"
:show-content="true"
:pulse-scale="1"
/>
<CrmCalendarLabDayRect
v-if="currentLevel === 'day'"
:is-active="true"
:is-loading="false"
:is-loaded="true"
:show-content="true"
:pulse-scale="1"
/>
</div>
</div>
</section>
</template>
<style scoped>
.canvas-lab-root {
height: calc(100dvh - 2.5rem);
min-height: 620px;
display: flex;
flex-direction: column;
gap: 10px;
padding: 10px;
}
.canvas-lab-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.canvas-lab-level-text {
font-size: 0.78rem;
color: color-mix(in oklab, var(--color-base-content) 72%, transparent);
}
/* ---- Zoom slider ---- */
.canvas-lab-zoom-control {
position: relative;
display: flex;
align-items: center;
width: 128px;
height: 22px;
padding: 0 10px;
}
.canvas-lab-zoom-slider {
width: 100%;
height: 18px;
margin: 0;
background: transparent;
-webkit-appearance: none;
appearance: none;
cursor: pointer;
}
.canvas-lab-zoom-slider:focus-visible {
outline: none;
}
.canvas-lab-zoom-slider::-webkit-slider-runnable-track {
height: 2px;
border-radius: 999px;
background: color-mix(in oklab, var(--color-base-content) 22%, transparent);
}
.canvas-lab-zoom-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 10px;
height: 10px;
margin-top: -4px;
border-radius: 999px;
background: color-mix(in oklab, var(--color-base-content) 88%, transparent);
border: 0;
}
.canvas-lab-zoom-slider::-moz-range-track {
height: 2px;
border-radius: 999px;
background: color-mix(in oklab, var(--color-base-content) 22%, transparent);
}
.canvas-lab-zoom-slider::-moz-range-progress {
height: 2px;
border-radius: 999px;
background: transparent;
}
.canvas-lab-zoom-slider::-moz-range-thumb {
width: 10px;
height: 10px;
border: 0;
border-radius: 999px;
background: color-mix(in oklab, var(--color-base-content) 88%, transparent);
}
.canvas-lab-zoom-marks {
position: absolute;
inset: 0 10px;
display: flex;
align-items: center;
justify-content: space-between;
pointer-events: none;
}
.canvas-lab-zoom-mark {
width: 4px;
height: 4px;
border-radius: 999px;
background: color-mix(in oklab, var(--color-base-content) 35%, transparent);
}
.canvas-lab-zoom-mark-active {
background: color-mix(in oklab, var(--color-base-content) 85%, transparent);
}
/* ---- Viewport ---- */
.canvas-lab-viewport {
position: relative;
flex: 1;
min-height: 0;
border-radius: 14px;
border: 1px solid color-mix(in oklab, var(--color-base-content) 18%, transparent);
background:
radial-gradient(circle at 15% 15%, color-mix(in oklab, var(--color-base-content) 6%, transparent), transparent 40%),
color-mix(in oklab, var(--color-base-100) 94%, transparent);
overflow: hidden;
}
.canvas-grid-layer {
position: absolute;
inset: 0;
}
.canvas-cell {
position: absolute;
border-radius: 12px;
border: 1px solid color-mix(in oklab, var(--color-base-content) 20%, transparent);
background: color-mix(in oklab, var(--color-base-200) 50%, transparent);
transition: border-color 140ms ease, box-shadow 140ms ease, transform 180ms ease;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.canvas-cell:hover {
border-color: color-mix(in oklab, var(--color-primary) 55%, transparent);
box-shadow: 0 0 0 2px color-mix(in oklab, var(--color-primary) 20%, transparent) inset;
}
.canvas-cell-priming {
z-index: 2;
border-color: color-mix(in oklab, var(--color-primary) 80%, transparent);
box-shadow: 0 0 0 3px color-mix(in oklab, var(--color-primary) 36%, transparent) inset;
}
.canvas-cell-label {
font-size: 0.82rem;
font-weight: 600;
color: color-mix(in oklab, var(--color-base-content) 60%, transparent);
pointer-events: none;
user-select: none;
}
.canvas-fly-rect {
position: absolute;
border-radius: 12px;
border: 2px solid color-mix(in oklab, var(--color-primary) 70%, transparent);
background: color-mix(in oklab, var(--color-base-200) 60%, transparent);
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
pointer-events: none;
will-change: left, top, width, height;
}
.canvas-fly-label {
font-size: 1rem;
font-weight: 700;
color: color-mix(in oklab, var(--color-base-content) 70%, transparent);
user-select: none;
}
.canvas-content-layer {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
pointer-events: none;
z-index: 5;
}
.canvas-content-visible {
/* pointer-events stay none — grid cells underneath must receive hover */
}
</style>

View File

@@ -1,712 +0,0 @@
<script setup lang="ts">
import Panzoom from "@panzoom/panzoom";
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue";
type Level = "year" | "month" | "week" | "day";
type Direction = "in" | "out";
const LEVELS: Level[] = ["year", "month", "week", "day"];
const LEVEL_LABELS: Record<Level, string> = {
year: "Year",
month: "Month",
week: "Week",
day: "Day",
};
const MONTH_LABELS = [
"Jan", "Feb", "Mar", "Apr",
"May", "Jun", "Jul", "Aug",
"Sep", "Oct", "Nov", "Dec",
];
const ZOOM_PRIME_STEPS = 2;
const ZOOM_ANIMATION_MS = 2000;
const VIEWPORT_PADDING = 20;
const viewportRef = ref<HTMLDivElement | null>(null);
const sceneRef = ref<HTMLDivElement | null>(null);
const panzoomRef = ref<ReturnType<typeof Panzoom> | null>(null);
const resizeObserver = ref<ResizeObserver | null>(null);
const currentLevel = ref<Level>("year");
const transitionTarget = ref<Level | null>(null);
const isAnimating = ref(false);
const selectedMonth = ref(0);
const selectedWeek = ref(0);
const selectedDay = ref(0);
const hoveredMonth = ref(0);
const hoveredWeek = ref(0);
const hoveredDay = ref(0);
const primeFocusId = ref("");
const wheelPrimeDirection = ref<"" | Direction>("");
const wheelPrimeTicks = ref(0);
let primeTimer: ReturnType<typeof setTimeout> | null = null;
let animationFrameId: number | null = null;
let animationToken = 0;
const currentLevelIndex = computed(() => LEVELS.indexOf(currentLevel.value));
const displayLevel = computed(() => transitionTarget.value ?? currentLevel.value);
const displayLevelIndex = computed(() => LEVELS.indexOf(displayLevel.value));
const canZoomIn = computed(() => currentLevelIndex.value < LEVELS.length - 1);
const canZoomOut = computed(() => currentLevelIndex.value > 0);
function getFocusId(level: Level) {
if (level === "year") return "focus-year";
if (level === "month") return `focus-month-${selectedMonth.value}`;
if (level === "week") return `focus-week-${selectedWeek.value}`;
return `focus-day-${selectedDay.value}`;
}
function findFocusElement(level: Level) {
const scene = sceneRef.value;
if (!scene) return null;
const id = getFocusId(level);
return scene.querySelector<HTMLElement>(`[data-focus-id="${id}"]`);
}
function getRectInScene(element: HTMLElement, scene: HTMLElement) {
let x = 0;
let y = 0;
let node: HTMLElement | null = element;
let reachedScene = false;
while (node && node !== scene) {
x += node.offsetLeft;
y += node.offsetTop;
node = node.offsetParent as HTMLElement | null;
}
if (node === scene) {
reachedScene = true;
}
if (!reachedScene) {
const sceneRect = scene.getBoundingClientRect();
const elementRect = element.getBoundingClientRect();
const scale = panzoomRef.value?.getScale() ?? 1;
return {
x: (elementRect.left - sceneRect.left) / Math.max(0.0001, scale),
y: (elementRect.top - sceneRect.top) / Math.max(0.0001, scale),
width: Math.max(1, elementRect.width / Math.max(0.0001, scale)),
height: Math.max(1, elementRect.height / Math.max(0.0001, scale)),
};
}
return {
x,
y,
width: Math.max(1, element.offsetWidth),
height: Math.max(1, element.offsetHeight),
};
}
function easing(t: number) {
return 1 - (1 - t) ** 3;
}
function stopCameraAnimation() {
animationToken += 1;
if (animationFrameId !== null) {
cancelAnimationFrame(animationFrameId);
animationFrameId = null;
}
}
function computeTransformForElement(element: HTMLElement) {
const viewport = viewportRef.value;
const scene = sceneRef.value;
if (!viewport || !scene) return null;
const targetRect = getRectInScene(element, scene);
const viewportWidth = Math.max(1, viewport.clientWidth);
const viewportHeight = Math.max(1, viewport.clientHeight);
const safeWidth = Math.max(1, viewportWidth - VIEWPORT_PADDING * 2);
const safeHeight = Math.max(1, viewportHeight - VIEWPORT_PADDING * 2);
const scale = Math.min(safeWidth / targetRect.width, safeHeight / targetRect.height);
const x = VIEWPORT_PADDING + (safeWidth - targetRect.width * scale) / 2 - targetRect.x * scale;
const y = VIEWPORT_PADDING + (safeHeight - targetRect.height * scale) / 2 - targetRect.y * scale;
return { x, y, scale };
}
function applyTransform(transform: { x: number; y: number; scale: number }) {
const panzoom = panzoomRef.value;
if (!panzoom) return;
panzoom.zoom(transform.scale, { animate: false, force: true });
panzoom.pan(transform.x, transform.y, { animate: false, force: true });
}
async function applyCameraToElement(element: HTMLElement, animate: boolean) {
const panzoom = panzoomRef.value;
if (!panzoom) return;
const target = computeTransformForElement(element);
if (!target) return;
if (!animate) {
stopCameraAnimation();
applyTransform(target);
return;
}
stopCameraAnimation();
const localToken = animationToken;
const startPan = panzoom.getPan();
const startScale = panzoom.getScale();
const startAt = performance.now();
await new Promise<void>((resolve) => {
const step = (now: number) => {
if (localToken !== animationToken) {
resolve();
return;
}
const elapsed = now - startAt;
const t = Math.max(0, Math.min(1, elapsed / ZOOM_ANIMATION_MS));
const k = easing(t);
applyTransform({
x: startPan.x + (target.x - startPan.x) * k,
y: startPan.y + (target.y - startPan.y) * k,
scale: startScale + (target.scale - startScale) * k,
});
if (t < 1) {
animationFrameId = requestAnimationFrame(step);
return;
}
animationFrameId = null;
resolve();
};
animationFrameId = requestAnimationFrame(step);
});
}
async function applyCameraToLevel(level: Level, animate: boolean) {
const element = findFocusElement(level);
if (!element) return;
await applyCameraToElement(element, animate);
}
function startPrime(focusId: string) {
if (primeTimer) {
clearTimeout(primeTimer);
primeTimer = null;
}
primeFocusId.value = focusId;
primeTimer = setTimeout(() => {
primeFocusId.value = "";
}, 170);
}
function resetWheelPrime() {
wheelPrimeDirection.value = "";
wheelPrimeTicks.value = 0;
}
function nextLevel(level: Level): Level | null {
const idx = LEVELS.indexOf(level);
if (idx < 0 || idx >= LEVELS.length - 1) return null;
return LEVELS[idx + 1] ?? null;
}
function prevLevel(level: Level): Level | null {
const idx = LEVELS.indexOf(level);
if (idx <= 0) return null;
return LEVELS[idx - 1] ?? null;
}
function prepareZoomTarget(direction: Direction): { level: Level; focusId: string } | null {
if (direction === "in") {
if (currentLevel.value === "year") {
selectedMonth.value = hoveredMonth.value;
selectedWeek.value = 0;
selectedDay.value = 0;
const level = nextLevel(currentLevel.value);
if (!level) return null;
return { level, focusId: getFocusId(level) };
}
if (currentLevel.value === "month") {
selectedWeek.value = hoveredWeek.value;
selectedDay.value = 0;
const level = nextLevel(currentLevel.value);
if (!level) return null;
return { level, focusId: getFocusId(level) };
}
if (currentLevel.value === "week") {
selectedDay.value = hoveredDay.value;
const level = nextLevel(currentLevel.value);
if (!level) return null;
return { level, focusId: getFocusId(level) };
}
return null;
}
const level = prevLevel(currentLevel.value);
if (!level) return null;
return { level, focusId: getFocusId(level) };
}
async function animateToLevel(level: Level) {
if (isAnimating.value) return;
isAnimating.value = true;
transitionTarget.value = level;
await nextTick();
await applyCameraToLevel(level, true);
currentLevel.value = level;
transitionTarget.value = null;
isAnimating.value = false;
}
async function zoom(direction: Direction) {
if (isAnimating.value) return false;
const target = prepareZoomTarget(direction);
if (!target) return false;
await animateToLevel(target.level);
return true;
}
function onWheel(event: WheelEvent) {
if (isAnimating.value) return;
const direction: Direction = event.deltaY < 0 ? "in" : "out";
const target = prepareZoomTarget(direction);
if (!target) return;
if (wheelPrimeDirection.value !== direction) {
wheelPrimeDirection.value = direction;
wheelPrimeTicks.value = 0;
}
if (wheelPrimeTicks.value < ZOOM_PRIME_STEPS) {
wheelPrimeTicks.value += 1;
startPrime(target.focusId);
return;
}
resetWheelPrime();
void animateToLevel(target.level);
}
async function onSliderInput(event: Event) {
const target = event.target as HTMLInputElement | null;
if (!target || isAnimating.value) return;
resetWheelPrime();
const targetIndex = Number(target.value);
const safeTargetIndex = Math.max(0, Math.min(LEVELS.length - 1, targetIndex));
while (!isAnimating.value && currentLevelIndex.value < safeTargetIndex) {
const moved = await zoom("in");
if (!moved) break;
}
while (!isAnimating.value && currentLevelIndex.value > safeTargetIndex) {
const moved = await zoom("out");
if (!moved) break;
}
}
function isPrime(id: string) {
return primeFocusId.value === id;
}
function isMonthSelected(index: number) {
return selectedMonth.value === index;
}
function isWeekSelected(index: number) {
return selectedWeek.value === index;
}
function isDaySelected(index: number) {
return selectedDay.value === index;
}
function showMonthContent(index: number) {
return isMonthSelected(index) && currentLevel.value !== "year";
}
function showWeekContent(index: number) {
return isWeekSelected(index) && (currentLevel.value === "week" || currentLevel.value === "day");
}
function showDayContent(index: number) {
return isDaySelected(index) && currentLevel.value === "day";
}
onMounted(async () => {
await nextTick();
if (sceneRef.value) {
panzoomRef.value = Panzoom(sceneRef.value, {
animate: false,
maxScale: 24,
minScale: 0.08,
disablePan: true,
origin: "0 0",
});
}
applyCameraToLevel("year", false);
resizeObserver.value = new ResizeObserver(() => {
applyCameraToLevel(displayLevel.value, false);
});
if (viewportRef.value) {
resizeObserver.value.observe(viewportRef.value);
}
});
onBeforeUnmount(() => {
if (primeTimer) clearTimeout(primeTimer);
stopCameraAnimation();
resizeObserver.value?.disconnect();
panzoomRef.value?.destroy();
panzoomRef.value = null;
});
</script>
<template>
<section class="calendar-lab-root">
<header class="calendar-lab-toolbar">
<p class="calendar-lab-level-text">
Current level: {{ LEVEL_LABELS[currentLevel] }}
</p>
<div class="calendar-zoom-inline" @click.stop>
<input
class="calendar-zoom-slider"
type="range"
min="0"
max="3"
step="1"
:value="displayLevelIndex"
aria-label="Calendar zoom level"
@input="onSliderInput"
>
<div class="calendar-zoom-marks" aria-hidden="true">
<span
v-for="index in 4"
:key="`calendar-lab-zoom-mark-${index}`"
class="calendar-zoom-mark"
:class="displayLevelIndex === index - 1 ? 'calendar-zoom-mark-active' : ''"
/>
</div>
</div>
</header>
<div
ref="viewportRef"
class="calendar-lab-viewport"
:class="canZoomIn ? 'cursor-zoom-in' : 'cursor-zoom-out'"
@wheel.prevent="onWheel"
>
<div ref="sceneRef" class="calendar-lab-scene">
<article class="calendar-year" data-focus-id="focus-year">
<div class="calendar-year-grid">
<div
v-for="(label, monthIndex) in MONTH_LABELS"
:key="`month-${label}`"
class="calendar-month-card"
:class="[
isMonthSelected(monthIndex) ? 'calendar-month-card-selected' : '',
currentLevel === 'year' && hoveredMonth === monthIndex ? 'calendar-hover-target' : '',
isPrime(`focus-month-${monthIndex}`) ? 'calendar-prime-target' : '',
]"
:data-focus-id="`focus-month-${monthIndex}`"
@mouseenter="currentLevel === 'year' ? (hoveredMonth = monthIndex) : undefined"
>
<p class="calendar-card-label">{{ label }}</p>
<div v-if="showMonthContent(monthIndex)" class="calendar-week-grid-wrap">
<div class="calendar-week-grid">
<div
v-for="weekIndex in 6"
:key="`week-${weekIndex - 1}`"
class="calendar-week-card"
:class="[
isWeekSelected(weekIndex - 1) ? 'calendar-week-card-selected' : '',
currentLevel === 'month' && hoveredWeek === weekIndex - 1 ? 'calendar-hover-target' : '',
isPrime(`focus-week-${weekIndex - 1}`) ? 'calendar-prime-target' : '',
]"
:data-focus-id="`focus-week-${weekIndex - 1}`"
@mouseenter="currentLevel === 'month' ? (hoveredWeek = weekIndex - 1) : undefined"
>
<p class="calendar-card-label">Week {{ weekIndex }}</p>
<div v-if="showWeekContent(weekIndex - 1)" class="calendar-day-grid-wrap">
<div class="calendar-day-grid">
<div
v-for="dayIndex in 7"
:key="`day-${dayIndex - 1}`"
class="calendar-day-card"
:class="[
isDaySelected(dayIndex - 1) ? 'calendar-day-card-selected' : '',
currentLevel === 'week' && hoveredDay === dayIndex - 1 ? 'calendar-hover-target' : '',
isPrime(`focus-day-${dayIndex - 1}`) ? 'calendar-prime-target' : '',
]"
:data-focus-id="`focus-day-${dayIndex - 1}`"
@mouseenter="currentLevel === 'week' ? (hoveredDay = dayIndex - 1) : undefined"
>
<p class="calendar-card-label">Day {{ dayIndex }}</p>
<div v-if="showDayContent(dayIndex - 1)" class="calendar-slot-grid-wrap">
<div class="calendar-slot-grid">
<span
v-for="slot in 12"
:key="`slot-${slot}`"
class="calendar-slot"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</article>
</div>
</div>
</section>
</template>
<style scoped>
.calendar-lab-root {
height: calc(100dvh - 2.5rem);
min-height: 620px;
display: flex;
flex-direction: column;
gap: 10px;
padding: 10px;
}
.calendar-lab-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.calendar-lab-level-text {
font-size: 0.78rem;
color: color-mix(in oklab, var(--color-base-content) 72%, transparent);
}
.calendar-lab-viewport {
position: relative;
flex: 1;
min-height: 0;
border-radius: 14px;
border: 1px solid color-mix(in oklab, var(--color-base-content) 18%, transparent);
background:
radial-gradient(circle at 15% 15%, color-mix(in oklab, var(--color-base-content) 6%, transparent), transparent 40%),
color-mix(in oklab, var(--color-base-100) 94%, transparent);
overflow: hidden;
}
.calendar-lab-scene {
position: relative;
width: 1400px;
height: 900px;
will-change: transform;
}
.calendar-year {
position: absolute;
left: 80px;
top: 50px;
width: 1240px;
height: 760px;
border-radius: 14px;
border: 1px solid color-mix(in oklab, var(--color-base-content) 24%, transparent);
background: color-mix(in oklab, var(--color-base-100) 96%, transparent);
padding: 14px;
}
.calendar-year-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-rows: repeat(3, minmax(0, 1fr));
gap: 10px;
width: 100%;
height: 100%;
}
.calendar-month-card,
.calendar-week-card,
.calendar-day-card {
border-radius: 12px;
border: 1px solid color-mix(in oklab, var(--color-base-content) 18%, transparent);
background: color-mix(in oklab, var(--color-base-200) 72%, transparent);
transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease;
min-width: 0;
min-height: 0;
padding: 8px;
}
.calendar-month-card-selected,
.calendar-week-card-selected,
.calendar-day-card-selected {
border-color: color-mix(in oklab, var(--color-primary) 70%, transparent);
}
.calendar-card-label {
font-size: 0.72rem;
font-weight: 600;
color: color-mix(in oklab, var(--color-base-content) 88%, transparent);
}
.calendar-week-grid-wrap,
.calendar-day-grid-wrap,
.calendar-slot-grid-wrap {
width: 100%;
height: calc(100% - 20px);
margin-top: 6px;
}
.calendar-week-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-rows: repeat(2, minmax(0, 1fr));
gap: 8px;
width: 100%;
height: 100%;
}
.calendar-day-grid {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 6px;
width: 100%;
height: 100%;
}
.calendar-slot-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-rows: repeat(3, minmax(0, 1fr));
gap: 5px;
width: 100%;
height: 100%;
}
.calendar-slot {
border-radius: 8px;
border: 1px solid color-mix(in oklab, var(--color-base-content) 16%, transparent);
background: color-mix(in oklab, var(--color-base-100) 88%, transparent);
}
.calendar-hover-target {
border-color: color-mix(in oklab, var(--color-primary) 70%, transparent);
box-shadow: 0 0 0 2px color-mix(in oklab, var(--color-primary) 36%, transparent) inset;
}
.calendar-prime-target {
transform: scale(1.05);
}
.calendar-zoom-inline {
position: relative;
display: flex;
align-items: center;
width: 128px;
height: 22px;
padding: 0 10px;
}
.calendar-zoom-slider {
width: 100%;
height: 18px;
margin: 0;
background: transparent;
-webkit-appearance: none;
appearance: none;
cursor: pointer;
}
.calendar-zoom-slider:focus-visible {
outline: none;
}
.calendar-zoom-slider::-webkit-slider-runnable-track {
height: 2px;
border-radius: 999px;
background: color-mix(in oklab, var(--color-base-content) 22%, transparent);
}
.calendar-zoom-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 10px;
height: 10px;
margin-top: -4px;
border-radius: 999px;
background: color-mix(in oklab, var(--color-base-content) 88%, transparent);
border: 0;
}
.calendar-zoom-slider::-moz-range-track {
height: 2px;
border-radius: 999px;
background: color-mix(in oklab, var(--color-base-content) 22%, transparent);
}
.calendar-zoom-slider::-moz-range-progress {
height: 2px;
border-radius: 999px;
background: transparent;
}
.calendar-zoom-slider::-moz-range-thumb {
width: 10px;
height: 10px;
border: 0;
border-radius: 999px;
background: color-mix(in oklab, var(--color-base-content) 88%, transparent);
}
.calendar-zoom-marks {
position: absolute;
inset: 0 10px;
display: flex;
align-items: center;
justify-content: space-between;
pointer-events: none;
}
.calendar-zoom-mark {
width: 4px;
height: 4px;
border-radius: 999px;
background: color-mix(in oklab, var(--color-base-content) 35%, transparent);
}
.calendar-zoom-mark-active {
background: color-mix(in oklab, var(--color-base-content) 85%, transparent);
}
</style>

View File

@@ -1,595 +0,0 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { createElement } from "react";
import { createRoot } from "react-dom/client";
import { Tldraw, createShapeId, toRichText } from "tldraw";
import "tldraw/tldraw.css";
/* ------------------------------------------------------------------ */
/* Constants */
/* ------------------------------------------------------------------ */
const LEVEL_LABELS = ["year", "month", "week", "day"] as const;
const MONTH_LABELS = [
"January", "February", "March", "April",
"May", "June", "July", "August",
"September", "October", "November", "December",
];
const DAY_LABELS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
const ZOOM_MS = 800;
const PRIME_TICKS_REQUIRED = 2;
const PRIME_RESET_MS = 600;
/* ------------------------------------------------------------------ */
/* Reactive state */
/* ------------------------------------------------------------------ */
const hostRef = ref<HTMLDivElement | null>(null);
const status = ref("Loading tldraw engine...");
const activeLevel = ref(0);
const activeLabel = ref("Year 2026");
const debugInfo = computed(
() => `${LEVEL_LABELS[activeLevel.value] ?? "year"}: ${activeLabel.value}`,
);
let reactRoot: { unmount: () => void } | null = null;
let teardown: (() => void) | null = null;
/* ------------------------------------------------------------------ */
/* Shape builders — one function per zoom level */
/* ------------------------------------------------------------------ */
type ShapeDef = {
id: string;
type: "geo";
x: number;
y: number;
isLocked: boolean;
props: Record<string, unknown>;
meta: Record<string, unknown>;
};
/** Level 0 — year view: 1 container + 12 month cards */
function buildYearShapes(): { shapes: ShapeDef[]; containerId: string; childIds: string[] } {
const containerId = createShapeId("year-2026");
const shapes: ShapeDef[] = [];
const childIds: string[] = [];
// Year container
shapes.push({
id: containerId,
type: "geo",
x: 0,
y: 0,
isLocked: true,
props: {
geo: "rectangle",
w: 3200,
h: 2200,
richText: toRichText("2026"),
color: "black",
fill: "none",
dash: "draw",
},
meta: { level: 0, label: "Year 2026", role: "container" },
});
const cols = 4;
const gap = 34;
const cardW = 730;
const cardH = 650;
const startX = 70;
const startY = 90;
for (let i = 0; i < 12; i++) {
const col = i % cols;
const row = Math.floor(i / cols);
const x = startX + col * (cardW + gap);
const y = startY + row * (cardH + gap);
const id = createShapeId(`month-${i}`);
childIds.push(id);
shapes.push({
id,
type: "geo",
x,
y,
isLocked: true,
props: {
geo: "rectangle",
w: cardW,
h: cardH,
richText: toRichText(MONTH_LABELS[i]!),
color: "black",
fill: "semi",
dash: "draw",
},
meta: { level: 1, label: MONTH_LABELS[i], role: "child", index: i },
});
}
return { shapes, containerId, childIds };
}
/** Level 1 — month view: 1 month container + 6 week rows */
function buildMonthShapes(monthIndex: number): { shapes: ShapeDef[]; containerId: string; childIds: string[] } {
const containerId = createShapeId(`month-${monthIndex}-expanded`);
const shapes: ShapeDef[] = [];
const childIds: string[] = [];
shapes.push({
id: containerId,
type: "geo",
x: 0,
y: 0,
isLocked: true,
props: {
geo: "rectangle",
w: 2400,
h: 1800,
richText: toRichText(MONTH_LABELS[monthIndex]!),
color: "black",
fill: "none",
dash: "draw",
},
meta: { level: 1, label: MONTH_LABELS[monthIndex], role: "container", index: monthIndex },
});
const weekGap = 20;
const weekH = (1800 - 100 - weekGap * 5) / 6;
const weekW = 2400 - 80;
const weekX = 40;
const weekStartY = 80;
for (let w = 0; w < 6; w++) {
const y = weekStartY + w * (weekH + weekGap);
const id = createShapeId(`month-${monthIndex}-week-${w}`);
childIds.push(id);
shapes.push({
id,
type: "geo",
x: weekX,
y,
isLocked: true,
props: {
geo: "rectangle",
w: weekW,
h: weekH,
richText: toRichText(`Week ${w + 1}`),
color: "black",
fill: "semi",
dash: "draw",
},
meta: { level: 2, label: `Week ${w + 1}`, role: "child", index: w },
});
}
return { shapes, containerId, childIds };
}
/** Level 2 — week view: 1 week container + 7 day columns */
function buildWeekShapes(monthIndex: number, weekIndex: number): { shapes: ShapeDef[]; containerId: string; childIds: string[] } {
const containerId = createShapeId(`week-${monthIndex}-${weekIndex}-expanded`);
const shapes: ShapeDef[] = [];
const childIds: string[] = [];
shapes.push({
id: containerId,
type: "geo",
x: 0,
y: 0,
isLocked: true,
props: {
geo: "rectangle",
w: 2800,
h: 1600,
richText: toRichText(`${MONTH_LABELS[monthIndex]} · Week ${weekIndex + 1}`),
color: "black",
fill: "none",
dash: "draw",
},
meta: { level: 2, label: `Week ${weekIndex + 1}`, role: "container", monthIndex, weekIndex },
});
const dayGap = 16;
const dayW = (2800 - 80 - dayGap * 6) / 7;
const dayH = 1600 - 120;
const dayX = 40;
const dayY = 80;
for (let d = 0; d < 7; d++) {
const x = dayX + d * (dayW + dayGap);
const id = createShapeId(`week-${monthIndex}-${weekIndex}-day-${d}`);
childIds.push(id);
shapes.push({
id,
type: "geo",
x,
y: dayY,
isLocked: true,
props: {
geo: "rectangle",
w: dayW,
h: dayH,
richText: toRichText(DAY_LABELS[d]!),
color: "black",
fill: "semi",
dash: "draw",
},
meta: { level: 3, label: DAY_LABELS[d], role: "child", index: d },
});
}
return { shapes, containerId, childIds };
}
/** Level 3 — day view: 1 day container + time slot blocks */
function buildDayShapes(monthIndex: number, weekIndex: number, dayIndex: number): { shapes: ShapeDef[]; containerId: string; childIds: string[] } {
const containerId = createShapeId(`day-${monthIndex}-${weekIndex}-${dayIndex}-expanded`);
const shapes: ShapeDef[] = [];
const childIds: string[] = [];
const dayLabel = DAY_LABELS[dayIndex] ?? `Day ${dayIndex + 1}`;
shapes.push({
id: containerId,
type: "geo",
x: 0,
y: 0,
isLocked: true,
props: {
geo: "rectangle",
w: 1200,
h: 2400,
richText: toRichText(`${MONTH_LABELS[monthIndex]} · Week ${weekIndex + 1} · ${dayLabel}`),
color: "black",
fill: "none",
dash: "draw",
},
meta: { level: 3, label: dayLabel, role: "container", monthIndex, weekIndex, dayIndex },
});
// Time slots from 8:00 to 20:00 (12 hour-slots)
const slotGap = 12;
const slotH = (2400 - 100 - slotGap * 11) / 12;
const slotW = 1200 - 80;
const slotX = 40;
const slotStartY = 80;
for (let s = 0; s < 12; s++) {
const y = slotStartY + s * (slotH + slotGap);
const hour = 8 + s;
const id = createShapeId(`day-${monthIndex}-${weekIndex}-${dayIndex}-slot-${s}`);
childIds.push(id);
shapes.push({
id,
type: "geo",
x: slotX,
y,
isLocked: true,
props: {
geo: "rectangle",
w: slotW,
h: slotH,
richText: toRichText(`${hour}:00`),
color: "light-violet",
fill: "semi",
dash: "draw",
},
meta: { level: 4, label: `${hour}:00`, role: "slot", index: s },
});
}
return { shapes, containerId, childIds };
}
/* ------------------------------------------------------------------ */
/* Main zoom flow controller */
/* ------------------------------------------------------------------ */
function setupZoomFlow(editor: any, host: HTMLDivElement) {
let currentLevel = 0;
let selectedMonth = 0;
let selectedWeek = 0;
let selectedDay = 0;
let containerId = "";
let childIds: string[] = [];
let animating = false;
let token = 0;
// Wheel prime state
let primeDirection: "in" | "out" | "" = "";
let primeTicks = 0;
let primeTimer: ReturnType<typeof setTimeout> | null = null;
/** Wipe canvas and draw shapes for a given level */
function renderLevel(level: number, immediate: boolean) {
// Delete all existing shapes
const allShapeIds = editor.getCurrentPageShapeIds();
if (allShapeIds.size > 0) {
editor.deleteShapes([...allShapeIds]);
}
let result: { shapes: ShapeDef[]; containerId: string; childIds: string[] };
switch (level) {
case 0:
result = buildYearShapes();
break;
case 1:
result = buildMonthShapes(selectedMonth);
break;
case 2:
result = buildWeekShapes(selectedMonth, selectedWeek);
break;
case 3:
result = buildDayShapes(selectedMonth, selectedWeek, selectedDay);
break;
default:
return;
}
editor.createShapes(result.shapes);
containerId = result.containerId;
childIds = result.childIds;
currentLevel = level;
// Update reactive debug state
activeLevel.value = level;
const containerMeta = result.shapes.find((s) => s.id === containerId)?.meta;
activeLabel.value = (containerMeta?.label as string) ?? LEVEL_LABELS[level] ?? "";
// Zoom camera to fit container
const bounds = editor.getShapePageBounds(containerId);
if (bounds) {
token += 1;
const localToken = token;
const duration = immediate ? 0 : ZOOM_MS;
animating = !immediate;
editor.stopCameraAnimation();
editor.zoomToBounds(bounds, {
inset: 40,
animation: { duration },
immediate,
force: true,
});
if (!immediate) {
setTimeout(() => {
if (localToken === token) animating = false;
}, duration + 50);
}
}
}
/** Resolve which child the mouse is hovering over */
function resolveHoveredChild(): string | null {
if (childIds.length === 0) return null;
const pointer = editor.inputs.currentPagePoint;
if (!pointer) return childIds[0] ?? null;
const hit = editor.getShapeAtPoint(pointer, {
margin: 0,
hitInside: true,
hitLocked: true,
hitLabels: true,
hitFrameInside: true,
});
if (hit && childIds.includes(hit.id)) return hit.id;
// If hit is the container itself, try first child
return childIds[0] ?? null;
}
function resetPrime() {
primeDirection = "";
primeTicks = 0;
if (primeTimer) {
clearTimeout(primeTimer);
primeTimer = null;
}
}
function zoomIn() {
if (currentLevel >= 3) return;
const targetId = resolveHoveredChild();
if (!targetId) return;
// Find the index of hovered child from its meta
const shape = editor.getShape(targetId);
const idx = (shape?.meta?.index as number) ?? 0;
switch (currentLevel) {
case 0:
selectedMonth = idx;
break;
case 1:
selectedWeek = idx;
break;
case 2:
selectedDay = idx;
break;
}
renderLevel(currentLevel + 1, false);
}
function zoomOut() {
if (currentLevel <= 0) return;
renderLevel(currentLevel - 1, false);
}
function onWheel(event: WheelEvent) {
event.preventDefault();
if (animating) return;
const direction: "in" | "out" = event.deltaY < 0 ? "in" : "out";
// Check if can go in this direction
if (direction === "in" && currentLevel >= 3) return;
if (direction === "out" && currentLevel <= 0) return;
// Reset prime if direction changed
if (primeDirection !== direction) {
resetPrime();
primeDirection = direction;
}
primeTicks += 1;
// Reset prime timer
if (primeTimer) clearTimeout(primeTimer);
primeTimer = setTimeout(resetPrime, PRIME_RESET_MS);
// Highlight target on pre-ticks
if (primeTicks < PRIME_TICKS_REQUIRED) {
// Visual hint: briefly scale hovered child
if (direction === "in") {
const targetId = resolveHoveredChild();
if (targetId) {
const shape = editor.getShape(targetId);
if (shape) {
// Flash the shape by toggling fill
editor.updateShape({
id: targetId,
type: "geo",
props: { fill: "solid" },
});
setTimeout(() => {
try {
editor.updateShape({
id: targetId,
type: "geo",
props: { fill: "semi" },
});
} catch (_) { /* shape might be gone */ }
}, 150);
}
}
}
return;
}
// Enough ticks — execute zoom
resetPrime();
if (direction === "in") {
zoomIn();
} else {
zoomOut();
}
}
function onDoubleClick() {
if (animating) return;
resetPrime();
if (currentLevel === 0) return;
renderLevel(0, false);
}
// Initial render
renderLevel(0, true);
// Event listeners on host element (outside tldraw's event system)
host.addEventListener("wheel", onWheel, { passive: false });
host.addEventListener("dblclick", onDoubleClick);
return () => {
resetPrime();
host.removeEventListener("wheel", onWheel);
host.removeEventListener("dblclick", onDoubleClick);
};
}
/* ------------------------------------------------------------------ */
/* Lifecycle */
/* ------------------------------------------------------------------ */
onMounted(async () => {
try {
if (!hostRef.value) return;
reactRoot = createRoot(hostRef.value);
reactRoot.render(
createElement(Tldraw, {
hideUi: true,
onMount: (editor: any) => {
status.value = "Wheel up = zoom in · wheel down = zoom out · double click = reset";
teardown = setupZoomFlow(editor, hostRef.value as HTMLDivElement);
},
}),
);
} catch (error) {
status.value = "Failed to initialize local tldraw engine";
console.error(error);
}
});
onBeforeUnmount(() => {
teardown?.();
teardown = null;
reactRoot?.unmount();
reactRoot = null;
});
</script>
<template>
<section class="tldraw-lab-root">
<header class="tldraw-lab-toolbar">
<p class="tldraw-lab-title">{{ status }}</p>
<p class="tldraw-lab-subtitle">{{ debugInfo }}</p>
</header>
<div ref="hostRef" class="tldraw-lab-canvas" />
</section>
</template>
<style scoped>
.tldraw-lab-root {
height: calc(100dvh - 1rem);
min-height: 640px;
border-radius: 14px;
border: 1px solid color-mix(in oklab, var(--color-base-content) 20%, transparent);
overflow: hidden;
display: flex;
flex-direction: column;
background: color-mix(in oklab, var(--color-base-100) 95%, transparent);
}
.tldraw-lab-toolbar {
height: 42px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 0 10px;
border-bottom: 1px solid color-mix(in oklab, var(--color-base-content) 16%, transparent);
background: color-mix(in oklab, var(--color-base-100) 94%, transparent);
}
.tldraw-lab-title {
font-size: 0.78rem;
color: color-mix(in oklab, var(--color-base-content) 72%, transparent);
}
.tldraw-lab-subtitle {
font-size: 0.74rem;
color: color-mix(in oklab, var(--color-base-content) 60%, transparent);
}
.tldraw-lab-canvas {
width: 100%;
height: calc(100% - 42px);
}
</style>

View File

@@ -1,493 +0,0 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
type ContactRightPanelMode = "summary" | "documents";
const props = defineProps<{
selectedWorkspaceContactDocuments: any[];
contactRightPanelMode: ContactRightPanelMode;
onContactRightPanelModeChange: (mode: ContactRightPanelMode) => void;
selectedDocumentId: string;
onSelectedDocumentIdChange: (documentId: string) => void;
contactDocumentsSearch: string;
onContactDocumentsSearchInput: (value: string) => void;
filteredSelectedWorkspaceContactDocuments: any[];
formatStamp: (iso: string) => string;
openDocumentsTab: (focusDocument?: boolean) => void;
selectedWorkspaceDeal: any | null;
isReviewHighlightedDeal: (dealId: string) => boolean;
contextPickerEnabled: boolean;
hasContextScope: (scope: "deal" | "summary") => boolean;
toggleContextScope: (scope: "deal" | "summary") => void;
formatDealHeadline: (deal: any) => string;
selectedWorkspaceDealSubtitle: string;
selectedWorkspaceDealSteps: any[];
selectedDealStepsExpanded: boolean;
onSelectedDealStepsExpandedChange: (value: boolean) => void;
isDealStepDone: (step: any) => boolean;
formatDealStepMeta: (step: any) => string;
dealStageOptions: string[];
createDealForContact: (input: {
contactId: string;
title: string;
stage: string;
amount: string;
paidAmount: string;
}) => Promise<any>;
dealCreateLoading: boolean;
updateDealDetails: (input: { dealId: string; stage: string; amount: string; paidAmount: string }) => Promise<boolean>;
dealUpdateLoading: boolean;
activeReviewContactDiff: {
contactId?: string;
before?: string;
after?: string;
} | null;
selectedWorkspaceContact: {
id: string;
name?: string;
description: string;
} | null;
}>();
function onDocumentsSearchInput(event: Event) {
const target = event.target as HTMLInputElement | null;
props.onContactDocumentsSearchInput(target?.value ?? "");
}
const dealStageDraft = ref("");
const dealAmountDraft = ref("");
const dealPaidAmountDraft = ref("");
const dealNewStageDraft = ref("");
const dealSaveError = ref("");
const dealSaveSuccess = ref("");
const dealCreateTitleDraft = ref("");
const dealCreateStageDraft = ref("");
const dealCreateAmountDraft = ref("");
const dealCreatePaidAmountDraft = ref("");
const dealCreateError = ref("");
const dealCreateSuccess = ref("");
const visibleDealStageOptions = computed(() => {
const unique = new Set<string>(props.dealStageOptions);
const current = dealStageDraft.value.trim();
if (current) unique.add(current);
return [...unique];
});
const visibleDealCreateStageOptions = computed(() => {
const unique = new Set<string>(props.dealStageOptions);
const current = dealCreateStageDraft.value.trim();
if (current) unique.add(current);
return [...unique];
});
watch(
() => props.selectedWorkspaceDeal?.id ?? "",
() => {
dealStageDraft.value = String(props.selectedWorkspaceDeal?.stage ?? "").trim();
dealAmountDraft.value = String(props.selectedWorkspaceDeal?.amount ?? "").trim();
dealPaidAmountDraft.value = String(props.selectedWorkspaceDeal?.paidAmount ?? "").trim();
dealNewStageDraft.value = "";
dealSaveError.value = "";
dealSaveSuccess.value = "";
},
{ immediate: true },
);
watch(
() => props.selectedWorkspaceContact?.id ?? "",
() => {
dealCreateTitleDraft.value = "";
dealCreateAmountDraft.value = "";
dealCreatePaidAmountDraft.value = "";
dealCreateStageDraft.value = props.dealStageOptions[0] ?? "Новый";
dealCreateError.value = "";
dealCreateSuccess.value = "";
},
{ immediate: true },
);
watch(
() => props.dealStageOptions.join("|"),
() => {
if (!dealCreateStageDraft.value.trim()) {
dealCreateStageDraft.value = props.dealStageOptions[0] ?? "Новый";
}
},
{ immediate: true },
);
function applyNewDealStage() {
const value = dealNewStageDraft.value.trim();
if (!value) {
dealSaveError.value = "Введите название статуса";
dealSaveSuccess.value = "";
return;
}
dealStageDraft.value = value;
dealNewStageDraft.value = "";
dealSaveError.value = "";
dealSaveSuccess.value = "";
}
async function saveDealDetails() {
if (!props.selectedWorkspaceDeal) return;
dealSaveError.value = "";
dealSaveSuccess.value = "";
try {
const changed = await props.updateDealDetails({
dealId: props.selectedWorkspaceDeal.id,
stage: dealStageDraft.value,
amount: dealAmountDraft.value,
paidAmount: dealPaidAmountDraft.value,
});
dealSaveSuccess.value = changed ? "Сделка обновлена" : "Изменений нет";
} catch (error) {
dealSaveError.value = error instanceof Error ? error.message : "Не удалось обновить сделку";
}
}
async function createDeal() {
if (!props.selectedWorkspaceContact) return;
dealCreateError.value = "";
dealCreateSuccess.value = "";
try {
const created = await props.createDealForContact({
contactId: props.selectedWorkspaceContact.id,
title: dealCreateTitleDraft.value,
stage: dealCreateStageDraft.value,
amount: dealCreateAmountDraft.value,
paidAmount: dealCreatePaidAmountDraft.value,
});
if (!created) {
dealCreateError.value = "Не удалось создать сделку";
return;
}
dealCreateSuccess.value = "Сделка создана";
dealCreateTitleDraft.value = "";
dealCreateAmountDraft.value = "";
dealCreatePaidAmountDraft.value = "";
} catch (error) {
dealCreateError.value = error instanceof Error ? error.message : "Не удалось создать сделку";
}
}
</script>
<template>
<aside class="h-full min-h-0">
<div class="flex h-full min-h-0 flex-col p-3">
<div
v-if="props.selectedWorkspaceContactDocuments.length"
class="mb-2 flex flex-wrap items-center gap-1.5 rounded-xl border border-base-300 bg-base-200/35 px-2 py-1.5"
>
<button
class="badge badge-sm badge-outline"
@click="props.onContactRightPanelModeChange('documents')"
>
{{ props.selectedWorkspaceContactDocuments.length }} documents
</button>
<button
v-for="doc in props.selectedWorkspaceContactDocuments.slice(0, 15)"
:key="`contact-doc-chip-${doc.id}`"
class="rounded-full border border-base-300 bg-base-100 px-2 py-0.5 text-[10px] text-base-content/80 hover:bg-base-200/70"
@click="props.onContactRightPanelModeChange('documents'); props.onSelectedDocumentIdChange(doc.id)"
>
{{ doc.title }}
</button>
</div>
<div v-if="props.contactRightPanelMode === 'documents'" class="min-h-0 flex-1 overflow-y-auto pr-1">
<div class="sticky top-0 z-10 border-b border-base-300 bg-base-100 pb-2">
<div class="flex items-center justify-between gap-2">
<p class="text-xs font-semibold uppercase tracking-wide text-base-content/60">
Contact documents
</p>
<button class="btn btn-ghost btn-xs" @click="props.onContactRightPanelModeChange('summary')">Summary</button>
</div>
<input
:value="props.contactDocumentsSearch"
type="text"
class="input input-bordered input-xs mt-2 w-full"
placeholder="Search documents..."
@input="onDocumentsSearchInput"
>
</div>
<div class="mt-2 space-y-1.5">
<article
v-for="doc in props.filteredSelectedWorkspaceContactDocuments"
:key="`contact-doc-right-${doc.id}`"
class="w-full rounded-xl border border-base-300 px-2.5 py-2 text-left transition hover:bg-base-200/50"
:class="props.selectedDocumentId === doc.id ? 'border-primary bg-primary/10' : ''"
@click="props.onSelectedDocumentIdChange(doc.id)"
>
<div class="flex items-start justify-between gap-2">
<p class="min-w-0 flex-1 truncate text-xs font-semibold">{{ doc.title }}</p>
</div>
<p class="mt-0.5 line-clamp-2 text-[11px] text-base-content/70">{{ doc.summary }}</p>
<div class="mt-1 flex items-center justify-between gap-2">
<p class="text-[10px] text-base-content/55">Updated {{ props.formatStamp(doc.updatedAt) }}</p>
<button class="btn btn-ghost btn-xs px-1" @click.stop="props.onSelectedDocumentIdChange(doc.id); props.openDocumentsTab(true)">Open</button>
</div>
</article>
<p v-if="props.filteredSelectedWorkspaceContactDocuments.length === 0" class="px-1 py-2 text-xs text-base-content/55">
No linked documents.
</p>
</div>
</div>
<div v-else class="min-h-0 flex-1 space-y-3 overflow-y-auto pr-1">
<div
v-if="props.selectedWorkspaceContact"
class="rounded-xl border border-base-300 bg-base-200/25 p-2.5"
>
<p class="text-xs font-semibold uppercase tracking-wide text-base-content/60">Новая сделка</p>
<input
v-model="dealCreateTitleDraft"
type="text"
class="input input-bordered input-sm mt-2 w-full"
:disabled="props.dealCreateLoading"
placeholder="Название сделки"
>
<div class="mt-2 grid grid-cols-2 gap-1.5">
<div class="space-y-1">
<p class="text-[10px] uppercase tracking-wide text-base-content/60">Статус</p>
<select
v-model="dealCreateStageDraft"
class="select select-bordered select-xs w-full"
:disabled="props.dealCreateLoading"
>
<option
v-for="stageOption in visibleDealCreateStageOptions"
:key="`create-deal-stage-${stageOption}`"
:value="stageOption"
>
{{ stageOption }}
</option>
</select>
</div>
<div class="space-y-1">
<p class="text-[10px] uppercase tracking-wide text-base-content/60">Сумма</p>
<input
v-model="dealCreateAmountDraft"
type="text"
inputmode="numeric"
class="input input-bordered input-xs h-7 min-h-7 w-full"
:disabled="props.dealCreateLoading"
placeholder="0"
>
</div>
</div>
<div class="mt-1.5 space-y-1">
<p class="text-[10px] uppercase tracking-wide text-base-content/60">Оплачено</p>
<input
v-model="dealCreatePaidAmountDraft"
type="text"
inputmode="numeric"
class="input input-bordered input-xs h-7 min-h-7 w-full"
:disabled="props.dealCreateLoading"
placeholder="0"
>
</div>
<p v-if="dealCreateError" class="mt-2 text-[10px] text-error">{{ dealCreateError }}</p>
<p v-if="dealCreateSuccess" class="mt-2 text-[10px] text-success">{{ dealCreateSuccess }}</p>
<div class="mt-2 flex justify-end">
<button
class="btn btn-primary btn-xs h-7 min-h-7 px-2.5"
:disabled="props.dealCreateLoading"
@click="createDeal"
>
Создать сделку
</button>
</div>
</div>
<div
v-if="props.selectedWorkspaceDeal"
class="rounded-xl border border-base-300 bg-base-200/30 p-2.5"
:class="[
props.isReviewHighlightedDeal(props.selectedWorkspaceDeal.id) ? 'border-primary/60 bg-primary/10' : '',
props.contextPickerEnabled ? 'context-scope-block context-scope-block-active' : '',
props.hasContextScope('deal') ? 'context-scope-block-selected' : '',
]"
@click="props.toggleContextScope('deal')"
>
<span v-if="props.contextPickerEnabled" class="context-scope-label">Сделка</span>
<p class="text-sm font-medium">
{{ props.formatDealHeadline(props.selectedWorkspaceDeal) }}
</p>
<p class="mt-1 text-[11px] text-base-content/75">
{{ props.selectedWorkspaceDealSubtitle }}
</p>
<div class="mt-2 space-y-2 rounded-lg border border-base-300/70 bg-base-100/75 p-2" @click.stop>
<div class="space-y-1">
<p class="text-[10px] uppercase tracking-wide text-base-content/60">Статус сделки</p>
<select
v-model="dealStageDraft"
class="select select-bordered select-xs w-full"
:disabled="props.dealUpdateLoading"
>
<option v-for="stageOption in visibleDealStageOptions" :key="`deal-stage-${stageOption}`" :value="stageOption">
{{ stageOption }}
</option>
</select>
</div>
<div class="flex items-center gap-1.5">
<input
v-model="dealNewStageDraft"
type="text"
class="input input-bordered input-xs h-7 min-h-7 flex-1"
:disabled="props.dealUpdateLoading"
placeholder="Добавить статус"
@keydown.enter.prevent="applyNewDealStage"
>
<button
class="btn btn-ghost btn-xs h-7 min-h-7 px-2"
:disabled="props.dealUpdateLoading"
@click="applyNewDealStage"
>
Добавить
</button>
</div>
<div class="grid grid-cols-2 gap-1.5">
<div class="space-y-1">
<p class="text-[10px] uppercase tracking-wide text-base-content/60">Сумма</p>
<input
v-model="dealAmountDraft"
type="text"
inputmode="numeric"
class="input input-bordered input-xs h-7 min-h-7 w-full"
:disabled="props.dealUpdateLoading"
placeholder="0"
>
</div>
<div class="space-y-1">
<p class="text-[10px] uppercase tracking-wide text-base-content/60">Оплачено</p>
<input
v-model="dealPaidAmountDraft"
type="text"
inputmode="numeric"
class="input input-bordered input-xs h-7 min-h-7 w-full"
:disabled="props.dealUpdateLoading"
placeholder="0"
>
</div>
</div>
<p v-if="dealSaveError" class="text-[10px] text-error">{{ dealSaveError }}</p>
<p v-if="dealSaveSuccess" class="text-[10px] text-success">{{ dealSaveSuccess }}</p>
<div class="flex justify-end">
<button
class="btn btn-primary btn-xs h-7 min-h-7 px-2.5"
:disabled="props.dealUpdateLoading"
@click="saveDealDetails"
>
Сохранить
</button>
</div>
</div>
<button
v-if="props.selectedWorkspaceDealSteps.length"
class="mt-2 text-[11px] font-medium text-primary hover:underline"
@click="props.onSelectedDealStepsExpandedChange(!props.selectedDealStepsExpanded)"
>
{{ props.selectedDealStepsExpanded ? "Скрыть шаги" : `Показать шаги (${props.selectedWorkspaceDealSteps.length})` }}
</button>
<div v-if="props.selectedDealStepsExpanded && props.selectedWorkspaceDealSteps.length" class="mt-2 space-y-1.5">
<div
v-for="step in props.selectedWorkspaceDealSteps"
:key="step.id"
class="flex items-start gap-2 rounded-lg border border-base-300/70 bg-base-100/80 px-2 py-1.5"
>
<input
type="checkbox"
class="checkbox checkbox-xs mt-0.5"
:checked="props.isDealStepDone(step)"
disabled
>
<div class="min-w-0 flex-1">
<p class="truncate text-[11px] font-medium" :class="props.isDealStepDone(step) ? 'line-through text-base-content/60' : 'text-base-content/90'">
{{ step.title }}
</p>
<p class="mt-0.5 text-[10px] text-base-content/55">{{ props.formatDealStepMeta(step) }}</p>
</div>
</div>
</div>
</div>
<div
class="relative"
:class="[
props.contextPickerEnabled ? 'context-scope-block context-scope-block-active' : '',
props.hasContextScope('summary') ? 'context-scope-block-selected' : '',
]"
@click="props.toggleContextScope('summary')"
>
<span v-if="props.contextPickerEnabled" class="context-scope-label">Summary</span>
<p class="mb-2 text-xs font-semibold uppercase tracking-wide text-base-content/60">Summary</p>
<div
v-if="props.activeReviewContactDiff && props.selectedWorkspaceContact && props.activeReviewContactDiff.contactId === props.selectedWorkspaceContact.id"
class="mb-2 rounded-xl border border-primary/35 bg-primary/5 p-2"
>
<p class="text-[11px] font-semibold uppercase tracking-wide text-primary/80">Review diff</p>
<p class="mt-1 text-[11px] text-base-content/65">Before</p>
<pre class="mt-1 whitespace-pre-wrap rounded-lg border border-base-300/70 bg-base-100 px-2 py-1.5 text-[11px] leading-relaxed text-base-content/65 line-through">{{ props.activeReviewContactDiff.before || "Empty" }}</pre>
<p class="mt-2 text-[11px] text-base-content/65">After</p>
<pre class="mt-1 whitespace-pre-wrap rounded-lg border border-success/40 bg-success/10 px-2 py-1.5 text-[11px] leading-relaxed text-base-content">{{ props.activeReviewContactDiff.after || "Empty" }}</pre>
</div>
<ContactCollaborativeEditor
v-if="props.selectedWorkspaceContact"
:key="`contact-summary-${props.selectedWorkspaceContact.id}`"
v-model="props.selectedWorkspaceContact.description"
:room="`crm-contact-${props.selectedWorkspaceContact.id}`"
placeholder="Contact summary..."
:plain="true"
/>
<p v-else class="text-xs text-base-content/60">No contact selected.</p>
</div>
</div>
</div>
</aside>
</template>
<style scoped>
.context-scope-block {
position: relative;
border-radius: 16px;
outline: 1px solid color-mix(in oklab, var(--color-base-content) 14%, transparent);
transition: outline-color 160ms ease, box-shadow 160ms ease;
}
.context-scope-block-active {
outline-color: color-mix(in oklab, var(--color-primary) 52%, transparent);
box-shadow:
0 0 0 1px color-mix(in oklab, var(--color-primary) 30%, transparent) inset,
0 0 0 3px color-mix(in oklab, var(--color-primary) 12%, transparent);
}
.context-scope-block-selected {
outline-color: color-mix(in oklab, var(--color-primary) 70%, transparent);
box-shadow: 0 0 0 1px color-mix(in oklab, var(--color-primary) 40%, transparent) inset;
}
.context-scope-label {
position: absolute;
top: -10px;
left: 12px;
padding: 2px 8px;
border-radius: 999px;
font-size: 10px;
letter-spacing: 0.06em;
text-transform: uppercase;
background: color-mix(in oklab, var(--color-primary) 18%, var(--color-base-100));
color: color-mix(in oklab, var(--color-primary-content) 72%, var(--color-base-content));
border: 1px solid color-mix(in oklab, var(--color-primary) 42%, transparent);
pointer-events: none;
}
</style>

View File

@@ -1,185 +0,0 @@
<script setup lang="ts">
type PeopleListMode = "contacts" | "deals";
defineProps<{
peopleListMode: PeopleListMode;
peopleSearch: string;
peopleSortOptions: Array<{ value: string; label: string }>;
peopleSortMode: string;
peopleVisibilityOptions: Array<{ value: string; label: string }>;
peopleVisibilityMode: string;
peopleContactList: any[];
selectedCommThreadId: string;
isReviewHighlightedContact: (contactId: string) => boolean;
openCommunicationThread: (contactName: string) => void;
avatarSrcForThread: (thread: any) => string;
markAvatarBroken: (threadId: string) => void;
contactInitials: (contactName: string) => string;
formatThreadTime: (iso: string) => string;
threadChannelLabel: (thread: any) => string;
peopleDealList: any[];
selectedDealId: string;
isReviewHighlightedDeal: (dealId: string) => boolean;
openDealThread: (deal: any) => void;
getDealCurrentStepLabel: (deal: any) => string;
onPeopleListModeChange: (mode: PeopleListMode) => void;
onPeopleSearchInput: (value: string) => void;
onPeopleSortModeChange: (mode: string) => void;
onPeopleVisibilityModeChange: (mode: string) => void;
}>();
function onSearchInput(event: Event) {
const target = event.target as HTMLInputElement | null;
onPeopleSearchInput(target?.value ?? "");
}
</script>
<template>
<aside class="h-full min-h-0 border-r border-base-300 flex flex-col md:row-span-2">
<div class="sticky top-0 z-20 h-12 border-b border-base-300 bg-base-100 px-2">
<div class="flex h-full items-center gap-1">
<div class="join rounded-lg border border-base-300 overflow-hidden">
<button
class="btn btn-ghost btn-sm join-item rounded-none"
:class="peopleListMode === 'contacts' ? 'bg-base-200/80 text-base-content' : 'text-base-content/65 hover:text-base-content'"
title="Contacts"
@click="onPeopleListModeChange('contacts')"
>
<svg viewBox="0 0 24 24" class="h-4 w-4 fill-current">
<path d="M12 12a5 5 0 1 0-5-5 5 5 0 0 0 5 5m0 2c-4.42 0-8 2.24-8 5v1h16v-1c0-2.76-3.58-5-8-5" />
</svg>
</button>
<button
class="btn btn-ghost btn-sm join-item rounded-none border-l border-base-300/70"
:class="peopleListMode === 'deals' ? 'bg-base-200/80 text-base-content' : 'text-base-content/65 hover:text-base-content'"
title="Deals"
@click="onPeopleListModeChange('deals')"
>
<svg viewBox="0 0 24 24" class="h-4 w-4 fill-current">
<path d="M10 3h4a2 2 0 0 1 2 2v2h3a2 2 0 0 1 2 2v3H3V9a2 2 0 0 1 2-2h3V5a2 2 0 0 1 2-2m0 4h4V5h-4zm11 7v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-5h7v2h4v-2z" />
</svg>
</button>
</div>
<input
:value="peopleSearch"
type="text"
class="input input-bordered input-sm w-full"
:placeholder="peopleListMode === 'contacts' ? 'Search contacts' : 'Search deals'"
@input="onSearchInput"
/>
<div class="dropdown dropdown-end">
<button
tabindex="0"
class="btn btn-ghost btn-sm btn-square"
:title="peopleListMode === 'contacts' ? 'Sort contacts' : 'Sort deals'"
>
<svg viewBox="0 0 24 24" class="h-4 w-4 fill-current">
<path d="M3 5h18v2H3zm3 6h12v2H6zm4 6h4v2h-4z" />
</svg>
</button>
<div tabindex="0" class="dropdown-content z-20 mt-2 w-52 rounded-xl border border-base-300 bg-base-100 p-2 shadow-lg">
<template v-if="peopleListMode === 'contacts'">
<p class="px-2 pb-1 text-[11px] font-semibold uppercase tracking-wide text-base-content/55">Sort contacts</p>
<button
v-for="option in peopleSortOptions"
:key="`people-sort-${option.value}`"
class="btn btn-ghost btn-sm w-full justify-between"
@click="onPeopleSortModeChange(option.value)"
>
<span>{{ option.label }}</span>
<span v-if="peopleSortMode === option.value"></span>
</button>
<div class="my-1 h-px bg-base-300/70" />
<p class="px-2 pb-1 text-[11px] font-semibold uppercase tracking-wide text-base-content/55">Filter contacts</p>
<button
v-for="option in peopleVisibilityOptions"
:key="`people-visibility-${option.value}`"
class="btn btn-ghost btn-sm w-full justify-between"
@click="onPeopleVisibilityModeChange(option.value)"
>
<span>{{ option.label }}</span>
<span v-if="peopleVisibilityMode === option.value"></span>
</button>
</template>
<p v-else class="px-2 py-1 text-xs text-base-content/60">Deals are sorted by title.</p>
</div>
</div>
</div>
</div>
<div class="min-h-0 flex-1 overflow-y-auto p-0">
<div
v-if="peopleListMode === 'contacts'"
v-for="thread in peopleContactList"
:key="thread.id"
class="w-full border-b border-base-300 px-3 py-2 text-left transition hover:bg-base-200/40"
:class="[
selectedCommThreadId === thread.id ? 'bg-primary/10' : '',
isReviewHighlightedContact(thread.id) ? 'bg-primary/10 ring-1 ring-primary/45' : '',
]"
@click="openCommunicationThread(thread.contact)"
role="button"
tabindex="0"
@keydown.enter.prevent="openCommunicationThread(thread.contact)"
@keydown.space.prevent="openCommunicationThread(thread.contact)"
>
<div class="flex items-start gap-2">
<div class="avatar shrink-0">
<div class="h-8 w-8 rounded-full ring-1 ring-base-300/70">
<img
v-if="avatarSrcForThread(thread)"
:src="avatarSrcForThread(thread)"
:alt="thread.contact"
@error="markAvatarBroken(thread.id)"
/>
<span v-else class="flex h-full w-full items-center justify-center text-[10px] font-semibold text-base-content/65">
{{ contactInitials(thread.contact) }}
</span>
</div>
</div>
<div class="min-w-0 flex-1">
<div class="flex items-start justify-between gap-2">
<div class="flex min-w-0 flex-1 items-center gap-1">
<span v-if="thread.hasUnread" class="h-2 w-2 shrink-0 rounded-full bg-primary" />
<p class="min-w-0 flex-1 truncate text-xs" :class="thread.hasUnread ? 'font-bold' : 'font-semibold'">{{ thread.contact }}</p>
</div>
<span class="shrink-0 text-[10px]" :class="thread.hasUnread ? 'font-semibold text-primary' : 'text-base-content/55'">{{ formatThreadTime(thread.lastAt) }}</span>
</div>
<p class="mt-0.5 min-w-0 truncate text-[11px]" :class="thread.hasUnread ? 'font-semibold text-base-content' : 'text-base-content/75'">
{{ thread.lastText || threadChannelLabel(thread) }}
</p>
</div>
</div>
</div>
<button
v-if="peopleListMode === 'deals'"
v-for="deal in peopleDealList"
:key="deal.id"
class="w-full border-b border-base-300 px-3 py-2 text-left transition hover:bg-base-200/40"
:class="[
selectedDealId === deal.id ? 'bg-primary/10' : '',
isReviewHighlightedDeal(deal.id) ? 'bg-primary/10 ring-1 ring-primary/45' : '',
]"
@click="openDealThread(deal)"
>
<div class="flex items-start justify-between gap-2">
<p class="truncate text-xs font-semibold">{{ deal.title }}</p>
<span class="shrink-0 text-[10px] text-base-content/55">{{ deal.amount }}</span>
</div>
<p class="mt-0.5 truncate text-[11px] text-base-content/75">{{ deal.stage }}</p>
<p class="mt-0.5 truncate text-[11px] text-base-content/60">{{ getDealCurrentStepLabel(deal) }}</p>
</button>
<p v-if="peopleListMode === 'contacts' && peopleContactList.length === 0" class="px-1 py-2 text-xs text-base-content/55">
{{ peopleVisibilityMode === 'hidden' ? 'No hidden contacts found.' : 'No contacts found.' }}
</p>
<p v-if="peopleListMode === 'deals' && peopleDealList.length === 0" class="px-1 py-2 text-xs text-base-content/55">
No deals found.
</p>
</div>
</aside>
</template>

View File

@@ -1,168 +0,0 @@
<script setup lang="ts">
import { onBeforeUnmount, ref, watch } from "vue";
import { isVoiceCaptureSupported, transcribeAudioBlob } from "~~/app/composables/useVoiceTranscription";
const props = defineProps<{
disabled?: boolean;
sessionKey?: string;
idleTitle?: string;
recordingTitle?: string;
transcribingTitle?: string;
}>();
const emit = defineEmits<{
(e: "update:recording", value: boolean): void;
(e: "update:transcribing", value: boolean): void;
(e: "transcript", value: string): void;
(e: "error", value: string): void;
}>();
const recording = ref(false);
const transcribing = ref(false);
let mediaRecorder: MediaRecorder | null = null;
let recorderStream: MediaStream | null = null;
let recorderMimeType = "audio/webm";
let recordingChunks: Blob[] = [];
let discardOnStop = false;
function setRecording(value: boolean) {
recording.value = value;
emit("update:recording", value);
}
function setTranscribing(value: boolean) {
transcribing.value = value;
emit("update:transcribing", value);
}
function clearRecorderResources() {
if (recorderStream) {
recorderStream.getTracks().forEach((track) => track.stop());
recorderStream = null;
}
mediaRecorder = null;
recordingChunks = [];
discardOnStop = false;
}
async function startRecording() {
if (recording.value || transcribing.value) return;
emit("error", "");
if (!isVoiceCaptureSupported()) {
emit("error", "Recording is not supported in this browser");
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const preferredMime = "audio/webm;codecs=opus";
const recorder = MediaRecorder.isTypeSupported(preferredMime)
? new MediaRecorder(stream, { mimeType: preferredMime })
: new MediaRecorder(stream);
recorderStream = stream;
recorderMimeType = recorder.mimeType || "audio/webm";
mediaRecorder = recorder;
recordingChunks = [];
discardOnStop = false;
setRecording(true);
recorder.ondataavailable = (event: BlobEvent) => {
if (event.data?.size) recordingChunks.push(event.data);
};
recorder.onstop = async () => {
const discard = discardOnStop;
const audioBlob = new Blob(recordingChunks, { type: recorderMimeType });
setRecording(false);
clearRecorderResources();
if (discard || audioBlob.size === 0) return;
setTranscribing(true);
try {
const text = await transcribeAudioBlob(audioBlob);
if (!text) {
emit("error", "Could not recognize speech");
return;
}
emit("error", "");
emit("transcript", text);
} catch (error: any) {
emit("error", String(error?.data?.message ?? error?.message ?? "Voice transcription failed"));
} finally {
setTranscribing(false);
}
};
recorder.start();
} catch {
setRecording(false);
clearRecorderResources();
emit("error", "No microphone access");
}
}
function stopRecording(discard = false) {
if (!mediaRecorder || mediaRecorder.state === "inactive") {
setRecording(false);
clearRecorderResources();
return;
}
discardOnStop = discard;
mediaRecorder.stop();
}
function toggleRecording() {
if (props.disabled || transcribing.value) return;
if (recording.value) {
stopRecording();
return;
}
void startRecording();
}
watch(
() => props.sessionKey,
() => {
if (recording.value) stopRecording(true);
},
);
watch(
() => props.disabled,
(disabled) => {
if (disabled && recording.value) stopRecording(true);
},
);
onBeforeUnmount(() => {
if (recording.value) {
stopRecording(true);
return;
}
clearRecorderResources();
});
</script>
<template>
<button
type="button"
:disabled="Boolean(props.disabled) || transcribing"
:title="
recording
? (props.recordingTitle || 'Stop and insert transcript')
: transcribing
? (props.transcribingTitle || 'Transcribing...')
: (props.idleTitle || 'Voice input')
"
@click="toggleRecording"
>
<slot :recording="recording" :transcribing="transcribing">
<svg viewBox="0 0 24 24" class="h-3.5 w-3.5 fill-current">
<path d="M12 15a3 3 0 0 0 3-3V7a3 3 0 1 0-6 0v5a3 3 0 0 0 3 3m5-3a1 1 0 1 1 2 0 7 7 0 0 1-6 6.92V21h3a1 1 0 1 1 0 2H8a1 1 0 1 1 0-2h3v-2.08A7 7 0 0 1 5 12a1 1 0 1 1 2 0 5 5 0 0 0 10 0" />
</svg>
</slot>
</button>
</template>

View File

@@ -1,211 +0,0 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from "vue";
import MarkdownRichEditor from "~~/app/components/workspace/documents/MarkdownRichEditor.client.vue";
type DocumentSortOption = {
value: string;
label: string;
};
type DocumentListItem = {
id: string;
title: string;
scope: string;
summary: string;
updatedAt: string;
};
type SelectedDocument = {
id: string;
title: string;
scope: string;
owner: string;
summary: string;
body: string;
};
const props = defineProps<{
documentSearch: string;
documentSortMode: string;
documentSortOptions: DocumentSortOption[];
filteredDocuments: DocumentListItem[];
selectedDocumentId: string;
selectedDocument: SelectedDocument | null;
formatDocumentScope: (scope: string) => string;
formatStamp: (iso: string) => string;
}>();
const emit = defineEmits<{
(e: "update:documentSearch", value: string): void;
(e: "update:documentSortMode", value: string): void;
(e: "select-document", documentId: string): void;
(e: "update-selected-document-body", value: string): void;
(e: "delete-document", documentId: string): void;
}>();
const documentContextMenu = ref<{
open: boolean;
x: number;
y: number;
documentId: string;
}>({
open: false,
x: 0,
y: 0,
documentId: "",
});
function closeDocumentContextMenu() {
if (!documentContextMenu.value.open) return;
documentContextMenu.value = {
open: false,
x: 0,
y: 0,
documentId: "",
};
}
function openDocumentContextMenu(event: MouseEvent, doc: DocumentListItem) {
event.preventDefault();
event.stopPropagation();
emit("select-document", doc.id);
const padding = 8;
const menuWidth = 176;
const menuHeight = 44;
const maxX = Math.max(padding, window.innerWidth - menuWidth - padding);
const maxY = Math.max(padding, window.innerHeight - menuHeight - padding);
documentContextMenu.value = {
open: true,
x: Math.min(maxX, Math.max(padding, event.clientX)),
y: Math.min(maxY, Math.max(padding, event.clientY)),
documentId: doc.id,
};
}
function deleteDocumentFromContextMenu() {
const documentId = documentContextMenu.value.documentId;
if (!documentId) return;
emit("delete-document", documentId);
closeDocumentContextMenu();
}
function onWindowKeydown(event: KeyboardEvent) {
if (event.key === "Escape") {
closeDocumentContextMenu();
}
}
onMounted(() => {
window.addEventListener("keydown", onWindowKeydown);
window.addEventListener("scroll", closeDocumentContextMenu, true);
});
onBeforeUnmount(() => {
window.removeEventListener("keydown", onWindowKeydown);
window.removeEventListener("scroll", closeDocumentContextMenu, true);
});
</script>
<template>
<section class="flex h-full min-h-0 flex-col gap-0" @click="closeDocumentContextMenu">
<div class="grid h-full min-h-0 flex-1 gap-0 md:grid-cols-[248px_minmax(0,1fr)]">
<aside class="h-full min-h-0 border-r border-base-300 flex flex-col">
<div class="sticky top-0 z-20 border-b border-base-300 bg-base-100 p-2">
<div class="flex items-center gap-1">
<input
:value="props.documentSearch"
type="text"
class="input input-bordered input-sm w-full"
placeholder="Search documents"
@input="emit('update:documentSearch', ($event.target as HTMLInputElement).value)"
>
<div class="dropdown dropdown-end">
<button
tabindex="0"
class="btn btn-ghost btn-sm btn-square"
title="Sort documents"
>
<svg viewBox="0 0 24 24" class="h-4 w-4 fill-current">
<path d="M3 5h18v2H3zm3 6h12v2H6zm4 6h4v2h-4z" />
</svg>
</button>
<div tabindex="0" class="dropdown-content z-20 mt-2 w-44 rounded-xl border border-base-300 bg-base-100 p-2 shadow-lg">
<p class="px-2 pb-1 text-[11px] font-semibold uppercase tracking-wide text-base-content/55">Sort docs</p>
<button
v-for="option in props.documentSortOptions"
:key="`document-sort-${option.value}`"
class="btn btn-ghost btn-sm w-full justify-between"
@click="emit('update:documentSortMode', option.value)"
>
<span>{{ option.label }}</span>
<span v-if="props.documentSortMode === option.value"></span>
</button>
</div>
</div>
</div>
</div>
<div class="min-h-0 flex-1 overflow-y-auto p-0">
<button
v-for="doc in props.filteredDocuments"
:key="doc.id"
class="w-full border-b border-base-300 px-3 py-2 text-left transition hover:bg-base-200/40"
:class="props.selectedDocumentId === doc.id ? 'bg-primary/10' : ''"
@click="emit('select-document', doc.id)"
@contextmenu="openDocumentContextMenu($event, doc)"
>
<div class="flex items-start justify-between gap-2">
<p class="min-w-0 flex-1 truncate text-xs font-semibold">{{ doc.title }}</p>
</div>
<p class="mt-0.5 truncate text-[11px] text-base-content/75">{{ props.formatDocumentScope(doc.scope) }}</p>
<p class="mt-0.5 line-clamp-2 text-[11px] text-base-content/70">{{ doc.summary }}</p>
<p class="mt-1 text-[10px] text-base-content/55">Updated {{ props.formatStamp(doc.updatedAt) }}</p>
</button>
<p v-if="props.filteredDocuments.length === 0" class="px-2 py-2 text-xs text-base-content/55">
No documents found.
</p>
</div>
</aside>
<article class="h-full min-h-0 flex flex-col">
<div v-if="props.selectedDocument" class="flex h-full min-h-0 flex-col p-3 md:p-4">
<div class="border-b border-base-300 pb-2">
<p class="font-medium">{{ props.selectedDocument.title }}</p>
<p class="text-xs text-base-content/60">
{{ props.formatDocumentScope(props.selectedDocument.scope) }} · {{ props.selectedDocument.owner }}
</p>
<p class="mt-1 text-sm text-base-content/80">{{ props.selectedDocument.summary }}</p>
</div>
<div class="mt-3 min-h-0 flex-1 overflow-y-auto">
<MarkdownRichEditor
:key="`doc-editor-${props.selectedDocument.id}`"
:model-value="props.selectedDocument.body"
placeholder="Describe policy, steps, rules, and exceptions..."
@update:model-value="emit('update-selected-document-body', $event)"
/>
</div>
</div>
<div v-else class="flex h-full items-center justify-center text-sm text-base-content/60">
No document selected.
</div>
</article>
</div>
<div
v-if="documentContextMenu.open"
class="fixed z-50 w-44 rounded-lg border border-base-300 bg-base-100 p-1 shadow-xl"
:style="{ left: `${documentContextMenu.x}px`, top: `${documentContextMenu.y}px` }"
@click.stop
>
<button
class="btn btn-ghost btn-sm w-full justify-start text-error"
@click="deleteDocumentFromContextMenu"
>
Delete document
</button>
</div>
</section>
</template>

View File

@@ -1,106 +0,0 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
type ToastUiEditor = {
destroy: () => void;
getMarkdown: () => string;
getHTML: () => string;
setMarkdown: (markdown: string, cursorToEnd?: boolean) => void;
setHTML: (html: string, cursorToEnd?: boolean) => void;
};
const props = defineProps<{
modelValue: string;
placeholder?: string;
}>();
const emit = defineEmits<{
(event: "update:modelValue", value: string): void;
}>();
const mountEl = ref<HTMLDivElement | null>(null);
const editor = ref<ToastUiEditor | null>(null);
const isSyncing = ref(false);
function looksLikeHtml(value: string) {
return /<([a-z][\w-]*)\b[^>]*>/i.test(value);
}
onMounted(async () => {
if (!mountEl.value) return;
const [{ default: Editor }] = await Promise.all([
import("@toast-ui/editor"),
import("@toast-ui/editor/dist/toastui-editor.css"),
]);
const initialValue = String(props.modelValue ?? "");
const instance = new Editor({
el: mountEl.value,
initialEditType: "wysiwyg",
previewStyle: "tab",
initialValue: looksLikeHtml(initialValue) ? "" : initialValue,
placeholder: props.placeholder ?? "Write with Markdown...",
height: "520px",
hideModeSwitch: true,
usageStatistics: false,
events: {
change: () => {
if (isSyncing.value) return;
emit("update:modelValue", instance.getMarkdown());
},
},
});
if (looksLikeHtml(initialValue)) {
isSyncing.value = true;
instance.setHTML(initialValue, false);
emit("update:modelValue", instance.getMarkdown());
isSyncing.value = false;
}
editor.value = instance as unknown as ToastUiEditor;
});
watch(
() => props.modelValue,
(incoming) => {
const instance = editor.value;
if (!instance || isSyncing.value) return;
const next = String(incoming ?? "");
const currentMarkdown = instance.getMarkdown();
const currentHtml = instance.getHTML();
if (next === currentMarkdown || next === currentHtml) return;
isSyncing.value = true;
if (looksLikeHtml(next)) {
instance.setHTML(next, false);
emit("update:modelValue", instance.getMarkdown());
} else {
instance.setMarkdown(next, false);
}
isSyncing.value = false;
},
);
onBeforeUnmount(() => {
editor.value?.destroy();
editor.value = null;
});
</script>
<template>
<div class="markdown-rich-editor min-h-[420px]">
<div ref="mountEl" />
</div>
</template>
<style scoped>
.markdown-rich-editor :deep(.toastui-editor-defaultUI) {
border-radius: 0.75rem;
border: 1px solid color-mix(in oklab, var(--color-base-content) 14%, transparent);
}
.markdown-rich-editor :deep(.toastui-editor-main-container) {
min-height: 360px;
}
</style>

View File

@@ -1,100 +0,0 @@
<script setup lang="ts">
const props = defineProps<{
selectedTab: "communications" | "documents";
peopleLeftMode: "contacts" | "calendar";
authInitials: string;
authDisplayName: string;
telegramStatusBadgeClass: string;
telegramStatusLabel: string;
telegramConnectBusy: boolean;
telegramConnectNotice: string;
}>();
const emit = defineEmits<{
(e: "open-contacts"): void;
(e: "open-calendar"): void;
(e: "open-documents"): void;
(e: "start-telegram-connect"): void;
(e: "logout"): void;
}>();
</script>
<template>
<div class="workspace-topbar border-b border-base-300 px-3 py-2 md:px-4">
<div class="flex items-center justify-between gap-3">
<div class="join">
<button
class="btn btn-sm join-item"
:class="
props.selectedTab === 'communications' && props.peopleLeftMode === 'contacts'
? 'btn-ghost border border-base-300 bg-base-200/70 text-base-content'
: 'btn-ghost border border-transparent text-base-content/65 hover:border-base-300/70 hover:text-base-content'
"
@click="emit('open-contacts')"
>
Contacts
</button>
<button
class="btn btn-sm join-item"
:class="
props.selectedTab === 'communications' && props.peopleLeftMode === 'calendar'
? 'btn-ghost border border-base-300 bg-base-200/70 text-base-content'
: 'btn-ghost border border-transparent text-base-content/65 hover:border-base-300/70 hover:text-base-content'
"
@click="emit('open-calendar')"
>
Calendar
</button>
<button
class="btn btn-sm join-item"
:class="
props.selectedTab === 'documents'
? 'btn-ghost border border-base-300 bg-base-200/70 text-base-content'
: 'btn-ghost border border-transparent text-base-content/65 hover:border-base-300/70 hover:text-base-content'
"
@click="emit('open-documents')"
>
Documents
</button>
</div>
<div class="dropdown dropdown-end">
<button tabindex="0" class="btn btn-sm btn-ghost gap-2">
<div class="avatar placeholder">
<div class="flex h-7 w-7 items-center justify-center rounded-full bg-primary text-primary-content">
<span class="text-[11px] font-semibold leading-none">{{ props.authInitials }}</span>
</div>
</div>
<span class="max-w-[160px] truncate text-xs font-medium">{{ props.authDisplayName }}</span>
</button>
<div tabindex="0" class="dropdown-content z-30 mt-2 w-80 rounded-box border border-base-300 bg-base-100 p-3 shadow-lg">
<div class="mb-2 border-b border-base-300 pb-2">
<p class="truncate text-sm font-semibold">{{ props.authDisplayName }}</p>
<p class="text-[11px] uppercase tracking-wide text-base-content/60">Settings</p>
</div>
<div class="space-y-2 rounded-lg border border-base-300 bg-base-50/40 p-2">
<div class="flex items-center justify-between gap-2">
<span class="text-xs font-medium">Telegram Business</span>
<span class="badge badge-xs" :class="props.telegramStatusBadgeClass">{{ props.telegramStatusLabel }}</span>
</div>
<button
class="btn btn-xs btn-primary w-full"
:disabled="props.telegramConnectBusy"
@click="emit('start-telegram-connect')"
>
{{ props.telegramConnectBusy ? "Connecting..." : "Connect Telegram" }}
</button>
<p v-if="props.telegramConnectNotice" class="text-[11px] leading-snug text-base-content/70">
{{ props.telegramConnectNotice }}
</p>
</div>
<div class="mt-3 border-t border-base-300 pt-2">
<button class="btn btn-sm w-full btn-ghost justify-start" @click="emit('logout')">Logout</button>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -1,599 +0,0 @@
<script setup lang="ts">
type ChatConversation = {
id: string;
title: string;
lastMessageText?: string | null;
};
type PilotMessage = {
id: string;
role: "user" | "assistant" | "system";
text: string;
messageKind?: string | null;
changeSummary?: string | null;
changeItems?: Array<{ id: string; entity: string; action: string; title: string }> | null;
changeSetId?: string | null;
changeStatus?: string | null;
createdAt?: string;
};
type ContextScopeChip = {
scope: string;
label: string;
};
defineProps<{
pilotHeaderText: string;
chatSwitching: boolean;
chatThreadsLoading: boolean;
chatConversations: ChatConversation[];
authMe: { conversation?: { title?: string | null } | null } | null;
chatCreating: boolean;
renderedPilotMessages: PilotMessage[];
pilotLiveLogs: Array<{ id: string; text: string }>;
pilotLiveLogsExpanded: boolean;
pilotLiveLogHiddenCount: number;
pilotVisibleLogCount: number;
pilotVisibleLiveLogs: Array<{ id: string; text: string }>;
chatThreadPickerOpen: boolean;
selectedChatId: string;
chatArchivingId: string;
pilotInput: string;
pilotRecording: boolean;
contextScopeChips: ContextScopeChip[];
contextPickerEnabled: boolean;
pilotTranscribing: boolean;
pilotSending: boolean;
pilotMicSupported: boolean;
pilotMicError: string | null;
toggleChatThreadPicker: () => void;
createNewChatConversation: () => void;
pilotRoleBadge: (role: PilotMessage["role"]) => string;
pilotRoleName: (role: PilotMessage["role"]) => string;
formatPilotStamp: (iso?: string) => string;
summarizeChangeActions: (items: PilotMessage["changeItems"] | null | undefined) => {
created: number;
updated: number;
deleted: number;
};
summarizeChangeEntities: (
items: PilotMessage["changeItems"] | null | undefined,
) => Array<{ entity: string; count: number }>;
openChangeReview: (changeSetId: string, step?: number, push?: boolean) => void;
togglePilotLiveLogsExpanded: () => void;
closeChatThreadPicker: () => void;
switchChatConversation: (id: string) => void;
formatChatThreadMeta: (conversation: ChatConversation) => string;
archiveChatConversation: (id: string) => void;
handlePilotComposerEnter: (event: KeyboardEvent) => void;
onPilotInput: (value: string) => void;
setPilotWaveContainerRef: (element: HTMLDivElement | null) => void;
toggleContextPicker: () => void;
removeContextScope: (scope: string) => void;
togglePilotRecording: () => void;
handlePilotSendAction: () => void;
}>();
</script>
<template>
<aside class="pilot-shell min-h-0 border-r border-base-300">
<div class="flex h-full min-h-0 flex-col p-0">
<div class="pilot-header">
<div>
<h2 class="text-sm font-semibold text-white/75">{{ pilotHeaderText }}</h2>
</div>
</div>
<div class="pilot-threads">
<div class="flex w-full items-center justify-between gap-2">
<button
class="btn btn-ghost btn-xs h-7 min-h-7 max-w-[228px] justify-start px-1 text-xs font-medium text-white/90 hover:bg-white/10"
:disabled="chatSwitching || chatThreadsLoading || chatConversations.length === 0"
:title="authMe?.conversation?.title || 'Thread'"
@click="toggleChatThreadPicker"
>
<span class="truncate">{{ authMe?.conversation?.title || "Thread" }}</span>
<svg viewBox="0 0 20 20" class="ml-1 h-3.5 w-3.5 fill-current opacity-80">
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z" />
</svg>
</button>
<button
class="btn btn-ghost btn-xs h-7 min-h-7 px-1 text-white/85 hover:bg-white/10"
:disabled="chatCreating"
title="New chat"
@click="createNewChatConversation"
>
{{ chatCreating ? "…" : "+" }}
</button>
</div>
</div>
<div class="pilot-stream-wrap min-h-0 flex-1">
<div class="pilot-timeline min-h-0 h-full overflow-y-auto">
<div
v-for="message in renderedPilotMessages"
:key="message.id"
class="pilot-row"
>
<div class="pilot-avatar" :class="message.role === 'user' ? 'pilot-avatar-user' : ''">
{{ pilotRoleBadge(message.role) }}
</div>
<div class="pilot-body">
<div class="pilot-meta">
<span class="pilot-author">{{ pilotRoleName(message.role) }}</span>
<span class="pilot-time">{{ formatPilotStamp(message.createdAt) }}</span>
</div>
<div v-if="message.messageKind === 'change_set_summary'" class="rounded-xl border border-amber-300/35 bg-amber-500/10 p-3">
<div class="flex items-center justify-between gap-2">
<p class="text-xs font-semibold text-amber-100">
{{ message.changeItems?.length || 0 }} changes
</p>
<button
v-if="message.changeSetId"
class="btn btn-xs btn-outline"
@click="openChangeReview(message.changeSetId, 0, true)"
>
View changes
</button>
</div>
</div>
<div v-else class="pilot-message-text">
{{ message.text }}
</div>
</div>
</div>
<div v-if="pilotLiveLogs.length" class="pilot-stream-status">
<div class="pilot-stream-head">
<p v-if="!pilotLiveLogsExpanded && pilotLiveLogHiddenCount > 0" class="pilot-stream-caption">
Showing last {{ pilotVisibleLogCount }} steps
</p>
<button
v-if="pilotLiveLogHiddenCount > 0 || pilotLiveLogsExpanded"
type="button"
class="pilot-stream-toggle"
@click="togglePilotLiveLogsExpanded"
>
{{ pilotLiveLogsExpanded ? "Show less" : `Show all (+${pilotLiveLogHiddenCount})` }}
</button>
</div>
<p
v-for="log in pilotVisibleLiveLogs"
:key="`pilot-log-${log.id}`"
class="pilot-stream-line"
:class="log.id === pilotLiveLogs[pilotLiveLogs.length - 1]?.id ? 'pilot-stream-line-current' : ''"
>
{{ log.text }}
</p>
</div>
</div>
<div v-if="chatThreadPickerOpen" class="pilot-thread-overlay">
<div class="mb-2 flex items-center justify-between">
<p class="text-[11px] font-semibold uppercase tracking-wide text-white/60">Threads</p>
<button class="btn btn-ghost btn-xs btn-square text-white/70 hover:bg-white/10" title="Close" @click="closeChatThreadPicker">
<svg viewBox="0 0 20 20" class="h-3.5 w-3.5 fill-current">
<path d="M11.06 10 15.53 5.53a.75.75 0 1 0-1.06-1.06L10 8.94 5.53 4.47a.75.75 0 0 0-1.06 1.06L8.94 10l-4.47 4.47a.75.75 0 1 0 1.06 1.06L10 11.06l4.47 4.47a.75.75 0 0 0 1.06-1.06z" />
</svg>
</button>
</div>
<div class="max-h-full space-y-1 overflow-y-auto pr-1">
<div
v-for="thread in chatConversations"
:key="`thread-row-${thread.id}`"
class="flex items-center gap-1 rounded-md"
>
<button
class="min-w-0 flex-1 rounded-md px-2 py-1.5 text-left transition hover:bg-white/10"
:class="selectedChatId === thread.id ? 'bg-white/12' : ''"
:disabled="chatSwitching || chatArchivingId === thread.id"
@click="switchChatConversation(thread.id)"
>
<p class="truncate text-xs font-medium text-white">{{ thread.title }}</p>
<p class="truncate text-[11px] text-white/55">
{{ thread.lastMessageText || "No messages yet" }} · {{ formatChatThreadMeta(thread) }}
</p>
</button>
<button
class="btn btn-ghost btn-xs btn-square text-white/55 hover:bg-white/10 hover:text-red-300"
:disabled="chatSwitching || chatArchivingId === thread.id || chatConversations.length <= 1"
title="Archive thread"
@click="archiveChatConversation(thread.id)"
>
<span v-if="chatArchivingId === thread.id" class="loading loading-spinner loading-xs" />
<svg v-else viewBox="0 0 24 24" class="h-3.5 w-3.5 fill-current">
<path d="M20.54 5.23 19 3H5L3.46 5.23A2 2 0 0 0 3 6.36V8a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6.36a2 2 0 0 0-.46-1.13M5.16 5h13.68l.5.73A.5.5 0 0 1 19.5 6H4.5a.5.5 0 0 1-.34-.27zM6 12h12v6a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2z" />
</svg>
</button>
</div>
</div>
</div>
</div>
<div class="pilot-input-wrap">
<div class="pilot-input-shell">
<textarea
:value="pilotInput"
class="pilot-input-textarea"
:placeholder="pilotRecording ? 'Recording... speak, then press mic to fill or send to submit' : 'Type a message for Pilot...'"
@input="onPilotInput(($event.target as HTMLTextAreaElement | null)?.value ?? '')"
@keydown.enter="handlePilotComposerEnter"
/>
<div v-if="pilotRecording" class="pilot-meter">
<div :ref="setPilotWaveContainerRef" class="pilot-wave-canvas" />
</div>
<div v-if="!pilotRecording" class="pilot-input-context">
<button
v-if="contextScopeChips.length === 0"
class="context-pipette-trigger"
:class="contextPickerEnabled ? 'context-pipette-active' : ''"
:disabled="pilotTranscribing || pilotSending"
aria-label="Контекстная пипетка"
:title="contextPickerEnabled ? 'Выключить пипетку' : 'Включить пипетку контекста'"
@click="toggleContextPicker"
>
<svg viewBox="0 0 24 24" class="h-3.5 w-3.5 fill-current">
<path d="M19.29 4.71a1 1 0 0 0-1.42 0l-2.58 2.58-1.17-1.17a1 1 0 0 0-1.41 0l-1.42 1.42a1 1 0 0 0 0 1.41l.59.59-5.3 5.3a2 2 0 0 0-.53.92l-.86 3.43a1 1 0 0 0 1.21 1.21l3.43-.86a2 2 0 0 0 .92-.53l5.3-5.3.59.59a1 1 0 0 0 1.41 0l1.42-1.42a1 1 0 0 0 0-1.41l-1.17-1.17 2.58-2.58a1 1 0 0 0 0-1.42z" />
</svg>
</button>
<div v-else class="pilot-context-chips">
<button
v-for="chip in contextScopeChips"
:key="`context-chip-${chip.scope}`"
type="button"
class="context-pipette-chip"
@click="removeContextScope(chip.scope)"
>
{{ chip.label }}
<span class="opacity-70">×</span>
</button>
</div>
</div>
<div class="pilot-input-actions">
<button
class="btn btn-xs btn-circle border border-white/20 bg-transparent text-white/90 hover:bg-white/10"
:class="pilotRecording ? 'pilot-mic-active' : ''"
:disabled="!pilotMicSupported || pilotTranscribing || pilotSending"
:title="pilotRecording ? 'Stop and insert transcript' : 'Voice input'"
@click="togglePilotRecording"
>
<svg v-if="!pilotTranscribing" viewBox="0 0 24 24" class="h-3.5 w-3.5 fill-current">
<path d="M12 15a3 3 0 0 0 3-3V7a3 3 0 1 0-6 0v5a3 3 0 0 0 3 3m5-3a1 1 0 1 1 2 0 7 7 0 0 1-6 6.92V21h3a1 1 0 1 1 0 2H8a1 1 0 1 1 0-2h3v-2.08A7 7 0 0 1 5 12a1 1 0 1 1 2 0 5 5 0 0 0 10 0" />
</svg>
<span v-else class="loading loading-spinner loading-xs" />
</button>
<button
class="btn btn-sm btn-circle border-0 bg-[#5865f2] text-white hover:bg-[#4752c4]"
:disabled="pilotTranscribing || pilotSending || (!pilotRecording && !String(pilotInput ?? '').trim())"
:title="pilotRecording ? 'Transcribe and send' : 'Send message'"
@click="handlePilotSendAction"
>
<svg viewBox="0 0 24 24" class="h-4 w-4 fill-current" :class="pilotSending ? 'opacity-50' : ''">
<path d="M4.5 19.5 21 12 4.5 4.5l.02 5.84L15 12l-10.48 1.66z" />
</svg>
</button>
</div>
</div>
<p v-if="pilotMicError" class="pilot-mic-error">{{ pilotMicError }}</p>
</div>
</div>
</aside>
</template>
<style scoped>
.pilot-shell {
background:
radial-gradient(circle at 10% -10%, rgba(124, 144, 255, 0.25), transparent 40%),
radial-gradient(circle at 85% 110%, rgba(88, 101, 242, 0.2), transparent 45%),
#151821;
color: #f5f7ff;
}
.pilot-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
padding: 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(8, 10, 16, 0.2);
}
.pilot-threads {
padding: 10px 10px 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.pilot-timeline {
padding: 10px 8px;
}
.pilot-stream-wrap {
position: relative;
}
.pilot-thread-overlay {
position: absolute;
inset: 0;
z-index: 20;
padding: 10px 8px;
background:
linear-gradient(180deg, rgba(15, 18, 28, 0.96), rgba(15, 18, 28, 0.92)),
rgba(15, 18, 28, 0.9);
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.pilot-row {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 8px 6px;
border-radius: 10px;
}
.pilot-row:hover {
background: rgba(255, 255, 255, 0.04);
}
.pilot-avatar {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 30px;
width: 30px;
height: 30px;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.05em;
color: #e6ebff;
background: linear-gradient(135deg, #5865f2, #7c90ff);
}
.pilot-avatar-user {
background: linear-gradient(135deg, #2a9d8f, #38b2a7);
}
.pilot-body {
min-width: 0;
width: 100%;
}
.pilot-meta {
display: flex;
align-items: center;
gap: 8px;
}
.pilot-author {
font-size: 13px;
font-weight: 700;
color: #f8f9ff;
}
.pilot-time {
font-size: 11px;
color: rgba(255, 255, 255, 0.45);
}
.pilot-message-text {
margin-top: 2px;
font-size: 13px;
line-height: 1.5;
white-space: pre-wrap;
color: rgba(255, 255, 255, 0.92);
}
.pilot-input-wrap {
display: grid;
gap: 6px;
padding: 10px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
background: transparent;
}
.pilot-input-shell {
position: relative;
}
.pilot-input-textarea {
width: 100%;
min-height: 96px;
resize: none;
border-radius: 0;
border: 0;
background: transparent;
color: #f5f7ff;
padding: 10px 88px 36px 12px;
font-size: 13px;
line-height: 1.4;
}
.pilot-input-textarea::placeholder {
color: rgba(255, 255, 255, 0.4);
}
.pilot-input-textarea:focus {
outline: none;
box-shadow: none;
}
.pilot-input-actions {
position: absolute;
right: 10px;
bottom: 8px;
display: flex;
align-items: center;
gap: 6px;
}
.pilot-input-context {
position: absolute;
left: 10px;
bottom: 8px;
right: 96px;
display: flex;
align-items: center;
min-height: 24px;
overflow: hidden;
}
.context-pipette-trigger {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.28);
background: rgba(255, 255, 255, 0.06);
color: rgba(245, 247, 255, 0.94);
padding: 0;
transition: transform 220ms ease, border-color 220ms ease, background-color 220ms ease;
}
.context-pipette-trigger:hover {
transform: scale(1.03);
border-color: color-mix(in oklab, var(--color-primary) 62%, transparent);
background: color-mix(in oklab, var(--color-primary) 18%, transparent);
}
.context-pipette-active {
border-color: color-mix(in oklab, var(--color-primary) 72%, transparent);
background: color-mix(in oklab, var(--color-primary) 24%, transparent);
box-shadow: 0 0 0 1px color-mix(in oklab, var(--color-primary) 38%, transparent) inset;
}
.pilot-context-chips {
display: flex;
align-items: center;
gap: 6px;
max-width: 100%;
overflow-x: auto;
padding-bottom: 1px;
}
.pilot-context-chips::-webkit-scrollbar {
height: 3px;
}
.pilot-context-chips::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.24);
border-radius: 999px;
}
.context-pipette-chip {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 6px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.28);
background: rgba(255, 255, 255, 0.08);
color: rgba(245, 247, 255, 0.95);
padding: 4px 9px;
font-size: 10px;
line-height: 1;
white-space: nowrap;
transition: transform 220ms ease, border-color 220ms ease, background-color 220ms ease;
}
.context-pipette-chip:hover {
transform: scale(1.03);
border-color: color-mix(in oklab, var(--color-primary) 62%, transparent);
background: color-mix(in oklab, var(--color-primary) 20%, transparent);
}
.pilot-meter {
position: absolute;
left: 12px;
right: 88px;
bottom: 9px;
height: 22px;
}
.pilot-wave-canvas {
width: 100%;
height: 100%;
display: block;
overflow: hidden;
}
.pilot-wave-canvas :deep(wave) {
display: block;
height: 100% !important;
}
.pilot-wave-canvas :deep(canvas) {
height: 100% !important;
}
.pilot-mic-active {
border-color: rgba(255, 95, 95, 0.8) !important;
background: rgba(255, 95, 95, 0.16) !important;
color: #ffd9d9 !important;
}
.pilot-mic-error {
margin: 0;
font-size: 11px;
color: rgba(255, 160, 160, 0.92);
}
.pilot-stream-status {
margin-top: 8px;
padding: 2px 4px 8px;
display: grid;
gap: 3px;
}
.pilot-stream-head {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-bottom: 2px;
}
.pilot-stream-caption {
margin: 0;
text-align: center;
font-size: 10px;
line-height: 1.3;
color: rgba(174, 185, 223, 0.72);
}
.pilot-stream-toggle {
border: 1px solid rgba(164, 179, 230, 0.35);
background: rgba(25, 33, 56, 0.45);
color: rgba(229, 235, 255, 0.92);
border-radius: 999px;
font-size: 10px;
line-height: 1.2;
padding: 3px 8px;
transition: background-color 0.15s ease, border-color 0.15s ease;
}
.pilot-stream-toggle:hover {
border-color: rgba(180, 194, 240, 0.6);
background: rgba(35, 45, 72, 0.7);
}
.pilot-stream-line {
margin: 0;
text-align: center;
font-size: 11px;
line-height: 1.35;
color: rgba(189, 199, 233, 0.72);
}
.pilot-stream-line-current {
color: rgba(234, 239, 255, 0.95);
}
</style>

View File

@@ -1,122 +0,0 @@
<script setup lang="ts">
type ChangeItem = {
id: string;
title: string;
entity: string;
action: string;
rolledBack?: boolean;
};
const props = defineProps<{
visible: boolean;
activeChangeStepNumber: number;
activeChangeItems: ChangeItem[];
activeChangeItem: ChangeItem | null;
activeChangeIndex: number;
rollbackableCount: number;
changeActionBusy: boolean;
describeChangeEntity: (entity: string) => string;
describeChangeAction: (action: string) => string;
}>();
const emit = defineEmits<{
(e: "close"): void;
(e: "open-item-target", item: ChangeItem): void;
(e: "rollback-item", itemId: string): void;
(e: "rollback-all"): void;
(e: "prev-step"): void;
(e: "next-step"): void;
(e: "done"): void;
}>();
</script>
<template>
<div
v-if="props.visible"
class="pointer-events-none fixed inset-x-2 bottom-2 z-40 md:inset-auto md:right-4 md:bottom-4 md:w-[390px]"
>
<section class="pointer-events-auto rounded-2xl border border-base-300 bg-base-100/95 p-3 shadow-2xl backdrop-blur">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<p class="text-[11px] font-semibold uppercase tracking-wide text-base-content/60">
Review {{ props.activeChangeStepNumber }}/{{ props.activeChangeItems.length }}
</p>
<p class="truncate text-sm font-semibold text-base-content">
{{ props.activeChangeItem?.title || "Change step" }}
</p>
</div>
<button class="btn btn-ghost btn-xs" @click="emit('close')">Close</button>
</div>
<div v-if="props.activeChangeItem" class="mt-2 rounded-xl border border-base-300 bg-base-200/35 p-2">
<p class="text-xs text-base-content/80">
{{ props.describeChangeEntity(props.activeChangeItem.entity) }}
{{ props.describeChangeAction(props.activeChangeItem.action) }}
</p>
</div>
<div class="mt-2 max-h-40 space-y-1 overflow-y-auto pr-1">
<div
v-for="(item, index) in props.activeChangeItems"
:key="`review-step-${item.id}`"
class="group flex items-center gap-2 rounded-lg border px-2 py-1"
:class="index === props.activeChangeIndex ? 'border-primary/45 bg-primary/10' : 'border-base-300 bg-base-100'"
>
<button
class="min-w-0 flex-1 text-left"
@click="emit('open-item-target', item)"
>
<p class="truncate text-xs font-medium text-base-content">
{{ index + 1 }}. {{ item.title }}
</p>
<p class="truncate text-[11px] text-base-content/65">
{{ props.describeChangeEntity(item.entity) }}
</p>
</button>
<button
v-if="!item.rolledBack"
class="btn btn-ghost btn-xs opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100"
:disabled="props.changeActionBusy"
@click="emit('rollback-item', item.id)"
>
Rollback
</button>
<span v-else class="text-[10px] font-medium uppercase tracking-wide text-warning">Rolled back</span>
</div>
</div>
<div class="mt-3 flex items-center justify-between gap-2">
<div class="join">
<button
class="btn btn-xs join-item"
:disabled="props.activeChangeIndex <= 0"
@click="emit('prev-step')"
>
Prev
</button>
<button
class="btn btn-xs join-item"
:disabled="props.activeChangeIndex >= props.activeChangeItems.length - 1"
@click="emit('next-step')"
>
Next
</button>
</div>
<p class="text-[11px] text-base-content/70">
Rollback available: {{ props.rollbackableCount }}
</p>
</div>
<div class="mt-2 flex flex-wrap gap-2">
<button
class="btn btn-xs btn-warning"
:disabled="props.changeActionBusy || props.rollbackableCount === 0"
@click="emit('rollback-all')"
>
{{ props.changeActionBusy ? "Applying..." : "Rollback all" }}
</button>
<button class="btn btn-xs btn-primary ml-auto" @click="emit('done')">Done</button>
</div>
</section>
</div>
</template>

View File

@@ -1,205 +0,0 @@
import { ref, computed, watch } from "vue";
import { useQuery, useMutation } from "@vue/apollo-composable";
import { MeQueryDocument, LogoutMutationDocument } from "~~/graphql/generated";
type TelegramConnectStatus =
| "not_connected"
| "pending_link"
| "pending_business_connection"
| "connected"
| "disabled"
| "no_reply_rights";
type TelegramConnectionSummary = {
businessConnectionId: string;
isEnabled: boolean | null;
canReply: boolean | null;
updatedAt: string;
};
export function useAuth() {
// -------------------------------------------------------------------------
// Auth state
// -------------------------------------------------------------------------
const authMe = ref<{
user: { id: string; phone: string; name: string };
team: { id: string; name: string };
conversation: { id: string; title: string };
} | null>(null);
const authResolved = ref(false);
const apolloAuthReady = computed(() => !!authMe.value);
// -------------------------------------------------------------------------
// Apollo: Me query
// -------------------------------------------------------------------------
const { result: meResult, refetch: refetchMe, loading: meLoading } = useQuery(
MeQueryDocument,
null,
{ fetchPolicy: "network-only" },
);
watch(() => meResult.value?.me, (me) => {
if (me) authMe.value = me as typeof authMe.value;
}, { immediate: true });
// -------------------------------------------------------------------------
// Apollo: Logout mutation
// -------------------------------------------------------------------------
const { mutate: doLogout } = useMutation(LogoutMutationDocument);
// -------------------------------------------------------------------------
// loadMe / logout
// -------------------------------------------------------------------------
async function loadMe() {
const result = await refetchMe();
const me = result?.data?.me;
if (me) authMe.value = me as typeof authMe.value;
}
async function logout() {
await doLogout();
authMe.value = null;
telegramConnectStatus.value = "not_connected";
telegramConnections.value = [];
telegramConnectUrl.value = "";
if (process.client) {
await navigateTo("/login", { replace: true });
}
}
// -------------------------------------------------------------------------
// Telegram connect state
// -------------------------------------------------------------------------
const telegramConnectStatus = ref<TelegramConnectStatus>("not_connected");
const telegramConnectStatusLoading = ref(false);
const telegramConnectBusy = ref(false);
const telegramConnectUrl = ref("");
const telegramConnections = ref<TelegramConnectionSummary[]>([]);
const telegramConnectNotice = ref("");
const telegramStatusLabel = computed(() => {
if (telegramConnectStatusLoading.value) return "Checking";
if (telegramConnectStatus.value === "connected") return "Connected";
if (telegramConnectStatus.value === "pending_link") return "Pending link";
if (telegramConnectStatus.value === "pending_business_connection") return "Waiting business connect";
if (telegramConnectStatus.value === "disabled") return "Disabled";
if (telegramConnectStatus.value === "no_reply_rights") return "No reply rights";
return "Not connected";
});
const telegramStatusBadgeClass = computed(() => {
if (telegramConnectStatus.value === "connected") return "badge-success";
if (telegramConnectStatus.value === "pending_link" || telegramConnectStatus.value === "pending_business_connection") return "badge-warning";
if (telegramConnectStatus.value === "disabled" || telegramConnectStatus.value === "no_reply_rights") return "badge-error";
return "badge-ghost";
});
// -------------------------------------------------------------------------
// Telegram connect functions
// -------------------------------------------------------------------------
async function loadTelegramConnectStatus() {
if (!authMe.value) {
telegramConnectStatus.value = "not_connected";
telegramConnections.value = [];
telegramConnectUrl.value = "";
return;
}
telegramConnectStatusLoading.value = true;
try {
const result = await $fetch<{
ok: boolean;
status: TelegramConnectStatus;
connections?: TelegramConnectionSummary[];
}>("/api/omni/telegram/business/connect/status", {
method: "GET",
});
telegramConnectStatus.value = result?.status ?? "not_connected";
telegramConnections.value = result?.connections ?? [];
} catch {
telegramConnectStatus.value = "not_connected";
telegramConnections.value = [];
} finally {
telegramConnectStatusLoading.value = false;
}
}
async function startTelegramBusinessConnect() {
if (telegramConnectBusy.value) return;
telegramConnectBusy.value = true;
try {
const result = await $fetch<{
ok: boolean;
status: TelegramConnectStatus;
connectUrl: string;
expiresAt: string;
}>("/api/omni/telegram/business/connect/start", { method: "POST" });
telegramConnectStatus.value = result?.status ?? "pending_link";
telegramConnectUrl.value = String(result?.connectUrl ?? "").trim();
if (telegramConnectUrl.value && process.client) {
window.location.href = telegramConnectUrl.value;
}
} catch {
telegramConnectStatus.value = "not_connected";
} finally {
telegramConnectBusy.value = false;
await loadTelegramConnectStatus();
}
}
async function completeTelegramBusinessConnectFromToken(token: string) {
const t = String(token || "").trim();
if (!t) return;
try {
const result = await $fetch<{
ok: boolean;
status: string;
businessConnectionId?: string;
}>("/api/omni/telegram/business/connect/complete", {
method: "POST",
body: { token: t },
});
if (result?.ok) {
telegramConnectStatus.value = "connected";
telegramConnectNotice.value = "Telegram успешно привязан.";
await loadTelegramConnectStatus();
return;
}
if (result?.status === "awaiting_telegram_start") {
telegramConnectNotice.value = "Сначала нажмите Start в Telegram, затем нажмите кнопку в боте снова.";
} else if (result?.status === "invalid_or_expired_token") {
telegramConnectNotice.value = "Ссылка привязки истекла. Нажмите Connect в CRM заново.";
} else {
telegramConnectNotice.value = "Не удалось завершить привязку. Запустите Connect заново.";
}
} catch {
telegramConnectNotice.value = "Ошибка завершения привязки. Попробуйте снова.";
}
}
return {
authMe,
authResolved,
apolloAuthReady,
meLoading,
loadMe,
logout,
// telegram
telegramConnectStatus,
telegramConnectStatusLoading,
telegramConnectBusy,
telegramConnectUrl,
telegramConnections,
telegramConnectNotice,
telegramStatusLabel,
telegramStatusBadgeClass,
loadTelegramConnectStatus,
startTelegramBusinessConnect,
completeTelegramBusinessConnectFromToken,
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,439 +0,0 @@
import { ref, nextTick } from "vue";
import {
UpdateCommunicationTranscriptMutationDocument,
CommunicationsQueryDocument,
} from "~~/graphql/generated";
import { useMutation } from "@vue/apollo-composable";
import { isVoiceCaptureSupported, transcribeAudioBlob } from "~/composables/useVoiceTranscription";
import type { CommItem } from "~/composables/useContacts";
export function useCallAudio() {
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
const commCallWaveHosts = new Map<string, HTMLDivElement>();
const commCallWaveSurfers = new Map<string, any>();
const commCallPlayableById = ref<Record<string, boolean>>({});
const commCallPlayingById = ref<Record<string, boolean>>({});
const callTranscriptOpen = ref<Record<string, boolean>>({});
const callTranscriptLoading = ref<Record<string, boolean>>({});
const callTranscriptText = ref<Record<string, string>>({});
const callTranscriptError = ref<Record<string, string>>({});
// Event archive recording state
const eventArchiveRecordingById = ref<Record<string, boolean>>({});
const eventArchiveTranscribingById = ref<Record<string, boolean>>({});
const eventArchiveMicErrorById = ref<Record<string, string>>({});
let eventArchiveMediaRecorder: MediaRecorder | null = null;
let eventArchiveRecorderStream: MediaStream | null = null;
let eventArchiveRecorderMimeType = "audio/webm";
let eventArchiveChunks: Blob[] = [];
let eventArchiveTargetEventId = "";
// WaveSurfer module cache
let waveSurferModulesPromise: Promise<{ WaveSurfer: any; RecordPlugin: any }> | null = null;
// ---------------------------------------------------------------------------
// Apollo Mutation
// ---------------------------------------------------------------------------
const { mutate: doUpdateCommunicationTranscript } = useMutation(UpdateCommunicationTranscriptMutationDocument, {
refetchQueries: [{ query: CommunicationsQueryDocument }],
});
// ---------------------------------------------------------------------------
// WaveSurfer lazy loading
// ---------------------------------------------------------------------------
async function loadWaveSurferModules() {
if (!waveSurferModulesPromise) {
waveSurferModulesPromise = Promise.all([
import("wavesurfer.js"),
import("wavesurfer.js/dist/plugins/record.esm.js"),
]).then(([ws, rec]) => ({
WaveSurfer: ws.default,
RecordPlugin: rec.default,
}));
}
return waveSurferModulesPromise;
}
// ---------------------------------------------------------------------------
// Call wave helpers
// ---------------------------------------------------------------------------
function setCommCallPlaying(itemId: string, value: boolean) {
commCallPlayingById.value = {
...commCallPlayingById.value,
[itemId]: value,
};
}
function isCommCallPlaying(itemId: string) {
return Boolean(commCallPlayingById.value[itemId]);
}
function getCallAudioUrl(item?: CommItem) {
return String(item?.audioUrl ?? "").trim();
}
function isCommCallPlayable(item: CommItem) {
const known = commCallPlayableById.value[item.id];
if (typeof known === "boolean") return known;
return Boolean(getCallAudioUrl(item));
}
function pauseOtherCommCallWaves(currentItemId: string) {
for (const [itemId, ws] of commCallWaveSurfers.entries()) {
if (itemId === currentItemId) continue;
ws.pause?.();
setCommCallPlaying(itemId, false);
}
}
function parseDurationToSeconds(raw?: string) {
if (!raw) return 0;
const text = raw.trim().toLowerCase();
if (!text) return 0;
const ms = text.match(/(\d+)\s*m(?:in)?\s*(\d+)?\s*s?/);
if (ms) {
const m = Number(ms[1] ?? 0);
const s = Number(ms[2] ?? 0);
return m * 60 + s;
}
const colon = text.match(/(\d+):(\d+)/);
if (colon) {
return Number(colon[1] ?? 0) * 60 + Number(colon[2] ?? 0);
}
const sec = text.match(/(\d+)\s*s/);
if (sec) return Number(sec[1] ?? 0);
return 0;
}
function buildCallWavePeaks(item: CommItem, size = 320) {
const stored = Array.isArray(item.waveform)
? item.waveform.map((value) => Number(value)).filter((value) => Number.isFinite(value) && value > 0)
: [];
if (stored.length) {
const sampled = new Float32Array(size);
for (let i = 0; i < size; i += 1) {
const t = size <= 1 ? 0 : i / (size - 1);
const idx = Math.min(stored.length - 1, Math.round(t * (stored.length - 1)));
sampled[i] = Math.max(0.05, Math.min(1, stored[idx] ?? 0.05));
}
return sampled;
}
const source = `${item.text} ${(item.transcript ?? []).join(" ")}`.trim() || item.contact;
let seed = 0;
for (let i = 0; i < source.length; i += 1) {
seed = (seed * 31 + source.charCodeAt(i)) >>> 0;
}
const rand = () => {
seed = (seed * 1664525 + 1013904223) >>> 0;
return seed / 0xffffffff;
};
const out = new Float32Array(size);
let smooth = 0;
for (let i = 0; i < size; i += 1) {
const t = i / Math.max(1, size - 1);
const burst = Math.max(0, Math.sin(t * Math.PI * (3 + (source.length % 7))));
const noise = (rand() * 2 - 1) * 0.65;
smooth = smooth * 0.7 + noise * 0.3;
out[i] = Math.max(0.05, Math.min(1, 0.12 + Math.abs(smooth) * 0.48 + burst * 0.4));
}
return out;
}
function destroyCommCallWave(itemId: string) {
const ws = commCallWaveSurfers.get(itemId);
if (!ws) return;
ws.destroy();
commCallWaveSurfers.delete(itemId);
const nextPlayable = { ...commCallPlayableById.value };
delete nextPlayable[itemId];
commCallPlayableById.value = nextPlayable;
const nextPlaying = { ...commCallPlayingById.value };
delete nextPlaying[itemId];
commCallPlayingById.value = nextPlaying;
}
function destroyAllCommCallWaves() {
for (const itemId of commCallWaveSurfers.keys()) {
destroyCommCallWave(itemId);
}
commCallWaveHosts.clear();
}
async function ensureCommCallWave(itemId: string, callItem?: CommItem) {
const host = commCallWaveHosts.get(itemId);
if (!host) return;
if (commCallWaveSurfers.has(itemId)) return;
if (!callItem) return;
const { WaveSurfer } = await loadWaveSurferModules();
const durationSeconds =
parseDurationToSeconds(callItem.duration) ||
Math.max(8, Math.min(120, Math.round(((callItem.transcript ?? []).join(" ").length || callItem.text.length) / 10)));
const peaks = buildCallWavePeaks(callItem, 360);
const audioUrl = getCallAudioUrl(callItem);
const ws = WaveSurfer.create({
container: host,
height: 30,
waveColor: "rgba(180, 206, 255, 0.88)",
progressColor: "rgba(118, 157, 248, 0.95)",
cursorWidth: 0,
interact: Boolean(audioUrl),
normalize: true,
barWidth: 0,
});
ws.on("play", () => setCommCallPlaying(itemId, true));
ws.on("pause", () => setCommCallPlaying(itemId, false));
ws.on("finish", () => setCommCallPlaying(itemId, false));
let playable = false;
if (audioUrl) {
try {
await ws.load(audioUrl, [peaks], durationSeconds);
playable = true;
} catch {
await ws.load("", [peaks], durationSeconds);
playable = false;
}
} else {
await ws.load("", [peaks], durationSeconds);
}
commCallPlayableById.value = {
...commCallPlayableById.value,
[itemId]: playable,
};
commCallWaveSurfers.set(itemId, ws);
}
async function syncCommCallWaves(activeCallIds: Set<string>, getCallItem: (id: string) => CommItem | undefined) {
await nextTick();
for (const id of commCallWaveSurfers.keys()) {
if (!activeCallIds.has(id) || !commCallWaveHosts.has(id)) {
destroyCommCallWave(id);
}
}
for (const id of activeCallIds) {
if (commCallWaveHosts.has(id)) {
await ensureCommCallWave(id, getCallItem(id));
}
}
}
function setCommCallWaveHost(itemId: string, element: Element | null) {
if (!(element instanceof HTMLDivElement)) {
commCallWaveHosts.delete(itemId);
destroyCommCallWave(itemId);
return;
}
commCallWaveHosts.set(itemId, element);
}
// ---------------------------------------------------------------------------
// Call playback toggle
// ---------------------------------------------------------------------------
async function toggleCommCallPlayback(item: CommItem) {
if (!isCommCallPlayable(item)) return;
const itemId = item.id;
await ensureCommCallWave(itemId, item);
const ws = commCallWaveSurfers.get(itemId);
if (!ws) return;
if (isCommCallPlaying(itemId)) {
ws.pause?.();
return;
}
pauseOtherCommCallWaves(itemId);
await ws.play?.();
}
// ---------------------------------------------------------------------------
// Call transcription
// ---------------------------------------------------------------------------
async function transcribeCallItem(item: CommItem) {
const itemId = item.id;
if (callTranscriptLoading.value[itemId]) return;
if (callTranscriptText.value[itemId]) return;
if (Array.isArray(item.transcript) && item.transcript.length) {
const persisted = item.transcript.map((line) => String(line ?? "").trim()).filter(Boolean).join("\n");
if (persisted) {
callTranscriptText.value[itemId] = persisted;
return;
}
}
const audioUrl = getCallAudioUrl(item);
if (!audioUrl) {
callTranscriptError.value[itemId] = "Audio source is missing";
return;
}
callTranscriptLoading.value[itemId] = true;
callTranscriptError.value[itemId] = "";
try {
const audioBlob = await fetch(audioUrl).then((res) => {
if (!res.ok) throw new Error(`Audio fetch failed: ${res.status}`);
return res.blob();
});
const text = await transcribeAudioBlob(audioBlob);
callTranscriptText.value[itemId] = text || "(empty transcript)";
await doUpdateCommunicationTranscript({ id: itemId, transcript: text ? [text] : [] });
} catch (error: any) {
callTranscriptError.value[itemId] = String(error?.message ?? error ?? "Transcription failed");
} finally {
callTranscriptLoading.value[itemId] = false;
}
}
function toggleCallTranscript(item: CommItem) {
const itemId = item.id;
const next = !callTranscriptOpen.value[itemId];
callTranscriptOpen.value[itemId] = next;
if (next) {
void transcribeCallItem(item);
}
}
function isCallTranscriptOpen(itemId: string) {
return Boolean(callTranscriptOpen.value[itemId]);
}
// ---------------------------------------------------------------------------
// Event archive recording
// ---------------------------------------------------------------------------
function isEventArchiveRecording(eventId: string) {
return Boolean(eventArchiveRecordingById.value[eventId]);
}
function isEventArchiveTranscribing(eventId: string) {
return Boolean(eventArchiveTranscribingById.value[eventId]);
}
async function startEventArchiveRecording(
eventId: string,
opts: {
pilotMicSupported: { value: boolean };
eventCloseDraft: { value: Record<string, string> };
},
) {
if (eventArchiveMediaRecorder || isEventArchiveTranscribing(eventId)) return;
eventArchiveMicErrorById.value = { ...eventArchiveMicErrorById.value, [eventId]: "" };
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const preferredMime = "audio/webm;codecs=opus";
const recorder = MediaRecorder.isTypeSupported(preferredMime)
? new MediaRecorder(stream, { mimeType: preferredMime })
: new MediaRecorder(stream);
eventArchiveRecorderStream = stream;
eventArchiveRecorderMimeType = recorder.mimeType || "audio/webm";
eventArchiveMediaRecorder = recorder;
eventArchiveChunks = [];
eventArchiveTargetEventId = eventId;
eventArchiveRecordingById.value = { ...eventArchiveRecordingById.value, [eventId]: true };
recorder.ondataavailable = (event: BlobEvent) => {
if (event.data?.size) eventArchiveChunks.push(event.data);
};
recorder.onstop = async () => {
const targetId = eventArchiveTargetEventId;
eventArchiveRecordingById.value = { ...eventArchiveRecordingById.value, [targetId]: false };
eventArchiveMediaRecorder = null;
eventArchiveTargetEventId = "";
if (eventArchiveRecorderStream) {
eventArchiveRecorderStream.getTracks().forEach((track) => track.stop());
eventArchiveRecorderStream = null;
}
const audioBlob = new Blob(eventArchiveChunks, { type: eventArchiveRecorderMimeType });
eventArchiveChunks = [];
if (!targetId || audioBlob.size === 0) return;
eventArchiveTranscribingById.value = { ...eventArchiveTranscribingById.value, [targetId]: true };
try {
const text = await transcribeAudioBlob(audioBlob);
if (!text) {
eventArchiveMicErrorById.value = { ...eventArchiveMicErrorById.value, [targetId]: "Could not recognize speech" };
return;
}
const previous = String(opts.eventCloseDraft.value[targetId] ?? "").trim();
const merged = previous ? `${previous} ${text}` : text;
opts.eventCloseDraft.value = { ...opts.eventCloseDraft.value, [targetId]: merged };
} catch (error: any) {
eventArchiveMicErrorById.value = {
...eventArchiveMicErrorById.value,
[targetId]: String(error?.data?.message ?? error?.message ?? "Voice transcription failed"),
};
} finally {
eventArchiveTranscribingById.value = { ...eventArchiveTranscribingById.value, [targetId]: false };
}
};
recorder.start();
} catch {
eventArchiveMicErrorById.value = { ...eventArchiveMicErrorById.value, [eventId]: "No microphone access" };
}
}
function stopEventArchiveRecording() {
if (!eventArchiveMediaRecorder || eventArchiveMediaRecorder.state === "inactive") return;
eventArchiveMediaRecorder.stop();
}
function toggleEventArchiveRecording(
eventId: string,
opts: {
pilotMicSupported: { value: boolean };
eventCloseDraft: { value: Record<string, string> };
},
) {
if (!opts.pilotMicSupported.value) {
eventArchiveMicErrorById.value = { ...eventArchiveMicErrorById.value, [eventId]: "Recording is not supported in this browser" };
return;
}
if (isEventArchiveRecording(eventId)) {
stopEventArchiveRecording();
return;
}
void startEventArchiveRecording(eventId, opts);
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
return {
commCallWaveHosts,
commCallPlayableById,
commCallPlayingById,
callTranscriptOpen,
callTranscriptLoading,
callTranscriptText,
callTranscriptError,
ensureCommCallWave,
destroyCommCallWave,
destroyAllCommCallWaves,
toggleCommCallPlayback,
syncCommCallWaves,
transcribeCallItem,
toggleCallTranscript,
isCallTranscriptOpen,
eventArchiveRecordingById,
eventArchiveTranscribingById,
eventArchiveMicErrorById,
startEventArchiveRecording,
stopEventArchiveRecording,
toggleEventArchiveRecording,
isCommCallPlayable,
isCommCallPlaying,
setCommCallWaveHost,
};
}

View File

@@ -1,306 +0,0 @@
import { ref, computed, watch, type Ref } from "vue";
import { useMutation } from "@vue/apollo-composable";
import {
ConfirmLatestChangeSetMutationDocument,
RollbackLatestChangeSetMutationDocument,
RollbackChangeSetItemsMutationDocument,
ChatMessagesQueryDocument,
ChatConversationsQueryDocument,
ContactsQueryDocument,
CommunicationsQueryDocument,
ContactInboxesQueryDocument,
CalendarQueryDocument,
DealsQueryDocument,
FeedQueryDocument,
PinsQueryDocument,
DocumentsQueryDocument,
} from "~~/graphql/generated";
import type { PilotMessage, PilotChangeItem } from "~/composables/usePilotChat";
export function useChangeReview(opts: {
pilotMessages: Ref<PilotMessage[]>;
refetchAllCrmQueries: () => Promise<void>;
refetchChatMessages: () => Promise<any>;
refetchChatConversations: () => Promise<any>;
}) {
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
const activeChangeSetId = ref("");
const activeChangeStep = ref(0);
const changeActionBusy = ref(false);
// ---------------------------------------------------------------------------
// All CRM query docs for refetch
// ---------------------------------------------------------------------------
const allCrmQueryDocs = [
{ query: ContactsQueryDocument },
{ query: CommunicationsQueryDocument },
{ query: ContactInboxesQueryDocument },
{ query: CalendarQueryDocument },
{ query: DealsQueryDocument },
{ query: FeedQueryDocument },
{ query: PinsQueryDocument },
{ query: DocumentsQueryDocument },
];
// ---------------------------------------------------------------------------
// Apollo Mutations
// ---------------------------------------------------------------------------
const { mutate: doConfirmLatestChangeSet } = useMutation(ConfirmLatestChangeSetMutationDocument, {
refetchQueries: [{ query: ChatMessagesQueryDocument }],
});
const { mutate: doRollbackLatestChangeSet } = useMutation(RollbackLatestChangeSetMutationDocument, {
refetchQueries: [{ query: ChatMessagesQueryDocument }, { query: ChatConversationsQueryDocument }, ...allCrmQueryDocs],
});
const { mutate: doRollbackChangeSetItems } = useMutation(RollbackChangeSetItemsMutationDocument, {
refetchQueries: [{ query: ChatMessagesQueryDocument }, { query: ChatConversationsQueryDocument }, ...allCrmQueryDocs],
});
// ---------------------------------------------------------------------------
// Computed
// ---------------------------------------------------------------------------
const latestChangeMessage = computed(() => {
return (
[...opts.pilotMessages.value]
.reverse()
.find((m) => m.role === "assistant" && m.changeSetId && m.changeStatus !== "rolled_back") ?? null
);
});
const activeChangeMessage = computed(() => {
const targetId = activeChangeSetId.value.trim();
if (!targetId) return latestChangeMessage.value;
return (
[...opts.pilotMessages.value]
.reverse()
.find((m) => m.role === "assistant" && m.changeSetId === targetId) ?? null
);
});
const activeChangeItems = computed(() => activeChangeMessage.value?.changeItems ?? []);
const activeChangeIndex = computed(() => {
const items = activeChangeItems.value;
if (!items.length) return 0;
return Math.max(0, Math.min(activeChangeStep.value, items.length - 1));
});
const activeChangeItem = computed(() => {
const items = activeChangeItems.value;
if (!items.length) return null;
return items[activeChangeIndex.value] ?? null;
});
const reviewActive = computed(() => Boolean(activeChangeSetId.value.trim() && activeChangeItems.value.length > 0));
const activeReviewCalendarEventId = computed(() => {
const item = activeChangeItem.value;
if (!item || item.entity !== "calendar_event" || !item.entityId) return "";
return item.entityId;
});
const activeReviewContactId = computed(() => {
const item = activeChangeItem.value;
if (!item || item.entity !== "contact_note" || !item.entityId) return "";
return item.entityId;
});
const activeReviewDealId = computed(() => {
const item = activeChangeItem.value;
if (!item || item.entity !== "deal" || !item.entityId) return "";
return item.entityId;
});
const activeReviewMessageId = computed(() => {
const item = activeChangeItem.value;
if (!item || item.entity !== "message" || !item.entityId) return "";
return item.entityId;
});
const activeReviewContactDiff = computed(() => {
const item = activeChangeItem.value;
if (!item || item.entity !== "contact_note" || !item.entityId) return null;
return {
contactId: item.entityId,
before: normalizeChangeText(item.before),
after: normalizeChangeText(item.after),
};
});
// ---------------------------------------------------------------------------
// Text helpers
// ---------------------------------------------------------------------------
function normalizeChangeText(raw: string | null | undefined) {
const text = String(raw ?? "").trim();
if (!text) return "";
try {
const parsed = JSON.parse(text) as Record<string, unknown>;
if (typeof parsed === "object" && parsed) {
const candidate = [parsed.description, parsed.summary, parsed.note, parsed.text]
.find((value) => typeof value === "string");
if (typeof candidate === "string") return candidate.trim();
}
} catch {
// No-op: keep original text when it is not JSON payload.
}
return text;
}
function describeChangeEntity(entity: string) {
if (entity === "contact_note") return "Contact summary";
if (entity === "calendar_event") return "Calendar event";
if (entity === "message") return "Message";
if (entity === "deal") return "Deal";
if (entity === "workspace_document") return "Workspace document";
return entity || "Change";
}
function describeChangeAction(action: string) {
if (action === "created") return "created";
if (action === "updated") return "updated";
if (action === "deleted") return "archived";
return action || "changed";
}
// ---------------------------------------------------------------------------
// Review navigation
// ---------------------------------------------------------------------------
function openChangeReview(changeSetId: string, step = 0) {
const targetId = String(changeSetId ?? "").trim();
if (!targetId) return;
activeChangeSetId.value = targetId;
const items = activeChangeMessage.value?.changeItems ?? [];
activeChangeStep.value = items.length ? Math.max(0, Math.min(step, items.length - 1)) : 0;
}
function goToChangeStep(step: number) {
const items = activeChangeItems.value;
if (!items.length) return;
activeChangeStep.value = Math.max(0, Math.min(step, items.length - 1));
}
function goToPreviousChangeStep() {
goToChangeStep(activeChangeIndex.value - 1);
}
function goToNextChangeStep() {
goToChangeStep(activeChangeIndex.value + 1);
}
function finishReview() {
activeChangeSetId.value = "";
activeChangeStep.value = 0;
}
// ---------------------------------------------------------------------------
// Highlight helpers
// ---------------------------------------------------------------------------
function isReviewHighlightedEvent(eventId: string) {
return Boolean(reviewActive.value && activeReviewCalendarEventId.value && activeReviewCalendarEventId.value === eventId);
}
function isReviewHighlightedContact(contactId: string) {
return Boolean(reviewActive.value && activeReviewContactId.value && activeReviewContactId.value === contactId);
}
function isReviewHighlightedDeal(dealId: string) {
return Boolean(reviewActive.value && activeReviewDealId.value && activeReviewDealId.value === dealId);
}
function isReviewHighlightedMessage(messageId: string) {
return Boolean(reviewActive.value && activeReviewMessageId.value && activeReviewMessageId.value === messageId);
}
// ---------------------------------------------------------------------------
// Change execution
// ---------------------------------------------------------------------------
async function confirmLatestChangeSet() {
if (changeActionBusy.value || !latestChangeMessage.value?.changeSetId) return;
changeActionBusy.value = true;
try {
await doConfirmLatestChangeSet();
} finally {
changeActionBusy.value = false;
}
}
async function rollbackLatestChangeSet() {
if (changeActionBusy.value || !latestChangeMessage.value?.changeSetId) return;
changeActionBusy.value = true;
try {
await doRollbackLatestChangeSet();
activeChangeSetId.value = "";
activeChangeStep.value = 0;
} finally {
changeActionBusy.value = false;
}
}
async function rollbackSelectedChangeItems() {
const targetChangeSetId = activeChangeMessage.value?.changeSetId?.trim() || activeChangeSetId.value.trim();
const itemIds = activeChangeItems.value.filter((item) => !item.rolledBack).map((item) => item.id);
if (changeActionBusy.value || !targetChangeSetId || itemIds.length === 0) return;
changeActionBusy.value = true;
try {
await doRollbackChangeSetItems({ changeSetId: targetChangeSetId, itemIds });
} finally {
changeActionBusy.value = false;
}
}
async function rollbackChangeItemById(itemId: string) {
const item = activeChangeItems.value.find((entry) => entry.id === itemId);
const targetChangeSetId = activeChangeMessage.value?.changeSetId?.trim() || activeChangeSetId.value.trim();
if (!item || item.rolledBack || !targetChangeSetId || changeActionBusy.value) return;
changeActionBusy.value = true;
try {
await doRollbackChangeSetItems({ changeSetId: targetChangeSetId, itemIds: [itemId] });
} finally {
changeActionBusy.value = false;
}
}
// ---------------------------------------------------------------------------
// Watcher: clamp step when change items list changes
// ---------------------------------------------------------------------------
watch(
() => activeChangeMessage.value?.changeSetId,
() => {
if (!activeChangeSetId.value.trim()) return;
const maxIndex = Math.max(0, (activeChangeItems.value.length || 1) - 1);
if (activeChangeStep.value > maxIndex) activeChangeStep.value = maxIndex;
},
);
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
return {
activeChangeSetId,
activeChangeStep,
changeActionBusy,
reviewActive,
activeChangeItems,
activeChangeItem,
activeChangeIndex,
openChangeReview,
goToChangeStep,
goToPreviousChangeStep,
goToNextChangeStep,
finishReview,
isReviewHighlightedEvent,
isReviewHighlightedContact,
isReviewHighlightedDeal,
isReviewHighlightedMessage,
activeReviewCalendarEventId,
activeReviewContactId,
activeReviewDealId,
activeReviewMessageId,
activeReviewContactDiff,
confirmLatestChangeSet,
rollbackLatestChangeSet,
rollbackSelectedChangeItems,
rollbackChangeItemById,
describeChangeEntity,
describeChangeAction,
normalizeChangeText,
};
}

View File

@@ -1,99 +0,0 @@
import { ref, watch, type ComputedRef } from "vue";
import { useQuery, useMutation } from "@vue/apollo-composable";
import {
ContactInboxesQueryDocument,
SetContactInboxHiddenDocument,
} from "~~/graphql/generated";
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>; onHidden?: () => void }) {
const { result: contactInboxesResult, refetch: refetchContactInboxes } = useQuery(
ContactInboxesQueryDocument,
null,
{ enabled: opts.apolloAuthReady },
);
const { mutate: doSetContactInboxHidden } = useMutation(SetContactInboxHiddenDocument, {
refetchQueries: [{ query: ContactInboxesQueryDocument }],
update: (cache, _result, { variables }) => {
if (!variables) return;
const existing = cache.readQuery({ query: ContactInboxesQueryDocument }) as { contactInboxes?: ContactInbox[] } | null;
if (!existing?.contactInboxes) return;
cache.writeQuery({
query: ContactInboxesQueryDocument,
data: {
contactInboxes: existing.contactInboxes.map((inbox) =>
inbox.id === variables.inboxId ? { ...inbox, isHidden: variables.hidden } : inbox,
),
},
});
},
});
const contactInboxes = ref<ContactInbox[]>([]);
const inboxToggleLoadingById = ref<Record<string, boolean>>({});
watch(() => contactInboxesResult.value?.contactInboxes, (v) => {
if (v) contactInboxes.value = v as ContactInbox[];
}, { immediate: true });
function isInboxToggleLoading(inboxId: string) {
return Boolean(inboxToggleLoadingById.value[inboxId]);
}
async function setInboxHidden(inboxId: string, hidden: boolean) {
const id = String(inboxId ?? "").trim();
if (!id || isInboxToggleLoading(id)) return;
inboxToggleLoadingById.value = { ...inboxToggleLoadingById.value, [id]: true };
try {
await doSetContactInboxHidden({ inboxId: id, hidden });
if (hidden && opts.onHidden) opts.onHidden();
} catch (e: unknown) {
console.error("[setInboxHidden] mutation failed:", e);
} finally {
inboxToggleLoadingById.value = { ...inboxToggleLoadingById.value, [id]: false };
}
}
function threadInboxes(thread: { id: string }) {
return contactInboxes.value
.filter((inbox) => inbox.contactId === thread.id)
.sort((a, b) => {
const aTime = a.lastMessageAt || a.updatedAt;
const bTime = b.lastMessageAt || b.updatedAt;
return bTime.localeCompare(aTime);
});
}
function formatInboxLabel(inbox: ContactInbox) {
const title = String(inbox.title ?? "").trim();
if (title) return `${inbox.channel} · ${title}`;
const source = String(inbox.sourceExternalId ?? "").trim();
if (!source) return inbox.channel;
const tail = source.length > 18 ? source.slice(-18) : source;
return `${inbox.channel} · ${tail}`;
}
return {
contactInboxes,
inboxToggleLoadingById,
setInboxHidden,
isInboxToggleLoading,
threadInboxes,
formatInboxLabel,
refetchContactInboxes,
};
}

View File

@@ -1,182 +0,0 @@
import { ref, computed, watch, watchEffect, type ComputedRef } from "vue";
import { useQuery, useMutation } from "@vue/apollo-composable";
import { ContactsQueryDocument, MarkThreadReadDocument } from "~~/graphql/generated";
export type Contact = {
id: string;
name: string;
avatar: string;
channels: string[];
lastContactAt: string;
lastMessageText: string;
lastMessageChannel: string;
hasUnread: boolean;
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> }) {
const { result: contactsResult, refetch: refetchContacts } = useQuery(
ContactsQueryDocument,
null,
{ enabled: opts.apolloAuthReady },
);
const contacts = ref<Contact[]>([]);
watch(
() => contactsResult.value?.contacts,
(rawContacts) => {
if (!rawContacts) return;
contacts.value = [...rawContacts] as Contact[];
},
{ immediate: true },
);
const contactSearch = ref("");
const selectedChannel = ref("All");
const sortMode = ref<SortMode>("name");
const channels = computed(() => ["All", ...new Set(contacts.value.flatMap((c) => c.channels))].sort());
function resetContactFilters() {
contactSearch.value = "";
selectedChannel.value = "All";
sortMode.value = "name";
}
const filteredContacts = computed(() => {
const query = contactSearch.value.trim().toLowerCase();
const data = contacts.value.filter((contact) => {
if (selectedChannel.value !== "All" && !contact.channels.includes(selectedChannel.value)) return false;
if (query) {
const haystack = [contact.name, contact.description, contact.channels.join(" ")]
.join(" ")
.toLowerCase();
if (!haystack.includes(query)) return false;
}
return true;
});
return data.sort((a, b) => {
if (sortMode.value === "lastContact") {
return b.lastContactAt.localeCompare(a.lastContactAt);
}
return a.name.localeCompare(b.name);
});
});
const groupedContacts = computed(() => {
if (sortMode.value === "lastContact") {
return [["Recent", filteredContacts.value]] as [string, Contact[]][];
}
const map = new Map<string, Contact[]>();
for (const contact of filteredContacts.value) {
const key = (contact.name[0] ?? "#").toUpperCase();
if (!map.has(key)) {
map.set(key, []);
}
map.get(key)?.push(contact);
}
return [...map.entries()].sort((a, b) => a[0].localeCompare(b[0]));
});
const selectedContactId = ref(contacts.value[0]?.id ?? "");
watchEffect(() => {
if (!filteredContacts.value.length) {
selectedContactId.value = "";
return;
}
if (!filteredContacts.value.some((item) => item.id === selectedContactId.value)) {
// Always pick the most recently active contact, regardless of current sort mode
const mostRecent = [...filteredContacts.value].sort((a, b) =>
b.lastContactAt.localeCompare(a.lastContactAt),
)[0];
if (mostRecent) selectedContactId.value = mostRecent.id;
}
});
const selectedContact = computed(() => contacts.value.find((item) => item.id === selectedContactId.value));
const { mutate: doMarkThreadRead } = useMutation(MarkThreadReadDocument);
function markContactRead(contactId: string) {
if (!contactId) return;
// Optimistically update local state
const idx = contacts.value.findIndex((c) => c.id === contactId);
if (idx >= 0 && contacts.value[idx]!.hasUnread) {
contacts.value[idx] = { ...contacts.value[idx]!, hasUnread: false };
}
// Fire-and-forget backend call
void doMarkThreadRead({ contactId }).catch(() => undefined);
}
const brokenAvatarByContactId = ref<Record<string, boolean>>({});
function contactInitials(name: string) {
const words = String(name ?? "")
.trim()
.split(/\s+/)
.filter(Boolean);
if (!words.length) return "?";
return words
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() ?? "")
.join("");
}
function avatarSrcForThread(thread: { id: string; avatar: string }) {
if (brokenAvatarByContactId.value[thread.id]) return "";
return String(thread.avatar ?? "").trim();
}
function markAvatarBroken(contactId: string) {
if (!contactId) return;
brokenAvatarByContactId.value = {
...brokenAvatarByContactId.value,
[contactId]: true,
};
}
return {
contacts,
contactSearch,
selectedChannel,
sortMode,
selectedContactId,
selectedContact,
filteredContacts,
groupedContacts,
channels,
resetContactFilters,
brokenAvatarByContactId,
avatarSrcForThread,
markAvatarBroken,
contactInitials,
markContactRead,
refetchContacts,
};
}

View File

@@ -1,158 +0,0 @@
import { ref } from "vue";
export type RealtimeNewMessage = {
contactId: string;
contactName: string;
text: string;
channel: string;
direction: string;
at: string;
};
export type RealtimePilotTrace = { text: string; at: string };
export function useCrmRealtime(opts: {
isAuthenticated: () => boolean;
onDashboardChanged: () => Promise<void>;
onNewMessage?: (msg: RealtimeNewMessage) => void;
onPilotTrace?: (log: RealtimePilotTrace) => void;
onPilotFinished?: () => void;
}) {
const crmRealtimeState = ref<"idle" | "connecting" | "open" | "error">("idle");
let crmRealtimeSocket: WebSocket | null = null;
let crmRealtimeReconnectTimer: ReturnType<typeof setTimeout> | null = null;
let crmRealtimeRefreshTimer: ReturnType<typeof setTimeout> | null = null;
let crmRealtimeRefreshInFlight = false;
let crmRealtimeReconnectAttempt = 0;
function clearCrmRealtimeReconnectTimer() {
if (!crmRealtimeReconnectTimer) return;
clearTimeout(crmRealtimeReconnectTimer);
crmRealtimeReconnectTimer = null;
}
function clearCrmRealtimeRefreshTimer() {
if (!crmRealtimeRefreshTimer) return;
clearTimeout(crmRealtimeRefreshTimer);
crmRealtimeRefreshTimer = null;
}
async function runCrmRealtimeRefresh() {
if (!opts.isAuthenticated() || crmRealtimeRefreshInFlight) return;
crmRealtimeRefreshInFlight = true;
try {
await opts.onDashboardChanged();
} catch {
// ignore transient realtime refresh errors
} finally {
crmRealtimeRefreshInFlight = false;
}
}
function scheduleCrmRealtimeRefresh(delayMs = 250) {
clearCrmRealtimeRefreshTimer();
crmRealtimeRefreshTimer = setTimeout(() => {
crmRealtimeRefreshTimer = null;
void runCrmRealtimeRefresh();
}, delayMs);
}
function scheduleCrmRealtimeReconnect() {
clearCrmRealtimeReconnectTimer();
const attempt = Math.min(crmRealtimeReconnectAttempt + 1, 8);
crmRealtimeReconnectAttempt = attempt;
const delayMs = Math.min(1000 * 2 ** (attempt - 1), 15000);
crmRealtimeReconnectTimer = setTimeout(() => {
crmRealtimeReconnectTimer = null;
startCrmRealtime();
}, delayMs);
}
function stopCrmRealtime() {
clearCrmRealtimeReconnectTimer();
clearCrmRealtimeRefreshTimer();
if (crmRealtimeSocket) {
const socket = crmRealtimeSocket;
crmRealtimeSocket = null;
socket.onopen = null;
socket.onmessage = null;
socket.onerror = null;
socket.onclose = null;
try {
socket.close(1000, "client stop");
} catch {
// ignore socket close errors
}
}
crmRealtimeState.value = "idle";
}
function startCrmRealtime() {
if (process.server || !opts.isAuthenticated()) return;
if (crmRealtimeSocket) {
const state = crmRealtimeSocket.readyState;
if (state === WebSocket.OPEN || state === WebSocket.CONNECTING) return;
}
clearCrmRealtimeReconnectTimer();
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const url = `${protocol}//${window.location.host}/ws/crm-updates`;
const socket = new WebSocket(url);
crmRealtimeSocket = socket;
crmRealtimeState.value = "connecting";
socket.onopen = () => {
crmRealtimeState.value = "open";
crmRealtimeReconnectAttempt = 0;
};
socket.onmessage = (event) => {
const raw = typeof event.data === "string" ? event.data : "";
if (!raw) return;
try {
const payload = JSON.parse(raw) as { type?: string; [key: string]: any };
if (payload.type === "dashboard.changed") {
scheduleCrmRealtimeRefresh();
}
if (payload.type === "message.new" && opts.onNewMessage) {
opts.onNewMessage(payload as unknown as RealtimeNewMessage);
}
if (payload.type === "pilot.trace" && opts.onPilotTrace) {
opts.onPilotTrace({ text: String(payload.text ?? ""), at: String(payload.at ?? "") });
}
if (payload.type === "pilot.catchup" && opts.onPilotTrace && Array.isArray(payload.logs)) {
for (const log of payload.logs) {
opts.onPilotTrace({ text: String((log as any).text ?? ""), at: String((log as any).at ?? "") });
}
}
if (payload.type === "pilot.finished" && opts.onPilotFinished) {
opts.onPilotFinished();
}
} catch {
// ignore malformed realtime payloads
}
};
socket.onerror = () => {
crmRealtimeState.value = "error";
};
socket.onclose = () => {
const wasActive = crmRealtimeSocket === socket;
if (wasActive) {
crmRealtimeSocket = null;
}
if (!opts.isAuthenticated()) {
crmRealtimeState.value = "idle";
return;
}
crmRealtimeState.value = "error";
scheduleCrmRealtimeReconnect();
};
}
return { crmRealtimeState, startCrmRealtime, stopCrmRealtime };
}

View File

@@ -1,388 +0,0 @@
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<boolean>;
contacts: Ref<Contact[]>;
calendarEvents: Ref<CalendarEvent[]>;
}) {
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<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;
},
);
const dealStageOptions = computed(() => {
const unique = new Set<string>();
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,
};
}

View File

@@ -1,204 +0,0 @@
import { ref, computed, watch, watchEffect, type ComputedRef } from "vue";
import { useQuery, useMutation } from "@vue/apollo-composable";
import {
DocumentsQueryDocument,
CreateWorkspaceDocumentDocument,
DeleteWorkspaceDocumentDocument,
} from "~~/graphql/generated";
import { formatDocumentScope } from "~/composables/useWorkspaceDocuments";
import type { ClientTimelineItem } from "~/composables/useTimeline";
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> }) {
const { result: documentsResult, refetch: refetchDocuments } = useQuery(
DocumentsQueryDocument,
null,
{ enabled: opts.apolloAuthReady },
);
const { mutate: doCreateWorkspaceDocument } = useMutation(CreateWorkspaceDocumentDocument, {
refetchQueries: [{ query: DocumentsQueryDocument }],
});
const { mutate: doDeleteWorkspaceDocument } = useMutation(DeleteWorkspaceDocumentDocument, {
refetchQueries: [{ query: DocumentsQueryDocument }],
});
const documents = ref<WorkspaceDocument[]>([]);
const documentSearch = ref("");
const documentSortMode = ref<DocumentSortMode>("updatedAt");
const selectedDocumentId = ref(documents.value[0]?.id ?? "");
const documentDeletingId = ref("");
const documentSortOptions: Array<{ value: DocumentSortMode; label: string }> = [
{ value: "updatedAt", label: "Updated" },
{ value: "title", label: "Title" },
{ value: "owner", label: "Owner" },
];
watch(() => documentsResult.value?.documents, (v) => {
if (v) documents.value = v as WorkspaceDocument[];
}, { immediate: true });
const filteredDocuments = computed(() => {
const query = documentSearch.value.trim().toLowerCase();
const list = documents.value
.filter((item) => {
if (!query) return true;
const haystack = [item.title, item.summary, item.owner, formatDocumentScope(item.scope), item.body].join(" ").toLowerCase();
return haystack.includes(query);
})
.sort((a, b) => {
if (documentSortMode.value === "title") return a.title.localeCompare(b.title);
if (documentSortMode.value === "owner") return a.owner.localeCompare(b.owner);
return b.updatedAt.localeCompare(a.updatedAt);
});
return list;
});
watchEffect(() => {
if (!filteredDocuments.value.length) {
selectedDocumentId.value = "";
return;
}
if (!filteredDocuments.value.some((item) => item.id === selectedDocumentId.value)) {
const first = filteredDocuments.value[0];
if (first) selectedDocumentId.value = first.id;
}
});
const selectedDocument = computed(() => documents.value.find((item) => item.id === selectedDocumentId.value));
function updateSelectedDocumentBody(value: string) {
if (!selectedDocument.value) return;
selectedDocument.value.body = value;
}
function openDocumentsTab(opts2: { setTab: (tab: string) => void; syncPath: (push: boolean) => void }, push = false) {
opts2.setTab("documents");
if (!selectedDocumentId.value && filteredDocuments.value.length) {
const first = filteredDocuments.value[0];
if (first) selectedDocumentId.value = first.id;
}
opts2.syncPath(push);
}
async function deleteWorkspaceDocumentById(
documentIdInput: string,
clientTimelineItems: { value: ClientTimelineItem[] },
) {
const documentId = safeTrim(documentIdInput);
if (!documentId) return;
if (documentDeletingId.value === documentId) return;
const target = documents.value.find((doc) => doc.id === documentId);
const targetLabel = safeTrim(target?.title) || "this document";
if (process.client && !window.confirm(`Delete ${targetLabel}?`)) return;
documentDeletingId.value = documentId;
try {
await doDeleteWorkspaceDocument({ id: documentId });
documents.value = documents.value.filter((doc) => doc.id !== documentId);
clientTimelineItems.value = clientTimelineItems.value.filter((item) => {
const isDocumentEntry = String(item.contentType).toLowerCase() === "document";
if (!isDocumentEntry) return true;
return item.contentId !== documentId && item.document?.id !== documentId;
});
if (selectedDocumentId.value === documentId) {
selectedDocumentId.value = "";
}
} finally {
if (documentDeletingId.value === documentId) {
documentDeletingId.value = "";
}
}
}
async function createCommDocument(
threadContact: { id: string; contact: string } | undefined,
draftText: string,
commDocumentForm: { value: { title: string } },
authDisplayName: string,
additionalCallbacks: {
buildScope: (contactId: string, contactName: string) => string;
onSuccess: (created: WorkspaceDocument | null) => void;
},
) {
if (!threadContact) return false;
const summary = draftText.trim();
if (!summary) return false;
const title = safeTrim(commDocumentForm.value.title)
|| buildCommDocumentTitle(summary, threadContact.contact);
const scope = additionalCallbacks.buildScope(threadContact.id, threadContact.contact);
const body = summary;
try {
const res = await doCreateWorkspaceDocument({
input: {
title,
owner: authDisplayName,
scope,
summary,
body,
},
});
const created = res?.data?.createWorkspaceDocument;
if (created) {
documents.value = [created as WorkspaceDocument, ...documents.value.filter((doc) => doc.id !== created.id)];
selectedDocumentId.value = created.id;
} else {
selectedDocumentId.value = "";
}
additionalCallbacks.onSuccess((created as WorkspaceDocument) ?? null);
return true;
} catch {
return false;
}
}
function buildCommDocumentTitle(text: string, contact: string) {
const cleaned = text.replace(/\s+/g, " ").trim();
if (cleaned) {
const sentence = cleaned.split(/[.!?\n]/)[0]?.trim() ?? "";
if (sentence) return sentence.slice(0, 120);
}
return `Документ для ${contact}`;
}
return {
documents,
documentSearch,
documentSortMode,
selectedDocumentId,
documentDeletingId,
documentSortOptions,
selectedDocument,
filteredDocuments,
updateSelectedDocumentBody,
createCommDocument,
buildCommDocumentTitle,
deleteWorkspaceDocumentById,
openDocumentsTab,
refetchDocuments,
};
}

View File

@@ -1,172 +0,0 @@
import { ref, watch, type ComputedRef } from "vue";
import { useQuery, useMutation } from "@vue/apollo-composable";
import {
FeedQueryDocument,
UpdateFeedDecisionMutationDocument,
CreateCalendarEventMutationDocument,
CreateCommunicationMutationDocument,
LogPilotNoteMutationDocument,
CalendarQueryDocument,
CommunicationsQueryDocument,
ContactInboxesQueryDocument,
ChatMessagesQueryDocument,
ChatConversationsQueryDocument,
} from "~~/graphql/generated";
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: {
apolloAuthReady: ComputedRef<boolean>;
onCreateFollowup: (card: FeedCard, event: CalendarEvent) => void;
onOpenComm: (card: FeedCard) => void;
}) {
const { result: feedResult, refetch: refetchFeed } = useQuery(
FeedQueryDocument,
null,
{ enabled: opts.apolloAuthReady },
);
const { mutate: doUpdateFeedDecision } = useMutation(UpdateFeedDecisionMutationDocument, {
refetchQueries: [{ query: FeedQueryDocument }],
});
const { mutate: doCreateCalendarEvent } = useMutation(CreateCalendarEventMutationDocument, {
refetchQueries: [{ query: CalendarQueryDocument }],
});
const { mutate: doCreateCommunication } = useMutation(CreateCommunicationMutationDocument, {
refetchQueries: [{ query: CommunicationsQueryDocument }, { query: ContactInboxesQueryDocument }],
});
const { mutate: doLogPilotNote } = useMutation(LogPilotNoteMutationDocument);
const feedCards = ref<FeedCard[]>([]);
watch(() => feedResult.value?.feed, (v) => {
if (v) feedCards.value = v as FeedCard[];
}, { immediate: true });
function pushPilotNote(text: string) {
doLogPilotNote({ text })
.then(() => {})
.catch(() => {});
}
async function executeFeedAction(card: FeedCard) {
const key = card.proposal.key;
if (key === "create_followup") {
const start = new Date();
start.setMinutes(start.getMinutes() + 30);
start.setSeconds(0, 0);
const end = new Date(start);
end.setMinutes(end.getMinutes() + 30);
const res = await doCreateCalendarEvent({
input: {
title: `Follow-up: ${card.contact.split(" ")[0] ?? "Contact"}`,
start: start.toISOString(),
end: end.toISOString(),
contact: card.contact,
note: "Created from feed action.",
},
});
const created = res?.data?.createCalendarEvent as CalendarEvent | undefined;
if (created) {
opts.onCreateFollowup(card, created);
}
return `Event created: Follow-up · ${formatDay(start.toISOString())} ${formatTime(start.toISOString())} · ${card.contact}`;
}
if (key === "open_comm") {
opts.onOpenComm(card);
return `Opened ${card.contact} communication thread.`;
}
if (key === "call") {
await doCreateCommunication({
input: {
contact: card.contact,
channel: "Phone",
kind: "call",
direction: "out",
text: "Call started from feed",
durationSec: 0,
},
});
opts.onOpenComm(card);
return `Call event created and ${card.contact} chat opened.`;
}
if (key === "draft_message") {
await doCreateCommunication({
input: {
contact: card.contact,
channel: "Email",
kind: "message",
direction: "out",
text: "Draft: onboarding plan + two slots for tomorrow.",
},
});
opts.onOpenComm(card);
return `Draft message added to ${card.contact} communications.`;
}
if (key === "run_summary") {
return "Call summary prepared: 5 next steps sent to Pilot.";
}
if (key === "prepare_question") {
await doCreateCommunication({
input: {
contact: card.contact,
channel: "Telegram",
kind: "message",
direction: "out",
text: "Draft: can you confirm your decision date for this cycle?",
},
});
opts.onOpenComm(card);
return `Question about decision date added to ${card.contact} chat.`;
}
return "Action completed.";
}
async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected") {
card.decision = decision;
if (decision === "rejected") {
const note = "Rejected. Nothing created.";
card.decisionNote = note;
await doUpdateFeedDecision({ id: card.id, decision: "rejected", decisionNote: note });
pushPilotNote(`[${card.contact}] recommendation rejected: ${card.proposal.title}`);
return;
}
const result = await executeFeedAction(card);
card.decisionNote = result;
await doUpdateFeedDecision({ id: card.id, decision: "accepted", decisionNote: result });
pushPilotNote(`[${card.contact}] ${result}`);
}
return {
feedCards,
decideFeedCard,
executeFeedAction,
pushPilotNote,
refetchFeed,
};
}

View File

@@ -1,713 +0,0 @@
import { ref, computed, watch, watchEffect, nextTick, type Ref, type ComputedRef } from "vue";
import { useQuery, useMutation } from "@vue/apollo-composable";
import {
ChatMessagesQueryDocument,
ChatConversationsQueryDocument,
CreateChatConversationMutationDocument,
SelectChatConversationMutationDocument,
ArchiveChatConversationMutationDocument,
LogPilotNoteMutationDocument,
MeQueryDocument,
} from "~~/graphql/generated";
import { Chat as AiChat } from "@ai-sdk/vue";
import { DefaultChatTransport, isReasoningUIPart, isTextUIPart, type UIMessage } from "ai";
import { isVoiceCaptureSupported, transcribeAudioBlob } from "~/composables/useVoiceTranscription";
import type { Contact } from "~/composables/useContacts";
import type { CalendarView, CalendarEvent } from "~/composables/useCalendar";
import type { Deal } from "~/composables/useDeals";
export type PilotChangeItem = {
id: string;
entity: string;
entityId?: string | null;
action: string;
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(); }
type PilotUiMessage = UIMessage;
export function usePilotChat(opts: {
apolloAuthReady: ComputedRef<boolean>;
authMe: Ref<any>;
selectedContact: ComputedRef<Contact | null>;
selectedDeal: ComputedRef<Deal | null>;
calendarView: Ref<CalendarView>;
calendarPeriodLabel: ComputedRef<string>;
selectedDateKey: Ref<string>;
focusedCalendarEvent: ComputedRef<CalendarEvent | null>;
calendarEvents: Ref<CalendarEvent[]>;
refetchAllCrmQueries: () => Promise<void>;
}) {
// ---------------------------------------------------------------------------
// State refs
// ---------------------------------------------------------------------------
const pilotMessages = ref<PilotMessage[]>([]);
const pilotInput = ref("");
const pilotSending = ref(false);
const pilotRecording = ref(false);
const pilotTranscribing = ref(false);
const pilotMicSupported = ref(false);
const pilotMicError = ref<string | null>(null);
const pilotWaveContainer = ref<HTMLDivElement | null>(null);
function setPilotWaveContainerRef(element: HTMLDivElement | null) {
pilotWaveContainer.value = element;
}
const livePilotUserText = ref("");
const livePilotAssistantText = ref("");
const contextPickerEnabled = ref(false);
const contextScopes = ref<ContextScope[]>([]);
const pilotLiveLogs = ref<Array<{ id: string; text: string; at: string }>>([]);
const PILOT_LIVE_LOGS_PREVIEW_LIMIT = 5;
const pilotLiveLogsExpanded = ref(false);
const pilotLiveLogHiddenCount = computed(() => {
const hidden = pilotLiveLogs.value.length - PILOT_LIVE_LOGS_PREVIEW_LIMIT;
return hidden > 0 ? hidden : 0;
});
const pilotVisibleLiveLogs = computed(() => {
if (pilotLiveLogsExpanded.value || pilotLiveLogHiddenCount.value === 0) return pilotLiveLogs.value;
return pilotLiveLogs.value.slice(-PILOT_LIVE_LOGS_PREVIEW_LIMIT);
});
const pilotVisibleLogCount = computed(() =>
Math.min(pilotLiveLogs.value.length, PILOT_LIVE_LOGS_PREVIEW_LIMIT),
);
const chatConversations = ref<ChatConversation[]>([]);
const chatThreadsLoading = ref(false);
const chatSwitching = ref(false);
const chatCreating = ref(false);
const chatArchivingId = ref("");
const chatThreadPickerOpen = ref(false);
const selectedChatId = ref("");
let pilotBackgroundPoll: ReturnType<typeof setInterval> | null = null;
// ---------------------------------------------------------------------------
// Media recorder vars (non-reactive)
// ---------------------------------------------------------------------------
let pilotMediaRecorder: MediaRecorder | null = null;
let pilotRecorderStream: MediaStream | null = null;
let pilotRecordingChunks: Blob[] = [];
let pilotRecorderMimeType = "audio/webm";
let pilotRecordingFinishMode: "fill" | "send" = "fill";
let waveSurferModulesPromise: Promise<{ WaveSurfer: any; RecordPlugin: any }> | null = null;
let pilotWaveSurfer: any = null;
let pilotWaveRecordPlugin: any = null;
let pilotWaveMicSession: { onDestroy: () => void; onEnd: () => void } | null = null;
// ---------------------------------------------------------------------------
// Apollo Queries
// ---------------------------------------------------------------------------
const { result: chatMessagesResult, refetch: refetchChatMessages } = useQuery(
ChatMessagesQueryDocument,
null,
{ enabled: opts.apolloAuthReady },
);
const { result: chatConversationsResult, refetch: refetchChatConversations } = useQuery(
ChatConversationsQueryDocument,
null,
{ enabled: opts.apolloAuthReady },
);
// ---------------------------------------------------------------------------
// Apollo Mutations
// ---------------------------------------------------------------------------
const { mutate: doLogPilotNote } = useMutation(LogPilotNoteMutationDocument);
const { mutate: doCreateChatConversation } = useMutation(CreateChatConversationMutationDocument, {
refetchQueries: [{ query: MeQueryDocument }, { query: ChatMessagesQueryDocument }, { query: ChatConversationsQueryDocument }],
});
const { mutate: doSelectChatConversation } = useMutation(SelectChatConversationMutationDocument, {
refetchQueries: [{ query: MeQueryDocument }, { query: ChatMessagesQueryDocument }, { query: ChatConversationsQueryDocument }],
});
const { mutate: doArchiveChatConversation } = useMutation(ArchiveChatConversationMutationDocument, {
refetchQueries: [{ query: MeQueryDocument }, { query: ChatMessagesQueryDocument }, { query: ChatConversationsQueryDocument }],
});
// ---------------------------------------------------------------------------
// AI SDK chat instance
// ---------------------------------------------------------------------------
const pilotChat = new AiChat<PilotUiMessage>({
transport: new DefaultChatTransport({
api: "/api/pilot-chat",
}),
onFinish: async () => {
pilotSending.value = false;
livePilotUserText.value = "";
livePilotAssistantText.value = "";
pilotLiveLogs.value = [];
await Promise.all([refetchChatMessages(), refetchChatConversations(), opts.refetchAllCrmQueries()]);
},
onError: () => {
pilotSending.value = false;
if (livePilotUserText.value) {
pilotInput.value = livePilotUserText.value;
}
livePilotUserText.value = "";
livePilotAssistantText.value = "";
pilotLiveLogs.value = [];
},
});
// ---------------------------------------------------------------------------
// Apollo → Ref Watchers (bridge Apollo reactive results to existing refs)
// ---------------------------------------------------------------------------
watch(() => chatMessagesResult.value?.chatMessages, (v) => {
if (v) {
pilotMessages.value = v as PilotMessage[];
syncPilotChatFromHistory(pilotMessages.value);
}
}, { immediate: true });
watch(() => chatConversationsResult.value?.chatConversations, (v) => {
if (v) chatConversations.value = v as ChatConversation[];
}, { immediate: true });
watch(
() => pilotLiveLogs.value.length,
(len) => {
if (len === 0 || len <= PILOT_LIVE_LOGS_PREVIEW_LIMIT) {
pilotLiveLogsExpanded.value = false;
}
},
);
// Live assistant text watcher
watchEffect(() => {
if (!pilotSending.value) return;
const latestAssistant = [...pilotChat.messages]
.reverse()
.find((message) => message.role === "assistant");
if (!latestAssistant) return;
const textPart = latestAssistant.parts.find(isTextUIPart);
livePilotAssistantText.value = textPart?.text ?? "";
// Use native AI SDK reasoning parts for live "thinking" output.
pilotLiveLogs.value = latestAssistant.parts
.filter(isReasoningUIPart)
.map((part, index) => ({
id: `${latestAssistant.id}-reasoning-${index}`,
text: safeTrim(part.text),
at: new Date().toISOString(),
}))
.filter((log) => Boolean(log.text));
});
// ---------------------------------------------------------------------------
// Context picker
// ---------------------------------------------------------------------------
function toggleContextPicker() {
contextPickerEnabled.value = !contextPickerEnabled.value;
}
function hasContextScope(scope: ContextScope) {
return contextScopes.value.includes(scope);
}
function toggleContextScope(scope: ContextScope) {
if (!contextPickerEnabled.value) return;
if (hasContextScope(scope)) {
contextScopes.value = contextScopes.value.filter((item) => item !== scope);
return;
}
contextScopes.value = [...contextScopes.value, scope];
contextPickerEnabled.value = false;
}
function removeContextScope(scope: ContextScope) {
contextScopes.value = contextScopes.value.filter((item) => item !== scope);
}
function togglePilotLiveLogsExpanded() {
pilotLiveLogsExpanded.value = !pilotLiveLogsExpanded.value;
}
// ---------------------------------------------------------------------------
// Pilot ↔ UIMessage bridge
// ---------------------------------------------------------------------------
function pilotToUiMessage(message: PilotMessage): PilotUiMessage {
const reasoningParts = (message.thinking ?? [])
.map((item) => safeTrim(item))
.filter(Boolean)
.map((text) => ({ type: "reasoning" as const, text, state: "done" as const }));
return {
id: message.id,
role: message.role,
parts: [...reasoningParts, { type: "text", text: message.text }],
metadata: {
createdAt: message.createdAt ?? null,
},
};
}
function syncPilotChatFromHistory(messages: PilotMessage[]) {
pilotChat.messages = messages.filter((m) => m.role !== "system").map(pilotToUiMessage);
}
// ---------------------------------------------------------------------------
// Context payload builder
// ---------------------------------------------------------------------------
function buildContextPayload(): PilotContextPayload | null {
const scopes = [...contextScopes.value];
if (!scopes.length) return null;
const payload: PilotContextPayload = { scopes };
if (hasContextScope("summary") && opts.selectedContact.value) {
payload.summary = {
contactId: opts.selectedContact.value.id,
name: opts.selectedContact.value.name,
};
}
if (hasContextScope("deal") && opts.selectedDeal.value) {
payload.deal = {
dealId: opts.selectedDeal.value.id,
title: opts.selectedDeal.value.title,
contact: opts.selectedDeal.value.contact,
};
}
if (hasContextScope("message")) {
payload.message = {
contactId: opts.selectedContact.value?.id || undefined,
contact: opts.selectedContact.value?.name || undefined,
intent: "add_message_or_reminder",
};
}
if (hasContextScope("calendar")) {
const eventIds = (() => {
if (opts.focusedCalendarEvent.value) return [opts.focusedCalendarEvent.value.id];
return opts.calendarEvents.value.map((event) => event.id);
})();
payload.calendar = {
view: opts.calendarView.value,
period: opts.calendarPeriodLabel.value,
selectedDateKey: opts.selectedDateKey.value,
focusedEventId: opts.focusedCalendarEvent.value?.id || undefined,
eventIds,
};
}
return payload;
}
// ---------------------------------------------------------------------------
// Send pilot message
// ---------------------------------------------------------------------------
async function sendPilotText(rawText: string) {
const text = safeTrim(rawText);
if (!text || pilotSending.value) return;
const contextPayload = buildContextPayload();
pilotSending.value = true;
pilotInput.value = "";
livePilotUserText.value = text;
livePilotAssistantText.value = "";
pilotLiveLogsExpanded.value = false;
pilotLiveLogs.value = [];
try {
await pilotChat.sendMessage(
{ text },
contextPayload
? {
body: {
contextPayload,
},
}
: undefined,
);
} catch {
pilotInput.value = text;
livePilotUserText.value = "";
pilotSending.value = false;
}
}
async function sendPilotMessage() {
await sendPilotText(pilotInput.value);
}
// ---------------------------------------------------------------------------
// WaveSurfer lazy loading for mic
// ---------------------------------------------------------------------------
async function loadWaveSurferModules() {
if (!waveSurferModulesPromise) {
waveSurferModulesPromise = Promise.all([
import("wavesurfer.js"),
import("wavesurfer.js/dist/plugins/record.esm.js"),
]).then(([ws, rec]) => ({
WaveSurfer: ws.default,
RecordPlugin: rec.default,
}));
}
return waveSurferModulesPromise;
}
async function ensurePilotWaveSurfer() {
if (pilotWaveSurfer && pilotWaveRecordPlugin) return;
if (!pilotWaveContainer.value) return;
const { WaveSurfer, RecordPlugin } = await loadWaveSurferModules();
pilotWaveSurfer = WaveSurfer.create({
container: pilotWaveContainer.value,
height: 22,
waveColor: "rgba(208, 226, 255, 0.95)",
progressColor: "rgba(141, 177, 255, 0.95)",
cursorWidth: 0,
normalize: true,
interact: false,
});
pilotWaveRecordPlugin = pilotWaveSurfer.registerPlugin(
RecordPlugin.create({
renderRecordedAudio: false,
scrollingWaveform: true,
scrollingWaveformWindow: 10,
mediaRecorderTimeslice: 250,
}),
);
}
async function stopPilotMeter() {
if (pilotWaveMicSession) {
pilotWaveMicSession.onDestroy();
pilotWaveMicSession = null;
}
}
async function startPilotMeter(stream: MediaStream) {
await nextTick();
await ensurePilotWaveSurfer();
await stopPilotMeter();
if (!pilotWaveRecordPlugin) return;
pilotWaveMicSession = pilotWaveRecordPlugin.renderMicStream(stream);
}
function destroyPilotWaveSurfer() {
stopPilotMeter();
if (pilotWaveSurfer) {
pilotWaveSurfer.destroy();
pilotWaveSurfer = null;
pilotWaveRecordPlugin = null;
}
}
// ---------------------------------------------------------------------------
// Audio recording & transcription
// ---------------------------------------------------------------------------
function appendPilotTranscript(text: string) {
const next = safeTrim(text);
if (!next) return "";
const merged = pilotInput.value.trim() ? `${pilotInput.value.trim()} ${next}` : next;
pilotInput.value = merged;
return merged;
}
async function transcribeRecordedPilotAudio(blob: Blob) {
pilotMicError.value = null;
pilotTranscribing.value = true;
try {
const text = await transcribeAudioBlob(blob);
if (!text) {
pilotMicError.value = "Не удалось распознать речь, попробуйте еще раз.";
return null;
}
return text;
} catch (error: any) {
pilotMicError.value = String(error?.data?.message ?? error?.message ?? "Ошибка распознавания аудио");
return null;
} finally {
pilotTranscribing.value = false;
}
}
async function startPilotRecording() {
if (pilotRecording.value || pilotTranscribing.value) return;
pilotMicError.value = null;
if (!pilotMicSupported.value) {
pilotMicError.value = "Запись не поддерживается в этом браузере.";
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const preferredMime = "audio/webm;codecs=opus";
const recorder = MediaRecorder.isTypeSupported(preferredMime)
? new MediaRecorder(stream, { mimeType: preferredMime })
: new MediaRecorder(stream);
pilotRecorderStream = stream;
pilotRecorderMimeType = recorder.mimeType || "audio/webm";
pilotMediaRecorder = recorder;
pilotRecordingFinishMode = "fill";
pilotRecordingChunks = [];
pilotRecording.value = true;
void startPilotMeter(stream);
recorder.ondataavailable = (event: BlobEvent) => {
if (event.data?.size) pilotRecordingChunks.push(event.data);
};
recorder.onstop = async () => {
pilotRecording.value = false;
await stopPilotMeter();
const mode = pilotRecordingFinishMode;
pilotRecordingFinishMode = "fill";
const audioBlob = new Blob(pilotRecordingChunks, { type: pilotRecorderMimeType });
pilotRecordingChunks = [];
pilotMediaRecorder = null;
if (pilotRecorderStream) {
pilotRecorderStream.getTracks().forEach((track) => track.stop());
pilotRecorderStream = null;
}
if (audioBlob.size > 0) {
const transcript = await transcribeRecordedPilotAudio(audioBlob);
if (!transcript) return;
const mergedText = appendPilotTranscript(transcript);
if (mode === "send" && !pilotSending.value && mergedText.trim()) {
await sendPilotText(mergedText);
return;
}
}
};
recorder.start();
} catch {
pilotMicError.value = "Нет доступа к микрофону.";
pilotRecording.value = false;
}
}
function stopPilotRecording(mode: "fill" | "send" = "fill") {
if (!pilotMediaRecorder || pilotMediaRecorder.state === "inactive") return;
pilotRecordingFinishMode = mode;
pilotRecording.value = false;
pilotMediaRecorder.stop();
}
function togglePilotRecording() {
if (pilotRecording.value) {
stopPilotRecording("fill");
} else {
startPilotRecording();
}
}
// ---------------------------------------------------------------------------
// Chat conversation management
// ---------------------------------------------------------------------------
function toggleChatThreadPicker() {
if (chatSwitching.value || chatThreadsLoading.value || chatConversations.value.length === 0) return;
chatThreadPickerOpen.value = !chatThreadPickerOpen.value;
}
function closeChatThreadPicker() {
chatThreadPickerOpen.value = false;
}
async function createNewChatConversation() {
if (chatCreating.value) return;
chatThreadPickerOpen.value = false;
chatCreating.value = true;
try {
await doCreateChatConversation();
} finally {
chatCreating.value = false;
}
}
async function switchChatConversation(id: string) {
if (!id || chatSwitching.value || opts.authMe.value?.conversation.id === id) return;
chatThreadPickerOpen.value = false;
chatSwitching.value = true;
try {
await doSelectChatConversation({ id });
} finally {
chatSwitching.value = false;
}
}
async function archiveChatConversation(id: string) {
if (!id || chatArchivingId.value) return;
chatArchivingId.value = id;
try {
await doArchiveChatConversation({ id });
} finally {
chatArchivingId.value = "";
}
}
// ---------------------------------------------------------------------------
// Background polling
// ---------------------------------------------------------------------------
async function loadPilotMessages() {
await refetchChatMessages();
}
function startPilotBackgroundPolling() {
if (pilotBackgroundPoll) return;
pilotBackgroundPoll = setInterval(() => {
if (!opts.authMe.value) return;
loadPilotMessages().catch(() => {});
}, 30_000);
}
function stopPilotBackgroundPolling() {
if (!pilotBackgroundPoll) return;
clearInterval(pilotBackgroundPoll);
pilotBackgroundPoll = null;
}
// ---------------------------------------------------------------------------
// Fire-and-forget pilot note
// ---------------------------------------------------------------------------
function pushPilotNote(text: string) {
// Fire-and-forget: log assistant note to the same conversation.
doLogPilotNote({ text })
.then(() => Promise.all([refetchChatMessages(), refetchChatConversations()]))
.catch(() => {});
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
return {
pilotMessages,
pilotInput,
pilotSending,
pilotRecording,
pilotTranscribing,
pilotMicSupported,
pilotMicError,
pilotWaveContainer,
setPilotWaveContainerRef,
livePilotUserText,
livePilotAssistantText,
contextPickerEnabled,
contextScopes,
pilotLiveLogs,
pilotLiveLogsExpanded,
pilotLiveLogHiddenCount,
pilotVisibleLiveLogs,
pilotVisibleLogCount,
chatConversations,
chatThreadsLoading,
chatSwitching,
chatCreating,
selectedChatId,
chatThreadPickerOpen,
chatArchivingId,
toggleContextPicker,
hasContextScope,
toggleContextScope,
removeContextScope,
togglePilotLiveLogsExpanded,
sendPilotText,
sendPilotMessage,
startPilotRecording,
stopPilotRecording,
togglePilotRecording,
createNewChatConversation,
switchChatConversation,
archiveChatConversation,
toggleChatThreadPicker,
closeChatThreadPicker,
startPilotBackgroundPolling,
stopPilotBackgroundPolling,
buildContextPayload,
pushPilotNote,
refetchChatMessages,
refetchChatConversations,
// realtime pilot trace handlers (called from useCrmRealtime)
handleRealtimePilotTrace(log: { text: string; at: string }) {
if (pilotSending.value) return;
const text = String(log.text ?? "").trim();
if (!text) return;
// Mark as sending so the UI shows live-log panel
if (!pilotSending.value) pilotSending.value = true;
pilotLiveLogs.value = [
...pilotLiveLogs.value,
{ id: `ws-${Date.now()}-${Math.random()}`, text, at: log.at || new Date().toISOString() },
];
},
async handleRealtimePilotFinished() {
pilotSending.value = false;
livePilotUserText.value = "";
livePilotAssistantText.value = "";
pilotLiveLogs.value = [];
await Promise.all([refetchChatMessages(), refetchChatConversations(), opts.refetchAllCrmQueries()]);
},
// cleanup
destroyPilotWaveSurfer,
};
}

View File

@@ -1,225 +0,0 @@
import { ref, computed, watch, type ComputedRef, type Ref } from "vue";
import { useQuery, useMutation } from "@vue/apollo-composable";
import { PinsQueryDocument, ToggleContactPinMutationDocument } from "~~/graphql/generated";
import type { CommItem } from "~/composables/useContacts";
import type { CalendarEvent, EventLifecyclePhase } from "~/composables/useCalendar";
import { formatDay, isEventFinalStatus } from "~/composables/useCalendar";
export type CommPin = {
id: string;
contact: string;
text: string;
};
export function usePins(opts: {
apolloAuthReady: ComputedRef<boolean>;
selectedCommThread: ComputedRef<{ id: string; contact: string; items: CommItem[] } | undefined>;
selectedCommLifecycleEvents: ComputedRef<Array<{ event: CalendarEvent; phase: EventLifecyclePhase; timelineAt: string }>>;
visibleThreadItems: ComputedRef<CommItem[]>;
}) {
const { result: pinsResult, refetch: refetchPins } = useQuery(
PinsQueryDocument,
null,
{ enabled: opts.apolloAuthReady },
);
const { mutate: doToggleContactPin } = useMutation(ToggleContactPinMutationDocument, {
refetchQueries: [{ query: PinsQueryDocument }],
});
const commPins = ref<CommPin[]>([]);
const commPinToggling = ref(false);
const commPinContextMenu = ref<{
open: boolean;
x: number;
y: number;
entry: any | null;
}>({
open: false,
x: 0,
y: 0,
entry: null,
});
watch(() => pinsResult.value?.pins, (v) => {
if (v) commPins.value = v as CommPin[];
}, { immediate: true });
const selectedCommPins = computed(() => {
if (!opts.selectedCommThread.value) return [];
return commPins.value.filter((item) => item.contact === opts.selectedCommThread.value?.contact);
});
function normalizePinText(value: string) {
return String(value ?? "").replace(/\s+/g, " ").trim();
}
function stripPinnedPrefix(value: string) {
return String(value ?? "").replace(/^\s*(закреплено|pinned)\s*:\s*/i, "").trim();
}
function isPinnedText(contact: string, value: string) {
const contactName = String(contact ?? "").trim();
const text = normalizePinText(value);
if (!contactName || !text) return false;
return commPins.value.some((pin) => pin.contact === contactName && normalizePinText(pin.text) === text);
}
function entryPinText(entry: any): string {
if (!entry) return "";
if (entry.kind === "pin") return normalizePinText(stripPinnedPrefix(entry.text ?? ""));
if (entry.kind === "recommendation") return normalizePinText(entry.card?.text ?? "");
if (entry.kind === "eventLifecycle") {
return normalizePinText(entry.event?.note || entry.event?.title || "");
}
if (entry.kind === "call") return normalizePinText(entry.item?.text || "");
return normalizePinText(entry.item?.text || "");
}
async function togglePinnedText(contact: string, value: string) {
if (commPinToggling.value) return;
const contactName = String(contact ?? "").trim();
const text = normalizePinText(value);
if (!contactName || !text) return;
commPinToggling.value = true;
try {
await doToggleContactPin({ contact: contactName, text });
} finally {
commPinToggling.value = false;
}
}
async function togglePinForEntry(entry: any) {
const contact = opts.selectedCommThread.value?.contact ?? "";
const text = entryPinText(entry);
await togglePinnedText(contact, text);
}
function isPinnedEntry(entry: any) {
const contact = opts.selectedCommThread.value?.contact ?? "";
const text = entryPinText(entry);
return isPinnedText(contact, text);
}
function closeCommPinContextMenu() {
commPinContextMenu.value = {
open: false,
x: 0,
y: 0,
entry: null,
};
}
function openCommPinContextMenu(event: MouseEvent, entry: any) {
const text = entryPinText(entry);
if (!text) return;
const menuWidth = 136;
const menuHeight = 46;
const padding = 8;
const maxX = Math.max(padding, window.innerWidth - menuWidth - padding);
const maxY = Math.max(padding, window.innerHeight - menuHeight - padding);
const x = Math.min(maxX, Math.max(padding, event.clientX));
const y = Math.min(maxY, Math.max(padding, event.clientY));
commPinContextMenu.value = {
open: true,
x,
y,
entry,
};
}
const commPinContextActionLabel = computed(() => {
const entry = commPinContextMenu.value.entry;
if (!entry) return "Pin";
return isPinnedEntry(entry) ? "Unpin" : "Pin";
});
async function applyCommPinContextAction() {
const entry = commPinContextMenu.value.entry;
if (!entry) return;
closeCommPinContextMenu();
await togglePinForEntry(entry);
}
function onWindowPointerDownForCommPinMenu(event: PointerEvent) {
if (!commPinContextMenu.value.open) return;
const target = event.target as HTMLElement | null;
if (target?.closest(".comm-pin-context-menu")) return;
closeCommPinContextMenu();
}
function onWindowKeyDownForCommPinMenu(event: KeyboardEvent) {
if (!commPinContextMenu.value.open) return;
if (event.key === "Escape") {
closeCommPinContextMenu();
}
}
const selectedCommPinnedStream = computed(() => {
const pins = selectedCommPins.value.map((pin) => {
const normalizedText = normalizePinText(stripPinnedPrefix(pin.text));
const sourceItem =
[...opts.visibleThreadItems.value]
.filter((item) => normalizePinText(item.text) === normalizedText)
.sort((a, b) => b.at.localeCompare(a.at))[0] ?? null;
return {
id: `pin-${pin.id}`,
kind: "pin" as const,
text: pin.text,
sourceItem,
};
});
const rank = (phase: EventLifecyclePhase) => {
if (phase === "awaiting_outcome") return 0;
if (phase === "due_soon") return 1;
if (phase === "scheduled") return 2;
return 3;
};
const events = opts.selectedCommLifecycleEvents.value
.filter((item) => !isEventFinalStatus(item.event.isArchived))
.sort((a, b) => rank(a.phase) - rank(b.phase) || a.event.start.localeCompare(b.event.start))
.map((item) => ({
id: `event-${item.event.id}`,
kind: "eventLifecycle" as const,
event: item.event,
phase: item.phase,
}));
return [...pins, ...events];
});
const latestPinnedItem = computed(() => selectedCommPinnedStream.value[0] ?? null);
const latestPinnedLabel = computed(() => {
if (!latestPinnedItem.value) return "No pinned items yet";
if (latestPinnedItem.value.kind === "pin") return stripPinnedPrefix(latestPinnedItem.value.text);
return `${latestPinnedItem.value.event.title} · ${formatDay(latestPinnedItem.value.event.start)}`;
});
return {
commPins,
commPinToggling,
commPinContextMenu,
selectedCommPins,
selectedCommPinnedStream,
togglePinnedText,
togglePinForEntry,
isPinnedText,
isPinnedEntry,
entryPinText,
normalizePinText,
stripPinnedPrefix,
latestPinnedItem,
latestPinnedLabel,
closeCommPinContextMenu,
openCommPinContextMenu,
commPinContextActionLabel,
applyCommPinContextAction,
onWindowPointerDownForCommPinMenu,
onWindowKeyDownForCommPinMenu,
refetchPins,
};
}

View File

@@ -1,83 +0,0 @@
import { ref, computed, watch, type ComputedRef } from "vue";
import { useQuery } from "@vue/apollo-composable";
import { GetClientTimelineQueryDocument } from "~~/graphql/generated";
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> }) {
const timelineContactId = ref("");
const timelineLimit = ref(500);
const { result: timelineResult, refetch: refetchTimeline } = useQuery(
GetClientTimelineQueryDocument,
() => ({ contactId: timelineContactId.value, limit: timelineLimit.value }),
{ enabled: computed(() => !!timelineContactId.value && opts.apolloAuthReady.value) },
);
const clientTimelineItems = ref<ClientTimelineItem[]>([]);
const timelineLoading = ref(false);
watch(() => timelineResult.value?.getClientTimeline, (v) => {
if (v) {
clientTimelineItems.value = v as ClientTimelineItem[];
timelineLoading.value = false;
}
}, { immediate: true });
async function loadClientTimeline(contactId: string, limit = 500) {
const normalizedContactId = String(contactId ?? "").trim();
if (!normalizedContactId) {
clientTimelineItems.value = [];
timelineContactId.value = "";
timelineLoading.value = false;
return;
}
// Clear old data immediately and show loader
clientTimelineItems.value = [];
timelineLoading.value = true;
timelineContactId.value = normalizedContactId;
timelineLimit.value = limit;
try {
await refetchTimeline();
} finally {
timelineLoading.value = false;
}
}
async function refreshSelectedClientTimeline(selectedCommThreadId: string) {
const contactId = String(selectedCommThreadId ?? "").trim();
if (!contactId) {
clientTimelineItems.value = [];
timelineLoading.value = false;
return;
}
await loadClientTimeline(contactId);
}
return {
clientTimelineItems,
timelineLoading,
timelineContactId,
timelineLimit,
loadClientTimeline,
refreshSelectedClientTimeline,
refetchTimeline,
};
}

View File

@@ -1,92 +0,0 @@
function getAudioContextCtor(): typeof AudioContext {
const Ctor = (window.AudioContext || (window as any).webkitAudioContext) as typeof AudioContext | undefined;
if (!Ctor) {
throw new Error("Web Audio API is not supported in this browser");
}
return Ctor;
}
function toMonoFloat32(buffer: AudioBuffer) {
if (buffer.numberOfChannels <= 1) return buffer.getChannelData(0).slice();
const length = buffer.length;
const output = new Float32Array(length);
for (let i = 0; i < length; i += 1) {
let sum = 0;
for (let ch = 0; ch < buffer.numberOfChannels; ch += 1) {
sum += buffer.getChannelData(ch)[i] ?? 0;
}
output[i] = sum / buffer.numberOfChannels;
}
return output;
}
function resampleFloat32Linear(input: Float32Array, fromRate: number, toRate: number) {
if (fromRate === toRate) return input;
const ratio = fromRate / toRate;
const outLength = Math.max(1, Math.round(input.length / ratio));
const out = new Float32Array(outLength);
for (let i = 0; i < outLength; i += 1) {
const src = i * ratio;
const left = Math.floor(src);
const right = Math.min(input.length - 1, left + 1);
const frac = src - left;
out[i] = (input[left] ?? 0) * (1 - frac) + (input[right] ?? 0) * frac;
}
return out;
}
function floatToPcm16Bytes(input: Float32Array) {
const out = new Uint8Array(input.length * 2);
const view = new DataView(out.buffer);
for (let i = 0; i < input.length; i += 1) {
const sample = Math.max(-1, Math.min(1, input[i] ?? 0));
const value = sample < 0 ? sample * 0x8000 : sample * 0x7fff;
view.setInt16(i * 2, Math.round(value), true);
}
return out;
}
function bytesToBase64(bytes: Uint8Array) {
let binary = "";
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) {
binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
}
return btoa(binary);
}
async function decodeAudioBlobToPcm16(blob: Blob) {
const AudioContextCtor = getAudioContextCtor();
const context = new AudioContextCtor();
try {
const arrayBuffer = await blob.arrayBuffer();
const decoded = await context.decodeAudioData(arrayBuffer);
const mono = toMonoFloat32(decoded);
const targetSampleRate = 16000;
const resampled = resampleFloat32Linear(mono, decoded.sampleRate, targetSampleRate);
const pcm16 = floatToPcm16Bytes(resampled);
return {
audioBase64: bytesToBase64(pcm16),
sampleRate: targetSampleRate,
};
} finally {
await context.close();
}
}
export function isVoiceCaptureSupported() {
if (typeof window === "undefined") return false;
if (typeof navigator === "undefined") return false;
return typeof MediaRecorder !== "undefined" && Boolean(navigator.mediaDevices?.getUserMedia);
}
export async function transcribeAudioBlob(blob: Blob) {
const payload = await decodeAudioBlobToPcm16(blob);
const result = await $fetch<{ text?: string }>("/api/pilot-transcribe", {
method: "POST",
body: payload,
});
return String(result?.text ?? "").trim();
}

View File

@@ -1,36 +0,0 @@
export const CONTACT_DOCUMENT_SCOPE_PREFIX = "contact:";
export function buildContactDocumentScope(contactId: string, contactName: string) {
return `${CONTACT_DOCUMENT_SCOPE_PREFIX}${encodeURIComponent(contactId)}:${encodeURIComponent(contactName)}`;
}
export function parseContactDocumentScope(scope: string) {
const raw = String(scope ?? "").trim();
if (!raw.startsWith(CONTACT_DOCUMENT_SCOPE_PREFIX)) return null;
const payload = raw.slice(CONTACT_DOCUMENT_SCOPE_PREFIX.length);
const [idRaw, ...nameParts] = payload.split(":");
const contactId = decodeURIComponent(idRaw ?? "").trim();
const contactName = decodeURIComponent(nameParts.join(":") ?? "").trim();
if (!contactId) return null;
return {
contactId,
contactName,
};
}
export function formatDocumentScope(scope: string) {
const linked = parseContactDocumentScope(scope);
if (!linked) return scope;
return linked.contactName ? `Contact · ${linked.contactName}` : "Contact document";
}
export function isDocumentLinkedToContact(
scope: string,
contact: { id: string; name: string } | null | undefined,
) {
if (!contact) return false;
const linked = parseContactDocumentScope(scope);
if (!linked) return false;
if (linked.contactId) return linked.contactId === contact.id;
return Boolean(linked.contactName && linked.contactName === contact.name);
}

View File

@@ -1,413 +0,0 @@
import { ref, type Ref, type ComputedRef } from "vue";
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: {
selectedTab: Ref<TabId>;
peopleLeftMode: Ref<PeopleLeftMode>;
peopleListMode: Ref<"contacts" | "deals">;
selectedContactId: Ref<string>;
selectedCommThreadId: Ref<string>;
selectedDealId: Ref<string>;
selectedChatId: Ref<string>;
calendarView: Ref<CalendarView>;
calendarCursor: Ref<Date>;
selectedDateKey: Ref<string>;
selectedDocumentId: Ref<string>;
focusedCalendarEventId: Ref<string>;
activeChangeSetId: Ref<string>;
activeChangeStep: Ref<number>;
// computed refs
sortedEvents: ComputedRef<CalendarEvent[]>;
commThreads: ComputedRef<{ id: string; [key: string]: any }[]>;
contacts: Ref<{ id: string; name: string; [key: string]: any }[]>;
deals: Ref<{ id: string; contact: string; [key: string]: any }[]>;
clientTimelineItems: Ref<{ id: string; contactId: string; contentType: string; message?: { contact: string } | null; [key: string]: any }[]>;
activeChangeMessage: ComputedRef<{ changeSetId?: string | null; changeItems?: PilotChangeItem[] | null } | null>;
activeChangeItem: ComputedRef<PilotChangeItem | null>;
activeChangeItems: ComputedRef<PilotChangeItem[]>;
activeChangeIndex: ComputedRef<number>;
authMe: Ref<{ conversation: { id: string } } | null>;
// functions from outside
pickDate: (key: string) => void;
openCommunicationThread: (contact: string) => void;
completeTelegramBusinessConnectFromToken: (token: string) => void;
}) {
const uiPathSyncLocked = ref(false);
let popstateHandler: (() => void) | null = null;
// ---------------------------------------------------------------------------
// Calendar route helpers (internal)
// ---------------------------------------------------------------------------
function calendarCursorToken(date: Date) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
return `${y}-${m}`;
}
function calendarRouteToken(view: CalendarView) {
if (view === "day" || view === "week") {
return opts.selectedDateKey.value;
}
if (view === "year") {
return String(opts.calendarCursor.value.getFullYear());
}
return calendarCursorToken(opts.calendarCursor.value);
}
function parseCalendarCursorToken(token: string | null | undefined) {
const text = String(token ?? "").trim();
const m = text.match(/^(\d{4})-(\d{2})$/);
if (!m) return null;
const year = Number(m[1]);
const month = Number(m[2]);
if (!Number.isFinite(year) || !Number.isFinite(month) || month < 1 || month > 12) return null;
return new Date(year, month - 1, 1);
}
function parseCalendarDateToken(token: string | null | undefined) {
const text = String(token ?? "").trim();
const m = text.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!m) return null;
const year = Number(m[1]);
const month = Number(m[2]);
const day = Number(m[3]);
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) return null;
if (month < 1 || month > 12 || day < 1 || day > 31) return null;
const parsed = new Date(year, month - 1, day);
if (Number.isNaN(parsed.getTime())) return null;
return parsed;
}
function parseCalendarYearToken(token: string | null | undefined) {
const text = String(token ?? "").trim();
const m = text.match(/^(\d{4})$/);
if (!m) return null;
const year = Number(m[1]);
if (!Number.isFinite(year)) return null;
return year;
}
// ---------------------------------------------------------------------------
// Core routing functions
// ---------------------------------------------------------------------------
function normalizedConversationId() {
return safeTrim(opts.selectedChatId.value || opts.authMe.value?.conversation.id || "pilot");
}
function withReviewQuery(path: string) {
const reviewSet = opts.activeChangeSetId.value.trim();
if (!reviewSet) return path;
const params = new URLSearchParams();
params.set("reviewSet", reviewSet);
params.set("reviewStep", String(Math.max(1, opts.activeChangeStep.value + 1)));
return `${path}?${params.toString()}`;
}
function currentUiPath() {
if (opts.selectedTab.value === "documents") {
const docId = opts.selectedDocumentId.value.trim();
if (docId) {
return withReviewQuery(`/documents/${encodeURIComponent(docId)}`);
}
return withReviewQuery("/documents");
}
if (opts.peopleLeftMode.value === "calendar") {
if (opts.focusedCalendarEventId.value.trim()) {
return withReviewQuery(`/calendar/event/${encodeURIComponent(opts.focusedCalendarEventId.value.trim())}`);
}
return withReviewQuery(`/calendar/${encodeURIComponent(opts.calendarView.value)}/${encodeURIComponent(calendarRouteToken(opts.calendarView.value))}`);
}
if (opts.peopleListMode.value === "deals" && opts.selectedDealId.value.trim()) {
return withReviewQuery(`/deal/${encodeURIComponent(opts.selectedDealId.value.trim())}`);
}
if (opts.selectedContactId.value.trim()) {
return withReviewQuery(`/contact/${encodeURIComponent(opts.selectedContactId.value.trim())}`);
}
return withReviewQuery(`/chat/${encodeURIComponent(normalizedConversationId())}`);
}
function syncPathFromUi(push = false) {
if (process.server) return;
const nextPath = currentUiPath();
const currentPath = `${window.location.pathname}${window.location.search}`;
if (nextPath === currentPath) return;
if (push) {
window.history.pushState({}, "", nextPath);
} else {
window.history.replaceState({}, "", nextPath);
}
}
function applyPathToUi(pathname: string, search = "") {
const path = String(pathname || "/").trim() || "/";
const params = new URLSearchParams(String(search || ""));
const reviewSet = (params.get("reviewSet") ?? "").trim();
const reviewStep = Number(params.get("reviewStep") ?? "1");
if (reviewSet) {
opts.activeChangeSetId.value = reviewSet;
opts.activeChangeStep.value = Number.isFinite(reviewStep) && reviewStep > 0 ? reviewStep - 1 : 0;
} else {
opts.activeChangeSetId.value = "";
opts.activeChangeStep.value = 0;
}
const calendarEventMatch = path.match(/^\/calendar\/event\/([^/]+)\/?$/i);
if (calendarEventMatch) {
const rawEventId = decodeURIComponent(calendarEventMatch[1] ?? "").trim();
opts.selectedTab.value = "communications";
opts.peopleLeftMode.value = "calendar";
const event = opts.sortedEvents.value.find((x) => x.id === rawEventId);
if (event) {
opts.pickDate(event.start.slice(0, 10));
}
opts.focusedCalendarEventId.value = rawEventId;
return;
}
const calendarMatch = path.match(/^\/calendar\/([^/]+)\/([^/]+)\/?$/i);
if (calendarMatch) {
const rawView = decodeURIComponent(calendarMatch[1] ?? "").trim();
const rawCursor = decodeURIComponent(calendarMatch[2] ?? "").trim();
const view = (["day", "week", "month", "year", "agenda"] as CalendarView[]).includes(rawView as CalendarView)
? (rawView as CalendarView)
: "month";
const cursorByMonth = parseCalendarCursorToken(rawCursor);
const cursorByDate = parseCalendarDateToken(rawCursor);
const cursorByYear = parseCalendarYearToken(rawCursor);
opts.selectedTab.value = "communications";
opts.peopleLeftMode.value = "calendar";
opts.focusedCalendarEventId.value = "";
opts.calendarView.value = view;
if (view === "day" || view === "week") {
const parsed = cursorByDate;
if (parsed) {
opts.selectedDateKey.value = dayKey(parsed);
opts.calendarCursor.value = new Date(parsed.getFullYear(), parsed.getMonth(), 1);
}
} else if (view === "year") {
if (cursorByYear) {
opts.calendarCursor.value = new Date(cursorByYear, 0, 1);
opts.selectedDateKey.value = dayKey(new Date(cursorByYear, 0, 1));
}
} else if (cursorByMonth) {
opts.calendarCursor.value = cursorByMonth;
opts.selectedDateKey.value = dayKey(cursorByMonth);
}
return;
}
const documentsMatch = path.match(/^\/documents(?:\/([^/]+))?\/?$/i);
if (documentsMatch) {
const rawDocumentId = decodeURIComponent(documentsMatch[1] ?? "").trim();
opts.selectedTab.value = "documents";
opts.focusedCalendarEventId.value = "";
if (rawDocumentId) opts.selectedDocumentId.value = rawDocumentId;
return;
}
const contactMatch = path.match(/^\/contact\/([^/]+)\/?$/i);
if (contactMatch) {
const rawContactId = decodeURIComponent(contactMatch[1] ?? "").trim();
opts.selectedTab.value = "communications";
opts.peopleLeftMode.value = "contacts";
opts.peopleListMode.value = "contacts";
if (rawContactId) {
opts.selectedContactId.value = rawContactId;
const linkedThread = opts.commThreads.value.find((thread) => thread.id === rawContactId);
if (linkedThread) opts.selectedCommThreadId.value = linkedThread.id;
}
opts.focusedCalendarEventId.value = "";
return;
}
const dealMatch = path.match(/^\/deal\/([^/]+)\/?$/i);
if (dealMatch) {
const rawDealId = decodeURIComponent(dealMatch[1] ?? "").trim();
opts.selectedTab.value = "communications";
opts.peopleLeftMode.value = "contacts";
opts.peopleListMode.value = "deals";
if (rawDealId) {
opts.selectedDealId.value = rawDealId;
const linkedDeal = opts.deals.value.find((deal) => deal.id === rawDealId);
const linkedContact = linkedDeal
? opts.contacts.value.find((contact) => contact.name === linkedDeal.contact)
: null;
if (linkedContact) {
opts.selectedContactId.value = linkedContact.id;
opts.selectedCommThreadId.value = linkedContact.id;
}
}
opts.focusedCalendarEventId.value = "";
return;
}
const chatMatch = path.match(/^\/chat\/([^/]+)\/?$/i);
if (chatMatch) {
const rawChatId = decodeURIComponent(chatMatch[1] ?? "").trim();
opts.selectedTab.value = "communications";
opts.peopleLeftMode.value = "contacts";
opts.peopleListMode.value = "contacts";
opts.focusedCalendarEventId.value = "";
if (rawChatId) opts.selectedChatId.value = rawChatId;
return;
}
const changesMatch = path.match(/^\/changes\/([^/]+)(?:\/step\/(\d+))?\/?$/i);
if (changesMatch) {
const rawId = decodeURIComponent(changesMatch[1] ?? "").trim();
const rawStep = Number(changesMatch[2] ?? "1");
if (rawId) {
opts.activeChangeSetId.value = rawId;
opts.activeChangeStep.value = Number.isFinite(rawStep) && rawStep > 0 ? rawStep - 1 : 0;
}
opts.selectedTab.value = "communications";
opts.peopleLeftMode.value = "contacts";
opts.peopleListMode.value = "contacts";
opts.focusedCalendarEventId.value = "";
return;
}
opts.selectedTab.value = "communications";
opts.peopleLeftMode.value = "contacts";
opts.peopleListMode.value = "contacts";
opts.focusedCalendarEventId.value = "";
}
function applyReviewStepToUi(push = false) {
const item = opts.activeChangeItem.value;
if (!item) {
syncPathFromUi(push);
return;
}
opts.selectedTab.value = "communications";
if (item.entity === "calendar_event" && item.entityId) {
opts.peopleLeftMode.value = "calendar";
opts.calendarView.value = "month";
const event = opts.sortedEvents.value.find((x) => x.id === item.entityId);
if (event) {
opts.pickDate(event.start.slice(0, 10));
}
opts.focusedCalendarEventId.value = item.entityId;
syncPathFromUi(push);
return;
}
if (item.entity === "contact_note" && item.entityId) {
opts.peopleLeftMode.value = "contacts";
opts.peopleListMode.value = "contacts";
opts.selectedContactId.value = item.entityId;
const thread = opts.commThreads.value.find((entry) => entry.id === item.entityId);
if (thread) opts.selectedCommThreadId.value = thread.id;
opts.focusedCalendarEventId.value = "";
syncPathFromUi(push);
return;
}
if (item.entity === "deal" && item.entityId) {
opts.peopleLeftMode.value = "contacts";
opts.peopleListMode.value = "deals";
opts.selectedDealId.value = item.entityId;
const deal = opts.deals.value.find((entry) => entry.id === item.entityId);
if (deal) {
const contact = opts.contacts.value.find((entry) => entry.name === deal.contact);
if (contact) {
opts.selectedContactId.value = contact.id;
opts.selectedCommThreadId.value = contact.id;
}
}
opts.focusedCalendarEventId.value = "";
syncPathFromUi(push);
return;
}
if (item.entity === "message" && item.entityId) {
opts.peopleLeftMode.value = "contacts";
opts.peopleListMode.value = "contacts";
const timelineEntry = opts.clientTimelineItems.value.find((entry) => entry.contentType === "message" && entry.message && entry.id === item.entityId);
if (timelineEntry?.message?.contact) {
opts.openCommunicationThread(timelineEntry.message.contact);
}
opts.focusedCalendarEventId.value = "";
syncPathFromUi(push);
return;
}
if (item.entity === "workspace_document" && item.entityId) {
opts.selectedTab.value = "documents";
opts.selectedDocumentId.value = item.entityId;
opts.focusedCalendarEventId.value = "";
syncPathFromUi(push);
return;
}
opts.peopleLeftMode.value = "contacts";
opts.focusedCalendarEventId.value = "";
syncPathFromUi(push);
}
// ---------------------------------------------------------------------------
// Lifecycle init / cleanup
// ---------------------------------------------------------------------------
function initRouting() {
uiPathSyncLocked.value = true;
try {
const params = new URLSearchParams(window.location.search);
const tgLinkToken = String(params.get("tg_link_token") ?? "").trim();
if (tgLinkToken) {
void opts.completeTelegramBusinessConnectFromToken(tgLinkToken);
params.delete("tg_link_token");
const nextSearch = params.toString();
window.history.replaceState({}, "", `${window.location.pathname}${nextSearch ? `?${nextSearch}` : ""}`);
}
applyPathToUi(window.location.pathname, window.location.search);
} finally {
uiPathSyncLocked.value = false;
}
syncPathFromUi(false);
popstateHandler = () => {
uiPathSyncLocked.value = true;
try {
applyPathToUi(window.location.pathname, window.location.search);
} finally {
uiPathSyncLocked.value = false;
}
};
window.addEventListener("popstate", popstateHandler);
}
function cleanupRouting() {
if (popstateHandler) {
window.removeEventListener("popstate", popstateHandler);
popstateHandler = null;
}
}
return {
uiPathSyncLocked,
currentUiPath,
applyPathToUi,
syncPathFromUi,
applyReviewStepToUi,
withReviewQuery,
initRouting,
cleanupRouting,
};
}

View File

@@ -1,20 +0,0 @@
export default defineNuxtRouteMiddleware(async (to) => {
const isLoginRoute = to.path === "/login";
const fetcher = import.meta.server ? useRequestFetch() : $fetch;
let authenticated = false;
try {
await fetcher("/api/auth/session", { method: "GET" });
authenticated = true;
} catch {
authenticated = false;
}
if (!authenticated && !isLoginRoute) {
return navigateTo("/login");
}
if (authenticated && isLoginRoute) {
return navigateTo("/");
}
});

View File

@@ -1,39 +0,0 @@
<script setup lang="ts">
import CrmAuthLoginForm from "~~/app/components/workspace/auth/CrmAuthLoginForm.vue";
import { useMutation } from "@vue/apollo-composable";
import { LoginMutationDocument } from "~~/graphql/generated";
const phone = ref("");
const password = ref("");
const error = ref<string | null>(null);
const busy = ref(false);
const { mutate: doLogin } = useMutation(LoginMutationDocument);
async function submit() {
error.value = null;
busy.value = true;
try {
await doLogin({ phone: phone.value, password: password.value });
await navigateTo("/", { replace: true });
} catch (e: any) {
error.value = e?.data?.message || e?.message || "Login failed";
} finally {
busy.value = false;
}
}
</script>
<template>
<div class="h-[100dvh] overflow-hidden bg-base-200/35">
<CrmAuthLoginForm
:phone="phone"
:password="password"
:error="error"
:busy="busy"
@update:phone="phone = $event"
@update:password="password = $event"
@submit="submit"
/>
</div>
</template>

View File

@@ -1,25 +0,0 @@
import type { CodegenConfig } from "@graphql-codegen/cli";
const config: CodegenConfig = {
schema: "graphql/schema.graphql",
documents: ["graphql/operations/**/*.graphql"],
generates: {
"graphql/generated.ts": {
plugins: [
"typescript",
"typescript-operations",
"typescript-vue-apollo",
],
config: {
withCompositionFunctions: true,
vueCompositionApiImportFrom: "vue",
dedupeFragments: true,
namingConvention: "keep",
useTypeImports: true,
},
},
},
ignoreNoDocuments: false,
};
export default config;

View File

@@ -1,6 +0,0 @@
// @ts-check
import withNuxt from './.nuxt/eslint.config.mjs'
export default withNuxt(
// Your custom configs here
)

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +0,0 @@
mutation ArchiveCalendarEventMutation($input: ArchiveCalendarEventInput!) {
archiveCalendarEvent(input: $input) {
id
title
start
end
contact
note
isArchived
createdAt
archiveNote
archivedAt
}
}

View File

@@ -1,5 +0,0 @@
mutation ArchiveChatConversationMutation($id: ID!) {
archiveChatConversation(id: $id) {
ok
}
}

View File

@@ -1,14 +0,0 @@
query CalendarQuery($from: String, $to: String) {
calendar(dateRange: { from: $from, to: $to }) {
id
title
start
end
contact
note
isArchived
createdAt
archiveNote
archivedAt
}
}

View File

@@ -1,10 +0,0 @@
query ChatConversationsQuery {
chatConversations {
id
title
createdAt
updatedAt
lastMessageAt
lastMessageText
}
}

View File

@@ -1,35 +0,0 @@
query ChatMessagesQuery {
chatMessages {
id
role
text
messageKind
requestId
eventType
phase
transient
thinking
tools
toolRuns {
name
status
input
output
at
}
changeSetId
changeStatus
changeSummary
changeItems {
id
entity
entityId
action
title
before
after
rolledBack
}
createdAt
}
}

View File

@@ -1,20 +0,0 @@
query CommunicationsQuery {
communications {
id
at
contactId
contact
contactInboxId
sourceExternalId
sourceTitle
channel
kind
direction
text
audioUrl
duration
waveform
transcript
deliveryStatus
}
}

View File

@@ -1,6 +0,0 @@
mutation ConfirmLatestChangeSetMutation {
confirmLatestChangeSet {
ok
}
}

View File

@@ -1,13 +0,0 @@
query ContactInboxesQuery {
contactInboxes {
id
contactId
contactName
channel
sourceExternalId
title
isHidden
lastMessageAt
updatedAt
}
}

View File

@@ -1,13 +0,0 @@
query ContactsQuery {
contacts {
id
name
avatar
channels
lastContactAt
lastMessageText
lastMessageChannel
hasUnread
description
}
}

View File

@@ -1,14 +0,0 @@
mutation CreateCalendarEventMutation($input: CreateCalendarEventInput!) {
createCalendarEvent(input: $input) {
id
title
start
end
contact
note
isArchived
createdAt
archiveNote
archivedAt
}
}

View File

@@ -1,10 +0,0 @@
mutation CreateChatConversationMutation($title: String) {
createChatConversation(title: $title) {
id
title
createdAt
updatedAt
lastMessageAt
lastMessageText
}
}

View File

@@ -1,6 +0,0 @@
mutation CreateCommunicationMutation($input: CreateCommunicationInput!) {
createCommunication(input: $input) {
ok
id
}
}

View File

@@ -1,22 +0,0 @@
mutation CreateDealMutation($input: CreateDealInput!) {
createDeal(input: $input) {
id
contact
title
stage
amount
paidAmount
nextStep
summary
currentStepId
steps {
id
title
description
status
dueAt
order
completedAt
}
}
}

View File

@@ -1,12 +0,0 @@
mutation CreateWorkspaceDocument($input: CreateWorkspaceDocumentInput!) {
createWorkspaceDocument(input: $input) {
id
title
type
owner
scope
updatedAt
summary
body
}
}

View File

@@ -1,22 +0,0 @@
query DealsQuery {
deals {
id
contact
title
stage
amount
paidAmount
nextStep
summary
currentStepId
steps {
id
title
description
status
dueAt
order
completedAt
}
}
}

View File

@@ -1,6 +0,0 @@
mutation DeleteWorkspaceDocument($id: ID!) {
deleteWorkspaceDocument(id: $id) {
ok
id
}
}

View File

@@ -1,12 +0,0 @@
query DocumentsQuery {
documents {
id
title
type
owner
scope
updatedAt
summary
body
}
}

View File

@@ -1,15 +0,0 @@
query FeedQuery {
feed {
id
at
contact
text
proposal {
title
details
key
}
decision
decisionNote
}
}

View File

@@ -1,62 +0,0 @@
query GetClientTimelineQuery($contactId: ID!, $limit: Int) {
getClientTimeline(contactId: $contactId, limit: $limit) {
id
contactId
contentType
contentId
datetime
message {
id
at
contactId
contact
contactInboxId
sourceExternalId
sourceTitle
channel
kind
direction
text
audioUrl
duration
waveform
transcript
deliveryStatus
}
calendarEvent {
id
title
start
end
contact
note
isArchived
createdAt
archiveNote
archivedAt
}
recommendation {
id
at
contact
text
proposal {
title
details
key
}
decision
decisionNote
}
document {
id
title
type
owner
scope
updatedAt
summary
body
}
}
}

View File

@@ -1,5 +0,0 @@
mutation LogPilotNoteMutation($text: String!) {
logPilotNote(text: $text) {
ok
}
}

View File

@@ -1,5 +0,0 @@
mutation LoginMutation($phone: String!, $password: String!) {
login(phone: $phone, password: $password) {
ok
}
}

View File

@@ -1,5 +0,0 @@
mutation LogoutMutation {
logout {
ok
}
}

View File

@@ -1,5 +0,0 @@
mutation MarkThreadRead($contactId: ID!) {
markThreadRead(contactId: $contactId) {
ok
}
}

View File

@@ -1,17 +0,0 @@
query MeQuery {
me {
user {
id
phone
name
}
team {
id
name
}
conversation {
id
title
}
}
}

View File

@@ -1,7 +0,0 @@
query PinsQuery {
pins {
id
contact
text
}
}

View File

@@ -1,5 +0,0 @@
mutation RollbackChangeSetItemsMutation($changeSetId: ID!, $itemIds: [ID!]!) {
rollbackChangeSetItems(changeSetId: $changeSetId, itemIds: $itemIds) {
ok
}
}

View File

@@ -1,6 +0,0 @@
mutation RollbackLatestChangeSetMutation {
rollbackLatestChangeSet {
ok
}
}

Some files were not shown because too many files have changed in this diff Show More