chore(repo): split frontend backend backend_worker into submodules
This commit is contained in:
12
.gitmodules
vendored
12
.gitmodules
vendored
@@ -1,3 +1,15 @@
|
|||||||
[submodule "instructions"]
|
[submodule "instructions"]
|
||||||
path = instructions
|
path = instructions
|
||||||
url = git@gitea.dsrptlab.com:dsrptlab/instructions.git
|
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
1
backend
Submodule
Submodule backend added at 42e9dc7bcb
@@ -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"]
|
|
||||||
@@ -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
1025
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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])
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
@@ -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");
|
|
||||||
});
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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})`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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
1
backend_worker
Submodule
Submodule backend_worker added at 653617983a
@@ -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"]
|
|
||||||
@@ -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.
|
|
||||||
1456
backend_worker/package-lock.json
generated
1456
backend_worker/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { HatchetClient } from "@hatchet-dev/typescript-sdk/v1";
|
|
||||||
|
|
||||||
export const hatchet = HatchetClient.init();
|
|
||||||
@@ -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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -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
1
frontend
Submodule
Submodule frontend added at e11185259f
@@ -1,9 +0,0 @@
|
|||||||
.git
|
|
||||||
.gitignore
|
|
||||||
node_modules
|
|
||||||
.nuxt
|
|
||||||
.output
|
|
||||||
.data
|
|
||||||
npm-debug.log*
|
|
||||||
dist
|
|
||||||
coverage
|
|
||||||
@@ -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=""
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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"]
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<template>
|
|
||||||
<NuxtLayout>
|
|
||||||
<NuxtPage />
|
|
||||||
</NuxtLayout>
|
|
||||||
</template>
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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 = {};
|
|
||||||
@@ -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("&", "&")
|
|
||||||
.replaceAll("<", "<")
|
|
||||||
.replaceAll(">", ">")
|
|
||||||
.replaceAll('"', """)
|
|
||||||
.replaceAll("'", "'");
|
|
||||||
}
|
|
||||||
|
|
||||||
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
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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("/");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -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>
|
|
||||||
@@ -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;
|
|
||||||
@@ -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
@@ -1,14 +0,0 @@
|
|||||||
mutation ArchiveCalendarEventMutation($input: ArchiveCalendarEventInput!) {
|
|
||||||
archiveCalendarEvent(input: $input) {
|
|
||||||
id
|
|
||||||
title
|
|
||||||
start
|
|
||||||
end
|
|
||||||
contact
|
|
||||||
note
|
|
||||||
isArchived
|
|
||||||
createdAt
|
|
||||||
archiveNote
|
|
||||||
archivedAt
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
mutation ArchiveChatConversationMutation($id: ID!) {
|
|
||||||
archiveChatConversation(id: $id) {
|
|
||||||
ok
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
query ChatConversationsQuery {
|
|
||||||
chatConversations {
|
|
||||||
id
|
|
||||||
title
|
|
||||||
createdAt
|
|
||||||
updatedAt
|
|
||||||
lastMessageAt
|
|
||||||
lastMessageText
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
query CommunicationsQuery {
|
|
||||||
communications {
|
|
||||||
id
|
|
||||||
at
|
|
||||||
contactId
|
|
||||||
contact
|
|
||||||
contactInboxId
|
|
||||||
sourceExternalId
|
|
||||||
sourceTitle
|
|
||||||
channel
|
|
||||||
kind
|
|
||||||
direction
|
|
||||||
text
|
|
||||||
audioUrl
|
|
||||||
duration
|
|
||||||
waveform
|
|
||||||
transcript
|
|
||||||
deliveryStatus
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
mutation ConfirmLatestChangeSetMutation {
|
|
||||||
confirmLatestChangeSet {
|
|
||||||
ok
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
query ContactInboxesQuery {
|
|
||||||
contactInboxes {
|
|
||||||
id
|
|
||||||
contactId
|
|
||||||
contactName
|
|
||||||
channel
|
|
||||||
sourceExternalId
|
|
||||||
title
|
|
||||||
isHidden
|
|
||||||
lastMessageAt
|
|
||||||
updatedAt
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
query ContactsQuery {
|
|
||||||
contacts {
|
|
||||||
id
|
|
||||||
name
|
|
||||||
avatar
|
|
||||||
channels
|
|
||||||
lastContactAt
|
|
||||||
lastMessageText
|
|
||||||
lastMessageChannel
|
|
||||||
hasUnread
|
|
||||||
description
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
mutation CreateCalendarEventMutation($input: CreateCalendarEventInput!) {
|
|
||||||
createCalendarEvent(input: $input) {
|
|
||||||
id
|
|
||||||
title
|
|
||||||
start
|
|
||||||
end
|
|
||||||
contact
|
|
||||||
note
|
|
||||||
isArchived
|
|
||||||
createdAt
|
|
||||||
archiveNote
|
|
||||||
archivedAt
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
mutation CreateChatConversationMutation($title: String) {
|
|
||||||
createChatConversation(title: $title) {
|
|
||||||
id
|
|
||||||
title
|
|
||||||
createdAt
|
|
||||||
updatedAt
|
|
||||||
lastMessageAt
|
|
||||||
lastMessageText
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
mutation CreateCommunicationMutation($input: CreateCommunicationInput!) {
|
|
||||||
createCommunication(input: $input) {
|
|
||||||
ok
|
|
||||||
id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
mutation CreateWorkspaceDocument($input: CreateWorkspaceDocumentInput!) {
|
|
||||||
createWorkspaceDocument(input: $input) {
|
|
||||||
id
|
|
||||||
title
|
|
||||||
type
|
|
||||||
owner
|
|
||||||
scope
|
|
||||||
updatedAt
|
|
||||||
summary
|
|
||||||
body
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
query DealsQuery {
|
|
||||||
deals {
|
|
||||||
id
|
|
||||||
contact
|
|
||||||
title
|
|
||||||
stage
|
|
||||||
amount
|
|
||||||
paidAmount
|
|
||||||
nextStep
|
|
||||||
summary
|
|
||||||
currentStepId
|
|
||||||
steps {
|
|
||||||
id
|
|
||||||
title
|
|
||||||
description
|
|
||||||
status
|
|
||||||
dueAt
|
|
||||||
order
|
|
||||||
completedAt
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
mutation DeleteWorkspaceDocument($id: ID!) {
|
|
||||||
deleteWorkspaceDocument(id: $id) {
|
|
||||||
ok
|
|
||||||
id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
query DocumentsQuery {
|
|
||||||
documents {
|
|
||||||
id
|
|
||||||
title
|
|
||||||
type
|
|
||||||
owner
|
|
||||||
scope
|
|
||||||
updatedAt
|
|
||||||
summary
|
|
||||||
body
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
query FeedQuery {
|
|
||||||
feed {
|
|
||||||
id
|
|
||||||
at
|
|
||||||
contact
|
|
||||||
text
|
|
||||||
proposal {
|
|
||||||
title
|
|
||||||
details
|
|
||||||
key
|
|
||||||
}
|
|
||||||
decision
|
|
||||||
decisionNote
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
mutation LogPilotNoteMutation($text: String!) {
|
|
||||||
logPilotNote(text: $text) {
|
|
||||||
ok
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
mutation LoginMutation($phone: String!, $password: String!) {
|
|
||||||
login(phone: $phone, password: $password) {
|
|
||||||
ok
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
mutation LogoutMutation {
|
|
||||||
logout {
|
|
||||||
ok
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
mutation MarkThreadRead($contactId: ID!) {
|
|
||||||
markThreadRead(contactId: $contactId) {
|
|
||||||
ok
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
query MeQuery {
|
|
||||||
me {
|
|
||||||
user {
|
|
||||||
id
|
|
||||||
phone
|
|
||||||
name
|
|
||||||
}
|
|
||||||
team {
|
|
||||||
id
|
|
||||||
name
|
|
||||||
}
|
|
||||||
conversation {
|
|
||||||
id
|
|
||||||
title
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
query PinsQuery {
|
|
||||||
pins {
|
|
||||||
id
|
|
||||||
contact
|
|
||||||
text
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
mutation RollbackChangeSetItemsMutation($changeSetId: ID!, $itemIds: [ID!]!) {
|
|
||||||
rollbackChangeSetItems(changeSetId: $changeSetId, itemIds: $itemIds) {
|
|
||||||
ok
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
mutation RollbackLatestChangeSetMutation {
|
|
||||||
rollbackLatestChangeSet {
|
|
||||||
ok
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user