Files
TaskTracker/TaskTracker.Web/src/pages/Mappings.tsx

290 lines
11 KiB
TypeScript

import { useState } from 'react'
import { Pencil, Trash2, Check, X, Plus, Link } from 'lucide-react'
import { useMappings, useCreateMapping, useUpdateMapping, useDeleteMapping } from '../api/mappings'
import { CATEGORY_COLORS } from '../lib/constants'
import type { AppMapping } from '../types'
const MATCH_TYPES = ['ProcessName', 'TitleContains', 'UrlContains'] as const
const MATCH_TYPE_COLORS: Record<string, string> = {
ProcessName: '#6366f1',
TitleContains: '#06b6d4',
UrlContains: '#f97316',
}
interface FormData {
pattern: string
matchType: string
category: string
friendlyName: string
}
const emptyForm: FormData = { pattern: '', matchType: 'ProcessName', category: '', friendlyName: '' }
function formFromMapping(m: AppMapping): FormData {
return {
pattern: m.pattern,
matchType: m.matchType,
category: m.category,
friendlyName: m.friendlyName ?? '',
}
}
export default function Mappings() {
const { data: mappings, isLoading } = useMappings()
const createMapping = useCreateMapping()
const updateMapping = useUpdateMapping()
const deleteMapping = useDeleteMapping()
const [addingNew, setAddingNew] = useState(false)
const [newForm, setNewForm] = useState<FormData>(emptyForm)
const [editingId, setEditingId] = useState<number | null>(null)
const [editForm, setEditForm] = useState<FormData>(emptyForm)
function handleAddSave() {
if (!newForm.pattern.trim() || !newForm.category.trim()) return
createMapping.mutate(
{
pattern: newForm.pattern.trim(),
matchType: newForm.matchType,
category: newForm.category.trim(),
friendlyName: newForm.friendlyName.trim() || undefined,
},
{
onSuccess: () => {
setAddingNew(false)
setNewForm(emptyForm)
},
},
)
}
function handleAddCancel() {
setAddingNew(false)
setNewForm(emptyForm)
}
function handleEditStart(mapping: AppMapping) {
setEditingId(mapping.id)
setEditForm(formFromMapping(mapping))
// Cancel any add-new row when starting an edit
setAddingNew(false)
}
function handleEditSave() {
if (editingId === null) return
if (!editForm.pattern.trim() || !editForm.category.trim()) return
updateMapping.mutate(
{
id: editingId,
pattern: editForm.pattern.trim(),
matchType: editForm.matchType,
category: editForm.category.trim(),
friendlyName: editForm.friendlyName.trim() || undefined,
},
{
onSuccess: () => {
setEditingId(null)
setEditForm(emptyForm)
},
},
)
}
function handleEditCancel() {
setEditingId(null)
setEditForm(emptyForm)
}
function handleDelete(id: number) {
if (!window.confirm('Delete this mapping rule?')) return
deleteMapping.mutate(id)
}
const inputClass =
'bg-[var(--color-page)] text-[var(--color-text-primary)] text-sm rounded border border-[var(--color-border)] px-2 py-1.5 focus:outline-none focus:border-[var(--color-accent)] transition-colors w-full'
const selectClass =
'bg-[var(--color-page)] text-[var(--color-text-primary)] text-sm rounded border border-[var(--color-border)] px-2 py-1.5 focus:outline-none focus:border-[var(--color-accent)] transition-colors appearance-none cursor-pointer w-full'
function renderFormRow(form: FormData, setForm: (f: FormData) => void, onSave: () => void, onCancel: () => void, isSaving: boolean) {
return (
<tr className="bg-white/[0.04]">
<td className="px-4 py-3">
<input
type="text"
placeholder="Pattern..."
value={form.pattern}
onChange={(e) => setForm({ ...form, pattern: e.target.value })}
className={inputClass}
autoFocus
/>
</td>
<td className="px-4 py-3">
<select
value={form.matchType}
onChange={(e) => setForm({ ...form, matchType: e.target.value })}
className={selectClass}
>
{MATCH_TYPES.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</td>
<td className="px-4 py-3">
<input
type="text"
placeholder="Category..."
value={form.category}
onChange={(e) => setForm({ ...form, category: e.target.value })}
className={inputClass}
/>
</td>
<td className="px-4 py-3">
<input
type="text"
placeholder="Friendly name (optional)"
value={form.friendlyName}
onChange={(e) => setForm({ ...form, friendlyName: e.target.value })}
className={inputClass}
/>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1">
<button
onClick={onSave}
disabled={isSaving}
className="p-1.5 rounded text-emerald-400 hover:bg-emerald-400/10 transition-colors disabled:opacity-50"
title="Save"
>
<Check size={16} />
</button>
<button
onClick={onCancel}
className="p-1.5 rounded text-[var(--color-text-secondary)] hover:bg-white/5 transition-colors"
title="Cancel"
>
<X size={16} />
</button>
</div>
</td>
</tr>
)
}
return (
<div className="max-w-6xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold text-[var(--color-text-primary)]">App Mappings</h1>
<button
onClick={() => {
setAddingNew(true)
setEditingId(null)
setNewForm(emptyForm)
}}
disabled={addingNew}
className="flex items-center gap-1.5 bg-gradient-to-r from-[var(--color-accent)] to-[var(--color-accent-end)] hover:brightness-110 disabled:opacity-50 text-white text-sm font-medium px-3 py-1.5 rounded-lg transition-all"
>
<Plus size={16} />
Add Rule
</button>
</div>
{/* Table */}
{isLoading ? (
<div className="text-[var(--color-text-secondary)] text-sm py-12 text-center">Loading mappings...</div>
) : !mappings?.length && !addingNew ? (
<div className="bg-[var(--color-surface)] rounded-xl border border-[var(--color-border)] p-12 text-center">
<Link size={40} className="text-[var(--color-text-tertiary)] mx-auto mb-3" />
<p className="text-[var(--color-text-secondary)] text-sm mb-3">No mappings configured</p>
<button
onClick={() => {
setAddingNew(true)
setNewForm(emptyForm)
}}
className="text-[var(--color-accent)] hover:brightness-110 text-sm font-medium transition-all"
>
+ Add your first mapping rule
</button>
</div>
) : (
<div className="bg-[var(--color-surface)] rounded-xl border border-[var(--color-border)] overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-[var(--color-surface)] border-b border-[var(--color-border)]">
<th className="text-left px-4 py-3 text-[10px] uppercase tracking-wider text-[var(--color-text-tertiary)] font-medium">Pattern</th>
<th className="text-left px-4 py-3 text-[10px] uppercase tracking-wider text-[var(--color-text-tertiary)] font-medium">Match Type</th>
<th className="text-left px-4 py-3 text-[10px] uppercase tracking-wider text-[var(--color-text-tertiary)] font-medium">Category</th>
<th className="text-left px-4 py-3 text-[10px] uppercase tracking-wider text-[var(--color-text-tertiary)] font-medium">Friendly Name</th>
<th className="text-left px-4 py-3 text-[10px] uppercase tracking-wider text-[var(--color-text-tertiary)] font-medium w-24">Actions</th>
</tr>
</thead>
<tbody>
{/* Add-new row */}
{addingNew &&
renderFormRow(newForm, setNewForm, handleAddSave, handleAddCancel, createMapping.isPending)}
{/* Data rows */}
{mappings?.map((m) =>
editingId === m.id ? (
renderFormRow(editForm, setEditForm, handleEditSave, handleEditCancel, updateMapping.isPending)
) : (
<tr
key={m.id}
className="border-b border-[var(--color-border)] hover:bg-white/[0.03] transition-colors"
>
<td className="px-4 py-3 text-[var(--color-text-primary)] font-mono text-xs">{m.pattern}</td>
<td className="px-4 py-3">
<span
className="inline-block text-xs font-medium px-2 py-0.5 rounded-full"
style={{
backgroundColor: `${MATCH_TYPE_COLORS[m.matchType] ?? '#64748b'}20`,
color: MATCH_TYPE_COLORS[m.matchType] ?? '#64748b',
}}
>
{m.matchType}
</span>
</td>
<td className="px-4 py-3">
<span className="inline-flex items-center gap-1.5 text-[var(--color-text-primary)] text-xs">
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: CATEGORY_COLORS[m.category] ?? '#64748b' }}
/>
{m.category}
</span>
</td>
<td className="px-4 py-3 text-[var(--color-text-secondary)]">
{m.friendlyName ?? '\u2014'}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1">
<button
onClick={() => handleEditStart(m)}
className="p-1.5 rounded text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-white/5 transition-colors"
title="Edit"
>
<Pencil size={14} />
</button>
<button
onClick={() => handleDelete(m.id)}
className="p-1.5 rounded text-[var(--color-text-secondary)] hover:text-rose-400 hover:bg-rose-400/10 transition-colors"
title="Delete"
>
<Trash2 size={14} />
</button>
</div>
</td>
</tr>
),
)}
</tbody>
</table>
</div>
)}
</div>
)
}