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"]
|
||||
path = instructions
|
||||
url = git@gitea.dsrptlab.com:dsrptlab/instructions.git
|
||||
[submodule "frontend"]
|
||||
path = frontend
|
||||
url = git@gitea.dsrptlab.com:clientflow/frontend.git
|
||||
branch = main
|
||||
[submodule "backend"]
|
||||
path = backend
|
||||
url = git@gitea.dsrptlab.com:clientflow/backend.git
|
||||
branch = main
|
||||
[submodule "backend_worker"]
|
||||
path = backend_worker
|
||||
url = git@gitea.dsrptlab.com:clientflow/backend_worker.git
|
||||
branch = main
|
||||
|
||||
1
backend
Submodule
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