feat: add telegram business settings in user dropdown
This commit is contained in:
258
frontend/app.vue
258
frontend/app.vue
@@ -577,6 +577,118 @@ const authInitials = computed(() => {
|
|||||||
return parts.map((part) => part[0]?.toUpperCase() ?? "").join("");
|
return parts.map((part) => part[0]?.toUpperCase() ?? "").join("");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
type TelegramConnectStatus =
|
||||||
|
| "not_connected"
|
||||||
|
| "pending_link"
|
||||||
|
| "pending_business_connection"
|
||||||
|
| "connected"
|
||||||
|
| "disabled"
|
||||||
|
| "no_reply_rights";
|
||||||
|
|
||||||
|
type TelegramConnectionSummary = {
|
||||||
|
businessConnectionId: string;
|
||||||
|
isEnabled: boolean | null;
|
||||||
|
canReply: boolean | null;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const telegramConnectStatus = ref<TelegramConnectStatus>("not_connected");
|
||||||
|
const telegramConnectStatusLoading = ref(false);
|
||||||
|
const telegramConnectBusy = ref(false);
|
||||||
|
const telegramRefreshBusy = ref(false);
|
||||||
|
const telegramConnectUrl = ref("");
|
||||||
|
const telegramConnections = ref<TelegramConnectionSummary[]>([]);
|
||||||
|
|
||||||
|
const telegramStatusLabel = computed(() => {
|
||||||
|
if (telegramConnectStatusLoading.value) return "Checking";
|
||||||
|
if (telegramConnectStatus.value === "connected") return "Connected";
|
||||||
|
if (telegramConnectStatus.value === "pending_link") return "Pending link";
|
||||||
|
if (telegramConnectStatus.value === "pending_business_connection") return "Waiting business connect";
|
||||||
|
if (telegramConnectStatus.value === "disabled") return "Disabled";
|
||||||
|
if (telegramConnectStatus.value === "no_reply_rights") return "No reply rights";
|
||||||
|
return "Not connected";
|
||||||
|
});
|
||||||
|
|
||||||
|
const telegramStatusBadgeClass = computed(() => {
|
||||||
|
if (telegramConnectStatus.value === "connected") return "badge-success";
|
||||||
|
if (telegramConnectStatus.value === "pending_link" || telegramConnectStatus.value === "pending_business_connection") return "badge-warning";
|
||||||
|
if (telegramConnectStatus.value === "disabled" || telegramConnectStatus.value === "no_reply_rights") return "badge-error";
|
||||||
|
return "badge-ghost";
|
||||||
|
});
|
||||||
|
|
||||||
|
const primaryTelegramBusinessConnectionId = computed(
|
||||||
|
() => telegramConnections.value.find((item) => (item.businessConnectionId ?? "").trim())?.businessConnectionId ?? "",
|
||||||
|
);
|
||||||
|
|
||||||
|
async function loadTelegramConnectStatus() {
|
||||||
|
if (!authMe.value) {
|
||||||
|
telegramConnectStatus.value = "not_connected";
|
||||||
|
telegramConnections.value = [];
|
||||||
|
telegramConnectUrl.value = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
telegramConnectStatusLoading.value = true;
|
||||||
|
try {
|
||||||
|
const result = await $fetch<{
|
||||||
|
ok: boolean;
|
||||||
|
status: TelegramConnectStatus;
|
||||||
|
connections?: TelegramConnectionSummary[];
|
||||||
|
}>("/api/omni/telegram/business/connect/status", {
|
||||||
|
method: "GET",
|
||||||
|
});
|
||||||
|
telegramConnectStatus.value = result?.status ?? "not_connected";
|
||||||
|
telegramConnections.value = result?.connections ?? [];
|
||||||
|
} catch {
|
||||||
|
telegramConnectStatus.value = "not_connected";
|
||||||
|
telegramConnections.value = [];
|
||||||
|
} finally {
|
||||||
|
telegramConnectStatusLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startTelegramBusinessConnect() {
|
||||||
|
if (telegramConnectBusy.value) return;
|
||||||
|
telegramConnectBusy.value = true;
|
||||||
|
try {
|
||||||
|
const result = await $fetch<{
|
||||||
|
ok: boolean;
|
||||||
|
status: TelegramConnectStatus;
|
||||||
|
connectUrl: string;
|
||||||
|
expiresAt: string;
|
||||||
|
}>("/api/omni/telegram/business/connect/start", { method: "POST" });
|
||||||
|
telegramConnectStatus.value = result?.status ?? "pending_link";
|
||||||
|
telegramConnectUrl.value = String(result?.connectUrl ?? "").trim();
|
||||||
|
if (telegramConnectUrl.value && process.client) {
|
||||||
|
window.open(telegramConnectUrl.value, "_blank", "noopener,noreferrer");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
telegramConnectBusy.value = false;
|
||||||
|
await loadTelegramConnectStatus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openTelegramConnectUrl() {
|
||||||
|
if (!telegramConnectUrl.value || !process.client) return;
|
||||||
|
window.open(telegramConnectUrl.value, "_blank", "noopener,noreferrer");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshTelegramBusinessConnectionFromApi() {
|
||||||
|
const businessConnectionId = primaryTelegramBusinessConnectionId.value;
|
||||||
|
if (!businessConnectionId || telegramRefreshBusy.value) return;
|
||||||
|
|
||||||
|
telegramRefreshBusy.value = true;
|
||||||
|
try {
|
||||||
|
await $fetch<{ ok: boolean }>("/api/omni/telegram/business/connect/refresh", {
|
||||||
|
method: "POST",
|
||||||
|
body: { businessConnectionId },
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
telegramRefreshBusy.value = false;
|
||||||
|
await loadTelegramConnectStatus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function pilotToUiMessage(message: PilotMessage): UIMessage {
|
function pilotToUiMessage(message: PilotMessage): UIMessage {
|
||||||
return {
|
return {
|
||||||
id: message.id,
|
id: message.id,
|
||||||
@@ -719,13 +831,19 @@ async function bootstrapSession() {
|
|||||||
if (!authMe.value) {
|
if (!authMe.value) {
|
||||||
pilotMessages.value = [];
|
pilotMessages.value = [];
|
||||||
chatConversations.value = [];
|
chatConversations.value = [];
|
||||||
|
telegramConnectStatus.value = "not_connected";
|
||||||
|
telegramConnections.value = [];
|
||||||
|
telegramConnectUrl.value = "";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await Promise.all([loadPilotMessages(), loadChatConversations(), refreshCrmData()]);
|
await Promise.all([loadPilotMessages(), loadChatConversations(), refreshCrmData(), loadTelegramConnectStatus()]);
|
||||||
} catch {
|
} catch {
|
||||||
authMe.value = null;
|
authMe.value = null;
|
||||||
pilotMessages.value = [];
|
pilotMessages.value = [];
|
||||||
chatConversations.value = [];
|
chatConversations.value = [];
|
||||||
|
telegramConnectStatus.value = "not_connected";
|
||||||
|
telegramConnections.value = [];
|
||||||
|
telegramConnectUrl.value = "";
|
||||||
} finally {
|
} finally {
|
||||||
authResolved.value = true;
|
authResolved.value = true;
|
||||||
}
|
}
|
||||||
@@ -776,7 +894,7 @@ async function login() {
|
|||||||
});
|
});
|
||||||
await loadMe();
|
await loadMe();
|
||||||
startPilotBackgroundPolling();
|
startPilotBackgroundPolling();
|
||||||
await Promise.all([loadPilotMessages(), loadChatConversations(), refreshCrmData()]);
|
await Promise.all([loadPilotMessages(), loadChatConversations(), refreshCrmData(), loadTelegramConnectStatus()]);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
loginError.value = e?.data?.message || e?.message || "Login failed";
|
loginError.value = e?.data?.message || e?.message || "Login failed";
|
||||||
} finally {
|
} finally {
|
||||||
@@ -793,6 +911,9 @@ async function logout() {
|
|||||||
livePilotAssistantText.value = "";
|
livePilotAssistantText.value = "";
|
||||||
pilotChat.messages = [];
|
pilotChat.messages = [];
|
||||||
chatConversations.value = [];
|
chatConversations.value = [];
|
||||||
|
telegramConnectStatus.value = "not_connected";
|
||||||
|
telegramConnections.value = [];
|
||||||
|
telegramConnectUrl.value = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshCrmData() {
|
async function refreshCrmData() {
|
||||||
@@ -3805,12 +3926,53 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
|||||||
</div>
|
</div>
|
||||||
<span class="max-w-[160px] truncate text-xs font-medium">{{ authDisplayName }}</span>
|
<span class="max-w-[160px] truncate text-xs font-medium">{{ authDisplayName }}</span>
|
||||||
</button>
|
</button>
|
||||||
<ul tabindex="0" class="menu dropdown-content z-30 mt-2 w-52 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg">
|
<div tabindex="0" class="dropdown-content z-30 mt-2 w-80 rounded-box border border-base-300 bg-base-100 p-3 shadow-lg">
|
||||||
<li class="menu-title px-2 py-1">
|
<div class="mb-2 border-b border-base-300 pb-2">
|
||||||
<span>{{ authDisplayName }}</span>
|
<p class="truncate text-sm font-semibold">{{ authDisplayName }}</p>
|
||||||
</li>
|
<p class="text-[11px] uppercase tracking-wide text-base-content/60">Settings</p>
|
||||||
<li><button @click="logout">Logout</button></li>
|
</div>
|
||||||
</ul>
|
|
||||||
|
<div class="space-y-2 rounded-lg border border-base-300 bg-base-50/40 p-2">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="text-xs font-medium">Telegram Business</span>
|
||||||
|
<span class="badge badge-xs" :class="telegramStatusBadgeClass">{{ telegramStatusLabel }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-1.5">
|
||||||
|
<button
|
||||||
|
class="btn btn-xs btn-primary"
|
||||||
|
:disabled="telegramConnectBusy"
|
||||||
|
@click="startTelegramBusinessConnect"
|
||||||
|
>
|
||||||
|
{{ telegramConnectBusy ? "Connecting..." : "Connect" }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-xs btn-ghost border border-base-300"
|
||||||
|
:disabled="telegramConnectStatusLoading"
|
||||||
|
@click="loadTelegramConnectStatus"
|
||||||
|
>
|
||||||
|
{{ telegramConnectStatusLoading ? "Refreshing..." : "Refresh status" }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-xs btn-ghost border border-base-300"
|
||||||
|
:disabled="!telegramConnectUrl"
|
||||||
|
@click="openTelegramConnectUrl"
|
||||||
|
>
|
||||||
|
Open link
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-xs btn-ghost border border-base-300"
|
||||||
|
:disabled="!primaryTelegramBusinessConnectionId || telegramRefreshBusy"
|
||||||
|
@click="refreshTelegramBusinessConnectionFromApi"
|
||||||
|
>
|
||||||
|
{{ telegramRefreshBusy ? "Syncing..." : "Refresh from API" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 border-t border-base-300 pt-2">
|
||||||
|
<button class="btn btn-sm w-full btn-ghost justify-start" @click="logout">Logout</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -3956,39 +4118,43 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="calendarView === 'week'" class="space-y-2">
|
<div v-else-if="calendarView === 'week'" class="calendar-week-scroll overflow-x-auto pb-1">
|
||||||
<article
|
<div class="calendar-week-grid">
|
||||||
v-for="day in weekDays"
|
<article
|
||||||
:key="day.key"
|
v-for="day in weekDays"
|
||||||
class="group relative rounded-xl border border-base-300 p-3"
|
:key="day.key"
|
||||||
:class="selectedDateKey === day.key ? 'border-primary bg-primary/5' : ''"
|
class="group relative flex min-h-[18rem] flex-col rounded-xl border border-base-300 bg-base-100 p-2.5"
|
||||||
@click="pickDate(day.key)"
|
:class="selectedDateKey === day.key ? 'border-primary bg-primary/5' : ''"
|
||||||
>
|
@click="pickDate(day.key)"
|
||||||
<p class="mb-2 text-sm font-semibold">{{ day.label }} {{ day.day }}</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="calendar-hover-jump calendar-hover-jump-week"
|
|
||||||
title="Expand day line"
|
|
||||||
aria-label="Expand day line"
|
|
||||||
@click.stop="openDayView(day.key)"
|
|
||||||
>
|
>
|
||||||
<svg viewBox="0 0 20 20" class="h-3.5 w-3.5 fill-none stroke-current" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
<div class="mb-2 flex items-start justify-between gap-2">
|
||||||
<path d="M10 3v14M6 7l4-4 4 4M6 13l4 4 4-4" />
|
<p class="text-sm font-semibold leading-tight">{{ day.label }} {{ day.day }}</p>
|
||||||
</svg>
|
<button
|
||||||
</button>
|
type="button"
|
||||||
<div class="space-y-1">
|
class="calendar-hover-jump calendar-hover-jump-week"
|
||||||
<button
|
title="Expand day line"
|
||||||
v-for="event in day.events"
|
aria-label="Expand day line"
|
||||||
:key="event.id"
|
@click.stop="openDayView(day.key)"
|
||||||
class="block w-full rounded px-2 py-1 text-left text-xs"
|
>
|
||||||
:class="isReviewHighlightedEvent(event.id) ? 'bg-success/20 ring-1 ring-success/45' : 'bg-base-200 hover:bg-base-300/80'"
|
<svg viewBox="0 0 20 20" class="h-3.5 w-3.5 fill-none stroke-current" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
@click.stop="openThreadFromCalendarItem(event)"
|
<path d="M10 3v14M6 7l4-4 4 4M6 13l4 4 4-4" />
|
||||||
>
|
</svg>
|
||||||
{{ formatTime(event.start) }} - {{ event.title }} ({{ event.contact }})
|
</button>
|
||||||
</button>
|
</div>
|
||||||
<p v-if="day.events.length === 0" class="text-xs text-base-content/50">No events</p>
|
<div class="space-y-1.5">
|
||||||
</div>
|
<button
|
||||||
</article>
|
v-for="event in day.events"
|
||||||
|
:key="event.id"
|
||||||
|
class="block w-full rounded-md px-2 py-1.5 text-left text-xs"
|
||||||
|
:class="isReviewHighlightedEvent(event.id) ? 'bg-success/20 ring-1 ring-success/45' : 'bg-base-200 hover:bg-base-300/80'"
|
||||||
|
@click.stop="openThreadFromCalendarItem(event)"
|
||||||
|
>
|
||||||
|
{{ formatTime(event.start) }} - {{ event.title }} ({{ event.contact }})
|
||||||
|
</button>
|
||||||
|
<p v-if="day.events.length === 0" class="pt-1 text-xs text-base-content/50">No events</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="calendarView === 'day'" class="space-y-2">
|
<div v-else-if="calendarView === 'day'" class="space-y-2">
|
||||||
@@ -5126,6 +5292,13 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.calendar-week-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, minmax(165px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 1180px;
|
||||||
|
}
|
||||||
|
|
||||||
.calendar-side-nav {
|
.calendar-side-nav {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
@@ -5233,6 +5406,11 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
|||||||
padding-right: 32px;
|
padding-right: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.calendar-week-grid {
|
||||||
|
grid-template-columns: repeat(7, minmax(150px, 1fr));
|
||||||
|
min-width: 1060px;
|
||||||
|
}
|
||||||
|
|
||||||
.calendar-side-nav {
|
.calendar-side-nav {
|
||||||
width: 24px;
|
width: 24px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
|
|||||||
Reference in New Issue
Block a user