This commit is contained in:
michael committed 2026-09-09 12:55:56 +02:00
1 parent c2063fe1ed
commit 07ef8ef025
10 files changed
+1624 -93

No files matched your search

+98 -3
View File
@@ -352,8 +352,10 @@ type ProjectFormFieldsProps = {
setForm: (form: ProjectForm) => void;
organizationLocked: boolean;
lookupStatus: "idle" | "loading" | "found" | "not-found";
zammadLookupBusy?: boolean;
saving: boolean;
submitLabel: string;
onZammadLookup?: () => void;
onSubmit: (event: FormEvent) => void;
};
@@ -378,6 +380,7 @@ function parseProjectTeamspaceEntryForms(entries: ProjectTeamspaceEntryForm[]) {
}
return {
id: /^\d+$/.test(entry.clientId) ? entry.clientId : null,
teamspaceProject: entry.teamspaceProject.trim() || null,
isDefault: entry.isDefault,
hourBudgetMinutes,
@@ -390,6 +393,7 @@ function parseProjectTeamspaceEntryForms(entries: ProjectTeamspaceEntryForm[]) {
}
return parsedEntries as Array<{
id?: string | null;
teamspaceProject: string | null;
isDefault: boolean;
hourBudgetMinutes: number | null;
@@ -483,7 +487,7 @@ function ProjectTeamspaceEntriesFields({
);
}
function ProjectFormFields({ form, setForm, organizationLocked, lookupStatus, saving, submitLabel, onSubmit }: ProjectFormFieldsProps) {
function ProjectFormFields({ form, setForm, organizationLocked, lookupStatus, zammadLookupBusy = false, saving, submitLabel, onZammadLookup, onSubmit }: ProjectFormFieldsProps) {
return (
<form className="space-y-4" onSubmit={onSubmit}>
<div className="grid gap-3 lg:grid-cols-[180px_minmax(240px,1fr)_180px]">
@@ -508,7 +512,17 @@ function ProjectFormFields({ form, setForm, organizationLocked, lookupStatus, sa
{form.ticketNumber && !ticketPattern.test(form.ticketNumber.trim()) ? <p className="text-xs text-destructive">Format: Ticket#XXXXXX</p> : null}
{lookupStatus === "loading" ? <p className="text-xs text-muted-foreground">Suche Ticket...</p> : null}
{lookupStatus === "found" ? <p className="text-xs text-muted-foreground">Ticket gefunden. Daten werden übernommen.</p> : null}
{lookupStatus === "not-found" ? <p className="text-xs text-muted-foreground">Neues Ticket. Organisation bitte wählen.</p> : null}
{lookupStatus === "not-found" ? (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">Neues Ticket. Organisation bitte wählen oder aus Zammad übernehmen.</p>
{onZammadLookup ? (
<Button type="button" size="sm" variant="secondary" disabled={zammadLookupBusy} onClick={onZammadLookup}>
<Search className="size-3.5" />
{zammadLookupBusy ? "Sucht..." : "In Zammad suchen"}
</Button>
) : null}
</div>
) : null}
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="project-title">
@@ -572,6 +586,8 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
const [form, setForm] = useState<ProjectForm>(() => emptyProjectForm());
const [lookupStatus, setLookupStatus] = useState<"idle" | "loading" | "found" | "not-found">("idle");
const [lookupResult, setLookupResult] = useState<TicketMeta | null>(null);
const [zammadLookupBusy, setZammadLookupBusy] = useState(false);
const [useZammadForCreate, setUseZammadForCreate] = useState(false);
const [saving, setSaving] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [editingProjectId, setEditingProjectId] = useState<string | null>(null);
@@ -608,6 +624,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
if (!ticketPattern.test(normalizedTicketNumber)) {
setLookupStatus("idle");
setLookupResult(null);
setUseZammadForCreate(false);
return;
}
@@ -624,6 +641,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
setLookupResult(result.ticket);
setLookupStatus(result.ticket ? "found" : "not-found");
setUseZammadForCreate(false);
if (result.ticket?.organization_id) {
setForm((current) =>
@@ -734,6 +752,70 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
viewSettings.sortDirection !== defaultProjectViewSettings.sortDirection
].filter(Boolean).length;
async function lookupProjectTicketInZammad() {
const ticketNumber = form.ticketNumber.trim();
if (!ticketPattern.test(ticketNumber)) {
toast.error("Ticketnummer prüfen", {
description: "Das Format muss Ticket#XXXXXX sein."
});
return;
}
setZammadLookupBusy(true);
try {
const result = await lookupTicket(ticketNumber, "zammad");
if (!result.ticket) {
setLookupStatus("not-found");
toast.error("Ticket nicht gefunden", {
description: "Zammad hat zu dieser Ticketnummer keinen Treffer geliefert."
});
return;
}
if (result.ticket.work_type && result.ticket.work_type !== "consulting") {
toast.error("Kein Consulting-Ticket", {
description: "Für Support-Tickets wird kein Projekt angelegt."
});
return;
}
setLookupResult(result.ticket);
setLookupStatus("found");
setUseZammadForCreate(true);
setForm((current) => {
const entries = normalizeProjectTeamspaceEntryForms(current.teamspaceEntries);
const defaultIndex = Math.max(0, entries.findIndex((entry) => entry.isDefault));
const nextEntries = entries.map((entry, index) =>
index === defaultIndex
? {
...entry,
teamspaceProject: entry.teamspaceProject || result.ticket?.project_name || "",
hourBudget: entry.hourBudget || formatHoursInput(result.ticket?.project_hour_budget_minutes ?? null)
}
: entry
);
return {
...current,
title: current.title || result.ticket?.title || "",
organizationId: result.ticket?.organization_id ?? "",
organizationName: result.ticket?.organization_name ?? result.ticket?.customer_name ?? null,
teamspaceEntries: nextEntries
};
});
toast.success("Zammad-Daten übernommen");
} catch (error) {
toast.error("Zammad-Suche fehlgeschlagen", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setZammadLookupBusy(false);
}
}
function setProjectSort(sortBy: ProjectSortBy) {
setViewSettings((current) => ({
...current,
@@ -793,6 +875,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
ticketNumber,
title: form.title || null,
organizationId: form.organizationId,
zammadLookup: useZammadForCreate,
teamspaceProject: defaultTeamspaceEntry?.teamspaceProject ?? null,
teamspaceEntries,
description: form.description,
@@ -809,6 +892,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
setCreateOpen(false);
setLookupResult(null);
setLookupStatus("idle");
setUseZammadForCreate(false);
await load();
} catch (error) {
toast.error("Projekt konnte nicht angelegt werden", {
@@ -938,8 +1022,10 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
setForm={setForm}
organizationLocked={organizationLocked}
lookupStatus={lookupStatus}
zammadLookupBusy={zammadLookupBusy}
saving={saving}
submitLabel="Projekt anlegen"
onZammadLookup={() => void lookupProjectTicketInZammad()}
onSubmit={submitProject}
/>
</DialogContent>
@@ -1099,6 +1185,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
const remaining = projectRemaining(project);
const usedPercent = projectConsumptionPercent(project);
const hasMultipleTeamspaceEntries = teamspaceEntries.length > 1;
const canEditProject = project.can_edit !== false;
return (
<div key={project.id} className="border-b last:border-b-0">
@@ -1119,6 +1206,8 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
</div>
<div className="flex flex-wrap gap-1.5">
{project.closed_at ? <Badge variant="secondary">geschlossen</Badge> : <Badge variant="success">offen</Badge>}
{!canEditProject ? <Badge variant="outline">geteilt</Badge> : null}
{(project.unassigned_teamspace_session_count ?? 0) > 0 ? <Badge variant="warning">{project.unassigned_teamspace_session_count} ohne TS</Badge> : null}
</div>
<div className="min-w-0">
<p className={cn("text-sm font-semibold", remaining !== null && remaining < 0 ? "text-destructive" : undefined)}>
@@ -1160,7 +1249,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
Projekt öffnen
</DropdownMenuItem>
<DropdownMenuItem
disabled={savingProjectStatusId === project.id}
disabled={!canEditProject || savingProjectStatusId === project.id}
className="hover:cursor-pointer"
onSelect={() => void setProjectClosed(project, !project.closed_at)}
>
@@ -1168,6 +1257,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
{project.closed_at ? "Projekt öffnen" : "Projekt schließen"}
</DropdownMenuItem>
<DropdownMenuItem
disabled={!canEditProject}
className="hover:cursor-pointer"
onSelect={() => {
setEditingProjectId(project.id);
@@ -1191,6 +1281,11 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
<div className="h-full rounded-full bg-muted-foreground/10" style={{ width: "0%" }} />
</div>
)}
{(project.unassigned_teamspace_session_count ?? 0) > 0 ? (
<p className="rounded-md border border-amber-500/30 bg-amber-500/10 px-2 py-1 text-xs text-amber-700 dark:text-amber-300">
{project.unassigned_teamspace_session_count} Session(s) haben keinen Teamspace-Eintrag.
</p>
) : null}
{hasMultipleTeamspaceEntries ? (
<div className="grid gap-1.5 md:grid-cols-2">
{teamspaceEntries.map((entry) => {