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:
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user