Fixes: 1. useFetch: add credentials:'same-origin' — cookie wasn't being sent with API calls, causing 401 → React error boundary → 'Etwas ist schiefgelaufen' 2. Instance detail Sheet now loads correctly (cookie sent with request) New instance actions (UI + API): - Redeploy: triggers Dokploy application.redeploy (rebuilds from latest commit) - Stop: triggers Dokploy application.stop (pauses container) - Start: triggers Dokploy application.start (resumes container) - Remove: existing delete (SlideCommit confirmation) Dokploy deploy fix: - deployApplication now calls markRunning before deploy - This ensures Dokploy creates a deployment job ActionsRow redesigned: - demo_active/active: [Redeploy] [Stop] [Demo +7d] [Remove] - grace: [Start] [Remove] - failed: [Retry] [Remove] - provisioning/deleting/deleted: no actions
209 lines
7.5 KiB
TypeScript
Executable File
209 lines
7.5 KiB
TypeScript
Executable File
'use client'
|
|
|
|
import { useEffect, useState } from 'react'
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Label } from '@/components/ui/label'
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select'
|
|
import { toast } from 'sonner'
|
|
import { Loader2 } from 'lucide-react'
|
|
|
|
export interface ProjectFormValue {
|
|
id?: string
|
|
companyId: string
|
|
name: string
|
|
type: string
|
|
budgetType: string
|
|
budgetHours: string
|
|
fixedPriceEuro: string
|
|
startDate: string
|
|
dueDate: string
|
|
}
|
|
|
|
interface Props {
|
|
open: boolean
|
|
onOpenChange: (o: boolean) => void
|
|
companies: { id: string; name: string }[]
|
|
// When `project` is null the dialog is in "create" mode.
|
|
project?: ProjectFormValue | null
|
|
onSaved: () => void
|
|
}
|
|
|
|
const TYPES = [
|
|
{ value: 'website', label: 'Website' },
|
|
{ value: 'shop', label: 'Shop' },
|
|
{ value: 'app', label: 'App' },
|
|
{ value: 'retainer', label: 'Retainer' },
|
|
]
|
|
|
|
const BUDGET_TYPES = [
|
|
{ value: 'fixed', label: 'Fixpreis' },
|
|
{ value: 'time-materials', label: 'Time & Materials' },
|
|
{ value: 'retainer', label: 'Retainer' },
|
|
]
|
|
|
|
const EMPTY: ProjectFormValue = {
|
|
companyId: '',
|
|
name: '',
|
|
type: 'website',
|
|
budgetType: 'fixed',
|
|
budgetHours: '',
|
|
fixedPriceEuro: '',
|
|
startDate: '',
|
|
dueDate: '',
|
|
}
|
|
|
|
// Single dialog for create + edit of a Project.
|
|
// - create mode → POST /api/aos/projects
|
|
// - edit mode → PATCH /api/aos/projects/[id]
|
|
export function ProjectFormDialog({ open, onOpenChange, companies, project, onSaved }: Props) {
|
|
const [form, setForm] = useState<ProjectFormValue>(EMPTY)
|
|
const [saving, setSaving] = useState(false)
|
|
const isEdit = !!project?.id
|
|
|
|
useEffect(() => {
|
|
if (open) {
|
|
setForm(project ?? EMPTY)
|
|
}
|
|
}, [open, project])
|
|
|
|
useEffect(() => {
|
|
if (open && !isEdit && !form.companyId && companies[0]) {
|
|
setForm((f) => ({ ...f, companyId: companies[0].id }))
|
|
}
|
|
}, [open, isEdit, form.companyId, companies])
|
|
|
|
const submit = async () => {
|
|
if (!form.companyId) { toast.error('Firma auswählen'); return }
|
|
if (!form.name.trim()) { toast.error('Projektname eingeben'); return }
|
|
const fixedPriceCents = form.fixedPriceEuro
|
|
? Math.round((parseFloat(form.fixedPriceEuro.replace(',', '.')) || 0) * 100)
|
|
: null
|
|
const budgetHours = form.budgetHours ? parseInt(form.budgetHours, 10) || null : null
|
|
const payload: Record<string, unknown> = {
|
|
companyId: form.companyId,
|
|
name: form.name.trim(),
|
|
type: form.type,
|
|
budgetType: form.budgetType,
|
|
budgetHours,
|
|
fixedPriceCents,
|
|
startDate: form.startDate || null,
|
|
dueDate: form.dueDate || null,
|
|
}
|
|
|
|
setSaving(true)
|
|
const t = toast.loading(isEdit ? 'Projekt wird gespeichert…' : 'Projekt wird angelegt…')
|
|
try {
|
|
const url = isEdit ? `/api/aos/projects/${project!.id}` : '/api/aos/projects'
|
|
const method = isEdit ? 'PATCH' : 'POST'
|
|
const r = await fetch(url, {
|
|
method,
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
})
|
|
if (!r.ok) {
|
|
const err = await r.json().catch(() => ({}))
|
|
throw new Error(err.error || `${method} failed`)
|
|
}
|
|
toast.success(isEdit ? 'Projekt aktualisiert' : 'Projekt angelegt', { id: t })
|
|
onOpenChange(false)
|
|
onSaved()
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : 'Speichern fehlgeschlagen', { id: t })
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>{isEdit ? 'Projekt bearbeiten' : 'Neues Projekt'}</DialogTitle>
|
|
<DialogDescription>
|
|
{isEdit
|
|
? 'Projekt-Stammdaten aktualisieren. Audit-Log wird automatisch geschrieben (GoBD).'
|
|
: 'Legt ein neues Projekt an. Audit-Log wird automatisch geschrieben.'}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-3">
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs">Firma</Label>
|
|
<Select value={form.companyId} onValueChange={(v) => setForm((f) => ({ ...f, companyId: v }))}>
|
|
<SelectTrigger className="w-full"><SelectValue placeholder="Firma wählen" /></SelectTrigger>
|
|
<SelectContent>
|
|
{companies.map((c) => <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>)}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs">Projektname</Label>
|
|
<Input value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} placeholder="z.B. Website-Relaunch" />
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs">Typ</Label>
|
|
<Select value={form.type} onValueChange={(v) => setForm((f) => ({ ...f, type: v }))}>
|
|
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
{TYPES.map((s) => <SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>)}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs">Budget-Typ</Label>
|
|
<Select value={form.budgetType} onValueChange={(v) => setForm((f) => ({ ...f, budgetType: v }))}>
|
|
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
{BUDGET_TYPES.map((s) => <SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>)}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs">Fixpreis (€)</Label>
|
|
<Input value={form.fixedPriceEuro} onChange={(e) => setForm((f) => ({ ...f, fixedPriceEuro: e.target.value }))} placeholder="9.800" inputMode="decimal" />
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs">Budget-Stunden</Label>
|
|
<Input value={form.budgetHours} onChange={(e) => setForm((f) => ({ ...f, budgetHours: e.target.value }))} placeholder="120" inputMode="numeric" />
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs">Startdatum</Label>
|
|
<Input type="date" value={form.startDate} onChange={(e) => setForm((f) => ({ ...f, startDate: e.target.value }))} />
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs">Fälligkeit</Label>
|
|
<Input type="date" value={form.dueDate} onChange={(e) => setForm((f) => ({ ...f, dueDate: e.target.value }))} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => onOpenChange(false)}>Abbrechen</Button>
|
|
<Button onClick={submit} disabled={saving} className="gap-1.5">
|
|
{saving && <Loader2 className="size-3.5 animate-spin" />}
|
|
{isEdit ? 'Speichern' : 'Anlegen'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|