Refine documents UX and extract document scope helpers

This commit is contained in:
Ruslan Bakiev
2026-02-23 08:04:58 +07:00
parent 9efb42f598
commit f81a0fde55
3 changed files with 270 additions and 105 deletions

View File

@@ -20,6 +20,11 @@ import toggleContactPinMutation from "./graphql/operations/toggle-contact-pin.gr
import confirmLatestChangeSetMutation from "./graphql/operations/confirm-latest-change-set.graphql?raw";
import rollbackLatestChangeSetMutation from "./graphql/operations/rollback-latest-change-set.graphql?raw";
import rollbackChangeSetItemsMutation from "./graphql/operations/rollback-change-set-items.graphql?raw";
import {
buildContactDocumentScope,
formatDocumentScope,
isDocumentLinkedToContact,
} from "./composables/useWorkspaceDocuments";
import { Chat as AiChat } from "@ai-sdk/vue";
import { DefaultChatTransport, isTextUIPart, type UIMessage } from "ai";
type TabId = "communications" | "documents";
@@ -27,6 +32,7 @@ type CalendarView = "day" | "week" | "month" | "year" | "agenda";
type SortMode = "name" | "lastContact";
type PeopleLeftMode = "contacts" | "calendar";
type PeopleSortMode = "name" | "lastContact" | "company" | "country";
type DocumentSortMode = "updatedAt" | "title" | "owner" | "type";
type FeedCard = {
id: string;
@@ -2803,58 +2809,32 @@ const selectedContactRecentMessages = computed(() => {
.slice(0, 8);
});
const CONTACT_DOCUMENT_SCOPE_PREFIX = "contact:";
function buildContactDocumentScope(contactId: string, contactName: string) {
return `${CONTACT_DOCUMENT_SCOPE_PREFIX}${encodeURIComponent(contactId)}:${encodeURIComponent(contactName)}`;
}
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,
};
}
function formatDocumentScope(scope: string) {
const linked = parseContactDocumentScope(scope);
if (!linked) return scope;
return linked.contactName ? `Contact · ${linked.contactName}` : "Contact document";
}
function isDocumentLinkedToContact(doc: WorkspaceDocument, contact: Contact | null | undefined) {
if (!contact) return false;
const linked = parseContactDocumentScope(doc.scope);
if (!linked) return false;
if (linked.contactId) return linked.contactId === contact.id;
return Boolean(linked.contactName && linked.contactName === contact.name);
}
const documentSearch = ref("");
const selectedDocumentType = ref<"All" | WorkspaceDocument["type"]>("All");
const documentTypes = computed(() =>
["All", ...new Set(documents.value.map((item) => item.type))] as ("All" | WorkspaceDocument["type"])[],
);
const documentSortMode = ref<DocumentSortMode>("updatedAt");
const documentSortOptions: Array<{ value: DocumentSortMode; label: string }> = [
{ value: "updatedAt", label: "Updated" },
{ value: "title", label: "Title" },
{ value: "owner", label: "Owner" },
{ value: "type", label: "Type" },
];
const filteredDocuments = computed(() => {
const query = documentSearch.value.trim().toLowerCase();
return documents.value
const list = documents.value
.filter((item) => {
if (selectedDocumentType.value !== "All" && item.type !== selectedDocumentType.value) return false;
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) => b.updatedAt.localeCompare(a.updatedAt));
.sort((a, b) => {
if (documentSortMode.value === "title") return a.title.localeCompare(b.title);
if (documentSortMode.value === "owner") return a.owner.localeCompare(b.owner);
if (documentSortMode.value === "type") return a.type.localeCompare(b.type);
return b.updatedAt.localeCompare(a.updatedAt);
});
return list;
});
const selectedDocumentId = ref(documents.value[0]?.id ?? "");
@@ -3387,7 +3367,7 @@ const selectedWorkspaceContactDocuments = computed(() => {
const contact = selectedWorkspaceContact.value;
if (!contact) return [];
return documents.value
.filter((doc) => isDocumentLinkedToContact(doc, contact))
.filter((doc) => isDocumentLinkedToContact(doc.scope, contact))
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
});
@@ -4497,7 +4477,7 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
</div>
<div
class="min-h-0 flex-1"
:class="selectedTab === 'communications' && peopleLeftMode === 'contacts' ? 'px-0 pt-0 pb-0' : 'px-3 pt-3 pb-0 md:px-4 md:pt-4 md:pb-0'"
:class="selectedTab === 'documents' || (selectedTab === 'communications' && peopleLeftMode === 'contacts') ? 'px-0 pt-0 pb-0' : 'px-3 pt-3 pb-0 md:px-4 md:pt-4 md:pb-0'"
>
<section
v-if="selectedTab === 'communications' && peopleLeftMode === 'calendar'"
@@ -5570,7 +5550,66 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
<aside class="h-full min-h-0">
<div class="flex h-full min-h-0 flex-col p-3">
<div class="min-h-0 flex-1 space-y-3 overflow-y-auto pr-1">
<div
v-if="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="contactRightPanelMode = 'documents'"
>
{{ selectedWorkspaceContactDocuments.length }} documents
</button>
<button
v-for="doc in 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="contactRightPanelMode = 'documents'; selectedDocumentId = doc.id"
>
{{ doc.title }}
</button>
</div>
<div v-if="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="contactRightPanelMode = 'summary'">Summary</button>
</div>
<input
v-model="contactDocumentsSearch"
type="text"
class="input input-bordered input-xs mt-2 w-full"
placeholder="Search documents..."
>
</div>
<div class="mt-2 space-y-1.5">
<article
v-for="doc in 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="selectedDocumentId === doc.id ? 'border-primary bg-primary/10' : ''"
@click="selectedDocumentId = 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>
<span class="shrink-0 text-[10px] text-base-content/55">{{ doc.type }}</span>
</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 {{ formatStamp(doc.updatedAt) }}</p>
<button class="btn btn-ghost btn-xs px-1" @click.stop="selectedDocumentId = doc.id; openDocumentsTab(true)">Open</button>
</div>
</article>
<p v-if="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="selectedWorkspaceDeal"
class="rounded-xl border border-base-300 bg-base-200/30 p-2.5"
@@ -5653,72 +5692,92 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
</div>
</section>
<section v-else-if="selectedTab === 'documents'" class="flex h-full min-h-0 flex-col gap-3">
<div class="rounded-xl border border-base-300 p-3">
<div class="grid gap-2 md:grid-cols-[1fr_220px]">
<label class="form-control">
<span class="label-text text-xs uppercase tracking-wide text-base-content/60">Search docs</span>
<input
v-model="documentSearch"
type="text"
class="input input-bordered input-sm"
placeholder="Title, owner, scope, content"
>
</label>
<section v-else-if="selectedTab === 'documents'" class="flex h-full min-h-0 flex-col gap-0">
<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
v-model="documentSearch"
type="text"
class="input input-bordered input-sm w-full"
placeholder="Search documents"
>
<label class="form-control">
<span class="label-text text-xs uppercase tracking-wide text-base-content/60">Type</span>
<select v-model="selectedDocumentType" class="select select-bordered select-sm">
<option v-for="item in documentTypes" :key="`doc-type-${item}`" :value="item">{{ item }}</option>
</select>
</label>
</div>
</div>
<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 class="grid min-h-0 flex-1 gap-0 md:grid-cols-12">
<aside class="min-h-0 border-r border-base-300 md:col-span-4 flex flex-col">
<div class="min-h-0 flex-1 space-y-2 overflow-y-auto p-2">
<button
v-for="doc in filteredDocuments"
:key="doc.id"
class="w-full rounded-xl border border-base-300 p-3 text-left transition hover:bg-base-200/60"
:class="selectedDocumentId === doc.id ? 'border-primary bg-primary/5' : ''"
@click="selectedDocumentId = doc.id"
>
<p class="font-medium">{{ doc.title }}</p>
<p class="mt-1 text-xs text-base-content/60">{{ doc.type }} · {{ doc.owner }}</p>
<p class="mt-1 line-clamp-2 text-xs text-base-content/75">{{ doc.summary }}</p>
<p class="mt-1 text-xs text-base-content/55">Updated {{ formatStamp(doc.updatedAt) }}</p>
</button>
</div>
</aside>
<article class="min-h-0 md:col-span-8 flex flex-col">
<div v-if="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">{{ selectedDocument.title }}</p>
<p class="text-xs text-base-content/60">
{{ selectedDocument.type }} · {{ selectedDocument.scope }} · {{ selectedDocument.owner }}
<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 documentSortOptions"
:key="`document-sort-${option.value}`"
class="btn btn-ghost btn-sm w-full justify-between"
@click="documentSortMode = option.value"
>
<span>{{ option.label }}</span>
<span v-if="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 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="selectedDocumentId === doc.id ? 'bg-primary/10' : ''"
@click="selectedDocumentId = 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>
<span class="shrink-0 text-[10px] text-base-content/55">{{ doc.type }}</span>
</div>
<p class="mt-0.5 truncate text-[11px] text-base-content/75">{{ 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 {{ formatStamp(doc.updatedAt) }}</p>
</button>
<p v-if="filteredDocuments.length === 0" class="px-2 py-2 text-xs text-base-content/55">
No documents found.
</p>
<p class="mt-1 text-sm text-base-content/80">{{ selectedDocument.summary }}</p>
</div>
</aside>
<article class="h-full min-h-0 flex flex-col">
<div v-if="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">{{ selectedDocument.title }}</p>
<p class="text-xs text-base-content/60">
{{ selectedDocument.type }} · {{ formatDocumentScope(selectedDocument.scope) }} · {{ selectedDocument.owner }}
</p>
<p class="mt-1 text-sm text-base-content/80">{{ selectedDocument.summary }}</p>
</div>
<div class="mt-3 min-h-0 flex-1 overflow-y-auto">
<ContactCollaborativeEditor
:key="`doc-editor-${selectedDocument.id}`"
v-model="selectedDocument.body"
:room="`crm-doc-${selectedDocument.id}`"
placeholder="Describe policy, steps, rules, and exceptions..."
/>
</div>
</div>
<div class="mt-3 min-h-0 flex-1 overflow-y-auto">
<ContactCollaborativeEditor
:key="`doc-editor-${selectedDocument.id}`"
v-model="selectedDocument.body"
:room="`crm-doc-${selectedDocument.id}`"
placeholder="Describe policy, steps, rules, and exceptions..."
/>
<div v-else class="flex h-full items-center justify-center text-sm text-base-content/60">
No document selected.
</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>
</section>
</article>
</div>
</section>
<div
v-if="reviewActive && selectedTab === 'communications'"

View File

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

View File

@@ -600,6 +600,52 @@ async function createCommunication(auth: AuthContext | null, input: {
return { ok: true, id: created.id };
}
async function createWorkspaceDocument(auth: AuthContext | null, input: {
title: string;
type?: string;
owner?: string;
scope: string;
summary: string;
body?: string;
}) {
const ctx = requireAuth(auth);
const title = String(input?.title ?? "").trim();
const scope = String(input?.scope ?? "").trim();
const summary = String(input?.summary ?? "").trim();
const body = String(input?.body ?? "").trim();
const owner = String(input?.owner ?? "").trim() || "Workspace";
const typeRaw = String(input?.type ?? "Template").trim();
const allowedTypes = new Set(["Regulation", "Playbook", "Policy", "Template"]);
const type = allowedTypes.has(typeRaw) ? typeRaw : "Template";
if (!title) throw new Error("title is required");
if (!scope) throw new Error("scope is required");
if (!summary) throw new Error("summary is required");
const created = await prisma.workspaceDocument.create({
data: {
teamId: ctx.teamId,
title,
type: type as any,
owner,
scope,
summary,
body: body || summary,
},
});
return {
id: created.id,
title: created.title,
type: created.type,
owner: created.owner,
scope: created.scope,
updatedAt: created.updatedAt.toISOString(),
summary: created.summary,
body: created.body,
};
}
async function updateCommunicationTranscript(auth: AuthContext | null, id: string, transcript: string[]) {
const ctx = requireAuth(auth);
const messageId = String(id ?? "").trim();
@@ -951,6 +997,7 @@ export const crmGraphqlSchema = buildSchema(`
createCalendarEvent(input: CreateCalendarEventInput!): CalendarEvent!
archiveCalendarEvent(input: ArchiveCalendarEventInput!): CalendarEvent!
createCommunication(input: CreateCommunicationInput!): MutationWithIdResult!
createWorkspaceDocument(input: CreateWorkspaceDocumentInput!): WorkspaceDocument!
updateCommunicationTranscript(id: ID!, transcript: [String!]!): MutationWithIdResult!
updateFeedDecision(id: ID!, decision: String!, decisionNote: String): MutationWithIdResult!
}
@@ -996,6 +1043,15 @@ export const crmGraphqlSchema = buildSchema(`
transcript: [String!]
}
input CreateWorkspaceDocumentInput {
title: String!
type: String
owner: String
scope: String!
summary: String!
body: String
}
type MePayload {
user: MeUser!
team: MeTeam!
@@ -1232,6 +1288,20 @@ export const crmGraphqlRoot = {
context: GraphQLContext,
) => createCommunication(context.auth, args.input),
createWorkspaceDocument: async (
args: {
input: {
title: string;
type?: string;
owner?: string;
scope: string;
summary: string;
body?: string;
};
},
context: GraphQLContext,
) => createWorkspaceDocument(context.auth, args.input),
updateCommunicationTranscript: async (
args: { id: string; transcript: string[] },
context: GraphQLContext,