From fdc348f06c07109c2fe853d75b4ad55c400c1f1e Mon Sep 17 00:00:00 2001 From: Z User Date: Thu, 29 Oct 2026 16:47:20 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20repos=20management=20UI=20=E2=80=94=20a?= =?UTF-8?q?dmin=20can=20add/remove=20repos,=20set=20env=20templates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ReposView: table with display name, Gitea repo, product, visibility, status, instances - RepoFormDialog: create/edit with owner, repo, displayName, description, product, isPublic switch, isActive switch, envTemplate textarea (JSON) - DeleteRepoDialog: AlertDialog with instance count check (409 → blocked) - Integration: store ViewId, sidebar nav (GitBranch icon), top-bar title, command-menu, i18n (de/en/tr) - Empty state: 'Keine Repos freigegeben' --- src/components/aos/app-shell.tsx | 2 + src/components/aos/command-menu.tsx | 2 + src/components/aos/sidebar-nav.tsx | 2 + src/components/aos/top-bar.tsx | 1 + src/components/aos/views/repos.tsx | 635 ++++++++++++++++++++++++++++ src/lib/i18n.ts | 3 + src/store/aos.ts | 1 + 7 files changed, 646 insertions(+) create mode 100644 src/components/aos/views/repos.tsx diff --git a/src/components/aos/app-shell.tsx b/src/components/aos/app-shell.tsx index 1721865..fc0e124 100755 --- a/src/components/aos/app-shell.tsx +++ b/src/components/aos/app-shell.tsx @@ -21,6 +21,7 @@ import { LicensesView } from './views/licenses' import { AgencyView } from './views/agency' import { ResellerPortalView } from './views/reseller-portal' import { InstancesView } from './views/instances' +import { ReposView } from './views/repos' import { ContractsView } from './views/contracts' import { ExpensesView } from './views/expenses' import { FinanceView } from './views/finance' @@ -100,6 +101,7 @@ export function AppShell() { {view === 'agency' && } {view === 'reseller_portal' && } {view === 'instances' && } + {view === 'repos' && } {view === 'contracts' && } {view === 'expenses' && } {view === 'finance' && } diff --git a/src/components/aos/command-menu.tsx b/src/components/aos/command-menu.tsx index 9d25743..53206de 100755 --- a/src/components/aos/command-menu.tsx +++ b/src/components/aos/command-menu.tsx @@ -28,6 +28,7 @@ import { Database, Server, Store, + GitBranch, } from 'lucide-react' import { useAos, type ViewId } from '@/store/aos' import { @@ -54,6 +55,7 @@ const VIEWS: { id: ViewId; label: string; icon: typeof LayoutDashboard; hint: st { id: 'agency', label: 'Reseller', icon: Users, hint: 'Reseller-Bewerbungen & Rabatte' }, { id: 'reseller_portal', label: 'Reseller-Portal', icon: Store, hint: 'Selbstbedienung: Kunden, Lizenzen, Tickets' }, { id: 'instances', label: 'Instanzen', icon: Server, hint: 'Tenant-Instanzen & Provisioning' }, + { id: 'repos', label: 'Repos', icon: GitBranch, hint: 'Gitea-Repository-Verwaltung' }, { id: 'contracts', label: 'Verträge', icon: FileSignature, hint: 'Werkvertrag, AVV, NDA' }, { id: 'expenses', label: 'Ausgaben', icon: Wallet, hint: 'Belastbar & DATEV' }, { id: 'finance', label: 'Finanzen', icon: Receipt, hint: 'Rechnungen & E-Rechnung' }, diff --git a/src/components/aos/sidebar-nav.tsx b/src/components/aos/sidebar-nav.tsx index 3fa62a2..b8ac3b0 100755 --- a/src/components/aos/sidebar-nav.tsx +++ b/src/components/aos/sidebar-nav.tsx @@ -30,6 +30,7 @@ import { Landmark, Server, Store, + GitBranch, } from 'lucide-react' import { useAos, type ViewId } from '@/store/aos' import { translate } from '@/lib/i18n' @@ -72,6 +73,7 @@ const NAV: { { id: 'agency', label: 'Reseller', icon: Users, permission: 'admin' }, { id: 'reseller_portal', label: 'Reseller-Portal', icon: Store, permission: 'admin' }, { id: 'instances', label: 'Instanzen', icon: Server, permission: 'admin' }, + { id: 'repos', label: 'Repos', icon: GitBranch, permission: 'admin' }, { id: 'contracts', label: 'Verträge', icon: FileSignature, permission: 'contracts:read' }, { id: 'expenses', label: 'Ausgaben', icon: Wallet, permission: 'expenses:read' }, { id: 'finance', label: 'Finanzen', icon: Receipt, permission: 'finance:read' }, diff --git a/src/components/aos/top-bar.tsx b/src/components/aos/top-bar.tsx index 6b8fa0f..c925c0b 100755 --- a/src/components/aos/top-bar.tsx +++ b/src/components/aos/top-bar.tsx @@ -31,6 +31,7 @@ const TITLES: Record = { agency: { title: 'Reseller', crumb: 'Betrieb / Reseller' }, reseller_portal: { title: 'Reseller-Portal', crumb: 'Betrieb / Reseller-Portal' }, instances: { title: 'Instanzen', crumb: 'Betrieb / Instanzen' }, + repos: { title: 'Repos', crumb: 'Betrieb / Repos' }, contracts: { title: 'Verträge', crumb: 'Betrieb / Verträge' }, expenses: { title: 'Ausgaben', crumb: 'Betrieb / Ausgaben' }, finance: { title: 'Finanzen', crumb: 'Betrieb / Finanzen' }, diff --git a/src/components/aos/views/repos.tsx b/src/components/aos/views/repos.tsx new file mode 100644 index 0000000..a9d4d17 --- /dev/null +++ b/src/components/aos/views/repos.tsx @@ -0,0 +1,635 @@ +'use client' + +import { useEffect, useState } from 'react' +import { + GitBranch, + Plus, + Pencil, + Trash2, + Loader2, + Globe, + Users, + CheckCircle2, + CircleDashed, + ExternalLink, +} from 'lucide-react' +import { useFetch } from '@/hooks/use-fetch' +import { Card } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Switch } from '@/components/ui/switch' +import { Textarea } from '@/components/ui/textarea' +import { Skeleton } from '@/components/ui/skeleton' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' +import { PageHeader, EmptyState } from '../primitives' +import { formatDate } from '@/lib/format' +import { cn } from '@/lib/utils' +import { toast } from 'sonner' + +// --------------------------------------------------------------------------- +// Types — mirror the AllowedRepo shape returned by /api/aos/repos +// --------------------------------------------------------------------------- + +interface AllowedRepo { + id: string + giteaOwner: string + giteaRepo: string + displayName: string + description: string | null + product: string + isPublic: boolean + isActive: boolean + envTemplate: string | null + createdAt: string + _count: { assignments: number; instances: number } +} + +interface ReposData { + repos: AllowedRepo[] +} + +// --------------------------------------------------------------------------- +// Main view +// --------------------------------------------------------------------------- + +export function ReposView() { + const { data, loading, refetch } = useFetch('/api/aos/repos') + const [dialogOpen, setDialogOpen] = useState(false) + const [editTarget, setEditTarget] = useState(null) + const [deleteTarget, setDeleteTarget] = useState(null) + + const openCreate = () => { + setEditTarget(null) + setDialogOpen(true) + } + + const openEdit = (repo: AllowedRepo) => { + setEditTarget(repo) + setDialogOpen(true) + } + + if (loading || !data) { + return ( +
+ + +
+ ) + } + + const { repos } = data + + return ( +
+ + + Repo hinzufügen + + } + /> + + {repos.length === 0 ? ( + + + Repo hinzufügen + + } + /> + ) : ( + + + + + Name + Gitea Repo + Produkt + Sichtbarkeit + Status + Instanzen + Aktionen + + + + {repos.map((repo) => ( + openEdit(repo)} + onDelete={() => setDeleteTarget(repo)} + /> + ))} + +
+
+ )} + + { + setDialogOpen(o) + if (!o) setEditTarget(null) + }} + repo={editTarget} + onSaved={refetch} + /> + + { + if (!o) setDeleteTarget(null) + }} + onDeleted={refetch} + /> +
+ ) +} + +// --------------------------------------------------------------------------- +// Repo row +// --------------------------------------------------------------------------- + +function RepoRow({ + repo, + onEdit, + onDelete, +}: { + repo: AllowedRepo + onEdit: () => void + onDelete: () => void +}) { + return ( + + +
+ {repo.displayName} + {repo.description && ( + + {repo.description} + + )} +
+
+ + + {repo.giteaOwner}/{repo.giteaRepo} + + + + + + {repo.product} + + + + + + + + + + {repo._count.instances} + + +
+ + +
+
+
+ ) +} + +// --------------------------------------------------------------------------- +// Badges +// --------------------------------------------------------------------------- + +function VisibilityBadge({ isPublic, assignments }: { isPublic: boolean; assignments: number }) { + if (isPublic) { + return ( + + + Public + + ) + } + return ( + + + Assigned + {assignments > 0 && ( + · {assignments} + )} + + ) +} + +function StatusBadge({ isActive }: { isActive: boolean }) { + return ( + + {isActive ? : } + {isActive ? 'Aktiv' : 'Inaktiv'} + + ) +} + +// --------------------------------------------------------------------------- +// Create / Edit dialog +// --------------------------------------------------------------------------- + +interface RepoFormValue { + giteaOwner: string + giteaRepo: string + displayName: string + description: string + product: string + isPublic: boolean + isActive: boolean + envTemplate: string +} + +const EMPTY_FORM: RepoFormValue = { + giteaOwner: '', + giteaRepo: '', + displayName: '', + description: '', + product: '', + isPublic: false, + isActive: true, + envTemplate: '', +} + +function RepoFormDialog({ + open, + onOpenChange, + repo, + onSaved, +}: { + open: boolean + onOpenChange: (o: boolean) => void + repo: AllowedRepo | null + onSaved: () => void +}) { + const isEdit = !!repo + const [form, setForm] = useState(EMPTY_FORM) + const [saving, setSaving] = useState(false) + + useEffect(() => { + if (open) { + if (repo) { + setForm({ + giteaOwner: repo.giteaOwner, + giteaRepo: repo.giteaRepo, + displayName: repo.displayName, + description: repo.description ?? '', + product: repo.product, + isPublic: repo.isPublic, + isActive: repo.isActive, + envTemplate: repo.envTemplate ?? '', + }) + } else { + setForm(EMPTY_FORM) + } + } + }, [open, repo]) + + const submit = async () => { + if (!form.giteaOwner.trim()) { toast.error('Gitea Owner eingeben'); return } + if (!form.giteaRepo.trim()) { toast.error('Gitea Repo eingeben'); return } + if (!form.displayName.trim()) { toast.error('Display Name eingeben'); return } + if (!form.product.trim()) { toast.error('Produkt eingeben'); return } + + // Validate envTemplate JSON if provided. + const env = form.envTemplate.trim() + if (env) { + try { + const parsed = JSON.parse(env) + if (typeof parsed !== 'object' || Array.isArray(parsed) || parsed === null) { + throw new Error('not an object') + } + } catch { + toast.error('Env Template ist kein gültiges JSON-Objekt') + return + } + } + + setSaving(true) + const t = toast.loading(isEdit ? 'Repo wird aktualisiert…' : 'Repo wird hinzugefügt…') + try { + if (isEdit) { + const payload: Record = { + displayName: form.displayName.trim(), + description: form.description.trim() || undefined, + isPublic: form.isPublic, + isActive: form.isActive, + envTemplate: env || undefined, + } + const r = await fetch(`/api/aos/repos/${repo!.id}`, { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }) + const res = await r.json().catch(() => ({})) + if (!r.ok) throw new Error(res.error || 'update failed') + toast.success('Repo aktualisiert', { id: t }) + } else { + const payload: Record = { + giteaOwner: form.giteaOwner.trim(), + giteaRepo: form.giteaRepo.trim(), + displayName: form.displayName.trim(), + description: form.description.trim() || undefined, + product: form.product.trim(), + isPublic: form.isPublic, + envTemplate: env || undefined, + } + const r = await fetch('/api/aos/repos', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }) + const res = await r.json().catch(() => ({})) + if (!r.ok) throw new Error(res.error || 'create failed') + toast.success('Repo hinzugefügt', { id: t }) + } + onOpenChange(false) + onSaved() + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Speichern fehlgeschlagen', { id: t }) + } finally { + setSaving(false) + } + } + + return ( + + + + {isEdit ? 'Repo bearbeiten' : 'Repo hinzufügen'} + + {isEdit + ? 'Repo-Konfiguration aktualisieren. Gitea-Owner und -Repo können nachträglich nicht geändert werden.' + : 'Erlaubt das Provisioning von Instanzen aus diesem Gitea-Repository.'} + + + +
+
+
+ + setForm((f) => ({ ...f, giteaOwner: e.target.value }))} + placeholder="dfmsolutions" + disabled={isEdit} + /> +
+
+ + setForm((f) => ({ ...f, giteaRepo: e.target.value }))} + placeholder="restov1" + disabled={isEdit} + /> +
+
+ +
+ + setForm((f) => ({ ...f, displayName: e.target.value }))} + placeholder="Pizzeria RESTO (restov1)" + /> +
+ +
+ +