Files
clientsflow/frontend/app/components/workspace/communications/CrmCommunicationsContextSidebar.vue

494 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>