302 lines
16 KiB
Go
302 lines
16 KiB
Go
package ui
|
||
|
||
const jobsTabHTML = `{{ define "jobsTab" }}
|
||
<div class="space-y-4">
|
||
<!-- Filter Bar -->
|
||
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
|
||
<div>
|
||
<h2 class="text-base font-bold text-white">Job Opportunities</h2>
|
||
<p class="text-xs text-slate-400 mt-0.5">All ingested postings · AI-scored · sortable</p>
|
||
</div>
|
||
<div class="flex items-center gap-2 flex-wrap">
|
||
<input id="job-filter-text" type="search" placeholder="Search title, company…"
|
||
class="px-3 py-1.5 text-xs rounded-lg bg-slate-800 border border-slate-700 text-white focus:outline-none focus:border-indigo-500 w-48 placeholder:text-slate-500">
|
||
<!-- Filter order: most frequent → least frequent use -->
|
||
<select id="job-filter-qual"
|
||
class="px-3 py-1.5 text-xs rounded-lg bg-slate-800 border border-slate-700 text-white focus:outline-none focus:border-indigo-500">
|
||
<option value="default">✅ Qualified (Default)</option>
|
||
<option value="not-qualified">❌ Not Qualified</option>
|
||
<option value="saved">💾 Saved / In Pipeline</option>
|
||
<option value="all">🌐 All Jobs</option>
|
||
<option value="opted-out">🚫 Opted-out</option>
|
||
</select>
|
||
<button hx-get="/jobs" hx-target="#jobs-container" hx-swap="innerHTML"
|
||
class="px-3 py-1.5 text-xs font-semibold text-indigo-400 hover:text-indigo-300 bg-slate-800 border border-slate-700 rounded-lg transition-colors">
|
||
↺ Refresh
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Job List Container (HTMX auto-refreshed every 12s) -->
|
||
<div id="jobs-container"
|
||
hx-get="/jobs" hx-trigger="refresh" hx-swap="innerHTML">
|
||
{{ template "jobs" .Jobs }}
|
||
</div>
|
||
</div>
|
||
{{ end }}`
|
||
|
||
const jobListHTML = `{{ define "jobs" }}
|
||
<div class="overflow-x-auto rounded-xl border border-slate-800">
|
||
<table id="jobs-table" class="w-full text-left text-xs text-slate-300">
|
||
<thead class="bg-slate-900/80 text-slate-400 uppercase font-semibold text-[10px]">
|
||
<tr>
|
||
<th class="px-4 py-3" data-sort="title">Job Title & Company</th>
|
||
<th class="px-4 py-3 text-center">Link</th>
|
||
<th class="px-4 py-3" data-sort="status">Analysis</th>
|
||
<th class="px-4 py-3" data-sort="score">Score & Qualification</th>
|
||
<th class="px-4 py-3">Reason / Tags</th>
|
||
<th class="px-4 py-3" data-sort="date">Crawled</th>
|
||
<th class="px-4 py-3 text-right">Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody class="divide-y divide-slate-800 bg-slate-900/40">
|
||
{{ range . }}
|
||
{{- $qual := "pending" -}}
|
||
{{- if .Analysis }}{{ if .Analysis.IsQualified }}{{ $qual = "qualified" }}{{ else }}{{ $qual = "not-qualified" }}{{ end }}{{ end -}}
|
||
{{- $appClass := "none" -}}
|
||
{{- if .App }}{{ if eq .App.Status "OPTED_OUT" }}{{ $appClass = "opted-out" }}{{ else }}{{ $appClass = "pipeline" }}{{ end }}{{ end -}}
|
||
<tr class="hover:bg-slate-800/40 transition-colors group" data-row data-qual="{{ $qual }}" data-app="{{ $appClass }}">
|
||
|
||
<!-- Title & Company -->
|
||
<td class="px-4 py-3 font-medium" data-sort-val="{{ .Title }}">
|
||
<button hx-get="/jobs/{{ .ID }}"
|
||
hx-target="#detail-modal-body"
|
||
hx-swap="innerHTML"
|
||
onclick="openDetailModal()"
|
||
class="text-left font-bold text-white group-hover:text-indigo-300 hover:underline transition-colors block truncate max-w-xs">
|
||
{{ .Title }}
|
||
</button>
|
||
<div class="flex items-center space-x-2 mt-0.5 text-[11px] text-slate-400">
|
||
<span class="font-semibold text-indigo-400">{{ .Company }}</span>
|
||
<span>•</span>
|
||
<span>📍 {{ .Location }}</span>
|
||
</div>
|
||
</td>
|
||
|
||
<!-- External Link -->
|
||
<td class="px-4 py-3 text-center">
|
||
<a href="{{ .ApplyURL }}" target="_blank" title="Open original job posting"
|
||
class="inline-flex items-center justify-center w-7 h-7 rounded bg-slate-800 hover:bg-slate-700 text-indigo-400 hover:text-indigo-300 border border-slate-700 transition-colors">
|
||
🔗
|
||
</a>
|
||
</td>
|
||
|
||
<!-- Analysis Status -->
|
||
<td class="px-4 py-3" data-sort-val="{{ if .Analysis }}{{ .Analysis.Status }}{{ else }}UNANALYZED{{ end }}">
|
||
{{ if .Analysis }}
|
||
{{ if eq .Analysis.Status "PARSED" }}
|
||
<span class="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold bg-indigo-500/20 text-indigo-300 border border-indigo-500/30">PARSED</span>
|
||
{{ else if eq .Analysis.Status "PENDING" }}
|
||
<span class="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold bg-amber-500/20 text-amber-400 border border-amber-500/30">PENDING</span>
|
||
{{ else if eq .Analysis.Status "DONE" }}
|
||
<span class="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold bg-emerald-500/20 text-emerald-400 border border-emerald-500/30">DONE</span>
|
||
{{ else }}
|
||
<span class="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold bg-slate-500/20 text-slate-400 border border-slate-600/30">{{ .Analysis.Status }}</span>
|
||
{{ end }}
|
||
{{ else }}
|
||
<span class="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-medium bg-slate-800 text-slate-500 border border-slate-700">UNANALYZED</span>
|
||
{{ end }}
|
||
</td>
|
||
|
||
<!-- Score & Qualification -->
|
||
<td class="px-4 py-3" data-sort-val="{{ if .Analysis }}{{ printf "%.0f" .Analysis.Score }}{{ else }}-1{{ end }}">
|
||
{{ if .Analysis }}
|
||
{{ if .Analysis.IsQualified }}
|
||
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-black border {{ scoreBadgeClass .Analysis.Score }}">
|
||
★ {{ printf "%.0f" .Analysis.Score }}
|
||
</span>
|
||
{{ else }}
|
||
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold bg-rose-500/10 text-rose-400 border border-rose-500/20">
|
||
✗ Not Qualified
|
||
</span>
|
||
{{ end }}
|
||
{{ else }}
|
||
<span class="text-slate-500 text-[11px]">–</span>
|
||
{{ end }}
|
||
</td>
|
||
|
||
<!-- Reason / Matched Tags -->
|
||
<td class="px-4 py-3 max-w-[180px]">
|
||
{{ if .App }}{{ if eq .App.Status "OPTED_OUT" }}
|
||
<span class="text-[10px] text-slate-500 italic">Opted out</span>
|
||
{{ end }}{{ end }}
|
||
{{ if .Analysis }}
|
||
{{ if .Analysis.IsQualified }}
|
||
<div class="flex flex-wrap gap-1">
|
||
{{ range parseJSON .Analysis.MatchedHighlights }}
|
||
<span class="px-1.5 py-0.5 rounded bg-emerald-500/10 text-emerald-300 text-[10px] font-medium border border-emerald-500/20">{{ . }}</span>
|
||
{{ else }}
|
||
<span class="text-slate-500 text-[10px] italic">No tags</span>
|
||
{{ end }}
|
||
</div>
|
||
{{ else }}
|
||
{{ $missing := parseJSON .Analysis.MissingHighlights }}
|
||
{{ if $missing }}
|
||
<span class="text-[10px] text-rose-400/90 leading-snug line-clamp-2" title="{{ index $missing 0 }}">{{ index $missing 0 }}</span>
|
||
{{ end }}
|
||
{{ end }}
|
||
{{ end }}
|
||
</td>
|
||
|
||
<!-- Date -->
|
||
<td class="px-4 py-3 text-slate-400 text-[11px]" data-sort-val="{{ .CrawledAt.Unix }}">
|
||
{{ formatDate .CrawledAt }}
|
||
</td>
|
||
|
||
<!-- Actions: Save + Opt-out OR status badge -->
|
||
<td class="px-4 py-3 text-right">
|
||
{{ if .App }}
|
||
{{ if eq .App.Status "OPTED_OUT" }}
|
||
<!-- Opted out: show undo button -->
|
||
<button hx-post="/applications"
|
||
hx-vals='{"raw_job_id": {{ .ID }}, "status": "SAVED"}'
|
||
hx-target="#jobs-container"
|
||
hx-swap="innerHTML"
|
||
title="Undo opt-out"
|
||
class="px-2.5 py-1 rounded bg-slate-800 hover:bg-slate-700 text-slate-400 border border-slate-700 text-[10px] transition-colors">
|
||
↩ Undo
|
||
</button>
|
||
{{ else }}
|
||
<!-- In pipeline -->
|
||
<span class="px-2 py-1 rounded bg-violet-500/20 text-violet-300 text-[10px] font-bold border border-violet-500/30">
|
||
{{ .App.Status }}
|
||
</span>
|
||
{{ end }}
|
||
{{ else }}
|
||
<!-- No app: show Save + Opt-out -->
|
||
<div class="flex items-center justify-end gap-1.5">
|
||
<button hx-post="/applications"
|
||
hx-vals='{"raw_job_id": {{ .ID }}, "status": "SAVED"}'
|
||
hx-target="#jobs-container"
|
||
hx-swap="innerHTML"
|
||
class="px-2.5 py-1 rounded bg-indigo-700 hover:bg-indigo-600 text-indigo-100 border border-indigo-600 text-[10px] font-semibold transition-colors">
|
||
+ Save
|
||
</button>
|
||
<button hx-post="/jobs/{{ .ID }}/opt-out"
|
||
hx-target="#jobs-container"
|
||
hx-swap="innerHTML"
|
||
title="Hide this job"
|
||
class="px-2.5 py-1 rounded bg-slate-800 hover:bg-rose-900/40 text-slate-500 hover:text-rose-400 border border-slate-700 text-[10px] transition-colors">
|
||
🚫
|
||
</button>
|
||
</div>
|
||
{{ end }}
|
||
</td>
|
||
</tr>
|
||
{{ else }}
|
||
<tr>
|
||
<td colspan="7" class="px-4 py-12 text-center text-slate-500">
|
||
<p class="text-lg mb-2">📭</p>
|
||
<p>No job postings found. Click <strong>🔍 Discover</strong> in the bottom bar to start crawling.</p>
|
||
</td>
|
||
</tr>
|
||
{{ end }}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
{{ end }}`
|
||
|
||
const jobDetailHTML = `{{ define "jobDetail" }}
|
||
<div class="space-y-5">
|
||
<!-- Header -->
|
||
<div class="flex items-start justify-between border-b border-slate-800 pb-4">
|
||
<div class="flex-1 min-w-0 pr-4">
|
||
<span class="text-xs font-bold text-indigo-400 uppercase tracking-wide">{{ .Company }}</span>
|
||
<h2 class="text-xl font-extrabold text-white mt-0.5 leading-tight">{{ .Title }}</h2>
|
||
<p class="text-xs text-slate-400 mt-1">📍 {{ .Location }} • Ingested {{ formatDate .CrawledAt }}</p>
|
||
</div>
|
||
<button onclick="closeDetailModal()" class="flex-shrink-0 text-slate-400 hover:text-white text-lg p-1 rounded hover:bg-slate-800 transition-colors">✕</button>
|
||
</div>
|
||
|
||
{{ if .Analysis }}
|
||
<!-- Score + Status Grid -->
|
||
<div class="grid grid-cols-2 gap-3">
|
||
<div class="p-4 rounded-xl bg-slate-800/60 border border-slate-700/60">
|
||
<span class="text-[10px] text-slate-400 uppercase tracking-wider font-semibold block">Match Score</span>
|
||
{{ if .Analysis.IsQualified }}
|
||
<span class="text-2xl font-black text-emerald-400 mt-1 block">{{ printf "%.1f" .Analysis.Score }} <span class="text-sm font-medium text-slate-400">/ 100</span></span>
|
||
{{ else }}
|
||
<span class="text-base font-bold text-rose-400 mt-1 block">Not Qualified</span>
|
||
{{ end }}
|
||
</div>
|
||
<div class="p-4 rounded-xl bg-slate-800/60 border border-slate-700/60">
|
||
<span class="text-[10px] text-slate-400 uppercase tracking-wider font-semibold block">Analysis Status</span>
|
||
<span class="text-sm font-bold text-indigo-300 mt-1 block uppercase">{{ .Analysis.Status }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Highlights -->
|
||
<div class="space-y-4">
|
||
{{ if .Analysis.IsQualified }}
|
||
<div>
|
||
<h4 class="text-xs font-bold text-emerald-400 uppercase tracking-wider mb-2">✓ Matched Highlights</h4>
|
||
<div class="flex flex-wrap gap-2">
|
||
{{ range parseJSON .Analysis.MatchedHighlights }}
|
||
<span class="px-2.5 py-1 rounded-lg bg-emerald-500/15 text-emerald-300 text-xs font-semibold border border-emerald-500/30">{{ . }}</span>
|
||
{{ else }}
|
||
<span class="text-slate-500 text-xs italic">None identified</span>
|
||
{{ end }}
|
||
</div>
|
||
</div>
|
||
{{ end }}
|
||
|
||
<div>
|
||
<h4 class="text-xs font-bold text-rose-400 uppercase tracking-wider mb-2">! {{ if .Analysis.IsQualified }}Gaps / Missing Skills{{ else }}Disqualification Reasons{{ end }}</h4>
|
||
<div class="flex flex-wrap gap-2">
|
||
{{ range parseJSON .Analysis.MissingHighlights }}
|
||
<span class="px-2.5 py-1 rounded-lg bg-rose-500/15 text-rose-300 text-xs font-semibold border border-rose-500/30">{{ . }}</span>
|
||
{{ else }}
|
||
<span class="text-slate-500 text-xs italic">None identified</span>
|
||
{{ end }}
|
||
</div>
|
||
</div>
|
||
|
||
{{ if .Analysis.IsQualified }}
|
||
<div>
|
||
<h4 class="text-xs font-bold text-amber-400 uppercase tracking-wider mb-2">💡 Resume Tailoring Tips</h4>
|
||
<ul class="space-y-2">
|
||
{{ range parseJSON .Analysis.TailoringSuggestions }}
|
||
<li class="p-3 rounded-lg bg-slate-800/80 border border-slate-700/60 text-xs text-slate-200 leading-relaxed">• {{ . }}</li>
|
||
{{ else }}
|
||
<li class="text-slate-500 text-xs italic">None</li>
|
||
{{ end }}
|
||
</ul>
|
||
</div>
|
||
{{ end }}
|
||
</div>
|
||
{{ end }}
|
||
|
||
<!-- Job Description (stored as Markdown) -->
|
||
<div class="border-t border-slate-800 pt-4">
|
||
<h4 class="text-xs font-bold text-slate-400 uppercase tracking-wider mb-2">Job Description</h4>
|
||
<div class="p-4 rounded-xl bg-slate-950 border border-slate-800 text-xs text-slate-300 max-h-72 overflow-y-auto leading-relaxed custom-scrollbar whitespace-pre-wrap font-mono">
|
||
{{ rawContent .RawContent }}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Actions Footer -->
|
||
<div class="flex items-center justify-between border-t border-slate-800 pt-4">
|
||
<a href="{{ .ApplyURL }}" target="_blank"
|
||
class="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-xs font-bold text-white rounded-lg shadow-md transition-all">
|
||
Apply External ↗
|
||
</a>
|
||
<div class="flex space-x-2">
|
||
<button hx-post="/jobs/{{ .ID }}/analyze"
|
||
hx-target="#detail-modal-body"
|
||
hx-swap="innerHTML"
|
||
class="px-3 py-2 bg-slate-800 hover:bg-slate-700 text-xs font-semibold text-slate-200 rounded-lg border border-slate-700">
|
||
🔄 Re-run LLM
|
||
</button>
|
||
<button hx-post="/applications"
|
||
hx-vals='{"raw_job_id": {{ .ID }}, "status": "APPLIED"}'
|
||
hx-target="#jobs-container"
|
||
hx-swap="innerHTML"
|
||
onclick="switchTab('apply'); closeDetailModal();"
|
||
class="px-3 py-2 bg-emerald-600 hover:bg-emerald-500 text-xs font-semibold text-white rounded-lg shadow-md">
|
||
+ Mark Applied
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{{ end }}`
|