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);
|
||||
}
|
||||
|
||||
function eventPhaseLabel(phase: EventLifecyclePhase) {
|
||||
if (phase === "scheduled") return "Scheduled";
|
||||
if (phase === "due_soon") return "Starts in <30 min";
|
||||
if (phase === "awaiting_outcome") return "Awaiting outcome";
|
||||
return "Closed";
|
||||
function eventRelativeLabel(event: CalendarEvent, nowMs: number) {
|
||||
if (event.isArchived) return "Archived";
|
||||
const diffMs = new Date(event.start).getTime() - nowMs;
|
||||
const minuteMs = 60 * 1000;
|
||||
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) {
|
||||
@@ -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) {
|
||||
pilotMicError.value = null;
|
||||
pilotTranscribing.value = true;
|
||||
try {
|
||||
const payload = await decodeAudioBlobToPcm16(blob);
|
||||
const result = await $fetch<{ text?: string }>("/api/pilot-transcribe", {
|
||||
method: "POST",
|
||||
body: payload,
|
||||
});
|
||||
const text = String(result?.text ?? "").trim();
|
||||
const text = await transcribeAudioBlob(blob);
|
||||
if (!text) {
|
||||
pilotMicError.value = "Не удалось распознать речь, попробуйте еще раз.";
|
||||
return null;
|
||||
@@ -1219,6 +1249,7 @@ onBeforeUnmount(() => {
|
||||
if (pilotRecording.value) {
|
||||
stopPilotRecording("fill");
|
||||
}
|
||||
stopEventArchiveRecording();
|
||||
destroyAllCommCallWaves();
|
||||
void stopPilotMeter();
|
||||
if (pilotWaveSurfer) {
|
||||
@@ -1662,23 +1693,30 @@ const commPinnedOnly = ref(false);
|
||||
const commDraft = ref("");
|
||||
const commSending = ref(false);
|
||||
const commRecording = ref(false);
|
||||
const commEventModalOpen = ref(false);
|
||||
const commComposerMode = ref<"message" | "planned" | "logged">("message");
|
||||
const commEventSaving = ref(false);
|
||||
const commEventError = ref("");
|
||||
const commEventMode = ref<"planned" | "logged">("planned");
|
||||
const commEventForm = ref({
|
||||
title: "",
|
||||
startDate: "",
|
||||
startTime: "",
|
||||
durationMinutes: 30,
|
||||
note: "",
|
||||
});
|
||||
const eventCloseOpen = ref<Record<string, boolean>>({});
|
||||
const eventCloseDraft = ref<Record<string, string>>({});
|
||||
const eventCloseSaving = ref<Record<string, boolean>>({});
|
||||
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, () => {
|
||||
stopEventArchiveRecording();
|
||||
destroyAllCommCallWaves();
|
||||
callTranscriptOpen.value = {};
|
||||
callTranscriptLoading.value = {};
|
||||
@@ -1686,12 +1724,15 @@ watch(selectedCommThreadId, () => {
|
||||
callTranscriptError.value = {};
|
||||
commPinnedOnly.value = false;
|
||||
commDraft.value = "";
|
||||
commEventModalOpen.value = false;
|
||||
commComposerMode.value = "message";
|
||||
commEventError.value = "";
|
||||
eventCloseOpen.value = {};
|
||||
eventCloseDraft.value = {};
|
||||
eventCloseSaving.value = {};
|
||||
eventCloseError.value = {};
|
||||
eventArchiveRecordingById.value = {};
|
||||
eventArchiveTranscribingById.value = {};
|
||||
eventArchiveMicErrorById.value = {};
|
||||
const fallback = selectedCommThread.value?.channels.find((channel) => channel !== "Phone") ?? "Telegram";
|
||||
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) {
|
||||
const eventId = event.id;
|
||||
const archiveNote = String(eventCloseDraft.value[eventId] ?? "").trim();
|
||||
@@ -2234,13 +2360,10 @@ function setDefaultCommEventForm(mode: "planned" | "logged") {
|
||||
const start = mode === "planned"
|
||||
? roundToNextQuarter(new Date(Date.now() + 15 * 60 * 1000))
|
||||
: roundToPrevQuarter(new Date(Date.now() - 30 * 60 * 1000));
|
||||
const titleSeed = selectedCommThread.value?.contact?.split(" ")[0] ?? "Contact";
|
||||
commEventForm.value = {
|
||||
title: mode === "planned" ? `Follow-up with ${titleSeed}` : `Call note with ${titleSeed}`,
|
||||
startDate: toInputDate(start),
|
||||
startTime: toInputTime(start),
|
||||
durationMinutes: 30,
|
||||
note: "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2249,22 +2372,38 @@ function openCommEventModal(mode: "planned" | "logged") {
|
||||
commEventMode.value = mode;
|
||||
setDefaultCommEventForm(mode);
|
||||
commEventError.value = "";
|
||||
commEventModalOpen.value = true;
|
||||
commComposerMode.value = mode;
|
||||
}
|
||||
|
||||
function closeCommEventModal() {
|
||||
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() {
|
||||
if (!selectedCommThread.value || commEventSaving.value) return;
|
||||
|
||||
const title = commEventForm.value.title.trim();
|
||||
const note = commEventForm.value.note.trim();
|
||||
const note = commDraft.value.trim();
|
||||
const title = buildCommEventTitle(note, commEventMode.value, selectedCommThread.value.contact);
|
||||
const duration = Number(commEventForm.value.durationMinutes || 0);
|
||||
if (!title) {
|
||||
commEventError.value = "Event title is required";
|
||||
if (!note) {
|
||||
commEventError.value = "Текст события обязателен";
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2300,7 +2439,9 @@ async function createCommEvent() {
|
||||
calendarEvents.value = [res.createCalendarEvent, ...calendarEvents.value];
|
||||
selectedDateKey.value = dayKey(start);
|
||||
calendarCursor.value = new Date(start.getFullYear(), start.getMonth(), 1);
|
||||
commEventModalOpen.value = false;
|
||||
commDraft.value = "";
|
||||
commComposerMode.value = "message";
|
||||
commEventError.value = "";
|
||||
} catch (error: any) {
|
||||
commEventError.value = String(error?.message ?? error ?? "Failed to create event");
|
||||
} finally {
|
||||
@@ -2342,7 +2483,11 @@ function toggleCommRecording() {
|
||||
function handleCommComposerEnter(event: KeyboardEvent) {
|
||||
if (event.shiftKey) return;
|
||||
event.preventDefault();
|
||||
void sendCommMessage();
|
||||
if (commComposerMode.value === "message") {
|
||||
void sendCommMessage();
|
||||
return;
|
||||
}
|
||||
void createCommEvent();
|
||||
}
|
||||
|
||||
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">
|
||||
<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="mt-1 text-xs text-base-content/65">{{ formatDay(entry.event.start) }} · {{ formatTime(entry.event.start) }}</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/75">{{ eventRelativeLabel(entry.event, lifecycleNowMs) }}</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>
|
||||
|
||||
@@ -3434,6 +3579,22 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
||||
rows="3"
|
||||
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>
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
@@ -3529,15 +3690,70 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
||||
</div>
|
||||
<div class="comm-input-wrap">
|
||||
<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
|
||||
v-model="commDraft"
|
||||
class="comm-input-textarea"
|
||||
placeholder="Type a message..."
|
||||
:disabled="commSending"
|
||||
:placeholder="commComposerPlaceholder()"
|
||||
:disabled="commSending || commEventSaving"
|
||||
@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
|
||||
tabindex="0"
|
||||
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
|
||||
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' : ''"
|
||||
:disabled="commSending"
|
||||
:disabled="commSending || commEventSaving"
|
||||
title="Voice input"
|
||||
@click="toggleCommRecording"
|
||||
>
|
||||
@@ -3574,9 +3790,9 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
||||
|
||||
<button
|
||||
class="btn btn-sm btn-circle border-0 bg-[#5865f2] text-white hover:bg-[#4752c4]"
|
||||
:disabled="commSending || !commDraft.trim()"
|
||||
:title="`Send via ${commSendChannel}`"
|
||||
@click="sendCommMessage"
|
||||
:disabled="commSending || commEventSaving || !commDraft.trim()"
|
||||
:title="commComposerMode === 'message' ? `Send via ${commSendChannel}` : (commComposerMode === 'logged' ? 'Save log event' : 'Create event')"
|
||||
@click="commComposerMode === 'message' ? sendCommMessage() : createCommEvent()"
|
||||
>
|
||||
<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" />
|
||||
@@ -3586,68 +3802,6 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
||||
</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 v-else class="flex h-full items-center justify-center text-sm text-base-content/60">
|
||||
|
||||
Reference in New Issue
Block a user