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
183 lines
6.4 KiB
TypeScript
Executable File
183 lines
6.4 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 { toast } from 'sonner'
|
|
import { Loader2 } from 'lucide-react'
|
|
|
|
// Shape shared between the table row and the edit form. Optional fields
|
|
// are stringified so the form has a single value type.
|
|
export interface CompanyFormValue {
|
|
id?: string
|
|
name: string
|
|
legalForm: string
|
|
vatId: string
|
|
registerCourt: string
|
|
registerNumber: string
|
|
website: string
|
|
email: string
|
|
phone: string
|
|
city: string
|
|
zip: string
|
|
street: string
|
|
tags: string
|
|
}
|
|
|
|
export const EMPTY_COMPANY: CompanyFormValue = {
|
|
name: '',
|
|
legalForm: '',
|
|
vatId: '',
|
|
registerCourt: '',
|
|
registerNumber: '',
|
|
website: '',
|
|
email: '',
|
|
phone: '',
|
|
city: '',
|
|
zip: '',
|
|
street: '',
|
|
tags: '',
|
|
}
|
|
|
|
interface Props {
|
|
open: boolean
|
|
onOpenChange: (o: boolean) => void
|
|
// When `company` is null, the dialog is in "create" mode.
|
|
company?: CompanyFormValue | null
|
|
onSaved: () => void
|
|
}
|
|
|
|
// Single dialog for create + edit of a CRM Company.
|
|
// - create mode → POST /api/aos/crm/companies
|
|
// - edit mode → PATCH /api/aos/crm/companies/[id]
|
|
// Both write an audit entry on the server side (GoBD).
|
|
export function CompanyFormDialog({ open, onOpenChange, company, onSaved }: Props) {
|
|
const [form, setForm] = useState<CompanyFormValue>(EMPTY_COMPANY)
|
|
const [saving, setSaving] = useState(false)
|
|
const isEdit = !!company?.id
|
|
|
|
useEffect(() => {
|
|
if (open) {
|
|
setForm(company ?? EMPTY_COMPANY)
|
|
}
|
|
}, [open, company])
|
|
|
|
const update = (k: keyof CompanyFormValue, v: string) =>
|
|
setForm((f) => ({ ...f, [k]: v }))
|
|
|
|
const submit = async () => {
|
|
if (!form.name.trim()) { toast.error('Firmenname ist Pflicht'); return }
|
|
setSaving(true)
|
|
const t = toast.loading(isEdit ? 'Firma wird gespeichert…' : 'Firma wird angelegt…')
|
|
try {
|
|
// Normalise empty strings → null on the server side (Prisma schema is
|
|
// nullable; the PATCH handler accepts strings or null).
|
|
const payload: Record<string, string | null> = {
|
|
name: form.name.trim(),
|
|
legalForm: form.legalForm.trim() || null,
|
|
vatId: form.vatId.trim() || null,
|
|
registerCourt: form.registerCourt.trim() || null,
|
|
registerNumber: form.registerNumber.trim() || null,
|
|
website: form.website.trim() || null,
|
|
email: form.email.trim() || null,
|
|
phone: form.phone.trim() || null,
|
|
city: form.city.trim() || null,
|
|
zip: form.zip.trim() || null,
|
|
street: form.street.trim() || null,
|
|
tags: form.tags.trim() || null,
|
|
}
|
|
const url = isEdit ? `/api/aos/crm/companies/${company!.id}` : '/api/aos/crm/companies'
|
|
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 ? 'Firma aktualisiert' : 'Firma 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-2xl">
|
|
<DialogHeader>
|
|
<DialogTitle>{isEdit ? 'Firma bearbeiten' : 'Neue Firma'}</DialogTitle>
|
|
<DialogDescription>
|
|
{isEdit
|
|
? 'Stammdaten aktualisieren. Änderungen werden im Audit-Log protokolliert (GoBD).'
|
|
: 'Erfasse eine neue Firma im CRM. Audit-Log wird automatisch geschrieben.'}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<Field label="Firmenname *" value={form.name} onChange={(v) => update('name', v)} className="col-span-2" />
|
|
<Field label="Rechtsform" value={form.legalForm} onChange={(v) => update('legalForm', v)} placeholder="GmbH, UG, e.K." />
|
|
<Field label="USt-IdNr." value={form.vatId} onChange={(v) => update('vatId', v)} placeholder="DE123456789" mono />
|
|
<Field label="Registergericht" value={form.registerCourt} onChange={(v) => update('registerCourt', v)} placeholder="Amtsgericht Hamburg" />
|
|
<Field label="HRB-Nummer" value={form.registerNumber} onChange={(v) => update('registerNumber', v)} mono />
|
|
<Field label="Website" value={form.website} onChange={(v) => update('website', v)} placeholder="mueller.de" />
|
|
<Field label="E-Mail" value={form.email} onChange={(v) => update('email', v)} placeholder="info@firma.de" />
|
|
<Field label="Telefon" value={form.phone} onChange={(v) => update('phone', v)} placeholder="+49 40 …" />
|
|
<Field label="Straße" value={form.street} onChange={(v) => update('street', v)} />
|
|
<Field label="PLZ" value={form.zip} onChange={(v) => update('zip', v)} />
|
|
<Field label="Ort" value={form.city} onChange={(v) => update('city', v)} className="col-span-2" />
|
|
<Field label="Tags (kommasepariert)" value={form.tags} onChange={(v) => update('tags', v)} placeholder="kunde, agency, vip" className="col-span-2" />
|
|
</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>
|
|
)
|
|
}
|
|
|
|
function Field({
|
|
label,
|
|
value,
|
|
onChange,
|
|
placeholder,
|
|
mono,
|
|
className,
|
|
}: {
|
|
label: string
|
|
value: string
|
|
onChange: (v: string) => void
|
|
placeholder?: string
|
|
mono?: boolean
|
|
className?: string
|
|
}) {
|
|
return (
|
|
<div className={`space-y-1.5 ${className ?? ''}`}>
|
|
<Label className="text-xs">{label}</Label>
|
|
<Input
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
placeholder={placeholder}
|
|
className={`h-9 text-xs ${mono ? 'font-mono' : ''}`}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|