Files
AOS/src/components/aos/crud/deal-form-dialog.tsx
T
Z User 32a8b01845 fix: instance detail crash + add play/stop/redeploy/remove actions
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
2026-10-29 16:29:28 +00:00

198 lines
7.1 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 DealFormValue {
id?: string
companyId: string
title: string
valueEuro: string
stage: string
probability: string
expectedClose: string
lossReason: string
}
interface Props {
open: boolean
onOpenChange: (o: boolean) => void
companies: { id: string; name: string }[]
// When `deal` is null the dialog is in "create" mode.
deal?: DealFormValue | null
onSaved: () => void
}
const STAGES = [
{ value: 'lead', label: 'Lead' },
{ value: 'qualified', label: 'Qualifiziert' },
{ value: 'proposal', label: 'Angebot' },
{ value: 'negotiation', label: 'Verhandlung' },
{ value: 'won', label: 'Gewonnen' },
{ value: 'lost', label: 'Verloren' },
]
const EMPTY: DealFormValue = {
companyId: '',
title: '',
valueEuro: '',
stage: 'lead',
probability: '10',
expectedClose: '',
lossReason: '',
}
// Single dialog for create + edit of a CRM Deal.
// - create mode → POST /api/aos/crm/deals
// - edit mode → PATCH /api/aos/crm/deals/[id]
export function DealFormDialog({ open, onOpenChange, companies, deal, onSaved }: Props) {
const [form, setForm] = useState<DealFormValue>(EMPTY)
const [saving, setSaving] = useState(false)
const isEdit = !!deal?.id
useEffect(() => {
if (open) {
setForm(deal ?? EMPTY)
}
}, [open, deal])
// Auto-pick the first company for create mode if none selected.
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.title.trim()) { toast.error('Titel eingeben'); return }
const valueCents = Math.round((parseFloat(form.valueEuro.replace(',', '.')) || 0) * 100)
const probability = Math.min(100, Math.max(0, parseInt(form.probability, 10) || 0))
const payload: Record<string, unknown> = {
companyId: form.companyId,
title: form.title.trim(),
valueCents,
stage: form.stage,
probability,
expectedClose: form.expectedClose || null,
}
if (form.stage === 'lost' && form.lossReason.trim()) {
payload.lossReason = form.lossReason.trim()
}
setSaving(true)
const t = toast.loading(isEdit ? 'Deal wird gespeichert…' : 'Deal wird angelegt…')
try {
const url = isEdit ? `/api/aos/crm/deals/${deal!.id}` : '/api/aos/crm/deals'
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 ? 'Deal aktualisiert' : 'Deal 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 ? 'Deal bearbeiten' : 'Neuer Deal'}</DialogTitle>
<DialogDescription>
{isEdit
? 'Deal-Daten aktualisieren. Änderungen werden im Audit-Log protokolliert (GoBD).'
: 'Legt einen Deal in der Pipeline 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">Titel</Label>
<Input value={form.title} onChange={(e) => setForm((f) => ({ ...f, title: 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">Wert (€)</Label>
<Input value={form.valueEuro} onChange={(e) => setForm((f) => ({ ...f, valueEuro: e.target.value }))} placeholder="9.800" inputMode="decimal" />
</div>
<div className="space-y-1.5">
<Label className="text-xs">Wahrscheinlichkeit (%)</Label>
<Input value={form.probability} onChange={(e) => setForm((f) => ({ ...f, probability: e.target.value }))} placeholder="50" inputMode="numeric" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Phase</Label>
<Select value={form.stage} onValueChange={(v) => setForm((f) => ({ ...f, stage: v }))}>
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
<SelectContent>
{STAGES.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">Erwarteter Abschluss</Label>
<Input
type="date"
value={form.expectedClose ? form.expectedClose.slice(0, 10) : ''}
onChange={(e) => setForm((f) => ({ ...f, expectedClose: e.target.value }))}
/>
</div>
</div>
{form.stage === 'lost' && (
<div className="space-y-1.5">
<Label className="text-xs">Verlustgrund</Label>
<Input value={form.lossReason} onChange={(e) => setForm((f) => ({ ...f, lossReason: e.target.value }))} placeholder="z.B. Preis, Konkurrenz, Timing" />
</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>
)
}