Weiter
This commit is contained in:
1 parent
c2063fe1ed
commit
07ef8ef025
10 files changed
+1624
-93
No files matched your search
@@ -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) => {
|
||||
|
||||
Reference in new issue
Block a user