diff --git a/src/components/TicketProjects.jsx b/src/components/TicketProjects.jsx index 7d94978..e1c395e 100644 --- a/src/components/TicketProjects.jsx +++ b/src/components/TicketProjects.jsx @@ -1,28 +1,13 @@ import { useState, useEffect, useCallback } from 'react' import { FaPlus, FaLink } from 'react-icons/fa6' import { useWebsiteProjects } from '../hooks/useWebsiteProjects' -import { createProjectFromTemplate, importNewRepos } from '../lib/projectAdminApi' -import { isOutsideOrg, assignSummary } from '../lib/projectAssign' +import { createProjectFromTemplate, importNewReposThrottled } from '../lib/projectAdminApi' +import { isOutsideOrg, assignSummary, assignableProjects } from '../lib/projectAssign' +import { slugify } from '../lib/subdomain' import AssignedProjectCard from './AssignedProjectCard' import { fetchServices, completeAssignment } from '../lib/projectKindsApi' import { kindLabelFor } from '../lib/assignFlow' -function slugify(v) { - return String(v || '').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 100) -} - -// Zuordnen lassen sich echte Repos des gleichen Kunden oder ohne Kunden, die nicht -// archiviert, keine Vorlage und noch nicht an diesem Ticket sind -function assignableProjects(all, ticket, assignedIds) { - return (all || []).filter((p) => - p.repoFullName && - !assignedIds.has(p.$id) && - p.status !== 'archived' && - !p.isTemplate && - (!p.customerId || p.customerId === ticket.customerId) - ) -} - // Projekte am Ticket: Liste, Zuweisen, Loesen und neues Projekt an einer Stelle. // Loesen nimmt das Projekt nur vom Ticket; die Kundenzuordnung (Sichtbarkeit im Portal) bleibt. export default function TicketProjects({ ticket }) { @@ -57,7 +42,7 @@ export default function TicketProjects({ ticket }) { setImporting(true) setImportHint('') try { - await importNewRepos() + await importNewReposThrottled() } catch (err) { if (!cancelled) setImportHint(`Neue Repos konnten nicht geladen werden (${err.message}).`) } finally { @@ -73,10 +58,11 @@ export default function TicketProjects({ ticket }) { setError('') try { await action() - await load() } catch (err) { setError(err.message || 'Aktion fehlgeschlagen') } finally { + // Auch nach einem Fehler neu laden: das Repo kann trotzdem entstanden sein + try { await load() } catch { /* Anzeige bleibt wie sie ist */ } setBusy(false) } } @@ -149,6 +135,10 @@ export default function TicketProjects({ ticket }) { const create = (e) => { e.preventDefault() + if (!slugify(createForm.subdomain)) { + setError('Bitte eine gültige Subdomain eingeben (Buchstaben, Ziffern, Bindestrich).') + return + } run(async () => { const subdomain = slugify(createForm.subdomain) await createProjectFromTemplate({ @@ -157,6 +147,8 @@ export default function TicketProjects({ ticket }) { subdomain, customerId: ticket.customerId || '', ticketId: ticket.$id, + // Aus der Website-Vorlage mit Subdomain: die Art steht fest (sonst "Art fehlt") + kind: 'website', }) setCreateForm({ displayName: '', subdomain: '' }) setShowCreate(false) diff --git a/src/hooks/useWebsiteProjects.js b/src/hooks/useWebsiteProjects.js index 1241fc1..0992339 100644 --- a/src/hooks/useWebsiteProjects.js +++ b/src/hooks/useWebsiteProjects.js @@ -59,5 +59,18 @@ export function useWebsiteProjects() { } } - return { projects, loading, error, fetchAllProjects, fetchByTicketId, assignProjects, unassignProject } + // Projektliste "Kein Kunde": Kunde UND Ticket entfernen (bewusste Auswahl, mit Rueckfrage) + const clearCustomer = async (projectId) => { + try { + await databases.updateDocument( + DATABASE_ID, COLLECTIONS.WEBSITE_PROJECTS, projectId, + { customerId: '', ticketId: '', updatedAt: new Date().toISOString() } + ) + return { success: true } + } catch (err) { + return { success: false, error: err.message } + } + } + + return { projects, loading, error, fetchAllProjects, fetchByTicketId, assignProjects, unassignProject, clearCustomer } } diff --git a/src/lib/projectAdminApi.js b/src/lib/projectAdminApi.js index 37ef772..fc8f06f 100644 --- a/src/lib/projectAdminApi.js +++ b/src/lib/projectAdminApi.js @@ -1,4 +1,5 @@ import { account } from './appwrite' +import { throttleRuns } from './throttleRuns' const PROJECT_ADMIN_URL = import.meta.env.VITE_PROJECT_ADMIN_URL || 'https://project.webklar.com' @@ -33,6 +34,11 @@ export async function importNewRepos() { return adminFetch('/api/admin/gitea/import-repos', { method: 'POST' }) } +// Jedes Aufklappen eines Tickets fragte frueher alle Gitea-Repos ab (serverseitig unter einer +// globalen Sperre). Neue Repos kommen ohnehin per Webhook und 10-Minuten-Lauf - hier hoechstens +// ein Abgleich pro Minute; ein laufender Abgleich wird geteilt. null = uebersprungen. +export const importNewReposThrottled = throttleRuns(() => importNewRepos(), 60000) + export async function assignWebsiteProject(id, { customerId, ticketId }) { return adminFetch(`/api/admin/website-projects/${id}/assign`, { method: 'POST', diff --git a/src/lib/projectAssign.js b/src/lib/projectAssign.js index 9f0b7f9..dfc28de 100644 --- a/src/lib/projectAssign.js +++ b/src/lib/projectAssign.js @@ -6,8 +6,34 @@ export function isOutsideOrg(repoFullName) { return org.toLowerCase() !== GITEA_ORG.toLowerCase() } +// "Preview einschalten" nur, solange das Projekt noch keine Website hat. Mit Subdomain laeuft die +// Preview schon oder wird gerade gebaut (der Runner setzt 'deployed' erst danach) - Neubauen +// geht dann ueber den Preview-Block. Archivierte Projekte erst reaktivieren. export function needsPreviewButton(project) { - return !project.previewUrl || project.status !== 'deployed' + if (project.archived) return false + return !project.subdomain || !project.previewUrl +} + +// Zuordnen lassen sich echte Repos des gleichen Kunden oder ohne Kunden, die nicht archiviert, +// keine Vorlage und noch nicht an diesem Ticket sind. Massgeblich ist das Flag `archived` +// (nicht status): nach dem Reaktivieren blieb status frueher auf 'archived' stehen. +export function assignableProjects(all, ticket, assignedIds) { + return (all || []).filter((p) => + p.repoFullName && + !assignedIds.has(p.$id) && + !p.archived && + !p.isTemplate && + (!p.customerId || p.customerId === ticket.customerId) + ) +} + +// Kundenwechsel in der Projektliste: das bisherige Ticket nur behalten, wenn es dem neuen Kunden +// gehoert - sonst haengt das Projekt von Kunde B am Ticket von Kunde A. +export function ticketIdForCustomer(project, customerId, workorderById = {}) { + const ticketId = project?.ticketId || '' + if (!ticketId) return '' + if (customerId === project.customerId) return ticketId + return workorderById[ticketId]?.customerId === customerId ? ticketId : '' } export function assignSummary(projectName, result) { diff --git a/src/lib/projectList.js b/src/lib/projectList.js new file mode 100644 index 0000000..53fac4f --- /dev/null +++ b/src/lib/projectList.js @@ -0,0 +1,72 @@ +// Ansichten der Projektliste (Projekte-Seite). Jedes Gitea-Repo ist ein Projekt; "Aktive Projekte" +// blendet Archiv und Vorlagen aus, "Alle inkl. Archiv" zeigt wirklich alles (Vorlagen nur Admins). + +const active = (p) => !p.archived && !p.isTemplate + +export function projectCounts(projects, { isAdmin = false } = {}) { + const list = projects || [] + const act = list.filter(active) + return { + all: act.length, + unassigned: act.filter((p) => !p.customerId).length, + assigned: act.filter((p) => Boolean(p.customerId)).length, + archived: list.filter((p) => p.archived && (isAdmin || !p.isTemplate)).length, + templates: list.filter((p) => p.isTemplate && !p.archived).length, + everything: list.filter((p) => isAdmin || !p.isTemplate).length, + } +} + +export function projectsInView(projects, filter, { isAdmin = false } = {}) { + const list = projects || [] + if (filter === 'templates') return list.filter((p) => p.isTemplate && !p.archived) + if (filter === 'archived') return list.filter((p) => p.archived && (isAdmin || !p.isTemplate)) + if (filter === 'everything') return list.filter((p) => isAdmin || !p.isTemplate) + const act = list.filter(active) + if (filter === 'unassigned') return act.filter((p) => !p.customerId) + if (filter === 'assigned') return act.filter((p) => Boolean(p.customerId)) + return act +} + +export const VIEW_LABELS = { all: 'Aktive Projekte', templates: 'Vorlagen', archived: 'Archiviert', everything: 'Alle inkl. Archiv' } + +// Ansichten, die ein Projekt zeigen koennen - die Vorlagen-Ansicht gibt es nur fuer Admins +const viewsFor = (isAdmin) => ['all', ...(isAdmin ? ['templates'] : []), 'archived', 'everything'] + +/** + * Ansicht, in der ein Projekt (z. B. nach dem Bearbeiten) zu sehen ist: die aktuelle, wenn es dort + * steht, sonst die erste passende. Ohne passende Ansicht bleibt es bei der aktuellen. + * Hintergrund: SHK-vorlage zur Vorlage gemacht -> verschwand kommentarlos aus "Aktive Projekte". + */ +export function viewShowing(project, filter, { isAdmin = false } = {}) { + if (!project) return filter + for (const view of [filter, ...viewsFor(isAdmin)]) { + if (view === 'templates' && !isAdmin) continue + if (projectsInView([project], view, { isAdmin }).length) return view + } + return filter +} + +/** Textsuche ueber Titel, Subdomain und Repo (leer passt immer). */ +export function matchesSearch(project, search) { + const q = String(search || '').trim().toLowerCase() + if (!q) return true + return [project.projectName, project.subdomain, project.repoFullName, project.giteaRepoName].some((v) => + String(v || '').toLowerCase().includes(q) + ) +} + +/** + * Treffer, die die aktuelle Ansicht ausblendet (Vorlagen nur fuer Admins, Archiv), je Ansicht gezaehlt. + * `matches` ist der Filter der Seite (Suche, Kunde, Mitarbeiter). + */ +export function hiddenMatches(projects, filter, matches, { isAdmin = false } = {}) { + if (filter === 'everything') return [] + const shown = new Set(projectsInView(projects, filter, { isAdmin }).map((p) => p.$id)) + const out = [] + for (const view of [...(isAdmin ? ['templates'] : []), 'archived']) { + if (view === filter) continue + const count = projectsInView(projects, view, { isAdmin }).filter((p) => !shown.has(p.$id) && matches(p)).length + if (count) out.push({ view, count }) + } + return out +} diff --git a/src/lib/subdomain.js b/src/lib/subdomain.js index 0a5b2bd..8237d28 100644 --- a/src/lib/subdomain.js +++ b/src/lib/subdomain.js @@ -5,18 +5,21 @@ const UMLAUT_MAP = { ß: 'ss', } +// Subdomain/Repo-Kuerzel wie im Portal (slug.js): Umlaute ausgeschrieben statt "m-ller", +// hoechstens 63 Zeichen (Laenge eines DNS-Labels), ohne Bindestrich am Rand. +export function slugify(value) { + let v = String(value ?? '').trim().toLowerCase() + if (!v) return '' + v = v.replace(/[äöüß]/g, (ch) => UMLAUT_MAP[ch]) + v = v.normalize('NFD').replace(/[\u0300-\u036f]/g, '') + v = v.replace(/['’‘`]/g, '') + v = v.replace(/[^a-z0-9]+/g, '-') + v = v.replace(/^-+|-+$/g, '') + v = v.slice(0, 63) + return v.replace(/-+$/g, '') +} + export function suggestSubdomain(customerName, projectName) { const base = (customerName || '').trim() || (projectName || '').trim() - if (!base) return '' - - let value = base.toLowerCase() - value = value.replace(/[äöüß]/g, (ch) => UMLAUT_MAP[ch]) - value = value.normalize('NFD').replace(/[̀-ͯ]/g, '') - value = value.replace(/['’‘`]/g, '') - value = value.replace(/[^a-z0-9]+/g, '-') - value = value.replace(/^-+|-+$/g, '') - value = value.slice(0, 63) - value = value.replace(/-+$/g, '') - - return value + return slugify(base) } diff --git a/src/lib/throttleRuns.js b/src/lib/throttleRuns.js new file mode 100644 index 0000000..537a716 --- /dev/null +++ b/src/lib/throttleRuns.js @@ -0,0 +1,19 @@ +// Ein Aufruf hoechstens alle `maxAgeMs`: ein laufender Aufruf wird geteilt, danach liefert die +// Funktion bis zum Ablauf null (uebersprungen). Nach einem Fehler darf sofort neu versucht +// werden (sonst verschwand der Hinweis und der Abgleich fiel eine Minute lang still aus). +// `now` nur fuer Tests. +export function throttleRuns(run, maxAgeMs) { + let pending = null + let doneAt = 0 + return ({ now = Date.now() } = {}) => { + if (pending) return pending + if (doneAt && now - doneAt < maxAgeMs) return Promise.resolve(null) + pending = Promise.resolve() + .then(run) + .then( + (result) => { doneAt = Date.now(); pending = null; return result }, + (err) => { doneAt = 0; pending = null; throw err } + ) + return pending + } +} diff --git a/src/pages/AssignProjectPage.jsx b/src/pages/AssignProjectPage.jsx index 2128f03..3731c9e 100644 --- a/src/pages/AssignProjectPage.jsx +++ b/src/pages/AssignProjectPage.jsx @@ -6,15 +6,13 @@ import { useAuth } from '../context/AuthContext' import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite' import { fetchServices, completeAssignment, fetchProject } from '../lib/projectKindsApi' import { nextStep, prevStep, newTicketDefaults, canFinish, openTicketsFor } from '../lib/assignFlow' -import { suggestSubdomain } from '../lib/subdomain' +import { suggestSubdomain, slugify } from '../lib/subdomain' import { isArchived } from '../lib/leads' import { PageHeader, Card, Button, Field } from '../components/ui' import { CreateCustomerForm } from './CustomersPage' const todayIso = () => new Date().toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' }) const fmtDate = (iso) => (iso ? new Date(iso).toLocaleDateString('de-DE') : '') -const slugify = (v) => - String(v || '').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 63) const STEP_TITLES = { kind: 'Was ist das?', @@ -95,11 +93,15 @@ export default function AssignProjectPage() { const openTickets = useMemo(() => openTicketsFor(tickets, state.customerId), [tickets, state.customerId]) const isWebsiteWithCustomer = state.kind === 'website' && state.forCustomer + // Hat das Projekt schon eine Website (z. B. ueber "Neues Projekt" mit Subdomain angelegt), bleibt + // sie wie sie ist: kein Vorschlag, kein erneutes Einschalten (das benannte sie sonst um und + // stellte eine oeffentliche Website auf Login-Preview). + const hasPreviewAlready = Boolean(project?.subdomain) useEffect(() => { - if (state.step === 'summary' && isWebsiteWithCustomer && !subdomain) { + if (state.step === 'summary' && isWebsiteWithCustomer && !hasPreviewAlready && !subdomain) { setSubdomain(suggestSubdomain('', repoName)) } - }, [state.step, isWebsiteWithCustomer, subdomain, repoName]) + }, [state.step, isWebsiteWithCustomer, hasPreviewAlready, subdomain, repoName]) const patch = (p) => setState((s) => ({ ...s, ...p })) const go = (step) => { setError(null); patch({ step }) } @@ -141,7 +143,7 @@ export default function AssignProjectPage() { const { topic, requestedBy, startDate, details } = state.newTicket body.newTicket = { topic, requestedBy, startDate, details } } - if (isWebsiteWithCustomer && previewOn && subdomain.trim()) { + if (isWebsiteWithCustomer && !hasPreviewAlready && previewOn && subdomain.trim()) { body.preview = { subdomain: slugify(subdomain) } } } @@ -353,7 +355,14 @@ export default function AssignProjectPage() { )} - {isWebsiteWithCustomer && ( + {isWebsiteWithCustomer && hasPreviewAlready && ( +
+ {['deployed', 'ready'].includes(String(project.status || '').toLowerCase()) + ? <>Preview läuft bereits unter https://{project.subdomain}.project.webklar.com und bleibt unverändert.> + : <>Preview ist unter https://{project.subdomain}.project.webklar.com eingerichtet (Status: {project.status || 'unbekannt'}). Neu bauen geht im Projekt über „Preview".>} +
+ )} + {isWebsiteWithCustomer && !hasPreviewAlready && (