Refine event card timing text and add voice archive note
This commit is contained in:
348
Frontend/app.vue
348
Frontend/app.vue
@@ -210,11 +210,37 @@ function eventTimelineAt(event: CalendarEvent, phase: EventLifecyclePhase) {
|
|||||||
return eventDueAt(event);
|
return eventDueAt(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
function eventPhaseLabel(phase: EventLifecyclePhase) {
|
function eventRelativeLabel(event: CalendarEvent, nowMs: number) {
|
||||||
if (phase === "scheduled") return "Scheduled";
|
if (event.isArchived) return "Archived";
|
||||||
if (phase === "due_soon") return "Starts in <30 min";
|
const diffMs = new Date(event.start).getTime() - nowMs;
|
||||||
if (phase === "awaiting_outcome") return "Awaiting outcome";
|
const minuteMs = 60 * 1000;
|
||||||
return "Closed";
|
const hourMs = 60 * minuteMs;
|
||||||
|
const dayMs = 24 * hourMs;
|
||||||
|
const abs = Math.abs(diffMs);
|
||||||
|
|
||||||
|
if (diffMs >= 0) {
|
||||||
|
if (abs >= dayMs) {
|
||||||
|
const days = Math.round(abs / dayMs);
|
||||||
|
return `Event in ${days} day${days === 1 ? "" : "s"}`;
|
||||||
|
}
|
||||||
|
if (abs >= hourMs) {
|
||||||
|
const hours = Math.round(abs / hourMs);
|
||||||
|
return `Event in ${hours} hour${hours === 1 ? "" : "s"}`;
|
||||||
|
}
|
||||||
|
const minutes = Math.max(1, Math.round(abs / minuteMs));
|
||||||
|
return `Event in ${minutes} minute${minutes === 1 ? "" : "s"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (abs >= dayMs) {
|
||||||
|
const days = Math.round(abs / dayMs);
|
||||||
|
return `Overdue by ${days} day${days === 1 ? "" : "s"}`;
|
||||||
|
}
|
||||||
|
if (abs >= hourMs) {
|
||||||
|
const hours = Math.round(abs / hourMs);
|
||||||
|
return `Overdue by ${hours} hour${hours === 1 ? "" : "s"}`;
|
||||||
|
}
|
||||||
|
const minutes = Math.max(1, Math.round(abs / minuteMs));
|
||||||
|
return `Overdue by ${minutes} minute${minutes === 1 ? "" : "s"}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function eventPhaseToneClass(phase: EventLifecyclePhase) {
|
function eventPhaseToneClass(phase: EventLifecyclePhase) {
|
||||||
@@ -1014,16 +1040,20 @@ async function decodeAudioBlobToPcm16(blob: Blob) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
async function transcribeRecordedPilotAudio(blob: Blob) {
|
async function transcribeRecordedPilotAudio(blob: Blob) {
|
||||||
pilotMicError.value = null;
|
pilotMicError.value = null;
|
||||||
pilotTranscribing.value = true;
|
pilotTranscribing.value = true;
|
||||||
try {
|
try {
|
||||||
const payload = await decodeAudioBlobToPcm16(blob);
|
const text = await transcribeAudioBlob(blob);
|
||||||
const result = await $fetch<{ text?: string }>("/api/pilot-transcribe", {
|
|
||||||
method: "POST",
|
|
||||||
body: payload,
|
|
||||||
});
|
|
||||||
const text = String(result?.text ?? "").trim();
|
|
||||||
if (!text) {
|
if (!text) {
|
||||||
pilotMicError.value = "Не удалось распознать речь, попробуйте еще раз.";
|
pilotMicError.value = "Не удалось распознать речь, попробуйте еще раз.";
|
||||||
return null;
|
return null;
|
||||||
@@ -1219,6 +1249,7 @@ onBeforeUnmount(() => {
|
|||||||
if (pilotRecording.value) {
|
if (pilotRecording.value) {
|
||||||
stopPilotRecording("fill");
|
stopPilotRecording("fill");
|
||||||
}
|
}
|
||||||
|
stopEventArchiveRecording();
|
||||||
destroyAllCommCallWaves();
|
destroyAllCommCallWaves();
|
||||||
void stopPilotMeter();
|
void stopPilotMeter();
|
||||||
if (pilotWaveSurfer) {
|
if (pilotWaveSurfer) {
|
||||||
@@ -1662,23 +1693,30 @@ const commPinnedOnly = ref(false);
|
|||||||
const commDraft = ref("");
|
const commDraft = ref("");
|
||||||
const commSending = ref(false);
|
const commSending = ref(false);
|
||||||
const commRecording = ref(false);
|
const commRecording = ref(false);
|
||||||
const commEventModalOpen = ref(false);
|
const commComposerMode = ref<"message" | "planned" | "logged">("message");
|
||||||
const commEventSaving = ref(false);
|
const commEventSaving = ref(false);
|
||||||
const commEventError = ref("");
|
const commEventError = ref("");
|
||||||
const commEventMode = ref<"planned" | "logged">("planned");
|
const commEventMode = ref<"planned" | "logged">("planned");
|
||||||
const commEventForm = ref({
|
const commEventForm = ref({
|
||||||
title: "",
|
|
||||||
startDate: "",
|
startDate: "",
|
||||||
startTime: "",
|
startTime: "",
|
||||||
durationMinutes: 30,
|
durationMinutes: 30,
|
||||||
note: "",
|
|
||||||
});
|
});
|
||||||
const eventCloseOpen = ref<Record<string, boolean>>({});
|
const eventCloseOpen = ref<Record<string, boolean>>({});
|
||||||
const eventCloseDraft = ref<Record<string, string>>({});
|
const eventCloseDraft = ref<Record<string, string>>({});
|
||||||
const eventCloseSaving = ref<Record<string, boolean>>({});
|
const eventCloseSaving = ref<Record<string, boolean>>({});
|
||||||
const eventCloseError = ref<Record<string, string>>({});
|
const eventCloseError = ref<Record<string, string>>({});
|
||||||
|
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 = "";
|
||||||
|
|
||||||
watch(selectedCommThreadId, () => {
|
watch(selectedCommThreadId, () => {
|
||||||
|
stopEventArchiveRecording();
|
||||||
destroyAllCommCallWaves();
|
destroyAllCommCallWaves();
|
||||||
callTranscriptOpen.value = {};
|
callTranscriptOpen.value = {};
|
||||||
callTranscriptLoading.value = {};
|
callTranscriptLoading.value = {};
|
||||||
@@ -1686,12 +1724,15 @@ watch(selectedCommThreadId, () => {
|
|||||||
callTranscriptError.value = {};
|
callTranscriptError.value = {};
|
||||||
commPinnedOnly.value = false;
|
commPinnedOnly.value = false;
|
||||||
commDraft.value = "";
|
commDraft.value = "";
|
||||||
commEventModalOpen.value = false;
|
commComposerMode.value = "message";
|
||||||
commEventError.value = "";
|
commEventError.value = "";
|
||||||
eventCloseOpen.value = {};
|
eventCloseOpen.value = {};
|
||||||
eventCloseDraft.value = {};
|
eventCloseDraft.value = {};
|
||||||
eventCloseSaving.value = {};
|
eventCloseSaving.value = {};
|
||||||
eventCloseError.value = {};
|
eventCloseError.value = {};
|
||||||
|
eventArchiveRecordingById.value = {};
|
||||||
|
eventArchiveTranscribingById.value = {};
|
||||||
|
eventArchiveMicErrorById.value = {};
|
||||||
const fallback = selectedCommThread.value?.channels.find((channel) => channel !== "Phone") ?? "Telegram";
|
const fallback = selectedCommThread.value?.channels.find((channel) => channel !== "Phone") ?? "Telegram";
|
||||||
commSendChannel.value = fallback;
|
commSendChannel.value = fallback;
|
||||||
});
|
});
|
||||||
@@ -1872,6 +1913,91 @@ function toggleEventClose(eventId: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isEventArchiveRecording(eventId: string) {
|
||||||
|
return Boolean(eventArchiveRecordingById.value[eventId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEventArchiveTranscribing(eventId: string) {
|
||||||
|
return Boolean(eventArchiveTranscribingById.value[eventId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startEventArchiveRecording(eventId: 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(eventCloseDraft.value[targetId] ?? "").trim();
|
||||||
|
const merged = previous ? `${previous} ${text}` : text;
|
||||||
|
eventCloseDraft.value = { ...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) {
|
||||||
|
if (!pilotMicSupported.value) {
|
||||||
|
eventArchiveMicErrorById.value = { ...eventArchiveMicErrorById.value, [eventId]: "Recording is not supported in this browser" };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isEventArchiveRecording(eventId)) {
|
||||||
|
stopEventArchiveRecording();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void startEventArchiveRecording(eventId);
|
||||||
|
}
|
||||||
|
|
||||||
async function archiveEventManually(event: CalendarEvent) {
|
async function archiveEventManually(event: CalendarEvent) {
|
||||||
const eventId = event.id;
|
const eventId = event.id;
|
||||||
const archiveNote = String(eventCloseDraft.value[eventId] ?? "").trim();
|
const archiveNote = String(eventCloseDraft.value[eventId] ?? "").trim();
|
||||||
@@ -2234,13 +2360,10 @@ function setDefaultCommEventForm(mode: "planned" | "logged") {
|
|||||||
const start = mode === "planned"
|
const start = mode === "planned"
|
||||||
? roundToNextQuarter(new Date(Date.now() + 15 * 60 * 1000))
|
? roundToNextQuarter(new Date(Date.now() + 15 * 60 * 1000))
|
||||||
: roundToPrevQuarter(new Date(Date.now() - 30 * 60 * 1000));
|
: roundToPrevQuarter(new Date(Date.now() - 30 * 60 * 1000));
|
||||||
const titleSeed = selectedCommThread.value?.contact?.split(" ")[0] ?? "Contact";
|
|
||||||
commEventForm.value = {
|
commEventForm.value = {
|
||||||
title: mode === "planned" ? `Follow-up with ${titleSeed}` : `Call note with ${titleSeed}`,
|
|
||||||
startDate: toInputDate(start),
|
startDate: toInputDate(start),
|
||||||
startTime: toInputTime(start),
|
startTime: toInputTime(start),
|
||||||
durationMinutes: 30,
|
durationMinutes: 30,
|
||||||
note: "",
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2249,22 +2372,38 @@ function openCommEventModal(mode: "planned" | "logged") {
|
|||||||
commEventMode.value = mode;
|
commEventMode.value = mode;
|
||||||
setDefaultCommEventForm(mode);
|
setDefaultCommEventForm(mode);
|
||||||
commEventError.value = "";
|
commEventError.value = "";
|
||||||
commEventModalOpen.value = true;
|
commComposerMode.value = mode;
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeCommEventModal() {
|
function closeCommEventModal() {
|
||||||
if (commEventSaving.value) return;
|
if (commEventSaving.value) return;
|
||||||
commEventModalOpen.value = false;
|
commComposerMode.value = "message";
|
||||||
|
commEventError.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function commComposerPlaceholder() {
|
||||||
|
if (commComposerMode.value === "planned") return "Опиши, что нужно запланировать...";
|
||||||
|
if (commComposerMode.value === "logged") return "Опиши итог/отчёт по прошедшему событию...";
|
||||||
|
return "Type a message...";
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCommEventTitle(text: string, mode: "planned" | "logged", 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 mode === "logged" ? `Отчёт по контакту ${contact}` : `Событие с ${contact}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createCommEvent() {
|
async function createCommEvent() {
|
||||||
if (!selectedCommThread.value || commEventSaving.value) return;
|
if (!selectedCommThread.value || commEventSaving.value) return;
|
||||||
|
|
||||||
const title = commEventForm.value.title.trim();
|
const note = commDraft.value.trim();
|
||||||
const note = commEventForm.value.note.trim();
|
const title = buildCommEventTitle(note, commEventMode.value, selectedCommThread.value.contact);
|
||||||
const duration = Number(commEventForm.value.durationMinutes || 0);
|
const duration = Number(commEventForm.value.durationMinutes || 0);
|
||||||
if (!title) {
|
if (!note) {
|
||||||
commEventError.value = "Event title is required";
|
commEventError.value = "Текст события обязателен";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2300,7 +2439,9 @@ async function createCommEvent() {
|
|||||||
calendarEvents.value = [res.createCalendarEvent, ...calendarEvents.value];
|
calendarEvents.value = [res.createCalendarEvent, ...calendarEvents.value];
|
||||||
selectedDateKey.value = dayKey(start);
|
selectedDateKey.value = dayKey(start);
|
||||||
calendarCursor.value = new Date(start.getFullYear(), start.getMonth(), 1);
|
calendarCursor.value = new Date(start.getFullYear(), start.getMonth(), 1);
|
||||||
commEventModalOpen.value = false;
|
commDraft.value = "";
|
||||||
|
commComposerMode.value = "message";
|
||||||
|
commEventError.value = "";
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
commEventError.value = String(error?.message ?? error ?? "Failed to create event");
|
commEventError.value = String(error?.message ?? error ?? "Failed to create event");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -2342,7 +2483,11 @@ function toggleCommRecording() {
|
|||||||
function handleCommComposerEnter(event: KeyboardEvent) {
|
function handleCommComposerEnter(event: KeyboardEvent) {
|
||||||
if (event.shiftKey) return;
|
if (event.shiftKey) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
void sendCommMessage();
|
if (commComposerMode.value === "message") {
|
||||||
|
void sendCommMessage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void createCommEvent();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function executeFeedAction(card: FeedCard) {
|
async function executeFeedAction(card: FeedCard) {
|
||||||
@@ -3416,8 +3561,8 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
|||||||
|
|
||||||
<div v-else-if="entry.kind === 'eventLifecycle'" class="flex justify-center">
|
<div v-else-if="entry.kind === 'eventLifecycle'" class="flex justify-center">
|
||||||
<article class="w-full max-w-[460px] rounded-xl border p-3 text-center" :class="eventPhaseToneClass(entry.phase)">
|
<article class="w-full max-w-[460px] rounded-xl border p-3 text-center" :class="eventPhaseToneClass(entry.phase)">
|
||||||
<p class="text-[11px] uppercase tracking-wide text-base-content/65">{{ eventPhaseLabel(entry.phase) }}</p>
|
<p class="text-xs text-base-content/65">{{ formatDay(entry.event.start) }} · {{ formatTime(entry.event.start) }}</p>
|
||||||
<p class="mt-1 text-xs text-base-content/65">{{ formatDay(entry.event.start) }} · {{ formatTime(entry.event.start) }}</p>
|
<p class="mt-1 text-xs text-base-content/75">{{ eventRelativeLabel(entry.event, lifecycleNowMs) }}</p>
|
||||||
<p class="mt-1 text-sm text-base-content/90">{{ entry.event.note || entry.event.title }}</p>
|
<p class="mt-1 text-sm text-base-content/90">{{ entry.event.note || entry.event.title }}</p>
|
||||||
<p v-if="entry.event.archiveNote" class="mt-2 text-xs text-base-content/70">Archive note: {{ entry.event.archiveNote }}</p>
|
<p v-if="entry.event.archiveNote" class="mt-2 text-xs text-base-content/70">Archive note: {{ entry.event.archiveNote }}</p>
|
||||||
|
|
||||||
@@ -3434,6 +3579,22 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
|||||||
rows="3"
|
rows="3"
|
||||||
placeholder="Archive note (optional)"
|
placeholder="Archive note (optional)"
|
||||||
/>
|
/>
|
||||||
|
<div class="flex justify-between gap-2">
|
||||||
|
<button
|
||||||
|
class="btn btn-xs btn-outline"
|
||||||
|
:disabled="isEventArchiveTranscribing(entry.event.id)"
|
||||||
|
@click="toggleEventArchiveRecording(entry.event.id)"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
isEventArchiveTranscribing(entry.event.id)
|
||||||
|
? "Transcribing..."
|
||||||
|
: isEventArchiveRecording(entry.event.id)
|
||||||
|
? "Stop mic"
|
||||||
|
: "Voice note"
|
||||||
|
}}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="eventArchiveMicErrorById[entry.event.id]" class="text-xs text-error">{{ eventArchiveMicErrorById[entry.event.id] }}</p>
|
||||||
<p v-if="eventCloseError[entry.event.id]" class="text-xs text-error">{{ eventCloseError[entry.event.id] }}</p>
|
<p v-if="eventCloseError[entry.event.id]" class="text-xs text-error">{{ eventCloseError[entry.event.id] }}</p>
|
||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
<button
|
<button
|
||||||
@@ -3529,15 +3690,70 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
|||||||
</div>
|
</div>
|
||||||
<div class="comm-input-wrap">
|
<div class="comm-input-wrap">
|
||||||
<div class="comm-input-shell">
|
<div class="comm-input-shell">
|
||||||
|
<div v-if="commComposerMode !== 'message'" class="mb-2 flex items-center justify-between gap-2">
|
||||||
|
<p class="text-xs font-medium text-base-content/75">
|
||||||
|
{{ commComposerMode === "logged" ? "Log past event" : "Plan event" }}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost btn-xs h-6 min-h-6 px-2"
|
||||||
|
:disabled="commEventSaving"
|
||||||
|
@click="closeCommEventModal"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
v-model="commDraft"
|
v-model="commDraft"
|
||||||
class="comm-input-textarea"
|
class="comm-input-textarea"
|
||||||
placeholder="Type a message..."
|
:placeholder="commComposerPlaceholder()"
|
||||||
:disabled="commSending"
|
:disabled="commSending || commEventSaving"
|
||||||
@keydown.enter="handleCommComposerEnter"
|
@keydown.enter="handleCommComposerEnter"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="comm-input-channel dropdown dropdown-top not-prose">
|
<div
|
||||||
|
v-if="commComposerMode !== 'message'"
|
||||||
|
class="mb-2 grid grid-cols-1 gap-2 sm:grid-cols-3"
|
||||||
|
>
|
||||||
|
<label class="form-control">
|
||||||
|
<span class="label-text text-[11px]">Date</span>
|
||||||
|
<input
|
||||||
|
v-model="commEventForm.startDate"
|
||||||
|
type="date"
|
||||||
|
class="input input-bordered input-sm"
|
||||||
|
:disabled="commEventSaving"
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
<label class="form-control">
|
||||||
|
<span class="label-text text-[11px]">Time</span>
|
||||||
|
<input
|
||||||
|
v-model="commEventForm.startTime"
|
||||||
|
type="time"
|
||||||
|
class="input input-bordered input-sm"
|
||||||
|
:disabled="commEventSaving"
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
<label class="form-control">
|
||||||
|
<span class="label-text text-[11px]">Duration</span>
|
||||||
|
<select
|
||||||
|
v-model.number="commEventForm.durationMinutes"
|
||||||
|
class="select select-bordered select-sm"
|
||||||
|
:disabled="commEventSaving"
|
||||||
|
>
|
||||||
|
<option :value="15">15 min</option>
|
||||||
|
<option :value="30">30 min</option>
|
||||||
|
<option :value="45">45 min</option>
|
||||||
|
<option :value="60">60 min</option>
|
||||||
|
<option :value="90">90 min</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="commEventError && commComposerMode !== 'message'" class="mb-2 text-xs text-error">
|
||||||
|
{{ commEventError }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-if="commComposerMode === 'message'" class="comm-input-channel dropdown dropdown-top not-prose">
|
||||||
<button
|
<button
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
class="btn btn-ghost btn-xs h-7 min-h-7 px-1 text-xs font-medium"
|
class="btn btn-ghost btn-xs h-7 min-h-7 px-1 text-xs font-medium"
|
||||||
@@ -3563,7 +3779,7 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
|||||||
<button
|
<button
|
||||||
class="btn btn-xs btn-circle border border-base-300 bg-base-100 text-base-content/80 hover:bg-base-200"
|
class="btn btn-xs btn-circle border border-base-300 bg-base-100 text-base-content/80 hover:bg-base-200"
|
||||||
:class="commRecording ? 'comm-mic-active' : ''"
|
:class="commRecording ? 'comm-mic-active' : ''"
|
||||||
:disabled="commSending"
|
:disabled="commSending || commEventSaving"
|
||||||
title="Voice input"
|
title="Voice input"
|
||||||
@click="toggleCommRecording"
|
@click="toggleCommRecording"
|
||||||
>
|
>
|
||||||
@@ -3574,9 +3790,9 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
class="btn btn-sm btn-circle border-0 bg-[#5865f2] text-white hover:bg-[#4752c4]"
|
class="btn btn-sm btn-circle border-0 bg-[#5865f2] text-white hover:bg-[#4752c4]"
|
||||||
:disabled="commSending || !commDraft.trim()"
|
:disabled="commSending || commEventSaving || !commDraft.trim()"
|
||||||
:title="`Send via ${commSendChannel}`"
|
:title="commComposerMode === 'message' ? `Send via ${commSendChannel}` : (commComposerMode === 'logged' ? 'Save log event' : 'Create event')"
|
||||||
@click="sendCommMessage"
|
@click="commComposerMode === 'message' ? sendCommMessage() : createCommEvent()"
|
||||||
>
|
>
|
||||||
<svg viewBox="0 0 24 24" class="h-4 w-4 fill-current" :class="commSending ? 'opacity-50' : ''">
|
<svg viewBox="0 0 24 24" class="h-4 w-4 fill-current" :class="commSending ? 'opacity-50' : ''">
|
||||||
<path d="M4.5 19.5 21 12 4.5 4.5l.02 5.84L15 12l-10.48 1.66z" />
|
<path d="M4.5 19.5 21 12 4.5 4.5l.02 5.84L15 12l-10.48 1.66z" />
|
||||||
@@ -3586,68 +3802,6 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="commEventModalOpen" class="comm-event-modal">
|
|
||||||
<div class="comm-event-modal-card">
|
|
||||||
<div class="mb-3 flex items-center justify-between gap-2">
|
|
||||||
<p class="text-sm font-semibold">{{ commEventMode === "logged" ? "Log past event" : "Plan new event" }}</p>
|
|
||||||
<button class="btn btn-ghost btn-xs" :disabled="commEventSaving" @click="closeCommEventModal">Close</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="mb-2 text-xs text-base-content/70">
|
|
||||||
Contact: <span class="font-medium text-base-content">{{ selectedCommThread.contact }}</span>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<label class="form-control mb-2">
|
|
||||||
<span class="label-text text-xs">Title</span>
|
|
||||||
<input
|
|
||||||
v-model="commEventForm.title"
|
|
||||||
type="text"
|
|
||||||
class="input input-bordered input-sm"
|
|
||||||
:placeholder="commEventMode === 'logged' ? 'Call recap / Meeting notes' : 'Meeting / Call / Follow-up'"
|
|
||||||
>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div class="mb-2 grid grid-cols-1 gap-2 sm:grid-cols-3">
|
|
||||||
<label class="form-control">
|
|
||||||
<span class="label-text text-xs">Date</span>
|
|
||||||
<input v-model="commEventForm.startDate" type="date" class="input input-bordered input-sm">
|
|
||||||
</label>
|
|
||||||
<label class="form-control">
|
|
||||||
<span class="label-text text-xs">Time</span>
|
|
||||||
<input v-model="commEventForm.startTime" type="time" class="input input-bordered input-sm">
|
|
||||||
</label>
|
|
||||||
<label class="form-control">
|
|
||||||
<span class="label-text text-xs">Duration</span>
|
|
||||||
<select v-model.number="commEventForm.durationMinutes" class="select select-bordered select-sm">
|
|
||||||
<option :value="15">15 min</option>
|
|
||||||
<option :value="30">30 min</option>
|
|
||||||
<option :value="45">45 min</option>
|
|
||||||
<option :value="60">60 min</option>
|
|
||||||
<option :value="90">90 min</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label class="form-control">
|
|
||||||
<span class="label-text text-xs">Note</span>
|
|
||||||
<textarea
|
|
||||||
v-model="commEventForm.note"
|
|
||||||
class="textarea textarea-bordered min-h-[84px] text-sm"
|
|
||||||
placeholder="Agenda, context, who joins..."
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<p v-if="commEventError" class="mt-2 text-xs text-error">{{ commEventError }}</p>
|
|
||||||
|
|
||||||
<div class="mt-3 flex items-center justify-end gap-2">
|
|
||||||
<button class="btn btn-ghost btn-sm" :disabled="commEventSaving" @click="closeCommEventModal">Cancel</button>
|
|
||||||
<button class="btn btn-sm" :disabled="commEventSaving" @click="createCommEvent">
|
|
||||||
{{ commEventSaving ? "Saving..." : (commEventMode === "logged" ? "Save log" : "Create event") }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="flex h-full items-center justify-center text-sm text-base-content/60">
|
<div v-else class="flex h-full items-center justify-center text-sm text-base-content/60">
|
||||||
|
|||||||
Reference in New Issue
Block a user