feat(interviews): collapsible Applied section, email sync pill, pre-list signal banners
This commit is contained in:
parent
5ca25e160c
commit
e24e0b7233
1 changed files with 293 additions and 30 deletions
|
|
@ -1,8 +1,10 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useInterviewsStore } from '../stores/interviews'
|
import { useInterviewsStore } from '../stores/interviews'
|
||||||
import type { PipelineJob, PipelineStage } from '../stores/interviews'
|
import type { PipelineJob, PipelineStage } from '../stores/interviews'
|
||||||
|
import type { StageSignal } from '../stores/interviews'
|
||||||
|
import { useApiFetch } from '../composables/useApi'
|
||||||
import InterviewCard from '../components/InterviewCard.vue'
|
import InterviewCard from '../components/InterviewCard.vue'
|
||||||
import MoveToSheet from '../components/MoveToSheet.vue'
|
import MoveToSheet from '../components/MoveToSheet.vue'
|
||||||
|
|
||||||
|
|
@ -11,9 +13,11 @@ const store = useInterviewsStore()
|
||||||
|
|
||||||
// ── Move sheet ────────────────────────────────────────────────────────────────
|
// ── Move sheet ────────────────────────────────────────────────────────────────
|
||||||
const moveTarget = ref<PipelineJob | null>(null)
|
const moveTarget = ref<PipelineJob | null>(null)
|
||||||
|
const movePreSelected = ref<PipelineStage | undefined>(undefined)
|
||||||
|
|
||||||
function openMove(jobId: number) {
|
function openMove(jobId: number, preSelectedStage?: PipelineStage) {
|
||||||
moveTarget.value = store.jobs.find(j => j.id === jobId) ?? null
|
moveTarget.value = store.jobs.find(j => j.id === jobId) ?? null
|
||||||
|
movePreSelected.value = preSelectedStage
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onMove(stage: PipelineStage, opts: { interview_date?: string; rejection_stage?: string }) {
|
async function onMove(stage: PipelineStage, opts: { interview_date?: string; rejection_stage?: string }) {
|
||||||
|
|
@ -24,6 +28,105 @@ async function onMove(stage: PipelineStage, opts: { interview_date?: string; rej
|
||||||
if (wasHired) triggerConfetti()
|
if (wasHired) triggerConfetti()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Collapsible Applied section ────────────────────────────────────────────
|
||||||
|
const APPLIED_EXPANDED_KEY = 'peregrine.interviews.appliedExpanded'
|
||||||
|
const appliedExpanded = ref(localStorage.getItem(APPLIED_EXPANDED_KEY) === 'true')
|
||||||
|
watch(appliedExpanded, v => localStorage.setItem(APPLIED_EXPANDED_KEY, String(v)))
|
||||||
|
|
||||||
|
const appliedSignalCount = computed(() =>
|
||||||
|
[...store.applied, ...store.survey]
|
||||||
|
.reduce((n, job) => n + (job.stage_signals?.length ?? 0), 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Signal metadata (pre-list rows) ───────────────────────────────────────
|
||||||
|
const SIGNAL_META_PRE = {
|
||||||
|
interview_scheduled: { label: 'Move to Phone Screen', stage: 'phone_screen' as PipelineStage, color: 'amber' },
|
||||||
|
positive_response: { label: 'Move to Phone Screen', stage: 'phone_screen' as PipelineStage, color: 'amber' },
|
||||||
|
offer_received: { label: 'Move to Offer', stage: 'offer' as PipelineStage, color: 'green' },
|
||||||
|
survey_received: { label: 'Move to Survey', stage: 'survey' as PipelineStage, color: 'amber' },
|
||||||
|
rejected: { label: 'Mark Rejected', stage: 'interview_rejected' as PipelineStage, color: 'red' },
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const sigExpandedIds = ref(new Set<number>())
|
||||||
|
// IMPORTANT: must reassign .value (not mutate in place) to trigger Vue reactivity
|
||||||
|
function togglePreSigExpand(jobId: number) {
|
||||||
|
const next = new Set(sigExpandedIds.value)
|
||||||
|
if (next.has(jobId)) next.delete(jobId)
|
||||||
|
else next.add(jobId)
|
||||||
|
sigExpandedIds.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dismissPreSignal(job: PipelineJob, sig: StageSignal) {
|
||||||
|
const idx = job.stage_signals.findIndex(s => s.id === sig.id)
|
||||||
|
if (idx !== -1) job.stage_signals.splice(idx, 1)
|
||||||
|
await useApiFetch(`/api/stage-signals/${sig.id}/dismiss`, { method: 'POST' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Email sync status ──────────────────────────────────────────────────────
|
||||||
|
interface SyncStatus {
|
||||||
|
state: 'idle' | 'queued' | 'running' | 'completed' | 'failed' | 'not_configured'
|
||||||
|
lastCompletedAt: string | null
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncStatus = ref<SyncStatus>({ state: 'idle', lastCompletedAt: null, error: null })
|
||||||
|
const now = ref(Date.now())
|
||||||
|
let syncPollId: ReturnType<typeof setInterval> | null = null
|
||||||
|
let nowTickId: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
function elapsedLabel(isoTs: string | null): string {
|
||||||
|
if (!isoTs) return ''
|
||||||
|
const diffMs = now.value - new Date(isoTs).getTime()
|
||||||
|
const mins = Math.floor(diffMs / 60000)
|
||||||
|
if (mins < 1) return 'just now'
|
||||||
|
if (mins < 60) return `${mins}m ago`
|
||||||
|
const hrs = Math.floor(mins / 60)
|
||||||
|
if (hrs < 24) return `${hrs}h ago`
|
||||||
|
return `${Math.floor(hrs / 24)}d ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchSyncStatus() {
|
||||||
|
const { data } = await useApiFetch<{
|
||||||
|
status: string; last_completed_at: string | null; error: string | null
|
||||||
|
}>('/api/email/sync/status')
|
||||||
|
if (!data) return
|
||||||
|
syncStatus.value = {
|
||||||
|
state: data.status as SyncStatus['state'],
|
||||||
|
lastCompletedAt: data.last_completed_at,
|
||||||
|
error: data.error,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startSyncPoll() {
|
||||||
|
if (syncPollId) return
|
||||||
|
syncPollId = setInterval(async () => {
|
||||||
|
await fetchSyncStatus()
|
||||||
|
if (syncStatus.value.state === 'completed' || syncStatus.value.state === 'failed') {
|
||||||
|
clearInterval(syncPollId!); syncPollId = null
|
||||||
|
if (syncStatus.value.state === 'completed') store.fetchAll()
|
||||||
|
}
|
||||||
|
}, 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function triggerSync() {
|
||||||
|
if (syncStatus.value.state === 'queued' || syncStatus.value.state === 'running') return
|
||||||
|
const { data, error } = await useApiFetch<{ task_id: number }>('/api/email/sync', { method: 'POST' })
|
||||||
|
if (error) {
|
||||||
|
if (error.kind === 'http' && error.status === 503) {
|
||||||
|
// Email integration not configured — set permanently for this session
|
||||||
|
syncStatus.value = { state: 'not_configured', lastCompletedAt: null, error: null }
|
||||||
|
} else {
|
||||||
|
// Transient error (network, server 5xx etc.) — show failed but allow retry
|
||||||
|
syncStatus.value = { ...syncStatus.value, state: 'failed', error: error.kind === 'http' ? error.detail : error.message }
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (data) {
|
||||||
|
syncStatus.value = { ...syncStatus.value, state: 'queued' }
|
||||||
|
startSyncPoll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Confetti (easter egg 9.5) ─────────────────────────────────────────────────
|
// ── Confetti (easter egg 9.5) ─────────────────────────────────────────────────
|
||||||
const showHiredToast = ref(false)
|
const showHiredToast = ref(false)
|
||||||
const confettiCanvas = ref<HTMLCanvasElement | null>(null)
|
const confettiCanvas = ref<HTMLCanvasElement | null>(null)
|
||||||
|
|
@ -100,8 +203,21 @@ function onKeydown(e: KeyboardEvent) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => { await store.fetchAll(); document.addEventListener('keydown', onKeydown) })
|
onMounted(async () => {
|
||||||
onUnmounted(() => document.removeEventListener('keydown', onKeydown))
|
await store.fetchAll()
|
||||||
|
document.addEventListener('keydown', onKeydown)
|
||||||
|
await fetchSyncStatus()
|
||||||
|
if (syncStatus.value.state === 'queued' || syncStatus.value.state === 'running') {
|
||||||
|
startSyncPoll()
|
||||||
|
}
|
||||||
|
nowTickId = setInterval(() => { now.value = Date.now() }, 60000)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('keydown', onKeydown)
|
||||||
|
if (syncPollId) { clearInterval(syncPollId); syncPollId = null }
|
||||||
|
if (nowTickId) { clearInterval(nowTickId); nowTickId = null }
|
||||||
|
})
|
||||||
|
|
||||||
function daysSince(dateStr: string | null) {
|
function daysSince(dateStr: string | null) {
|
||||||
if (!dateStr) return null
|
if (!dateStr) return null
|
||||||
|
|
@ -121,24 +237,74 @@ function daysSince(dateStr: string | null) {
|
||||||
|
|
||||||
<header class="view-header">
|
<header class="view-header">
|
||||||
<h1 class="view-title">Interviews</h1>
|
<h1 class="view-title">Interviews</h1>
|
||||||
|
<div class="header-actions">
|
||||||
|
<!-- Email sync pill -->
|
||||||
|
<button
|
||||||
|
v-if="syncStatus.state === 'not_configured'"
|
||||||
|
class="sync-pill sync-pill--muted"
|
||||||
|
disabled
|
||||||
|
aria-label="Email not configured"
|
||||||
|
>📧 Email not configured</button>
|
||||||
|
<button
|
||||||
|
v-else-if="syncStatus.state === 'queued' || syncStatus.state === 'running'"
|
||||||
|
class="sync-pill sync-pill--syncing"
|
||||||
|
disabled
|
||||||
|
aria-label="Syncing emails"
|
||||||
|
>⏳ Syncing…</button>
|
||||||
|
<button
|
||||||
|
v-else-if="(syncStatus.state === 'completed' || syncStatus.state === 'idle') && syncStatus.lastCompletedAt"
|
||||||
|
class="sync-pill sync-pill--synced"
|
||||||
|
@click="triggerSync"
|
||||||
|
:aria-label="`Email synced ${elapsedLabel(syncStatus.lastCompletedAt)} — click to re-sync`"
|
||||||
|
>📧 Synced {{ elapsedLabel(syncStatus.lastCompletedAt) }}</button>
|
||||||
|
<button
|
||||||
|
v-else-if="syncStatus.state === 'failed'"
|
||||||
|
class="sync-pill sync-pill--failed"
|
||||||
|
@click="triggerSync"
|
||||||
|
aria-label="Sync failed — click to retry"
|
||||||
|
>⚠ Sync failed</button>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
class="sync-pill sync-pill--idle"
|
||||||
|
@click="triggerSync"
|
||||||
|
aria-label="Sync emails"
|
||||||
|
>📧 Sync Emails</button>
|
||||||
|
|
||||||
<button class="btn-refresh" @click="store.fetchAll()" :disabled="store.loading" aria-label="Refresh">
|
<button class="btn-refresh" @click="store.fetchAll()" :disabled="store.loading" aria-label="Refresh">
|
||||||
{{ store.loading ? '⟳' : '↺' }}
|
{{ store.loading ? '⟳' : '↺' }}
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div v-if="store.error" class="error-banner">{{ store.error }}</div>
|
<div v-if="store.error" class="error-banner">{{ store.error }}</div>
|
||||||
|
|
||||||
<!-- Pre-list: Applied + Survey -->
|
<!-- Pre-list: Applied + Survey (collapsible) -->
|
||||||
<section class="pre-list" aria-label="Applied jobs">
|
<section class="pre-list" aria-label="Applied jobs">
|
||||||
<div class="pre-list-header">
|
<button
|
||||||
<span>Applied ({{ store.applied.length + store.survey.length }})</span>
|
class="pre-list-toggle"
|
||||||
<span class="pre-list-hint">Move here when a recruiter reaches out →</span>
|
@click="appliedExpanded = !appliedExpanded"
|
||||||
</div>
|
:aria-expanded="appliedExpanded"
|
||||||
|
aria-controls="pre-list-body"
|
||||||
|
>
|
||||||
|
<span class="pre-list-chevron" :class="{ 'is-expanded': appliedExpanded }">▶</span>
|
||||||
|
<span class="pre-list-toggle-title">
|
||||||
|
Applied
|
||||||
|
<span class="pre-list-count">{{ store.applied.length + store.survey.length }}</span>
|
||||||
|
</span>
|
||||||
|
<span v-if="appliedSignalCount > 0" class="pre-list-signal-count">⚡ {{ appliedSignalCount }} signal{{ appliedSignalCount !== 1 ? 's' : '' }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="pre-list-body"
|
||||||
|
class="pre-list-body"
|
||||||
|
:class="{ 'is-expanded': appliedExpanded }"
|
||||||
|
>
|
||||||
<div v-if="store.applied.length === 0 && store.survey.length === 0" class="pre-list-empty">
|
<div v-if="store.applied.length === 0 && store.survey.length === 0" class="pre-list-empty">
|
||||||
<span class="empty-bird">🦅</span>
|
<span class="empty-bird">🦅</span>
|
||||||
<span>No applied jobs yet. <RouterLink to="/apply">Go to Apply</RouterLink> to submit applications.</span>
|
<span>No applied jobs yet. <RouterLink to="/apply">Go to Apply</RouterLink> to submit applications.</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-for="job in [...store.applied, ...store.survey]" :key="job.id" class="pre-list-row">
|
<template v-for="job in [...store.applied, ...store.survey]" :key="job.id">
|
||||||
|
<div class="pre-list-row">
|
||||||
<div class="pre-row-info">
|
<div class="pre-row-info">
|
||||||
<span class="pre-row-title">{{ job.title }}</span>
|
<span class="pre-row-title">{{ job.title }}</span>
|
||||||
<span class="pre-row-company">{{ job.company }}</span>
|
<span class="pre-row-company">{{ job.company }}</span>
|
||||||
|
|
@ -149,6 +315,32 @@ function daysSince(dateStr: string | null) {
|
||||||
<button class="btn-move-pre" @click="openMove(job.id)" :aria-label="`Move ${job.title}`">Move to… ›</button>
|
<button class="btn-move-pre" @click="openMove(job.id)" :aria-label="`Move ${job.title}`">Move to… ›</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Signal banners for pre-list rows -->
|
||||||
|
<template v-if="job.stage_signals?.length">
|
||||||
|
<div
|
||||||
|
v-for="sig in (job.stage_signals ?? []).slice(0, sigExpandedIds.has(job.id) ? undefined : 1)"
|
||||||
|
:key="sig.id"
|
||||||
|
class="pre-signal-banner"
|
||||||
|
:data-color="SIGNAL_META_PRE[sig.stage_signal]?.color"
|
||||||
|
>
|
||||||
|
<span class="signal-label">📧 Email suggests: <strong>{{ SIGNAL_META_PRE[sig.stage_signal]?.label }}</strong></span>
|
||||||
|
<span class="signal-subject">{{ sig.subject.slice(0, 60) }}{{ sig.subject.length > 60 ? '…' : '' }}</span>
|
||||||
|
<div class="signal-actions">
|
||||||
|
<button
|
||||||
|
class="btn-signal-move"
|
||||||
|
@click="openMove(job.id, SIGNAL_META_PRE[sig.stage_signal]?.stage)"
|
||||||
|
>→ {{ SIGNAL_META_PRE[sig.stage_signal]?.label }}</button>
|
||||||
|
<button class="btn-signal-dismiss" @click="dismissPreSignal(job, sig)">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="(job.stage_signals?.length ?? 0) > 1"
|
||||||
|
class="btn-sig-expand"
|
||||||
|
@click="togglePreSigExpand(job.id)"
|
||||||
|
>{{ sigExpandedIds.has(job.id) ? '− less' : `+${(job.stage_signals?.length ?? 1) - 1} more` }}</button>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Kanban columns -->
|
<!-- Kanban columns -->
|
||||||
|
|
@ -218,8 +410,9 @@ function daysSince(dateStr: string | null) {
|
||||||
v-if="moveTarget"
|
v-if="moveTarget"
|
||||||
:currentStatus="moveTarget.status"
|
:currentStatus="moveTarget.status"
|
||||||
:jobTitle="`${moveTarget.title} at ${moveTarget.company}`"
|
:jobTitle="`${moveTarget.title} at ${moveTarget.company}`"
|
||||||
|
:preSelectedStage="movePreSelected"
|
||||||
@move="onMove"
|
@move="onMove"
|
||||||
@close="moveTarget = null"
|
@close="moveTarget = null; movePreSelected = undefined"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -243,9 +436,52 @@ function daysSince(dateStr: string | null) {
|
||||||
.view-title { font-size: 1.5rem; font-weight: 700; margin: 0; }
|
.view-title { font-size: 1.5rem; font-weight: 700; margin: 0; }
|
||||||
.btn-refresh { background: none; border: 1px solid var(--color-border); border-radius: 6px; cursor: pointer; padding: 4px 10px; font-size: 1rem; color: var(--color-text-muted); }
|
.btn-refresh { background: none; border: 1px solid var(--color-border); border-radius: 6px; cursor: pointer; padding: 4px 10px; font-size: 1rem; color: var(--color-text-muted); }
|
||||||
.error-banner { background: color-mix(in srgb, var(--color-error) 10%, var(--color-surface)); color: var(--color-error); padding: var(--space-2) var(--space-3); border-radius: 8px; margin-bottom: var(--space-4); }
|
.error-banner { background: color-mix(in srgb, var(--color-error) 10%, var(--color-surface)); color: var(--color-error); padding: var(--space-2) var(--space-3); border-radius: 8px; margin-bottom: var(--space-4); }
|
||||||
|
|
||||||
|
/* Header actions */
|
||||||
|
.header-actions { display: flex; align-items: center; gap: var(--space-2); margin-left: auto; }
|
||||||
|
|
||||||
|
/* Email sync pill */
|
||||||
|
.sync-pill {
|
||||||
|
border-radius: 999px; padding: 3px 10px; font-size: 0.78em; font-weight: 600; cursor: pointer;
|
||||||
|
border: 1px solid transparent; transition: opacity 150ms;
|
||||||
|
}
|
||||||
|
.sync-pill:disabled { cursor: default; opacity: 0.8; }
|
||||||
|
.sync-pill--idle { border-color: var(--color-border); background: none; color: var(--color-text-muted); }
|
||||||
|
.sync-pill--syncing { background: color-mix(in srgb, var(--color-info) 10%, var(--color-surface)); color: var(--color-info); border-color: color-mix(in srgb, var(--color-info) 30%, transparent); animation: pulse 1.5s ease-in-out infinite; }
|
||||||
|
.sync-pill--synced { background: color-mix(in srgb, var(--color-success) 12%, var(--color-surface)); color: var(--color-success); border-color: color-mix(in srgb, var(--color-success) 30%, transparent); }
|
||||||
|
.sync-pill--failed { background: color-mix(in srgb, var(--color-error) 10%, var(--color-surface)); color: var(--color-error); border-color: color-mix(in srgb, var(--color-error) 30%, transparent); }
|
||||||
|
.sync-pill--muted { background: var(--color-surface-alt); color: var(--color-text-muted); border-color: var(--color-border-light); }
|
||||||
|
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.55} }
|
||||||
|
|
||||||
|
/* Collapsible pre-list toggle header */
|
||||||
|
.pre-list-toggle {
|
||||||
|
display: flex; align-items: center; gap: var(--space-2); width: 100%;
|
||||||
|
background: none; border: none; cursor: pointer; padding: var(--space-1) 0;
|
||||||
|
font-size: 0.9rem; font-weight: 700; color: var(--color-text);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.pre-list-chevron { font-size: 0.7em; color: var(--color-text-muted); transition: transform 200ms; display: inline-block; }
|
||||||
|
.pre-list-chevron.is-expanded { transform: rotate(90deg); }
|
||||||
|
.pre-list-count {
|
||||||
|
display: inline-block; background: var(--color-surface-raised); border-radius: 999px;
|
||||||
|
padding: 1px 8px; font-size: 0.75em; font-weight: 700; margin-left: var(--space-1);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
.pre-list-signal-count { margin-left: auto; font-size: 0.75em; font-weight: 700; color: #e67e22; }
|
||||||
|
|
||||||
|
/* Collapsible pre-list body */
|
||||||
|
.pre-list-body {
|
||||||
|
max-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: max-height 300ms ease;
|
||||||
|
}
|
||||||
|
.pre-list-body.is-expanded { max-height: 800px; }
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.pre-list-body, .pre-list-chevron { transition: none; }
|
||||||
|
}
|
||||||
|
|
||||||
.pre-list { background: var(--color-surface); border-radius: 10px; padding: var(--space-3) var(--space-4); margin-bottom: var(--space-6); }
|
.pre-list { background: var(--color-surface); border-radius: 10px; padding: var(--space-3) var(--space-4); margin-bottom: var(--space-6); }
|
||||||
.pre-list-header { display: flex; justify-content: space-between; align-items: center; font-weight: 700; font-size: 0.85rem; color: var(--color-text-muted); margin-bottom: var(--space-2); }
|
.pre-list-toggle-title { display: flex; align-items: center; }
|
||||||
.pre-list-hint { font-weight: 400; font-size: 0.75rem; }
|
|
||||||
.pre-list-empty { display: flex; align-items: center; gap: var(--space-2); font-size: 0.85rem; color: var(--color-text-muted); padding: var(--space-2) 0; }
|
.pre-list-empty { display: flex; align-items: center; gap: var(--space-2); font-size: 0.85rem; color: var(--color-text-muted); padding: var(--space-2) 0; }
|
||||||
.pre-list-row { display: flex; align-items: center; justify-content: space-between; padding: var(--space-2) 0; border-top: 1px solid var(--color-border-light); gap: var(--space-3); }
|
.pre-list-row { display: flex; align-items: center; justify-content: space-between; padding: var(--space-2) 0; border-top: 1px solid var(--color-border-light); gap: var(--space-3); }
|
||||||
.pre-row-info { display: flex; align-items: center; gap: var(--space-2); flex: 1; min-width: 0; }
|
.pre-row-info { display: flex; align-items: center; gap: var(--space-2); flex: 1; min-width: 0; }
|
||||||
|
|
@ -255,6 +491,33 @@ function daysSince(dateStr: string | null) {
|
||||||
.pre-row-meta { display: flex; align-items: center; gap: var(--space-2); flex-shrink: 0; }
|
.pre-row-meta { display: flex; align-items: center; gap: var(--space-2); flex-shrink: 0; }
|
||||||
.pre-row-days { font-size: 0.75rem; color: var(--color-text-muted); }
|
.pre-row-days { font-size: 0.75rem; color: var(--color-text-muted); }
|
||||||
.btn-move-pre { background: none; border: 1px solid var(--color-border); border-radius: 6px; padding: 2px 8px; font-size: 0.75rem; font-weight: 700; color: var(--color-info); cursor: pointer; }
|
.btn-move-pre { background: none; border: 1px solid var(--color-border); border-radius: 6px; padding: 2px 8px; font-size: 0.75rem; font-weight: 700; color: var(--color-info); cursor: pointer; }
|
||||||
|
|
||||||
|
/* Pre-list signal banners */
|
||||||
|
.pre-signal-banner {
|
||||||
|
padding: 8px 12px; border-radius: 6px; margin: 4px 0;
|
||||||
|
border-top: 1px solid transparent;
|
||||||
|
display: flex; flex-direction: column; gap: 4px;
|
||||||
|
}
|
||||||
|
.pre-signal-banner[data-color="amber"] { background: rgba(245,158,11,0.08); border-top-color: rgba(245,158,11,0.4); }
|
||||||
|
.pre-signal-banner[data-color="green"] { background: rgba(39,174,96,0.08); border-top-color: rgba(39,174,96,0.4); }
|
||||||
|
.pre-signal-banner[data-color="red"] { background: rgba(192,57,43,0.08); border-top-color: rgba(192,57,43,0.4); }
|
||||||
|
|
||||||
|
.signal-label { font-size: 0.82em; }
|
||||||
|
.signal-subject { font-size: 0.78em; color: var(--color-text-muted); }
|
||||||
|
.signal-actions { display: flex; gap: 6px; align-items: center; }
|
||||||
|
.btn-signal-move {
|
||||||
|
background: var(--color-primary); color: #fff;
|
||||||
|
border: none; border-radius: 4px; padding: 2px 8px; font-size: 0.78em; cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-signal-dismiss {
|
||||||
|
background: none; border: none; color: var(--color-text-muted); font-size: 0.85em; cursor: pointer;
|
||||||
|
padding: 2px 4px;
|
||||||
|
}
|
||||||
|
.btn-sig-expand {
|
||||||
|
background: none; border: none; font-size: 0.75em; color: var(--color-info); cursor: pointer;
|
||||||
|
padding: 4px 12px; text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
.kanban {
|
.kanban {
|
||||||
display: grid; grid-template-columns: repeat(3, 1fr);
|
display: grid; grid-template-columns: repeat(3, 1fr);
|
||||||
gap: var(--space-4); margin-bottom: var(--space-6);
|
gap: var(--space-4); margin-bottom: var(--space-6);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue