peregrine/web/src/views/MessagingView.vue

585 lines
22 KiB
Vue

<!-- web/src/views/MessagingView.vue -->
<template>
<div class="messaging-layout">
<!-- Left panel: job list -->
<aside class="job-panel" role="complementary" aria-label="Jobs with messages">
<div class="job-panel__header">
<h1 class="job-panel__title">Messages</h1>
</div>
<ul class="job-list" role="list" aria-label="Jobs">
<li
v-for="job in jobsWithMessages"
:key="job.id"
class="job-list__item"
:class="{ 'job-list__item--active': selectedJobId === job.id }"
role="listitem"
:aria-label="`${job.company}, ${job.title}`"
>
<button class="job-list__btn" @click="selectJob(job.id)">
<span class="job-list__company">{{ job.company }}</span>
<span class="job-list__role">{{ job.title }}</span>
<span v-if="job.preview" class="job-list__preview">{{ job.preview }}</span>
</button>
</li>
<li v-if="jobsWithMessages.length === 0" class="job-list__empty">
No messages yet. Select a job to log a call or use a template.
</li>
</ul>
</aside>
<!-- Right panel: thread view -->
<main class="thread-panel" aria-label="Message thread">
<div v-if="!selectedJobId" class="thread-panel__empty">
<p>Select a job to view its communication timeline.</p>
</div>
<template v-else>
<!-- Action bar -->
<div class="action-bar" role="toolbar" aria-label="Message actions">
<button class="btn btn--ghost" @click="openLogModal('call_note')">Log call</button>
<button class="btn btn--ghost" @click="openLogModal('in_person')">Log note</button>
<button class="btn btn--ghost" @click="openTemplateModal('apply')">Use template</button>
<button
v-if="canDraftLlm"
class="btn btn--primary"
:disabled="store.loading"
@click="requestDraft"
>
{{ store.loading ? 'Drafting…' : 'Draft reply with LLM' }}
</button>
<a
v-else-if="!canDraftLlm"
href="#/settings/connections"
class="btn-enable-llm"
title="Configure an LLM backend to enable AI reply drafting"
>
Enable LLM drafts
</a>
<!-- Osprey (Phase 2 stub) aria-disabled, never hidden -->
<button
class="btn btn--osprey"
aria-disabled="true"
:title="ospreyTitle"
@mouseenter="handleOspreyHover"
@focus="handleOspreyHover"
>
📞 Call via Osprey
</button>
</div>
<!-- Draft pending announcement (screen reader) -->
<div aria-live="polite" aria-atomic="true" class="sr-only">
{{ draftAnnouncement }}
</div>
<!-- Error banner -->
<p v-if="store.error" class="thread-error" role="alert">{{ store.error }}</p>
<!-- Timeline -->
<div v-if="store.loading && timeline.length === 0" class="thread-loading">
Loading messages
</div>
<ul v-else class="timeline" role="list" aria-label="Message timeline">
<li
v-for="item in timeline"
:key="item._key"
class="timeline__item"
:class="[`timeline__item--${item.type}`, item.approved_at === null && item.type === 'draft' ? 'timeline__item--draft-pending' : '']"
role="listitem"
:aria-label="`${typeLabel(item.type)}, ${item.direction || ''}, ${item.logged_at}`"
>
<span class="timeline__icon" aria-hidden="true">{{ typeIcon(item.type) }}</span>
<div class="timeline__content">
<div class="timeline__meta">
<span class="timeline__type-label">{{ typeLabel(item.type) }}</span>
<span v-if="item.direction" class="timeline__direction">{{ item.direction }}</span>
<time class="timeline__time">{{ formatTime(item.logged_at) }}</time>
<span
v-if="item.type === 'draft' && item.approved_at === null"
class="timeline__badge timeline__badge--pending"
>
Pending approval
</span>
<span
v-if="item.type === 'draft' && item.approved_at !== null"
class="timeline__badge timeline__badge--approved"
>
Approved
</span>
</div>
<p v-if="item.subject" class="timeline__subject">{{ item.subject }}</p>
<!-- Draft body is editable before approval -->
<template v-if="item.type === 'draft' && item.approved_at === null">
<textarea
:ref="el => setDraftRef(item.id, el)"
class="timeline__draft-body"
:value="item.body ?? ''"
@input="updateDraftBody(item.id, ($event.target as HTMLTextAreaElement).value)"
rows="6"
aria-label="Edit draft reply before approving"
/>
<div class="timeline__draft-actions">
<button class="btn btn--primary btn--sm" @click="approveDraft(item.id)">
Approve + copy
</button>
<a
v-if="item.to_addr"
:href="`mailto:${item.to_addr}?subject=${encodeURIComponent(item.subject ?? '')}&body=${encodeURIComponent(item.body ?? '')}`"
class="btn btn--ghost btn--sm"
target="_blank"
rel="noopener"
>
Open in email client
</a>
<button class="btn btn--ghost btn--sm btn--danger" @click="confirmDelete(item.id)">
Discard
</button>
</div>
</template>
<template v-else>
<p class="timeline__body">{{ item.body }}</p>
</template>
</div>
</li>
<li v-if="timeline.length === 0" class="timeline__empty">
No messages logged yet for this job.
</li>
</ul>
</template>
</main>
<!-- Modals -->
<MessageLogModal
:show="logModal.show"
:job-id="selectedJobId ?? 0"
:type="logModal.type"
@close="logModal.show = false"
@saved="onLogSaved"
/>
<MessageTemplateModal
:show="tplModal.show"
:mode="tplModal.mode"
:job-tokens="jobTokens"
:edit-template="tplModal.editTemplate"
@close="tplModal.show = false"
@saved="onTemplateSaved"
/>
<!-- Delete confirmation -->
<div v-if="deleteConfirm !== null" class="modal-backdrop" @click.self="deleteConfirm = null">
<div class="modal-dialog modal-dialog--sm" role="dialog" aria-modal="true" aria-label="Confirm delete">
<div class="modal-body">
<p>Are you sure you want to delete this message? This cannot be undone.</p>
<div class="modal-footer">
<button class="btn btn--ghost" @click="deleteConfirm = null">Cancel</button>
<button class="btn btn--danger" @click="doDelete">Delete</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { useMessagingStore, type MessageTemplate } from '../stores/messaging'
import { useApiFetch } from '../composables/useApi'
import MessageLogModal from '../components/MessageLogModal.vue'
import MessageTemplateModal from '../components/MessageTemplateModal.vue'
const store = useMessagingStore()
// ── Jobs list ──────────────────────────────────────────────────────────────
interface JobSummary { id: number; company: string; title: string; preview?: string }
const allJobs = ref<JobSummary[]>([])
const selectedJobId = ref<number | null>(null)
async function loadJobs() {
const { data } = await useApiFetch<Array<{ id: number; company: string; title: string }>>('/api/jobs?status=applied&limit=200')
allJobs.value = data ?? []
}
const jobsWithMessages = computed(() => allJobs.value)
async function selectJob(id: number) {
selectedJobId.value = id
draftBodyEdits.value = {}
await store.fetchMessages(id)
}
// ── Timeline: UNION of job_contacts + messages ─────────────────────────────
interface TimelineItem {
_key: string
id: number
type: 'call_note' | 'in_person' | 'email' | 'draft'
direction: string | null
subject: string | null
body: string | null
to_addr: string | null
logged_at: string
approved_at: string | null
}
interface JobContact {
id: number
direction: string | null
subject: string | null
from_addr: string | null
to_addr: string | null
body: string | null
received_at: string | null
}
const jobContacts = ref<JobContact[]>([])
watch(selectedJobId, async (id) => {
if (id === null) { jobContacts.value = []; return }
const { data } = await useApiFetch<JobContact[]>(`/api/contacts?job_id=${id}`)
jobContacts.value = data ?? []
})
const timeline = computed<TimelineItem[]>(() => {
const contactItems: TimelineItem[] = jobContacts.value.map(c => ({
_key: `jc-${c.id}`,
id: c.id,
type: 'email',
direction: c.direction,
subject: c.subject,
body: c.body,
to_addr: c.to_addr,
logged_at: c.received_at ?? '',
approved_at: 'n/a', // contacts are always "approved"
}))
const messageItems: TimelineItem[] = store.messages.map(m => ({
_key: `msg-${m.id}`,
id: m.id,
type: m.type,
direction: m.direction,
subject: m.subject,
body: draftBodyEdits.value[m.id] ?? m.body,
to_addr: m.to_addr,
logged_at: m.logged_at,
approved_at: m.approved_at,
}))
return [...contactItems, ...messageItems].sort(
(a, b) => new Date(b.logged_at).getTime() - new Date(a.logged_at).getTime()
)
})
// ── Draft body edits (local, before approve) ──────────────────────────────
const draftBodyEdits = ref<Record<number, string>>({})
const draftRefs = ref<Record<number, HTMLTextAreaElement | null>>({})
function setDraftRef(id: number, el: unknown) {
draftRefs.value[id] = el as HTMLTextAreaElement | null
}
function updateDraftBody(id: number, value: string) {
draftBodyEdits.value = { ...draftBodyEdits.value, [id]: value }
}
// ── LLM draft + approval ──────────────────────────────────────────────────
const canDraftLlm = ref(false)
const draftAnnouncement = ref('')
async function checkLlmAvailable() {
await useApiFetch<{ available: boolean }>('/api/vision/health')
// Re-use vision health route as a proxy for LLM config — replace with a dedicated
// /api/llm/health endpoint if one is added in future.
// For now: canDraftLlm is set based on whether user has any configured LLM
// (the server will 402 if not — the button is just a hint, not a gate)
canDraftLlm.value = true // Always show; server enforces the real gate
}
async function requestDraft() {
// Find the most recent inbound job_contact for this job
const inbound = jobContacts.value.find(c => c.direction === 'inbound')
if (!inbound) {
store.error = 'No inbound emails found for this job to draft a reply to.'
return
}
const msgId = await store.requestDraft(inbound.id)
if (msgId) {
draftAnnouncement.value = 'Draft reply generated and ready for review.'
await store.fetchMessages(selectedJobId.value!)
setTimeout(() => { draftAnnouncement.value = '' }, 3000)
}
}
async function approveDraft(messageId: number) {
// Use the locally-edited body if the user changed it
const editedBody = draftBodyEdits.value[messageId]
if (editedBody !== undefined) {
// Patch body via a NOOP approve — server sets approved_at and returns body
// The edited body is in our local state; copy it to clipboard from local state
}
const body = await store.approveDraft(messageId)
if (body) {
const finalBody = editedBody ?? body
await navigator.clipboard.writeText(finalBody)
draftAnnouncement.value = 'Approved and copied to clipboard.'
setTimeout(() => { draftAnnouncement.value = '' }, 3000 )
}
}
// ── Delete confirmation ───────────────────────────────────────────────────
const deleteConfirm = ref<number | null>(null)
function confirmDelete(id: number) {
deleteConfirm.value = id
}
async function doDelete() {
if (deleteConfirm.value === null) return
await store.deleteMessage(deleteConfirm.value)
deleteConfirm.value = null
}
// ── Osprey easter egg ─────────────────────────────────────────────────────
const OSPREY_HOVER_KEY = 'peregrine-osprey-hover-count'
const ospreyTitle = ref('Osprey IVR calling — coming in Phase 2')
function handleOspreyHover() {
const count = parseInt(localStorage.getItem(OSPREY_HOVER_KEY) ?? '0', 10) + 1
localStorage.setItem(OSPREY_HOVER_KEY, String(count))
if (count >= 10) {
ospreyTitle.value = "Osprey is still learning to fly... 🦅"
}
}
// ── Modals ────────────────────────────────────────────────────────────────
const logModal = ref<{ show: boolean; type: 'call_note' | 'in_person' }>({
show: false, type: 'call_note',
})
function openLogModal(type: 'call_note' | 'in_person') {
logModal.value = { show: true, type }
}
function onLogSaved() {
logModal.value.show = false
if (selectedJobId.value) store.fetchMessages(selectedJobId.value)
}
const tplModal = ref<{
show: boolean
mode: 'apply' | 'create' | 'edit'
editTemplate?: MessageTemplate
}>({ show: false, mode: 'apply' })
function openTemplateModal(mode: 'apply' | 'create' | 'edit', tpl?: MessageTemplate) {
tplModal.value = { show: true, mode, editTemplate: tpl }
}
function onTemplateSaved() {
tplModal.value.show = false
store.fetchTemplates()
}
// ── Job tokens for template substitution ─────────────────────────────────
const jobTokens = computed<Record<string, string>>(() => {
const job = allJobs.value.find(j => j.id === selectedJobId.value)
return {
company: job?.company ?? '',
role: job?.title ?? '',
name: '', // loaded from user profile; left empty — user fills in
recruiter_name: '',
date: new Date().toLocaleDateString(),
accommodation_details: '',
}
})
// ── Helpers ───────────────────────────────────────────────────────────────
function typeIcon(type: string): string {
return { call_note: '📞', in_person: '🤝', email: '✉️', draft: '📝' }[type] ?? '💬'
}
function typeLabel(type: string): string {
return {
call_note: 'Call note', in_person: 'In-person note',
email: 'Email', draft: 'Draft reply',
}[type] ?? type
}
function formatTime(iso: string): string {
if (!iso) return ''
return new Date(iso).toLocaleString(undefined, {
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
})
}
// ── Lifecycle ─────────────────────────────────────────────────────────────
onMounted(async () => {
await Promise.all([loadJobs(), store.fetchTemplates(), checkLlmAvailable()])
})
onUnmounted(() => {
store.clear()
})
</script>
<style scoped>
.messaging-layout {
display: flex;
height: 100%;
min-height: 0;
}
/* ── Left panel ─────────────────────── */
.job-panel {
width: 260px;
min-width: 200px;
flex-shrink: 0;
border-right: 1px solid var(--color-border);
display: flex;
flex-direction: column;
overflow: hidden;
}
.job-panel__header {
padding: var(--space-4);
border-bottom: 1px solid var(--color-border-light);
}
.job-panel__title { font-size: var(--text-lg); font-weight: 600; margin: 0; }
.job-list {
flex: 1; overflow-y: auto;
list-style: none; margin: 0; padding: var(--space-2) 0;
}
.job-list__item { margin: 0; }
.job-list__item--active .job-list__btn {
background: var(--app-primary-light);
color: var(--app-primary);
}
.job-list__btn {
width: 100%; padding: var(--space-3) var(--space-4);
text-align: left; background: none; border: none; cursor: pointer;
display: flex; flex-direction: column; gap: 2px;
transition: background 150ms;
}
.job-list__btn:hover { background: var(--color-surface-alt); }
.job-list__company { font-size: var(--text-sm); font-weight: 600; }
.job-list__role { font-size: var(--text-xs); color: var(--color-text-muted); }
.job-list__preview { font-size: var(--text-xs); color: var(--color-text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 220px; }
.job-list__empty { padding: var(--space-4); font-size: var(--text-sm); color: var(--color-text-muted); }
/* ── Right panel ────────────────────── */
.thread-panel {
flex: 1; min-width: 0;
display: flex; flex-direction: column;
overflow: hidden;
}
.thread-panel__empty {
flex: 1; display: flex; align-items: center; justify-content: center;
color: var(--color-text-muted);
}
.action-bar {
display: flex; flex-wrap: wrap; gap: var(--space-2); align-items: center;
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border-light);
}
.btn-enable-llm {
font-size: var(--text-sm); color: var(--app-primary);
text-decoration: none; padding: var(--space-2) var(--space-3);
}
.btn--osprey {
opacity: 0.5; cursor: not-allowed;
background: none; border: 1px dashed var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text-muted); font-size: var(--text-sm);
padding: var(--space-2) var(--space-3); min-height: 36px;
}
.thread-error {
margin: var(--space-2) var(--space-4);
color: var(--app-accent); font-size: var(--text-sm);
}
.thread-loading { padding: var(--space-4); color: var(--color-text-muted); font-size: var(--text-sm); }
.timeline {
flex: 1; overflow-y: auto;
list-style: none; margin: 0; padding: var(--space-4);
display: flex; flex-direction: column; gap: var(--space-3);
}
.timeline__item {
display: flex; gap: var(--space-3);
padding: var(--space-3); border-radius: var(--radius-md);
background: var(--color-surface-alt);
border: 1px solid var(--color-border);
}
.timeline__item--draft-pending {
border-color: var(--app-accent);
background: color-mix(in srgb, var(--app-accent) 8%, var(--color-surface));
}
.timeline__icon { font-size: 1.2rem; flex-shrink: 0; }
.timeline__content { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--space-1); }
.timeline__meta { display: flex; align-items: center; gap: var(--space-2); flex-wrap: wrap; }
.timeline__type-label { font-size: var(--text-sm); font-weight: 600; }
.timeline__direction { font-size: var(--text-xs); color: var(--color-text-muted); text-transform: capitalize; }
.timeline__time { font-size: var(--text-xs); color: var(--color-text-muted); margin-left: auto; }
.timeline__badge {
font-size: var(--text-xs); font-weight: 700;
padding: 1px 6px; border-radius: var(--radius-full);
}
.timeline__badge--pending { background: #fef3c7; color: #d97706; }
.timeline__badge--approved { background: #d1fae5; color: #065f46; }
.timeline__subject { font-size: var(--text-sm); font-weight: 500; margin: 0; }
.timeline__body { font-size: var(--text-sm); white-space: pre-wrap; margin: 0; color: var(--color-text); }
.timeline__draft-body {
width: 100%; font-size: var(--text-sm); font-family: var(--font-body);
padding: var(--space-2); border: 1px solid var(--color-border);
border-radius: var(--radius-md); background: var(--color-surface);
color: var(--color-text); resize: vertical;
}
.timeline__draft-body:focus-visible { outline: 2px solid var(--app-primary); outline-offset: 2px; }
.timeline__draft-actions { display: flex; gap: var(--space-2); flex-wrap: wrap; }
.timeline__empty { color: var(--color-text-muted); font-size: var(--text-sm); padding: var(--space-2); }
/* Buttons */
.btn { padding: var(--space-2) var(--space-3); border-radius: var(--radius-md); font-size: var(--text-sm); font-weight: 500; cursor: pointer; min-height: 36px; }
.btn--sm { padding: var(--space-1) var(--space-3); min-height: 30px; font-size: var(--text-xs); }
.btn--primary { background: var(--app-primary); color: var(--color-surface); border: none; }
.btn--primary:hover:not(:disabled) { opacity: 0.9; }
.btn--primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn--ghost { background: none; border: 1px solid var(--color-border); color: var(--color-text); }
.btn--ghost:hover { background: var(--color-surface-alt); }
.btn--danger { background: var(--app-accent); color: white; border: none; }
.btn--danger:hover { opacity: 0.9; }
/* Modals (delete confirm) */
.modal-backdrop {
position: fixed; inset: 0;
background: rgba(0,0,0,0.5);
display: flex; align-items: center; justify-content: center;
z-index: 200;
}
.modal-dialog {
background: var(--color-surface-raised); border: 1px solid var(--color-border);
border-radius: var(--radius-lg); width: min(400px, 95vw); outline: none;
}
.modal-dialog--sm { width: min(360px, 95vw); }
.modal-body { padding: var(--space-5); display: flex; flex-direction: column; gap: var(--space-4); }
.modal-footer { display: flex; justify-content: flex-end; gap: var(--space-3); }
/* Screen-reader only utility */
.sr-only {
position: absolute; width: 1px; height: 1px;
padding: 0; margin: -1px; overflow: hidden;
clip: rect(0,0,0,0); white-space: nowrap; border: 0;
}
/* Responsive: stack panels on narrow screens */
@media (max-width: 700px) {
.messaging-layout { flex-direction: column; }
.job-panel { width: 100%; border-right: none; border-bottom: 1px solid var(--color-border); max-height: 180px; }
}
</style>