494 lines
20 KiB
Vue
494 lines
20 KiB
Vue
<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>
|