[31188f42] einfügen
einfügen
This commit is contained in:
@@ -0,0 +1,279 @@
|
||||
import { useEffect, useState, useMemo } from 'react'
|
||||
import axios from 'axios'
|
||||
import { Search, Edit2, X, Check, Filter, Download } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface MarketingMatrixManagerProps {
|
||||
apiBase: string
|
||||
}
|
||||
|
||||
interface MatrixEntry {
|
||||
id: number
|
||||
industry_id: number
|
||||
persona_id: number
|
||||
industry_name: string
|
||||
persona_name: string
|
||||
subject: string | null
|
||||
intro: string | null
|
||||
social_proof: string | null
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
interface Industry {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
interface Persona {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
export function MarketingMatrixManager({ apiBase }: MarketingMatrixManagerProps) {
|
||||
const [entries, setEntries] = useState<MatrixEntry[]>([])
|
||||
const [industries, setIndustries] = useState<Industry[]>([])
|
||||
const [personas, setPersonas] = useState<Persona[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
// Filters
|
||||
const [industryFilter, setIndustryFilter] = useState<number | 'all'>('all')
|
||||
const [personaFilter, setPersonaFilter] = useState<number | 'all'>('all')
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
|
||||
// Editing state
|
||||
const [editingId, setEditingId] = useState<number | null>(null)
|
||||
const [editValues, setEditValues] = useState<{
|
||||
subject: string
|
||||
intro: string
|
||||
social_proof: string
|
||||
}>({ subject: '', intro: '', social_proof: '' })
|
||||
|
||||
const fetchMetadata = async () => {
|
||||
try {
|
||||
const [resInd, resPers] = await Promise.all([
|
||||
axios.get(`${apiBase}/industries`),
|
||||
axios.get(`${apiBase}/matrix/personas`)
|
||||
])
|
||||
setIndustries(resInd.data)
|
||||
setPersonas(resPers.data)
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch metadata:", e)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchEntries = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const params: any = {}
|
||||
if (industryFilter !== 'all') params.industry_id = industryFilter
|
||||
if (personaFilter !== 'all') params.persona_id = personaFilter
|
||||
|
||||
const res = await axios.get(`${apiBase}/matrix`, { params })
|
||||
setEntries(res.data)
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch matrix entries:", e)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchMetadata()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchEntries()
|
||||
}, [industryFilter, personaFilter])
|
||||
|
||||
const filteredEntries = useMemo(() => {
|
||||
if (!searchTerm) return entries
|
||||
const s = searchTerm.toLowerCase()
|
||||
return entries.filter(e =>
|
||||
e.industry_name.toLowerCase().includes(s) ||
|
||||
e.persona_name.toLowerCase().includes(s) ||
|
||||
(e.subject?.toLowerCase().includes(s)) ||
|
||||
(e.intro?.toLowerCase().includes(s)) ||
|
||||
(e.social_proof?.toLowerCase().includes(s))
|
||||
)
|
||||
}, [entries, searchTerm])
|
||||
|
||||
const startEditing = (entry: MatrixEntry) => {
|
||||
setEditingId(entry.id)
|
||||
setEditValues({
|
||||
subject: entry.subject || '',
|
||||
intro: entry.intro || '',
|
||||
social_proof: entry.social_proof || ''
|
||||
})
|
||||
}
|
||||
|
||||
const cancelEditing = () => {
|
||||
setEditingId(null)
|
||||
}
|
||||
|
||||
const saveEditing = async (id: number) => {
|
||||
try {
|
||||
await axios.put(`${apiBase}/matrix/${id}`, editValues)
|
||||
setEntries(prev => prev.map(e => e.id === id ? { ...e, ...editValues } : e))
|
||||
setEditingId(null)
|
||||
} catch (e) {
|
||||
alert("Save failed")
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownloadCSV = () => {
|
||||
let url = `${apiBase}/matrix/export`
|
||||
const params = new URLSearchParams()
|
||||
if (industryFilter !== 'all') params.append('industry_id', industryFilter.toString())
|
||||
if (personaFilter !== 'all') params.append('persona_id', personaFilter.toString())
|
||||
|
||||
if (params.toString()) {
|
||||
url += `?${params.toString()}`
|
||||
}
|
||||
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-wrap items-center gap-3 bg-slate-50 dark:bg-slate-950 p-3 rounded-lg border border-slate-200 dark:border-slate-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="h-4 w-4 text-slate-400" />
|
||||
<span className="text-xs font-bold text-slate-500 uppercase">Filters:</span>
|
||||
</div>
|
||||
|
||||
<select
|
||||
className="bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-700 rounded px-2 py-1.5 text-xs outline-none focus:ring-1 focus:ring-blue-500"
|
||||
value={industryFilter}
|
||||
onChange={e => setIndustryFilter(e.target.value === 'all' ? 'all' : parseInt(e.target.value))}
|
||||
>
|
||||
<option value="all">All Industries</option>
|
||||
{industries.map(i => <option key={i.id} value={i.id}>{i.name}</option>)}
|
||||
</select>
|
||||
|
||||
<select
|
||||
className="bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-700 rounded px-2 py-1.5 text-xs outline-none focus:ring-1 focus:ring-blue-500"
|
||||
value={personaFilter}
|
||||
onChange={e => setPersonaFilter(e.target.value === 'all' ? 'all' : parseInt(e.target.value))}
|
||||
>
|
||||
<option value="all">All Personas</option>
|
||||
{personas.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
</select>
|
||||
|
||||
<div className="flex-1 min-w-[200px] relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search in texts..."
|
||||
className="w-full bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-700 rounded px-9 py-1.5 text-xs outline-none focus:ring-1 focus:ring-blue-500"
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleDownloadCSV}
|
||||
className="flex items-center gap-2 bg-slate-200 dark:bg-slate-800 hover:bg-slate-300 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 px-3 py-1.5 rounded text-xs font-bold transition-all border border-slate-300 dark:border-slate-700"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
EXPORT CSV
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="border border-slate-200 dark:border-slate-800 rounded-lg overflow-hidden bg-white dark:bg-slate-950">
|
||||
<table className="w-full text-left text-xs table-fixed">
|
||||
<thead className="bg-slate-50 dark:bg-slate-900 border-b border-slate-200 dark:border-slate-800 text-slate-500 font-bold uppercase">
|
||||
<tr>
|
||||
<th className="p-3 w-40">Combination</th>
|
||||
<th className="p-3 w-1/4">Subject Line</th>
|
||||
<th className="p-3 w-1/3">Intro Text</th>
|
||||
<th className="p-3">Social Proof</th>
|
||||
<th className="p-3 w-20 text-center">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100 dark:divide-slate-800/50">
|
||||
{isLoading ? (
|
||||
<tr><td colSpan={5} className="p-12 text-center text-slate-400 italic">Loading matrix entries...</td></tr>
|
||||
) : filteredEntries.length === 0 ? (
|
||||
<tr><td colSpan={5} className="p-12 text-center text-slate-400 italic">No entries found for the selected filters.</td></tr>
|
||||
) : filteredEntries.map(entry => (
|
||||
<tr key={entry.id} className={clsx("group transition-colors", editingId === entry.id ? "bg-blue-50/50 dark:bg-blue-900/10" : "hover:bg-slate-50/50 dark:hover:bg-slate-900/30")}>
|
||||
<td className="p-3 align-top">
|
||||
<div className="font-bold text-slate-900 dark:text-white leading-tight mb-1">{entry.industry_name}</div>
|
||||
<div className="text-[10px] text-blue-600 dark:text-blue-400 font-bold uppercase tracking-wider">{entry.persona_name}</div>
|
||||
</td>
|
||||
|
||||
<td className="p-3 align-top">
|
||||
{editingId === entry.id ? (
|
||||
<input
|
||||
className="w-full bg-white dark:bg-slate-900 border border-blue-300 dark:border-blue-700 rounded p-1.5 outline-none"
|
||||
value={editValues.subject}
|
||||
onChange={e => setEditValues(v => ({ ...v, subject: e.target.value }))}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-slate-700 dark:text-slate-300">{entry.subject || <span className="text-slate-400 italic">Empty</span>}</div>
|
||||
)}
|
||||
</td>
|
||||
|
||||
<td className="p-3 align-top">
|
||||
{editingId === entry.id ? (
|
||||
<textarea
|
||||
className="w-full bg-white dark:bg-slate-900 border border-blue-300 dark:border-blue-700 rounded p-1.5 outline-none h-24 text-[11px]"
|
||||
value={editValues.intro}
|
||||
onChange={e => setEditValues(v => ({ ...v, intro: e.target.value }))}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-slate-600 dark:text-slate-400 line-clamp-4 hover:line-clamp-none transition-all">{entry.intro || <span className="text-slate-400 italic">Empty</span>}</div>
|
||||
)}
|
||||
</td>
|
||||
|
||||
<td className="p-3 align-top">
|
||||
{editingId === entry.id ? (
|
||||
<textarea
|
||||
className="w-full bg-white dark:bg-slate-900 border border-blue-300 dark:border-blue-700 rounded p-1.5 outline-none h-24 text-[11px]"
|
||||
value={editValues.social_proof}
|
||||
onChange={e => setEditValues(v => ({ ...v, social_proof: e.target.value }))}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-slate-600 dark:text-slate-400 line-clamp-4 hover:line-clamp-none transition-all">{entry.social_proof || <span className="text-slate-400 italic">Empty</span>}</div>
|
||||
)}
|
||||
</td>
|
||||
|
||||
<td className="p-3 align-top text-center">
|
||||
{editingId === entry.id ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
onClick={() => saveEditing(entry.id)}
|
||||
className="p-1.5 bg-green-600 text-white rounded hover:bg-green-500 transition-colors shadow-sm"
|
||||
title="Save Changes"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={cancelEditing}
|
||||
className="p-1.5 bg-slate-200 dark:bg-slate-800 text-slate-600 dark:text-slate-400 rounded hover:bg-slate-300 dark:hover:bg-slate-700 transition-colors"
|
||||
title="Cancel"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => startEditing(entry)}
|
||||
className="p-2 text-slate-400 hover:text-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-full transition-all opacity-0 group-hover:opacity-100"
|
||||
title="Edit Entry"
|
||||
>
|
||||
<Edit2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user