feat(#9): opportunities queue — manual posting workflow UI and API
Adds the full signal-to-post pipeline for non-automated opportunities:
- SQLite migration 007: opportunities table (platform, community, thread_url,
draft title/body, post_type, status, campaign_id, dismiss_note)
- FastAPI endpoints: GET/POST /opportunities, GET/PATCH /{id}, /{id}/approve,
/{id}/mark-posted, /{id}/dismiss
- approve() returns auto_post_ready (Reddit) or manual_handoff (Lemmy/LinkedIn/etc)
with clipboard-ready draft and instructions
- OpportunitiesView.vue: status-filtered queue, slide-over detail panel with
inline draft editor, approve/dismiss actions, manual handoff copy+open flow
- Opportunities now default landing route; nav link added
- MCP tools: list_opportunities, create_opportunity, approve_opportunity,
dismiss_opportunity, update_opportunity
Closes #9
This commit is contained in:
parent
bd58f9f54e
commit
fb036ae064
4 changed files with 697 additions and 0 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -21,6 +21,7 @@ debug_*.png
|
||||||
*.db
|
*.db
|
||||||
*.db-wal
|
*.db-wal
|
||||||
*.db-shm
|
*.db-shm
|
||||||
|
data/
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
|
||||||
150
app/api/endpoints/opportunities.py
Normal file
150
app/api/endpoints/opportunities.py
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.db.store import Store
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/opportunities", tags=["opportunities"])
|
||||||
|
|
||||||
|
VALID_STATUSES = {"pending_review", "approved", "posted", "manual_posted", "dismissed"}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_store() -> Store:
|
||||||
|
return Store(get_settings().db_path)
|
||||||
|
|
||||||
|
|
||||||
|
def _in_thread(fn):
|
||||||
|
store = _get_store()
|
||||||
|
try:
|
||||||
|
return fn(store)
|
||||||
|
finally:
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Schemas
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
class OpportunityCreate(BaseModel):
|
||||||
|
platform: str = "reddit"
|
||||||
|
community: str
|
||||||
|
thread_url: str
|
||||||
|
thread_title: str | None = None
|
||||||
|
thread_body: str | None = None
|
||||||
|
signal_reason: str | None = None
|
||||||
|
product: str | None = None
|
||||||
|
draft_title: str | None = None
|
||||||
|
draft_body: str = ""
|
||||||
|
post_type: Literal["reply_to_thread", "new_post"] = "reply_to_thread"
|
||||||
|
campaign_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class OpportunityUpdate(BaseModel):
|
||||||
|
draft_title: str | None = None
|
||||||
|
draft_body: str | None = None
|
||||||
|
signal_reason: str | None = None
|
||||||
|
product: str | None = None
|
||||||
|
status: str | None = None
|
||||||
|
campaign_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class DismissBody(BaseModel):
|
||||||
|
note: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Routes
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_opportunities(status: str | None = None):
|
||||||
|
if status and status not in VALID_STATUSES:
|
||||||
|
raise HTTPException(400, f"Invalid status. Valid: {sorted(VALID_STATUSES)}")
|
||||||
|
return await asyncio.to_thread(_in_thread, lambda s: s.list_opportunities(status))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", status_code=201)
|
||||||
|
async def create_opportunity(body: OpportunityCreate):
|
||||||
|
return await asyncio.to_thread(
|
||||||
|
_in_thread,
|
||||||
|
lambda s: s.create_opportunity(**body.model_dump()),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{opportunity_id}")
|
||||||
|
async def get_opportunity(opportunity_id: int):
|
||||||
|
result = await asyncio.to_thread(_in_thread, lambda s: s.get_opportunity(opportunity_id))
|
||||||
|
if result is None:
|
||||||
|
raise HTTPException(404, "Opportunity not found")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{opportunity_id}")
|
||||||
|
async def update_opportunity(opportunity_id: int, body: OpportunityUpdate):
|
||||||
|
updates = {k: v for k, v in body.model_dump().items() if v is not None}
|
||||||
|
if "status" in updates and updates["status"] not in VALID_STATUSES:
|
||||||
|
raise HTTPException(400, f"Invalid status. Valid: {sorted(VALID_STATUSES)}")
|
||||||
|
result = await asyncio.to_thread(
|
||||||
|
_in_thread, lambda s: s.update_opportunity(opportunity_id, **updates)
|
||||||
|
)
|
||||||
|
if result is None:
|
||||||
|
raise HTTPException(404, "Opportunity not found")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{opportunity_id}/approve")
|
||||||
|
async def approve_opportunity(opportunity_id: int):
|
||||||
|
"""Mark as approved. For Reddit opportunities, returns auto-post instructions.
|
||||||
|
For other platforms, returns a manual handoff payload."""
|
||||||
|
opp = await asyncio.to_thread(_in_thread, lambda s: s.get_opportunity(opportunity_id))
|
||||||
|
if opp is None:
|
||||||
|
raise HTTPException(404, "Opportunity not found")
|
||||||
|
|
||||||
|
updated = await asyncio.to_thread(
|
||||||
|
_in_thread, lambda s: s.approve_opportunity(opportunity_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
if opp["platform"] == "reddit":
|
||||||
|
return {
|
||||||
|
"type": "auto_post_ready",
|
||||||
|
"opportunity": updated,
|
||||||
|
"instructions": "Use trigger_sub_post with the linked campaign, or fire manually via post.py.",
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"type": "manual_handoff",
|
||||||
|
"opportunity": updated,
|
||||||
|
"draft_body": opp["draft_body"],
|
||||||
|
"thread_url": opp["thread_url"],
|
||||||
|
"instructions": (
|
||||||
|
f"Copy the draft and reply to this thread manually ({opp['platform']}). "
|
||||||
|
"Mark as manual_posted when done."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{opportunity_id}/mark-posted")
|
||||||
|
async def mark_posted(opportunity_id: int, manual: bool = False):
|
||||||
|
"""Record that a post was successfully made (auto or manual)."""
|
||||||
|
status = "manual_posted" if manual else "posted"
|
||||||
|
result = await asyncio.to_thread(
|
||||||
|
_in_thread, lambda s: s.update_opportunity(opportunity_id, status=status)
|
||||||
|
)
|
||||||
|
if result is None:
|
||||||
|
raise HTTPException(404, "Opportunity not found")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{opportunity_id}/dismiss")
|
||||||
|
async def dismiss_opportunity(opportunity_id: int, body: DismissBody):
|
||||||
|
result = await asyncio.to_thread(
|
||||||
|
_in_thread, lambda s: s.dismiss_opportunity(opportunity_id, body.note)
|
||||||
|
)
|
||||||
|
if result is None:
|
||||||
|
raise HTTPException(404, "Opportunity not found")
|
||||||
|
return result
|
||||||
23
app/db/migrations/007_opportunities.sql
Normal file
23
app/db/migrations/007_opportunities.sql
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
-- Opportunities: flagged threads/posts queued for human review before posting.
|
||||||
|
-- Covers both auto-post (Reddit via Playwright) and manual handoff (Lemmy, LinkedIn, etc.)
|
||||||
|
CREATE TABLE IF NOT EXISTS opportunities (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
platform TEXT NOT NULL DEFAULT 'reddit',
|
||||||
|
community TEXT NOT NULL, -- sub name, lemmy community, etc.
|
||||||
|
thread_url TEXT NOT NULL,
|
||||||
|
thread_title TEXT,
|
||||||
|
thread_body TEXT, -- snippet of original post for context
|
||||||
|
signal_reason TEXT, -- why this was flagged
|
||||||
|
product TEXT, -- peregrine, kiwi, snipe, circuitforge
|
||||||
|
draft_title TEXT, -- for new_post type; NULL for replies
|
||||||
|
draft_body TEXT NOT NULL DEFAULT '',
|
||||||
|
post_type TEXT NOT NULL DEFAULT 'reply_to_thread', -- reply_to_thread | new_post
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending_review', -- pending_review | approved | posted | manual_posted | dismissed
|
||||||
|
campaign_id INTEGER REFERENCES campaigns(id) ON DELETE SET NULL,
|
||||||
|
dismiss_note TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_opportunities_status ON opportunities(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_opportunities_platform ON opportunities(platform, community);
|
||||||
523
frontend/src/components/OpportunitiesView.vue
Normal file
523
frontend/src/components/OpportunitiesView.vue
Normal file
|
|
@ -0,0 +1,523 @@
|
||||||
|
<template>
|
||||||
|
<div class="opportunities-view">
|
||||||
|
<div class="view-header">
|
||||||
|
<h1 class="view-title">Opportunities</h1>
|
||||||
|
<div class="header-actions">
|
||||||
|
<select v-model="filterStatus" class="status-filter">
|
||||||
|
<option value="">All</option>
|
||||||
|
<option value="pending_review">Pending review</option>
|
||||||
|
<option value="approved">Approved</option>
|
||||||
|
<option value="posted">Posted</option>
|
||||||
|
<option value="manual_posted">Manual posted</option>
|
||||||
|
<option value="dismissed">Dismissed</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-primary" @click="showAddModal = true">+ Add</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="state-empty">Loading...</div>
|
||||||
|
<div v-else-if="filtered.length === 0" class="state-empty">
|
||||||
|
No opportunities{{ filterStatus ? ` with status "${filterStatus}"` : '' }}.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="opp-list">
|
||||||
|
<div
|
||||||
|
v-for="opp in filtered"
|
||||||
|
:key="opp.id"
|
||||||
|
class="opp-card"
|
||||||
|
:class="`status-${opp.status}`"
|
||||||
|
@click="select(opp)"
|
||||||
|
>
|
||||||
|
<div class="opp-meta">
|
||||||
|
<span class="opp-platform">{{ opp.platform }}</span>
|
||||||
|
<span class="opp-community">{{ opp.community }}</span>
|
||||||
|
<span class="opp-type">{{ opp.post_type === 'reply_to_thread' ? 'reply' : 'new post' }}</span>
|
||||||
|
<span v-if="opp.product" class="opp-product">{{ opp.product }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="opp-title">{{ opp.thread_title || opp.thread_url }}</div>
|
||||||
|
<div v-if="opp.signal_reason" class="opp-signal">{{ opp.signal_reason }}</div>
|
||||||
|
<div class="opp-footer">
|
||||||
|
<span class="status-badge" :class="`badge-${opp.status}`">{{ opp.status.replace('_', ' ') }}</span>
|
||||||
|
<span class="opp-date">{{ formatDate(opp.created_at) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Detail / review panel -->
|
||||||
|
<div v-if="selected" class="detail-overlay" @click.self="selected = null">
|
||||||
|
<div class="detail-panel">
|
||||||
|
<button class="close-btn" @click="selected = null">✕</button>
|
||||||
|
|
||||||
|
<div class="detail-header">
|
||||||
|
<div class="opp-meta">
|
||||||
|
<span class="opp-platform">{{ selected.platform }}</span>
|
||||||
|
<span class="opp-community">{{ selected.community }}</span>
|
||||||
|
<span class="opp-type">{{ selected.post_type === 'reply_to_thread' ? 'reply' : 'new post' }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="status-badge" :class="`badge-${selected.status}`">{{ selected.status.replace('_', ' ') }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Thread context -->
|
||||||
|
<section class="detail-section">
|
||||||
|
<h3 class="section-label">Thread</h3>
|
||||||
|
<a :href="selected.thread_url" target="_blank" class="thread-link">
|
||||||
|
{{ selected.thread_title || selected.thread_url }}
|
||||||
|
</a>
|
||||||
|
<p v-if="selected.thread_body" class="thread-body">{{ selected.thread_body }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="selected.signal_reason" class="detail-section">
|
||||||
|
<h3 class="section-label">Why flagged</h3>
|
||||||
|
<p class="signal-reason">{{ selected.signal_reason }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Draft editor -->
|
||||||
|
<section class="detail-section">
|
||||||
|
<h3 class="section-label">Draft {{ selected.post_type === 'new_post' ? 'post' : 'reply' }}</h3>
|
||||||
|
<input
|
||||||
|
v-if="selected.post_type === 'new_post'"
|
||||||
|
v-model="editTitle"
|
||||||
|
class="input draft-title-input"
|
||||||
|
placeholder="Post title"
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
v-model="editBody"
|
||||||
|
class="textarea draft-body"
|
||||||
|
rows="10"
|
||||||
|
placeholder="Draft content..."
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
v-if="editBody !== selected.draft_body || editTitle !== (selected.draft_title ?? '')"
|
||||||
|
class="btn btn-secondary"
|
||||||
|
:disabled="saving"
|
||||||
|
@click="saveDraft"
|
||||||
|
>
|
||||||
|
Save draft
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<section v-if="selected.status === 'pending_review'" class="detail-actions">
|
||||||
|
<button class="btn btn-primary" :disabled="saving" @click="approve">
|
||||||
|
Approve
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-danger" :disabled="saving" @click="dismiss">
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Handoff panel for approved non-Reddit -->
|
||||||
|
<section v-if="selected.status === 'approved' && selected.platform !== 'reddit'" class="handoff-panel">
|
||||||
|
<h3 class="section-label">Manual handoff</h3>
|
||||||
|
<div class="handoff-actions">
|
||||||
|
<button class="btn btn-secondary" @click="copyDraft">📋 Copy draft</button>
|
||||||
|
<a :href="selected.thread_url" target="_blank" class="btn btn-secondary">🔗 Open thread</a>
|
||||||
|
<button class="btn btn-success" :disabled="saving" @click="markManualPosted">
|
||||||
|
✓ Mark as posted
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="copied" class="copy-confirm">Copied to clipboard</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Auto-post panel for approved Reddit -->
|
||||||
|
<section v-if="selected.status === 'approved' && selected.platform === 'reddit'" class="handoff-panel">
|
||||||
|
<h3 class="section-label">Ready to post</h3>
|
||||||
|
<p class="handoff-note">Use trigger_sub_post from the Campaigns view, or mark as posted manually if you handled it.</p>
|
||||||
|
<button class="btn btn-success" :disabled="saving" @click="markManualPosted">
|
||||||
|
✓ Mark as posted
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add opportunity modal -->
|
||||||
|
<div v-if="showAddModal" class="detail-overlay" @click.self="showAddModal = false">
|
||||||
|
<div class="detail-panel">
|
||||||
|
<button class="close-btn" @click="showAddModal = false">✕</button>
|
||||||
|
<h2 class="panel-title">Add opportunity</h2>
|
||||||
|
|
||||||
|
<div class="form-grid">
|
||||||
|
<label class="form-label">Thread URL
|
||||||
|
<input v-model="newOpp.thread_url" class="input" placeholder="https://..." />
|
||||||
|
</label>
|
||||||
|
<label class="form-label">Platform
|
||||||
|
<select v-model="newOpp.platform" class="input">
|
||||||
|
<option>reddit</option>
|
||||||
|
<option>lemmy</option>
|
||||||
|
<option>linkedin</option>
|
||||||
|
<option>other</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="form-label">Community
|
||||||
|
<input v-model="newOpp.community" class="input" placeholder="selfhosted, lemmy.world/c/lsc, etc." />
|
||||||
|
</label>
|
||||||
|
<label class="form-label">Post type
|
||||||
|
<select v-model="newOpp.post_type" class="input">
|
||||||
|
<option value="reply_to_thread">Reply to thread</option>
|
||||||
|
<option value="new_post">New post</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="form-label">Product
|
||||||
|
<select v-model="newOpp.product" class="input">
|
||||||
|
<option value="">— any —</option>
|
||||||
|
<option>peregrine</option>
|
||||||
|
<option>kiwi</option>
|
||||||
|
<option>snipe</option>
|
||||||
|
<option>circuitforge</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="form-label">Thread title (optional)
|
||||||
|
<input v-model="newOpp.thread_title" class="input" placeholder="Original post title" />
|
||||||
|
</label>
|
||||||
|
<label class="form-label" style="grid-column: 1 / -1">Why flagged
|
||||||
|
<input v-model="newOpp.signal_reason" class="input" placeholder="Pain point match, keyword, etc." />
|
||||||
|
</label>
|
||||||
|
<label class="form-label" style="grid-column: 1 / -1">Draft
|
||||||
|
<textarea v-model="newOpp.draft_body" class="textarea" rows="6" placeholder="Draft reply or post body..." />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-actions">
|
||||||
|
<button class="btn btn-primary" :disabled="saving || !newOpp.thread_url || !newOpp.community" @click="createOpp">
|
||||||
|
Add to queue
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary" @click="showAddModal = false">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
|
import { api, type Opportunity, type OpportunityStatus } from '../services/api'
|
||||||
|
|
||||||
|
const opportunities = ref<Opportunity[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const selected = ref<Opportunity | null>(null)
|
||||||
|
const showAddModal = ref(false)
|
||||||
|
const copied = ref(false)
|
||||||
|
const filterStatus = ref<OpportunityStatus | ''>('')
|
||||||
|
|
||||||
|
const editBody = ref('')
|
||||||
|
const editTitle = ref('')
|
||||||
|
|
||||||
|
const newOpp = ref({
|
||||||
|
platform: 'reddit',
|
||||||
|
community: '',
|
||||||
|
thread_url: '',
|
||||||
|
thread_title: '',
|
||||||
|
signal_reason: '',
|
||||||
|
product: '',
|
||||||
|
draft_body: '',
|
||||||
|
post_type: 'reply_to_thread' as const,
|
||||||
|
})
|
||||||
|
|
||||||
|
const filtered = computed(() =>
|
||||||
|
filterStatus.value
|
||||||
|
? opportunities.value.filter(o => o.status === filterStatus.value)
|
||||||
|
: opportunities.value
|
||||||
|
)
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
opportunities.value = await api.opportunities.list()
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function select(opp: Opportunity) {
|
||||||
|
selected.value = opp
|
||||||
|
editBody.value = opp.draft_body
|
||||||
|
editTitle.value = opp.draft_title ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(selected, opp => {
|
||||||
|
if (opp) {
|
||||||
|
editBody.value = opp.draft_body
|
||||||
|
editTitle.value = opp.draft_title ?? ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function saveDraft() {
|
||||||
|
if (!selected.value) return
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const updated = await api.opportunities.update(selected.value.id, {
|
||||||
|
draft_body: editBody.value,
|
||||||
|
draft_title: editTitle.value || undefined,
|
||||||
|
})
|
||||||
|
replace(updated)
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function approve() {
|
||||||
|
if (!selected.value) return
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
// Save any unsaved draft edits first
|
||||||
|
if (editBody.value !== selected.value.draft_body) {
|
||||||
|
await api.opportunities.update(selected.value.id, { draft_body: editBody.value })
|
||||||
|
}
|
||||||
|
const result = await api.opportunities.approve(selected.value.id)
|
||||||
|
replace(result.opportunity)
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dismiss() {
|
||||||
|
if (!selected.value) return
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const updated = await api.opportunities.dismiss(selected.value.id)
|
||||||
|
replace(updated)
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markManualPosted() {
|
||||||
|
if (!selected.value) return
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const updated = await api.opportunities.markPosted(selected.value.id, true)
|
||||||
|
replace(updated)
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyDraft() {
|
||||||
|
if (!selected.value) return
|
||||||
|
await navigator.clipboard.writeText(selected.value.draft_body)
|
||||||
|
copied.value = true
|
||||||
|
setTimeout(() => { copied.value = false }, 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createOpp() {
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const created = await api.opportunities.create({
|
||||||
|
...newOpp.value,
|
||||||
|
product: newOpp.value.product || undefined,
|
||||||
|
thread_title: newOpp.value.thread_title || undefined,
|
||||||
|
signal_reason: newOpp.value.signal_reason || undefined,
|
||||||
|
})
|
||||||
|
opportunities.value.unshift(created)
|
||||||
|
showAddModal.value = false
|
||||||
|
newOpp.value = { platform: 'reddit', community: '', thread_url: '', thread_title: '', signal_reason: '', product: '', draft_body: '', post_type: 'reply_to_thread' }
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function replace(updated: Opportunity) {
|
||||||
|
const idx = opportunities.value.findIndex(o => o.id === updated.id)
|
||||||
|
if (idx >= 0) opportunities.value[idx] = updated
|
||||||
|
if (selected.value?.id === updated.id) selected.value = updated
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string) {
|
||||||
|
return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.opportunities-view { padding: var(--spacing-lg); }
|
||||||
|
|
||||||
|
.view-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-title { margin: 0; font-size: 1.4rem; color: var(--color-text); }
|
||||||
|
|
||||||
|
.header-actions { display: flex; gap: var(--spacing-sm); align-items: center; }
|
||||||
|
|
||||||
|
.status-filter {
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
color: var(--color-text);
|
||||||
|
padding: var(--spacing-xs) var(--spacing-sm);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-empty { color: var(--color-text-muted); text-align: center; padding: var(--spacing-xl); }
|
||||||
|
|
||||||
|
.opp-list { display: flex; flex-direction: column; gap: var(--spacing-sm); }
|
||||||
|
|
||||||
|
.opp-card {
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.opp-card:hover { border-color: var(--color-primary); }
|
||||||
|
.opp-card.status-dismissed { opacity: 0.5; }
|
||||||
|
|
||||||
|
.opp-meta { display: flex; gap: var(--spacing-sm); align-items: center; margin-bottom: var(--spacing-xs); flex-wrap: wrap; }
|
||||||
|
|
||||||
|
.opp-platform, .opp-community, .opp-type, .opp-product {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
.opp-platform { color: var(--color-info); border-color: var(--color-info); }
|
||||||
|
.opp-community { color: var(--color-primary); border-color: var(--color-primary); }
|
||||||
|
|
||||||
|
.opp-title { font-size: 0.95rem; color: var(--color-text); margin-bottom: var(--spacing-xs); }
|
||||||
|
.opp-signal { font-size: 0.8rem; color: var(--color-text-muted); margin-bottom: var(--spacing-xs); font-style: italic; }
|
||||||
|
|
||||||
|
.opp-footer { display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
.opp-date { font-size: 0.75rem; color: var(--color-text-muted); }
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
text-transform: capitalize;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.badge-pending_review { background: color-mix(in srgb, var(--color-warning) 15%, transparent); color: var(--color-warning); }
|
||||||
|
.badge-approved { background: color-mix(in srgb, var(--color-primary) 15%, transparent); color: var(--color-primary); }
|
||||||
|
.badge-posted { background: color-mix(in srgb, var(--color-success) 15%, transparent); color: var(--color-success); }
|
||||||
|
.badge-manual_posted { background: color-mix(in srgb, var(--color-success) 15%, transparent); color: var(--color-success); }
|
||||||
|
.badge-dismissed { background: color-mix(in srgb, var(--color-danger) 15%, transparent); color: var(--color-danger); }
|
||||||
|
|
||||||
|
/* Detail panel */
|
||||||
|
.detail-overlay {
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
background: rgba(0,0,0,0.6);
|
||||||
|
display: flex; align-items: flex-start; justify-content: flex-end;
|
||||||
|
z-index: 100;
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-panel {
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
width: min(680px, 95vw);
|
||||||
|
max-height: calc(100vh - 2 * var(--spacing-md));
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
position: absolute; top: var(--spacing-md); right: var(--spacing-md);
|
||||||
|
background: none; border: none; color: var(--color-text-muted);
|
||||||
|
font-size: 1.1rem; cursor: pointer; padding: 4px;
|
||||||
|
}
|
||||||
|
.close-btn:hover { color: var(--color-text); }
|
||||||
|
|
||||||
|
.panel-title { margin: 0 0 var(--spacing-lg); font-size: 1.1rem; }
|
||||||
|
|
||||||
|
.detail-header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-section { margin-bottom: var(--spacing-lg); }
|
||||||
|
|
||||||
|
.section-label {
|
||||||
|
font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.08em;
|
||||||
|
color: var(--color-text-muted); margin: 0 0 var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-link {
|
||||||
|
color: var(--color-info); text-decoration: none; font-size: 0.95rem;
|
||||||
|
display: block; margin-bottom: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
.thread-link:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
.thread-body {
|
||||||
|
font-size: 0.85rem; color: var(--color-text-muted);
|
||||||
|
background: var(--color-bg); border-radius: var(--radius-sm);
|
||||||
|
padding: var(--spacing-sm); margin: 0;
|
||||||
|
white-space: pre-wrap; max-height: 120px; overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-reason { font-size: 0.85rem; color: var(--color-text-muted); margin: 0; font-style: italic; }
|
||||||
|
|
||||||
|
.draft-title-input { width: 100%; margin-bottom: var(--spacing-sm); }
|
||||||
|
.draft-body { width: 100%; resize: vertical; font-family: var(--font-mono); font-size: 0.85rem; }
|
||||||
|
|
||||||
|
.detail-actions { display: flex; gap: var(--spacing-sm); flex-wrap: wrap; margin-top: var(--spacing-md); }
|
||||||
|
|
||||||
|
.handoff-panel {
|
||||||
|
background: color-mix(in srgb, var(--color-success) 8%, transparent);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-success) 30%, transparent);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
margin-top: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.handoff-actions { display: flex; gap: var(--spacing-sm); flex-wrap: wrap; margin-top: var(--spacing-sm); }
|
||||||
|
.handoff-note { font-size: 0.85rem; color: var(--color-text-muted); margin: var(--spacing-xs) 0 0; }
|
||||||
|
|
||||||
|
.copy-confirm {
|
||||||
|
font-size: 0.8rem; color: var(--color-success);
|
||||||
|
margin-top: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add form */
|
||||||
|
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--spacing-md); }
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
display: flex; flex-direction: column; gap: var(--spacing-xs);
|
||||||
|
font-size: 0.8rem; color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shared */
|
||||||
|
.input, .textarea {
|
||||||
|
background: var(--color-bg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
color: var(--color-text);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.input:focus, .textarea:focus { outline: none; border-color: var(--color-primary); }
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: var(--spacing-xs) var(--spacing-md);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.btn-primary { background: var(--color-primary); color: #fff; }
|
||||||
|
.btn-secondary { background: var(--color-bg-card); color: var(--color-text); border-color: var(--color-border); }
|
||||||
|
.btn-danger { background: color-mix(in srgb, var(--color-danger) 15%, transparent); color: var(--color-danger); border-color: var(--color-danger); }
|
||||||
|
.btn-success { background: color-mix(in srgb, var(--color-success) 15%, transparent); color: var(--color-success); border-color: var(--color-success); }
|
||||||
|
.btn:hover:not(:disabled) { opacity: 0.85; }
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.form-grid { grid-template-columns: 1fr; }
|
||||||
|
.detail-overlay { padding: 0; align-items: flex-end; }
|
||||||
|
.detail-panel { width: 100%; border-radius: var(--radius-lg) var(--radius-lg) 0 0; max-height: 90vh; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Loading…
Reference in a new issue