From 860079f51d9ba2bf65e4c4869ce99a111b2f27d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 10:28:55 +0000 Subject: [PATCH] Projekte: Liste als Tabelle mit Reitern, Neu- und Bearbeiten-Dialog, Laden nur beim ersten Mal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Seite mit PageHeader, Reitern (Zaehler aus projectTabs), Filterzeile und Tabelle (Rollen table/row/cell/columnheader, Zeilenklick ueber isRowActivation) - Neues Projekt und Projekt bearbeiten als Dialog mit Field/htmlFor; Speichern im Fuss ueber das Attribut form - Ladetext "Wird geladen …" nur beim ersten Laden (loadedOnce), danach bleibt die Tabelle beim Nachladen stehen - global.css: Ticket-Zelle bricht lange IDs um, .faint in archivierten Zeilen ohne Kontrastverlust (Block Projekte (P5)) Co-Authored-By: Claude Sonnet 5.5 --- src/pages/ProjectsPage.jsx | 801 +++++++++++++++---------------------- src/styles/global.css | 2 + 2 files changed, 334 insertions(+), 469 deletions(-) diff --git a/src/pages/ProjectsPage.jsx b/src/pages/ProjectsPage.jsx index 50d92a8..34f1c11 100644 --- a/src/pages/ProjectsPage.jsx +++ b/src/pages/ProjectsPage.jsx @@ -1,18 +1,9 @@ import { useState, useEffect, useMemo, useCallback } from 'react' -import { useNavigate } from 'react-router-dom' +import { Link, useNavigate } from 'react-router-dom' import PreviewBlock from '../components/PreviewBlock' -import { - FaFolder, - FaPlus, - FaArrowsRotate, - FaPen, - FaBoxArchive, - FaBoxOpen, - FaGlobe, - FaLock, - FaCircleInfo, - FaClone, -} from 'react-icons/fa6' +import PreviewLinkButton from '../components/PreviewLinkButton' +import GiteaLinkButton from '../components/GiteaLinkButton' +import { PageHeader, Tabs, Button, Badge, EmptyState, Field, Dialog } from '../components/ui' import { useWebsiteProjects } from '../hooks/useWebsiteProjects' import { useCustomers } from '../hooks/useCustomers' import { useWorkorders } from '../hooks/useWorkorders' @@ -27,70 +18,22 @@ import { importNewReposThrottled, } from '../lib/projectAdminApi' import { assignSummary, ticketIdForCustomer } from '../lib/projectAssign' -import { projectCounts, projectsInView, viewShowing, matchesSearch, hiddenMatches, VIEW_LABELS } from '../lib/projectList' +import { projectCounts, projectsInView, projectTabs, viewShowing, matchesSearch, hiddenMatches, VIEW_LABELS } from '../lib/projectList' +import { PROJECT_TYPES, typeOf, projectActions, projectTicketRef, updatedLabel } from '../lib/projectRow' +import { ticketPath } from '../lib/ticketRoutes' +import { isRowActivation } from '../lib/rowClick' import { slugify } from '../lib/subdomain' -import PreviewLinkButton from '../components/PreviewLinkButton' -import GiteaLinkButton from '../components/GiteaLinkButton' const WORKORDER_FILTERS = { limit: 500 } -// Projekt-Typen (= "Template"/Hosting-Modus) -const TYPES = { - preview: { - label: 'Preview (mit Login)', - short: 'Preview', - badge: 'badge-info', - icon: , - hint: 'Login-geschuetzte Vorschau unter .project.webklar.com', - }, - website: { - label: 'Oeffentliche Website', - short: 'Website', - badge: 'badge-ok', - icon: , - hint: 'Oeffentlich erreichbar ohne Login unter .project.webklar.com', - }, - template: { - label: 'Vorlage', - short: 'Vorlage', - badge: 'badge-warn', - icon: , - hint: 'Website-Vorlage (login-geschuetzt) - erscheint nur fuer Admins in der Vorlagen-Ansicht', - adminOnly: true, - }, - project: { - label: 'Internes Projekt (kein Hosting)', - short: 'Projekt', - badge: 'badge-muted', - icon: , - hint: 'Nur Gitea-Repository, keine Website/Subdomain', - }, -} - -function typeOf(project) { - if (project.isTemplate) return 'template' - if (project.isPublic) return 'website' - if (project.subdomain) return 'preview' - return 'project' -} - -// Typen mit Hosting (Subdomain + Deploy) -const HOSTED_TYPES = new Set(['preview', 'website', 'template']) - -// Gleiche Logik wie isPreviewProjectReady im Backend (auth.js): -// nur 'ready'/'deployed' gelten als tatsaechlich gehostet. -function isProjectReady(project) { - const st = String(project.provisioningStatus || project.status || '').toLowerCase() - return ['ready', 'deployed'].includes(st) -} - export default function ProjectsPage() { const navigate = useNavigate() - const { projects, loading, error, fetchAllProjects, assignProjects, clearCustomer } = useWebsiteProjects() + const { projects, error, fetchAllProjects, assignProjects, clearCustomer } = useWebsiteProjects() const { customers } = useCustomers() const { workorders } = useWorkorders(WORKORDER_FILTERS) const { employees } = useEmployees() const { isAdmin } = useAuth() + const [loadedOnce, setLoadedOnce] = useState(false) const [filter, setFilter] = useState('all') const [search, setSearch] = useState('') const [customerFilter, setCustomerFilter] = useState('') @@ -121,21 +64,13 @@ export default function ProjectsPage() { // sonst fehlte ein frisch angelegtes Repo hier bis zum naechsten 10-Minuten-Lauf. useEffect(() => { let cancelled = false - fetchAllProjects() + fetchAllProjects().then(() => { if (!cancelled) setLoadedOnce(true) }) importNewReposThrottled() .then((r) => { if (r && !cancelled) fetchAllProjects() }) .catch(() => { /* Import-Fehler zeigt die Ticketkarte; die Liste bleibt wie sie ist */ }) return () => { cancelled = true } }, [fetchAllProjects]) - const customerMap = useMemo(() => { - const map = {} - for (const c of customers) { - map[c.$id] = c.name || c.code || c.$id - } - return map - }, [customers]) - const ticketMap = useMemo(() => { const map = {} for (const wo of workorders) { @@ -355,199 +290,57 @@ export default function ProjectsPage() { } } - const FILTER_OPTIONS = [ - { value: 'all', label: `Aktive Projekte (${counts.all})` }, - { value: 'unassigned', label: `Unzugeordnet (${counts.unassigned})` }, - { value: 'assigned', label: `Zugeordnet (${counts.assigned})` }, - // Vorlagen-Ansicht nur fuer Admins - ...(isAdmin ? [{ value: 'templates', label: `Vorlagen (${counts.templates})` }] : []), - { value: 'archived', label: `Archiviert (${counts.archived})` }, - { value: 'everything', label: `Alle inkl. Archiv (${counts.everything})` }, - ] + // Typ-Optionen im Bearbeiten-Dialog: adminOnly-Typen (Vorlage) nur fuer Admins + const typeOptions = Object.entries(PROJECT_TYPES).filter(([, meta]) => isAdmin || !meta.adminOnly) - // Typ-Optionen im Bearbeiten-Modal: adminOnly-Typen (Vorlage) nur fuer Admins - const typeOptions = Object.entries(TYPES).filter(([, meta]) => isAdmin || !meta.adminOnly) - - return ( -
-
-

Website-Projekte

-
- -
- setSearch(e.target.value)} - style={{ flex: '1 1 220px', minWidth: '180px', maxWidth: '320px' }} - /> - - - - - -
- - {notice && ( -
- {notice} -
- )} - - {hiddenHits.length > 0 && ( -
- Weitere Treffer: - {hiddenHits.map((h, i) => ( - - {i > 0 && · } - - - ))} -
- )} - - {error && ( -
- {error} -
- )} - - {loading ? ( -

Projekte werden geladen...

- ) : filteredProjects.length === 0 ? ( -
- -

- {filter === 'archived' ? 'Keine archivierten Projekte.' : 'Keine Projekte gefunden.'} -

-
- ) : ( -
+ let content + if (!loadedOnce) { + // Nur beim ersten Laden: spaeter bleibt die Tabelle beim Nachladen stehen + content =
Wird geladen …
+ } else if (filteredProjects.length === 0) { + content = + } else { + content = ( + + + + + + + + + + + + + + {filteredProjects.map((project) => { - const type = typeOf(project) - const t = TYPES[type] - const hosted = HOSTED_TYPES.has(type) - const ready = isProjectReady(project) - const needsHosting = hosted && !ready && !project.archived + const actions = projectActions(project) + const archived = Boolean(project.archived) + const type = PROJECT_TYPES[actions.type] + const name = project.projectName || project.giteaRepoName || '(ohne Name)' + const ticketRef = projectTicketRef(project, ticketMap) + const updated = updatedLabel(project.$updatedAt) return ( -
{ if (isRowActivation(e)) openEdit(project) }} > -
-
-
- {project.projectName || project.giteaRepoName || '(ohne Name)'} -
- {project.repoFullName && ( -
- {project.repoFullName} -
- )} +
+ + + + + + + + ) })} - + +
ProjektKundeArtAdresseTicketStatusZuletzt geändertAktionen
+
+
-
- - {t.icon} {t.short} - - {hosted && !project.archived && ( - - {ready ? 'gehostet' : 'nicht deployt'} - - )} -
- - -
- - {project.subdomain - ? {project.subdomain} - : ohne Website} - - - {project.ticketId ? (ticketMap[project.ticketId] || project.ticketId) : -} - - - {project.archived ? 'archiviert' : (project.status || '-')} - -
- -
- + {project.repoFullName &&
{project.repoFullName}
} +
- - -
- - {needsHosting ? ( - - ) : ( - - )} - {hosted && ready && !project.archived && ( - - )} - -
- +
+ {type.short} + + {project.subdomain + ? {project.subdomain} + : ohne Website} + + {ticketRef + ? {ticketRef} + : -} + + {actions.hostedBadge && {actions.hostedBadge.text}} +
{archived ? 'archiviert' : (project.status || '-')}
+
+ {updated.title ? updated.text : {updated.text}} + +
+ {actions.host && ( + + )} + {actions.preview && } + {actions.refresh && ( + + )} + {actions.gitea && } +
+
+ ) + } + + return ( +
+ + + + + } + /> + + + +
+ setSearch(e.target.value)} + /> + + +
+ + {notice &&

{notice}

} + + {hiddenHits.length > 0 && ( +

+ Weitere Treffer:{' '} + {hiddenHits.map((h, i) => ( + + {i > 0 && ' · '} + + + ))} +

)} + {error &&

{error}

} + + {content} + {/* --- Neues Projekt --- */} {showCreateModal && ( -
- setShowCreateModal(false)}>× -
-

Neues Website-Projekt

- {createError && ( -
- {createError} -
- )} -
-
- - setCreateForm((p) => ({ ...p, displayName: e.target.value }))} - required - /> -
-
- - setCreateForm((p) => ({ ...p, subdomain: e.target.value }))} - placeholder="leer lassen = Projekt ohne Website" - /> - - Mit Subdomain wird die Website automatisch unter https://<subdomain>.project.webklar.com deployt. - Ohne Subdomain wird nur das Gitea-Repo als normales Projekt angelegt. - -
-
- - setCreateForm((p) => ({ ...p, repoName: e.target.value }))} - placeholder="leer = aus Anzeigename/Subdomain abgeleitet" - /> -
-
- - -
-
- -
-
-
-
+ setShowCreateModal(false)} + footer={ + <> + + + + } + > + {createError &&

{createError}

} + + setCreateForm((p) => ({ ...p, displayName: e.target.value }))} + required + /> + + + setCreateForm((p) => ({ ...p, subdomain: e.target.value }))} + placeholder="leer lassen = Projekt ohne Website" + /> + + + setCreateForm((p) => ({ ...p, repoName: e.target.value }))} + placeholder="leer = aus Anzeigename/Subdomain abgeleitet" + /> + + + + +
)} {/* --- Projekt bearbeiten --- */} {editProject && ( -
- setEditProject(null)}>× -
-

Projekt bearbeiten

-

- {editProject.repoFullName} -

- {editError && ( -
- {editError} -
- )} -
-
- - setEditForm((p) => ({ ...p, projectName: e.target.value }))} - required - /> -
- -
- - - - {TYPES[editForm.projectType].hint} - -
- - {editForm.projectType !== 'project' && ( -
- - setEditForm((p) => ({ ...p, subdomain: e.target.value }))} - placeholder="z.B. musterfirma" - /> - - Ergebnis: https://{slugify(editForm.subdomain) || ''}.project.webklar.com - {' - '}Aenderung wird neu deployt (alte Subdomain wird abgebaut). - -
- )} - -
+ setEditProject(null)} + footer={ + <> + {editProject.archived ? ( - -
- {editProject.archived && ( - - Archiviert: zum Bearbeiten oder Hosten erst reaktivieren. - + Repository archivieren + )} -
- {/* Nur mit gespeicherter Subdomain - sonst meldet der Block "keine Preview-Subdomain" */} - {editForm.projectType !== 'project' && editProject.subdomain && !editProject.archived && ( -
- - -
+ + {/* Im Fuss ausserhalb des
, verbunden ueber das Attribut form: Enter im Namensfeld speichert, im PreviewBlock nie */} + + + } + > + {editProject.repoFullName &&

{editProject.repoFullName}

} + {editError &&

{editError}

} + + + setEditForm((p) => ({ ...p, projectName: e.target.value }))} + required + /> + + + + + {editForm.projectType !== 'project' && ( + '}.project.webklar.com - Aenderung wird neu deployt (alte Subdomain wird abgebaut).`} + > + setEditForm((p) => ({ ...p, subdomain: e.target.value }))} + placeholder="z.B. musterfirma" + /> + )} -
-
+ + {editProject.archived &&

Archiviert: zum Bearbeiten oder Hosten erst reaktivieren.

} + {/* Nur mit gespeicherter Subdomain - sonst meldet der Block "keine Preview-Subdomain" */} + {editForm.projectType !== 'project' && editProject.subdomain && !editProject.archived && ( +
+

Preview

+ +
+ )} + )}
) } - -function Row({ label, children }) { - return ( -
- {label} - {children} -
- ) -} diff --git a/src/styles/global.css b/src/styles/global.css index 6448d6b..7853c44 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -1117,10 +1117,12 @@ button.link { text-align: left; } /* Reset und Gewicht von button.link stehen im .projects-table th, .projects-table td { padding-right: 8px; } .projects-table th:last-child, .projects-table td:last-child { padding-right: 0; } .projects-table .cell-title .link, .projects-table .cell-sub { overflow-wrap: anywhere; } +.projects-table td.num { min-width: 64px; overflow-wrap: anywhere; } /* Ticket ohne Eintrag in der Ticketliste zeigt die lange $id: sie bricht um, die Tabelle waechst nicht ueber den Rahmen; 64 px halten 6-stellige Nummern in einer Zeile */ .projects-table .select { width: clamp(104px, 10vw, 180px); max-width: 100%; } .projects-table td.cell-address code { display: block; max-width: 84px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; } .projects-table td.cell-updated { min-width: 70px; } .projects-table tr.is-archived td { opacity: .72; } +.projects-table tr.is-archived .faint { color: var(--text-2); } /* mit opacity .72 fiele das helle Grau von .faint unter 3:1 (Muster: .table tr.row:hover .faint) */ .row-actions { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 14px; font-size: 13px; } /* Links (a) und Textknoepfe (button) auf einer Zeile; ohne baseline sitzt ein button.link-danger in einer hoeheren Zeile mittig statt oben */ .row-actions .link { white-space: nowrap; } .projects-table .row-actions { justify-content: flex-end; }