refactor(frontend): split documents and review into workspace components

This commit is contained in:
Ruslan Bakiev
2026-02-23 11:22:05 +07:00
parent 47ed805ac7
commit 2b72d42956
3 changed files with 315 additions and 182 deletions

View File

@@ -2,7 +2,9 @@
import { nextTick, onBeforeUnmount, onMounted } from "vue";
import CrmAuthLoading from "~~/app/components/workspace/auth/CrmAuthLoading.vue";
import CrmAuthLoginForm from "~~/app/components/workspace/auth/CrmAuthLoginForm.vue";
import CrmDocumentsPanel from "~~/app/components/workspace/documents/CrmDocumentsPanel.vue";
import CrmWorkspaceTopbar from "~~/app/components/workspace/header/CrmWorkspaceTopbar.vue";
import CrmChangeReviewOverlay from "~~/app/components/workspace/review/CrmChangeReviewOverlay.vue";
import meQuery from "~~/graphql/operations/me.graphql?raw";
import chatMessagesQuery from "~~/graphql/operations/chat-messages.graphql?raw";
import dashboardQuery from "~~/graphql/operations/dashboard.graphql?raw";
@@ -3318,6 +3320,11 @@ watchEffect(() => {
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(push = false) {
selectedTab.value = "documents";
focusedCalendarEventId.value = "";
@@ -6323,189 +6330,45 @@ 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-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"
>
<CrmDocumentsPanel
v-else-if="selectedTab === 'documents'"
:document-search="documentSearch"
:document-sort-mode="documentSortMode"
:document-sort-options="documentSortOptions"
:filtered-documents="filteredDocuments"
:selected-document-id="selectedDocumentId"
:selected-document="selectedDocument"
:format-document-scope="formatDocumentScope"
:format-stamp="formatStamp"
@update:document-search="documentSearch = $event"
@update:document-sort-mode="documentSortMode = $event"
@select-document="selectedDocumentId = $event"
@update-selected-document-body="updateSelectedDocumentBody"
/>
<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 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>
</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>
</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">
{{ 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 v-else class="flex h-full items-center justify-center text-sm text-base-content/60">
No document selected.
</div>
</article>
</div>
</section>
<div
v-if="reviewActive && selectedTab === 'communications'"
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 {{ activeChangeStepNumber }}/{{ activeChangeItems.length }}
</p>
<p class="truncate text-sm font-semibold text-base-content">
{{ activeChangeItem?.title || "Change step" }}
</p>
</div>
<button class="btn btn-ghost btn-xs" @click="finishReview(true)">Close</button>
</div>
<div v-if="activeChangeItem" class="mt-2 rounded-xl border border-base-300 bg-base-200/35 p-2">
<p class="text-xs text-base-content/80">
{{ describeChangeEntity(activeChangeItem.entity) }} {{ describeChangeAction(activeChangeItem.action) }}
</p>
<label class="mt-1 inline-flex items-center gap-2 text-xs">
<input
type="checkbox"
class="checkbox checkbox-xs"
:checked="activeChangeApproved"
:disabled="activeChangeItem.rolledBack"
@change="onActiveReviewApprovalInput"
>
<span>{{ activeChangeItem.rolledBack ? "Already rolled back" : "Approve this step" }}</span>
</label>
</div>
<div class="mt-2 max-h-40 space-y-1 overflow-y-auto pr-1">
<div
v-for="(item, index) in activeChangeItems"
:key="`review-step-${item.id}`"
class="flex items-center gap-2 rounded-lg border px-2 py-1"
:class="index === activeChangeIndex ? 'border-primary/45 bg-primary/10' : 'border-base-300 bg-base-100'"
>
<button
class="min-w-0 flex-1 text-left"
@click="openChangeItemTarget(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">
{{ describeChangeEntity(item.entity) }}
</p>
</button>
<input
type="checkbox"
class="checkbox checkbox-xs"
:checked="isReviewItemApproved(item)"
:disabled="item.rolledBack"
@change="onReviewItemApprovalInput(item.id, $event)"
>
</div>
</div>
<div class="mt-3 flex items-center justify-between gap-2">
<div class="join">
<button
class="btn btn-xs join-item"
:disabled="activeChangeIndex <= 0"
@click="goToPreviousChangeStep"
>
Prev
</button>
<button
class="btn btn-xs join-item"
:disabled="activeChangeIndex >= activeChangeItems.length - 1"
@click="goToNextChangeStep"
>
Next
</button>
</div>
<p class="text-[11px] text-base-content/70">
Rollback marked: {{ selectedRollbackCount }}
</p>
</div>
<div class="mt-2 flex flex-wrap gap-2">
<button class="btn btn-xs btn-outline" @click="setReviewApprovalForAll(true)">Approve all</button>
<button class="btn btn-xs btn-outline" @click="setReviewApprovalForAll(false)">Mark all rollback</button>
<button
class="btn btn-xs btn-warning"
:disabled="changeActionBusy || selectedRollbackCount === 0"
@click="rollbackSelectedChangeItems"
>
{{ changeActionBusy ? "Applying..." : "Rollback selected" }}
</button>
<button class="btn btn-xs btn-primary ml-auto" @click="finishReview(true)">Done</button>
</div>
</section>
</div>
<CrmChangeReviewOverlay
:visible="reviewActive && selectedTab === 'communications'"
:active-change-step-number="activeChangeStepNumber"
:active-change-items="activeChangeItems"
:active-change-item="activeChangeItem"
:active-change-approved="activeChangeApproved"
:active-change-index="activeChangeIndex"
:selected-rollback-count="selectedRollbackCount"
:change-action-busy="changeActionBusy"
:describe-change-entity="describeChangeEntity"
:describe-change-action="describeChangeAction"
:is-review-item-approved="isReviewItemApproved"
@close="finishReview(true)"
@active-approval-change="onActiveReviewApprovalInput"
@item-approval-change="onReviewItemApprovalInput($event.itemId, $event.event)"
@open-item-target="openChangeItemTarget"
@prev-step="goToPreviousChangeStep"
@next-step="goToNextChangeStep"
@approve-all="setReviewApprovalForAll(true)"
@mark-all-rollback="setReviewApprovalForAll(false)"
@rollback-selected="rollbackSelectedChangeItems"
@done="finishReview(true)"
/>
</div>

View File

@@ -0,0 +1,133 @@
<script setup lang="ts">
import ContactCollaborativeEditor from "~~/app/components/ContactCollaborativeEditor.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;
}>();
</script>
<template>
<section 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
: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)"
>
<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">
<ContactCollaborativeEditor
:key="`doc-editor-${props.selectedDocument.id}`"
:model-value="props.selectedDocument.body"
:room="`crm-doc-${props.selectedDocument.id}`"
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>
</section>
</template>

View File

@@ -0,0 +1,137 @@
<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;
activeChangeApproved: boolean;
activeChangeIndex: number;
selectedRollbackCount: number;
changeActionBusy: boolean;
describeChangeEntity: (entity: string) => string;
describeChangeAction: (action: string) => string;
isReviewItemApproved: (item: ChangeItem | null | undefined) => boolean;
}>();
const emit = defineEmits<{
(e: "close"): void;
(e: "active-approval-change", event: Event): void;
(e: "item-approval-change", payload: { itemId: string; event: Event }): void;
(e: "open-item-target", item: ChangeItem): void;
(e: "prev-step"): void;
(e: "next-step"): void;
(e: "approve-all"): void;
(e: "mark-all-rollback"): void;
(e: "rollback-selected"): 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>
<label class="mt-1 inline-flex items-center gap-2 text-xs">
<input
type="checkbox"
class="checkbox checkbox-xs"
:checked="props.activeChangeApproved"
:disabled="props.activeChangeItem.rolledBack"
@change="emit('active-approval-change', $event)"
>
<span>{{ props.activeChangeItem.rolledBack ? "Already rolled back" : "Approve this step" }}</span>
</label>
</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="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>
<input
type="checkbox"
class="checkbox checkbox-xs"
:checked="props.isReviewItemApproved(item)"
:disabled="item.rolledBack"
@change="emit('item-approval-change', { itemId: item.id, event: $event })"
>
</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 marked: {{ props.selectedRollbackCount }}
</p>
</div>
<div class="mt-2 flex flex-wrap gap-2">
<button class="btn btn-xs btn-outline" @click="emit('approve-all')">Approve all</button>
<button class="btn btn-xs btn-outline" @click="emit('mark-all-rollback')">Mark all rollback</button>
<button
class="btn btn-xs btn-warning"
:disabled="props.changeActionBusy || props.selectedRollbackCount === 0"
@click="emit('rollback-selected')"
>
{{ props.changeActionBusy ? "Applying..." : "Rollback selected" }}
</button>
<button class="btn btn-xs btn-primary ml-auto" @click="emit('done')">Done</button>
</div>
</section>
</div>
</template>