feat: Projektseite-Toolbar - Icon-Buttons + Textsuche + Kunden-/Mitarbeiter-Filter

- Sync/Neu als Icon-Buttons (Tooltip statt Text), Sync-Icon dreht beim Laden
- Textsuche nach Titel/Subdomain/Repo
- Filter nach Kunde (Dropdown)
- Filter nach Mitarbeiter (ueber zugeordnetes Ticket -> assignedTo)
- alle Filter kombinierbar mit der bestehenden Kategorie-Auswahl

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Claude
2026-07-12 09:44:57 +00:00
parent 612063455e
commit 8855fe92ba

View File

@@ -14,6 +14,7 @@ import {
import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
import { useCustomers } from '../hooks/useCustomers'
import { useWorkorders } from '../hooks/useWorkorders'
import { useEmployees } from '../hooks/useEmployees'
import { useAuth } from '../context/AuthContext'
import {
createProjectFromTemplate,
@@ -89,8 +90,12 @@ export default function ProjectsPage() {
const { projects, loading, error, fetchAllProjects, assignProjects, unassignProject } = useWebsiteProjects()
const { customers } = useCustomers()
const { workorders } = useWorkorders(WORKORDER_FILTERS)
const { employees } = useEmployees()
const { isAdmin } = useAuth()
const [filter, setFilter] = useState('all')
const [search, setSearch] = useState('')
const [customerFilter, setCustomerFilter] = useState('')
const [employeeFilter, setEmployeeFilter] = useState('')
const [showCreateModal, setShowCreateModal] = useState(false)
const [createForm, setCreateForm] = useState({
displayName: '',
@@ -132,6 +137,22 @@ export default function ProjectsPage() {
return map
}, [workorders])
// Ticket-Dokument-ID -> Workorder (fuer Mitarbeiter-Zuordnung ueber assignedTo)
const workorderById = useMemo(() => {
const map = {}
for (const wo of workorders) map[wo.$id] = wo
return map
}, [workorders])
const sortedCustomers = useMemo(
() => [...customers].sort((a, b) => String(a.name || '').localeCompare(String(b.name || ''))),
[customers]
)
const sortedEmployees = useMemo(
() => [...employees].sort((a, b) => String(a.displayName || '').localeCompare(String(b.displayName || ''))),
[employees]
)
const counts = useMemo(() => {
// Vorlagen sind aus den normalen Ansichten ausgeblendet (eigene Ansicht, nur Admin)
const active = projects.filter((p) => !p.archived && !p.isTemplate)
@@ -145,13 +166,40 @@ export default function ProjectsPage() {
}, [projects])
const filteredProjects = useMemo(() => {
if (filter === 'templates') return projects.filter((p) => p.isTemplate && !p.archived)
if (filter === 'archived') return projects.filter((p) => p.archived)
const active = projects.filter((p) => !p.archived && !p.isTemplate)
if (filter === 'unassigned') return active.filter((p) => !p.customerId)
if (filter === 'assigned') return active.filter((p) => Boolean(p.customerId))
return active
}, [projects, filter])
// 1) Kategorie (Dropdown)
let base
if (filter === 'templates') base = projects.filter((p) => p.isTemplate && !p.archived)
else if (filter === 'archived') base = projects.filter((p) => p.archived)
else {
const active = projects.filter((p) => !p.archived && !p.isTemplate)
if (filter === 'unassigned') base = active.filter((p) => !p.customerId)
else if (filter === 'assigned') base = active.filter((p) => Boolean(p.customerId))
else base = active
}
// 2) Textsuche (Titel/Subdomain/Repo)
const q = search.trim().toLowerCase()
if (q) {
base = base.filter((p) =>
[p.projectName, p.subdomain, p.repoFullName, p.giteaRepoName].some((v) =>
String(v || '').toLowerCase().includes(q)
)
)
}
// 3) Kunde
if (customerFilter) base = base.filter((p) => p.customerId === customerFilter)
// 4) Mitarbeiter (ueber das zugeordnete Ticket -> assignedTo)
if (employeeFilter) {
base = base.filter((p) => {
const wo = p.ticketId ? workorderById[p.ticketId] : null
return wo && wo.assignedTo === employeeFilter
})
}
return base
}, [projects, filter, search, customerFilter, employeeFilter, workorderById])
const handleCreate = async (e) => {
e.preventDefault()
@@ -304,10 +352,18 @@ export default function ProjectsPage() {
<h2>Website-Projekte</h2>
</header>
<div className="text-center mb-2" style={{ display: 'flex', gap: '12px', justifyContent: 'center', flexWrap: 'wrap' }}>
<div className="mb-2" style={{ display: 'flex', gap: '10px', justifyContent: 'center', flexWrap: 'wrap', alignItems: 'center' }}>
<input
type="search"
className="form-control"
placeholder="Nach Titel suchen…"
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{ flex: '1 1 220px', minWidth: '180px', maxWidth: '320px' }}
/>
<select
className="form-control"
style={{ width: 'auto', minWidth: '190px' }}
style={{ width: 'auto', minWidth: '170px' }}
value={filter}
onChange={(e) => setFilter(e.target.value)}
>
@@ -315,11 +371,54 @@ export default function ProjectsPage() {
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
<button type="button" className="btn btn-teal" onClick={() => setShowCreateModal(true)}>
<FaPlus /> Neues Projekt
<select
className="form-control"
style={{ width: 'auto', minWidth: '150px' }}
value={customerFilter}
onChange={(e) => setCustomerFilter(e.target.value)}
title="Nach Kunde filtern"
>
<option value="">Alle Kunden</option>
{sortedCustomers.map((c) => (
<option key={c.$id} value={c.$id}>
{c.name}{c.code ? ` (${c.code})` : ''}
</option>
))}
</select>
<select
className="form-control"
style={{ width: 'auto', minWidth: '150px' }}
value={employeeFilter}
onChange={(e) => setEmployeeFilter(e.target.value)}
title="Nach Mitarbeiter filtern"
>
<option value="">Alle Mitarbeiter</option>
{sortedEmployees.map((emp) => (
<option key={emp.$id} value={emp.userId}>
{emp.displayName || emp.userId}
</option>
))}
</select>
<button
type="button"
className="btn btn-teal"
style={{ padding: '8px 12px' }}
onClick={() => setShowCreateModal(true)}
title="Neues Projekt"
aria-label="Neues Projekt"
>
<FaPlus />
</button>
<button type="button" className="btn btn-dark" onClick={handleSyncRepos} disabled={syncLoading}>
<FaArrowsRotate /> {syncLoading ? 'Synchronisiere...' : 'Gitea-Repos synchronisieren'}
<button
type="button"
className="btn btn-dark"
style={{ padding: '8px 12px' }}
onClick={handleSyncRepos}
disabled={syncLoading}
title="Gitea-Repos synchronisieren"
aria-label="Gitea-Repos synchronisieren"
>
<FaArrowsRotate style={syncLoading ? { animation: 'spin 1s linear infinite' } : undefined} />
</button>
</div>