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 { useWebsiteProjects } from '../hooks/useWebsiteProjects'
|
||||||
import { useCustomers } from '../hooks/useCustomers'
|
import { useCustomers } from '../hooks/useCustomers'
|
||||||
import { useWorkorders } from '../hooks/useWorkorders'
|
import { useWorkorders } from '../hooks/useWorkorders'
|
||||||
|
import { useEmployees } from '../hooks/useEmployees'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import {
|
import {
|
||||||
createProjectFromTemplate,
|
createProjectFromTemplate,
|
||||||
@@ -89,8 +90,12 @@ export default function ProjectsPage() {
|
|||||||
const { projects, loading, error, fetchAllProjects, assignProjects, unassignProject } = useWebsiteProjects()
|
const { projects, loading, error, fetchAllProjects, assignProjects, unassignProject } = useWebsiteProjects()
|
||||||
const { customers } = useCustomers()
|
const { customers } = useCustomers()
|
||||||
const { workorders } = useWorkorders(WORKORDER_FILTERS)
|
const { workorders } = useWorkorders(WORKORDER_FILTERS)
|
||||||
|
const { employees } = useEmployees()
|
||||||
const { isAdmin } = useAuth()
|
const { isAdmin } = useAuth()
|
||||||
const [filter, setFilter] = useState('all')
|
const [filter, setFilter] = useState('all')
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [customerFilter, setCustomerFilter] = useState('')
|
||||||
|
const [employeeFilter, setEmployeeFilter] = useState('')
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||||
const [createForm, setCreateForm] = useState({
|
const [createForm, setCreateForm] = useState({
|
||||||
displayName: '',
|
displayName: '',
|
||||||
@@ -132,6 +137,22 @@ export default function ProjectsPage() {
|
|||||||
return map
|
return map
|
||||||
}, [workorders])
|
}, [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(() => {
|
const counts = useMemo(() => {
|
||||||
// Vorlagen sind aus den normalen Ansichten ausgeblendet (eigene Ansicht, nur Admin)
|
// Vorlagen sind aus den normalen Ansichten ausgeblendet (eigene Ansicht, nur Admin)
|
||||||
const active = projects.filter((p) => !p.archived && !p.isTemplate)
|
const active = projects.filter((p) => !p.archived && !p.isTemplate)
|
||||||
@@ -145,13 +166,40 @@ export default function ProjectsPage() {
|
|||||||
}, [projects])
|
}, [projects])
|
||||||
|
|
||||||
const filteredProjects = useMemo(() => {
|
const filteredProjects = useMemo(() => {
|
||||||
if (filter === 'templates') return projects.filter((p) => p.isTemplate && !p.archived)
|
// 1) Kategorie (Dropdown)
|
||||||
if (filter === 'archived') return projects.filter((p) => p.archived)
|
let base
|
||||||
const active = projects.filter((p) => !p.archived && !p.isTemplate)
|
if (filter === 'templates') base = projects.filter((p) => p.isTemplate && !p.archived)
|
||||||
if (filter === 'unassigned') return active.filter((p) => !p.customerId)
|
else if (filter === 'archived') base = projects.filter((p) => p.archived)
|
||||||
if (filter === 'assigned') return active.filter((p) => Boolean(p.customerId))
|
else {
|
||||||
return active
|
const active = projects.filter((p) => !p.archived && !p.isTemplate)
|
||||||
}, [projects, filter])
|
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) => {
|
const handleCreate = async (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -304,10 +352,18 @@ export default function ProjectsPage() {
|
|||||||
<h2>Website-Projekte</h2>
|
<h2>Website-Projekte</h2>
|
||||||
</header>
|
</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
|
<select
|
||||||
className="form-control"
|
className="form-control"
|
||||||
style={{ width: 'auto', minWidth: '190px' }}
|
style={{ width: 'auto', minWidth: '170px' }}
|
||||||
value={filter}
|
value={filter}
|
||||||
onChange={(e) => setFilter(e.target.value)}
|
onChange={(e) => setFilter(e.target.value)}
|
||||||
>
|
>
|
||||||
@@ -315,11 +371,54 @@ export default function ProjectsPage() {
|
|||||||
<option key={o.value} value={o.value}>{o.label}</option>
|
<option key={o.value} value={o.value}>{o.label}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<button type="button" className="btn btn-teal" onClick={() => setShowCreateModal(true)}>
|
<select
|
||||||
<FaPlus /> Neues Projekt
|
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>
|
||||||
<button type="button" className="btn btn-dark" onClick={handleSyncRepos} disabled={syncLoading}>
|
<button
|
||||||
<FaArrowsRotate /> {syncLoading ? 'Synchronisiere...' : 'Gitea-Repos synchronisieren'}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user