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

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
DATABASE_URL=postgresql://ticket:ticket@10.10.10.21:5433/tickettracker_dev
APP_PORT=3910
TICKETTRACKER_IMAGE=git.d-razz.de/michael/tickettracker:latest
COOKIE_SECURE=false
DB_POOL_SIZE=10
+941 -58
View File
File diff suppressed because it is too large. Load diff
+59
View File
@@ -139,6 +139,12 @@ export async function migrate() {
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS title TEXT;");
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS organization_id BIGINT REFERENCES organizations(zammad_id);");
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS work_type TEXT;");
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_ticket_id BIGINT UNIQUE;");
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_group_id BIGINT;");
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_priority_id BIGINT;");
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_state_id BIGINT;");
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_owner_id BIGINT;");
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_preferences JSONB NOT NULL DEFAULT '{}'::jsonb;");
await query(`
ALTER TABLE tickets
DROP CONSTRAINT IF EXISTS ticket_number_format,
@@ -186,6 +192,7 @@ export async function migrate() {
await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS recurring_billing_slot_id BIGINT;");
await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS recurring_occurrence_date DATE;");
await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS recurring_occurrence_key TEXT;");
await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS project_teamspace_entry_id BIGINT;");
if (adminId) {
await query("UPDATE sessions SET user_id = $1 WHERE user_id IS NULL;", [adminId]);
}
@@ -505,6 +512,18 @@ export async function migrate() {
await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_project_teamspace_entries_default ON project_teamspace_entries(project_id) WHERE is_default;");
await query("CREATE INDEX IF NOT EXISTS idx_project_teamspace_entries_project_order ON project_teamspace_entries(project_id, display_order, id);");
await query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'sessions_project_teamspace_entry_fkey'
) THEN
ALTER TABLE sessions
ADD CONSTRAINT sessions_project_teamspace_entry_fkey
FOREIGN KEY (project_teamspace_entry_id) REFERENCES project_teamspace_entries(id) ON DELETE SET NULL;
END IF;
END $$;
`);
await query(`
UPDATE tickets t
@@ -538,6 +557,45 @@ export async function migrate() {
);
`);
await query(`
WITH single_entry_projects AS (
SELECT
p.id AS project_id,
p.user_id,
p.ticket_id,
MIN(pte.id) AS project_teamspace_entry_id
FROM projects p
JOIN project_teamspace_entries pte ON pte.project_id = p.id
GROUP BY p.id, p.user_id, p.ticket_id
HAVING COUNT(pte.id) = 1
)
UPDATE sessions s
SET project_teamspace_entry_id = sep.project_teamspace_entry_id
FROM single_entry_projects sep
WHERE s.ticket_id = sep.ticket_id
AND s.user_id = sep.user_id
AND s.work_type = 'consulting'
AND s.project_teamspace_entry_id IS NULL;
`);
await query(`
WITH single_entry_tickets AS (
SELECT
p.ticket_id,
MIN(pte.id) AS project_teamspace_entry_id
FROM projects p
JOIN project_teamspace_entries pte ON pte.project_id = p.id
GROUP BY p.ticket_id
HAVING COUNT(pte.id) = 1
)
UPDATE sessions s
SET project_teamspace_entry_id = single_ticket.project_teamspace_entry_id
FROM single_entry_tickets single_ticket
WHERE s.ticket_id = single_ticket.ticket_id
AND s.work_type = 'consulting'
AND s.project_teamspace_entry_id IS NULL;
`);
await query(`
CREATE TABLE IF NOT EXISTS ticket_todos (
id BIGSERIAL PRIMARY KEY,
@@ -601,6 +659,7 @@ export async function migrate() {
await query("CREATE INDEX IF NOT EXISTS idx_sessions_user_started ON sessions(user_id, started_at);");
await query("CREATE INDEX IF NOT EXISTS idx_sessions_user_ticket_started ON sessions(user_id, ticket_id, started_at);");
await query("CREATE INDEX IF NOT EXISTS idx_sessions_user_organization_started ON sessions(user_id, organization_id, started_at);");
await query("CREATE INDEX IF NOT EXISTS idx_sessions_project_teamspace_entry ON sessions(project_teamspace_entry_id);");
await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_recurring_occurrence_key ON sessions(recurring_occurrence_key) WHERE recurring_occurrence_key IS NOT NULL;");
await query("CREATE INDEX IF NOT EXISTS idx_sessions_billing_status ON sessions(billing_status);");
await query("CREATE INDEX IF NOT EXISTS idx_ticket_day_closures_day ON ticket_day_closures(day);");
+16 -2
View File
@@ -26,6 +26,8 @@ type CreateSessionPayload = {
startedAt: string;
endedAt: string;
durationSeconds: number;
zammadLookup?: boolean;
projectTeamspaceEntryId?: string | null;
};
type DeleteSessionResult = {
@@ -35,6 +37,8 @@ type DeleteSessionResult = {
started_at: string;
userTicketEmpty: boolean;
ticketDeleted: boolean;
projectDeleteAvailable: boolean;
project_id: string | null;
};
};
@@ -338,8 +342,10 @@ export function createProject(payload: {
ticketNumber: string;
title?: string | null;
organizationId?: string | null;
zammadLookup?: boolean;
teamspaceProject?: string | null;
teamspaceEntries?: Array<{
id?: string | null;
teamspaceProject?: string | null;
isDefault: boolean;
hourBudgetMinutes?: number | null;
@@ -366,6 +372,7 @@ export function updateProject(
organizationId: string;
teamspaceProject?: string | null;
teamspaceEntries?: Array<{
id?: string | null;
teamspaceProject?: string | null;
isDefault: boolean;
hourBudgetMinutes?: number | null;
@@ -461,8 +468,14 @@ export function createSession(payload: CreateSessionPayload) {
});
}
export function lookupTicket(ticketNumber: string) {
return request<{ ticket: TicketMeta | null }>(`/api/tickets/lookup?ticketNumber=${encodeURIComponent(ticketNumber)}`);
export function lookupTicket(ticketNumber: string, source: "local" | "zammad" = "local") {
const params = new URLSearchParams({ ticketNumber });
if (source === "zammad") {
params.set("source", source);
}
return request<{ ticket: TicketMeta | null }>(`/api/tickets/lookup?${params}`);
}
export function searchTickets(query: string) {
@@ -492,6 +505,7 @@ export function updateSessionDetails(
activity: string;
workType: WorkType;
removeProjectData?: boolean;
projectTeamspaceEntryId?: string | null;
startedAt: string;
endedAt: string;
}
+20
View File
@@ -89,9 +89,20 @@ export type TicketMeta = {
organization_name: string | null;
customer_name: string | null;
work_type: WorkType | null;
zammad_ticket_id?: string | null;
zammad_group_id?: string | null;
zammad_priority_id?: string | null;
zammad_state_id?: string | null;
zammad_owner_id?: string | null;
project_id?: string | null;
is_project?: boolean;
project_name?: string | null;
project_hour_budget_minutes?: number | null;
project_teamspace_entry_count?: number;
project_teamspace_entries?: ProjectTeamspaceEntry[];
project_owner_user_id?: string | null;
project_owner_display_name?: string | null;
can_edit_project?: boolean;
has_recurring_billing?: boolean;
};
@@ -114,6 +125,10 @@ export type TicketSearchResult = {
export type ProjectSummary = {
id: string;
user_id?: string;
owner_username?: string | null;
owner_display_name?: string | null;
can_edit?: boolean;
ticket_id: string;
ticket_number: string;
title: string | null;
@@ -143,6 +158,7 @@ export type ProjectSummary = {
done_todos?: number;
teamspace_entry_count?: number;
teamspace_entries?: ProjectTeamspaceEntry[];
unassigned_teamspace_session_count?: number;
};
export type ProjectTeamspaceEntry = {
@@ -212,6 +228,8 @@ export type OpenSession = {
customer_name: string;
activity: string;
work_type: WorkType;
project_teamspace_entry_id?: string | null;
project_teamspace_project?: string | null;
started_at: string;
rounded_minutes: number;
recurring_billing_id?: string | null;
@@ -260,6 +278,8 @@ export type SessionEntry = {
customer_name: string;
activity: string;
work_type: WorkType;
project_teamspace_entry_id?: string | null;
project_teamspace_project?: string | null;
started_at: string;
ended_at: string;
duration_seconds: number;
+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) => {
+184 -9
View File
@@ -14,6 +14,7 @@ import { OrganizationSelect } from "@/components/OrganizationSelect";
import { TodoPanel } from "@/components/TodoPanel";
import {
createSession,
deleteProject,
deleteSession,
addFavoriteTicket,
getDashboardPreference,
@@ -53,6 +54,7 @@ type TicketDetailPageProps = {
type TicketSessionFormState = {
organizationId: string;
organizationName: string | null;
projectTeamspaceEntryId: string;
activity: string;
workType: WorkType;
day: string;
@@ -82,6 +84,7 @@ function emptyTicketSessionForm(): TicketSessionFormState {
return {
organizationId: "",
organizationName: null,
projectTeamspaceEntryId: "",
activity: "",
workType: "support",
day: currentDay(),
@@ -281,6 +284,7 @@ function parseProjectTeamspaceEntryForms(entries: ProjectTeamspaceEntryForm[]) {
}
return {
id: /^\d+$/.test(entry.clientId) ? entry.clientId : null,
teamspaceProject: entry.teamspaceProject.trim() || null,
isDefault: entry.isDefault,
hourBudgetMinutes,
@@ -293,6 +297,7 @@ function parseProjectTeamspaceEntryForms(entries: ProjectTeamspaceEntryForm[]) {
}
return parsedEntries as Array<{
id?: string | null;
teamspaceProject: string | null;
isDefault: boolean;
hourBudgetMinutes: number | null;
@@ -379,10 +384,12 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
const [editOrganizationName, setEditOrganizationName] = useState<string | null>(null);
const [editWorkType, setEditWorkType] = useState<WorkType>("support");
const [editingSession, setEditingSession] = useState<SessionEntry | null>(null);
const [pendingProjectDelete, setPendingProjectDelete] = useState<{ projectId: string } | null>(null);
const [editSessionOrganizationId, setEditSessionOrganizationId] = useState("");
const [editSessionOrganizationName, setEditSessionOrganizationName] = useState<string | null>(null);
const [editSessionActivity, setEditSessionActivity] = useState("");
const [editSessionWorkType, setEditSessionWorkType] = useState<WorkType>("support");
const [editSessionProjectTeamspaceEntryId, setEditSessionProjectTeamspaceEntryId] = useState("");
const [editSessionDay, setEditSessionDay] = useState("");
const [editSessionStartTime, setEditSessionStartTime] = useState("");
const [editSessionEndTime, setEditSessionEndTime] = useState("");
@@ -396,6 +403,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
const [globalSourceFilter, setGlobalSourceFilter] = useState<"all" | "manual" | "recurring">("all");
const [editingProject, setEditingProject] = useState(false);
const [savingProject, setSavingProject] = useState(false);
const [deletingEmptyProject, setDeletingEmptyProject] = useState(false);
const [savingProjectStatus, setSavingProjectStatus] = useState(false);
const [projectForm, setProjectForm] = useState<ProjectEditFormState>({
title: "",
@@ -463,13 +471,15 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
setEditOrganizationId(data.ticket.organization_id ?? "");
setEditOrganizationName(data.ticket.organization_name ?? null);
setEditWorkType(data.ticket.work_type ?? "support");
const loadedProjectEntries = data.project ? projectTeamspaceEntries(data.project) : [];
setTicketSession((current) => ({
...current,
organizationId: data.ticket.organization_id ?? current.organizationId,
organizationName: data.ticket.organization_name ?? current.organizationName,
projectTeamspaceEntryId: loadedProjectEntries.length === 1 ? loadedProjectEntries[0].id : current.projectTeamspaceEntryId,
workType: data.ticket.work_type ?? current.workType
}));
}, [data?.ticket]);
}, [data?.ticket, data?.project]);
useEffect(() => {
setProjectDescriptionExpanded(false);
@@ -481,9 +491,12 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
const projectDescription = project ? combinedProjectDescription(project) : "";
const projectEntries = useMemo(() => (project ? projectTeamspaceEntries(project) : []), [project]);
const hasMultipleProjectTeamspaceEntries = projectEntries.length > 1;
const requiresProjectTeamspaceEntry = Boolean(project && hasMultipleProjectTeamspaceEntries);
const projectConsumptionMinutes = project?.consumption_minutes ?? 0;
const projectBudgetMinutes = project?.hour_budget_minutes ?? null;
const projectConsumptionUsagePercent = project ? projectConsumptionPercent(projectConsumptionMinutes, projectBudgetMinutes) : null;
const canEditProject = project?.can_edit !== false;
const canEditTicketData = !project || canEditProject;
const isProject = Boolean(project);
const isGlobalTicket = periodType === "all";
const isPeriodClosed = Boolean(data?.closed);
@@ -684,6 +697,13 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
return;
}
if (!canEditTicketData) {
toast.error("Projekt ist geteilt", {
description: "Nur der Zammad-Owner kann Ticket- und Projektdaten ändern."
});
return;
}
if (!editOrganizationId) {
toast.error("Organisation wählen");
return;
@@ -728,6 +748,13 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
return;
}
if (project.can_edit === false) {
toast.error("Projekt ist geteilt", {
description: "Nur der Zammad-Owner kann die Projektdaten bearbeiten."
});
return;
}
setProjectForm(projectEditForm(project));
setEditingProject(true);
}
@@ -739,6 +766,13 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
return;
}
if (project.can_edit === false) {
toast.error("Projekt ist geteilt", {
description: "Nur der Zammad-Owner kann die Projektdaten bearbeiten."
});
return;
}
if (!projectForm.organizationId) {
toast.error("Organisation wählen");
return;
@@ -787,6 +821,13 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
return;
}
if (project.can_edit === false) {
toast.error("Projekt ist geteilt", {
description: "Nur der Zammad-Owner kann den Projektstatus ändern."
});
return;
}
setSavingProjectStatus(true);
try {
await updateProjectStatus(project.id, closed);
@@ -823,6 +864,13 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
return;
}
if (effectiveWorkType === "consulting" && requiresProjectTeamspaceEntry && !ticketSession.projectTeamspaceEntryId) {
toast.error("Teamspace-Eintrag wählen", {
description: "Dieses Projekt hat mehrere Teamspace-Einträge."
});
return;
}
const startedAt = new Date(`${ticketSession.day}T${ticketSession.startTime}:00`);
const endedAt = new Date(`${ticketSession.day}T${ticketSession.endTime}:00`);
@@ -836,6 +884,13 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
return;
}
if (editSessionWorkType === "consulting" && requiresProjectTeamspaceEntry && !editSessionProjectTeamspaceEntryId) {
toast.error("Teamspace-Eintrag wählen", {
description: "Dieses Projekt hat mehrere Teamspace-Einträge."
});
return;
}
const durationSeconds = Math.floor((endedAt.getTime() - startedAt.getTime()) / 1000);
setSavingTicketSession(true);
@@ -848,7 +903,8 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
workType: effectiveWorkType,
startedAt: startedAt.toISOString(),
endedAt: endedAt.toISOString(),
durationSeconds
durationSeconds,
projectTeamspaceEntryId: effectiveWorkType === "consulting" && requiresProjectTeamspaceEntry ? ticketSession.projectTeamspaceEntryId : null
});
toast.success("Session nachgetragen", {
description: "Der Eintrag wurde diesem Ticket zugeordnet. Betroffene Abschlüsse wurden wieder geöffnet."
@@ -857,6 +913,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
...emptyTicketSessionForm(),
organizationId: ticket.organization_id ?? "",
organizationName: ticket.organization_name ?? null,
projectTeamspaceEntryId: "",
workType: ticket.work_type ?? "support",
day: ticketSession.day
});
@@ -876,6 +933,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
setEditSessionOrganizationName(session.organization_name ?? null);
setEditSessionActivity(session.activity);
setEditSessionWorkType(session.work_type);
setEditSessionProjectTeamspaceEntryId(session.project_teamspace_entry_id ?? "");
setEditSessionDay(toDateLocalValue(session.started_at));
setEditSessionStartTime(toTimeLocalValue(session.started_at));
setEditSessionEndTime(toTimeLocalValue(session.ended_at));
@@ -925,6 +983,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
activity: editSessionActivity,
workType: editSessionWorkType,
removeProjectData,
projectTeamspaceEntryId: editSessionWorkType === "consulting" && requiresProjectTeamspaceEntry ? editSessionProjectTeamspaceEntryId : null,
startedAt: startedAt.toISOString(),
endedAt: endedAt.toISOString()
});
@@ -951,6 +1010,14 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
try {
const result = await deleteSession(session.id);
if (result.deleted.projectDeleteAvailable && result.deleted.project_id) {
setPendingProjectDelete({ projectId: result.deleted.project_id });
toast.success("Session gelöscht", {
description: "Für dieses Projekt sind keine Sessions mehr vorhanden."
});
return;
}
if (result.deleted.ticketDeleted || result.deleted.userTicketEmpty) {
toast.success("Letzte Session gelöscht", {
description: result.deleted.ticketDeleted ? "Das leere Ticket wurde entfernt." : "Für dich gibt es zu diesem Ticket keine Einträge mehr."
@@ -972,6 +1039,32 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
}
}
async function keepEmptyProject() {
setPendingProjectDelete(null);
toast.success("Projekt behalten");
await load();
}
async function deleteEmptyProject() {
if (!pendingProjectDelete) {
return;
}
setDeletingEmptyProject(true);
try {
await deleteProject(pendingProjectDelete.projectId);
toast.success("Projekt gelöscht");
setPendingProjectDelete(null);
onNavigate(returnPath);
} catch (error) {
toast.error("Projekt konnte nicht gelöscht werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setDeletingEmptyProject(false);
}
}
async function toggleTicketFavorite() {
if (!ticket?.ticket_number) {
return;
@@ -1101,7 +1194,11 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle>Ticketdaten</CardTitle>
<CardDescription>Änderungen an Organisation und Art werden auf alle vorhandenen Sessions dieses Tickets übernommen.</CardDescription>
<CardDescription>
{canEditTicketData
? "Änderungen an Organisation und Art werden auf alle vorhandenen Sessions dieses Tickets übernommen."
: "Dieses Projekt ist geteilt. Nur der Zammad-Owner kann Ticket- und Projektdaten ändern."}
</CardDescription>
</div>
<HelpLink anchor="ticketdaten" label="Hilfe zu Ticketdaten" />
</div>
@@ -1110,11 +1207,11 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
<form className="grid gap-3 lg:grid-cols-[180px_minmax(220px,1fr)_minmax(220px,1fr)_160px_auto] lg:items-end" onSubmit={saveTicketData}>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="edit-ticket-number">Ticketnummer</label>
<Input id="edit-ticket-number" value={editTicketNumber} onChange={(event) => setEditTicketNumber(event.currentTarget.value)} required />
<Input id="edit-ticket-number" value={editTicketNumber} disabled={!canEditTicketData} onChange={(event) => setEditTicketNumber(event.currentTarget.value)} required />
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="edit-ticket-title">Titel</label>
<Input id="edit-ticket-title" value={editTicketTitle} onChange={(event) => setEditTicketTitle(event.currentTarget.value)} placeholder="optional" />
<Input id="edit-ticket-title" value={editTicketTitle} disabled={!canEditTicketData} onChange={(event) => setEditTicketTitle(event.currentTarget.value)} placeholder="optional" />
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="edit-ticket-organization">Organisation</label>
@@ -1124,6 +1221,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
<OrganizationSelect
value={editOrganizationId}
selectedName={editOrganizationName}
disabled={!canEditTicketData}
onChange={(organization) => {
setEditOrganizationId(organization.id);
setEditOrganizationName(organization.name);
@@ -1137,13 +1235,14 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
id="edit-ticket-work-type"
className="h-8 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
value={editWorkType}
disabled={!canEditTicketData}
onChange={(event) => setEditWorkType(event.currentTarget.value as WorkType)}
>
<option value="support">Support</option>
<option value="consulting">Consulting</option>
</select>
</div>
<Button type="submit" disabled={savingTicket}>
<Button type="submit" disabled={!canEditTicketData || savingTicket}>
<Save className="size-4" />
{savingTicket ? "Speichert..." : "Speichern"}
</Button>
@@ -1161,9 +1260,13 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
<FolderKanban className="size-5 text-muted-foreground" />
Projekt
{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}
</CardTitle>
<CardDescription>
{hasMultipleProjectTeamspaceEntries
{!canEditProject && project.owner_display_name
? `Eigentümer: ${project.owner_display_name}`
: hasMultipleProjectTeamspaceEntries
? `${projectEntries.length} Teamspace-Einträge`
: project.teamspace_project || "Kein Teamspace-Projekt hinterlegt"}
</CardDescription>
@@ -1173,13 +1276,13 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
type="button"
size="sm"
variant="secondary"
disabled={savingProjectStatus}
disabled={!canEditProject || savingProjectStatus}
onClick={() => void setProjectClosed(!project.closed_at)}
>
{project.closed_at ? <RotateCcw className="size-4" /> : <CheckCircle2 className="size-4" />}
{project.closed_at ? "Öffnen" : "Schließen"}
</Button>
<Button type="button" size="sm" variant="secondary" onClick={openProjectEditor}>
<Button type="button" size="sm" variant="secondary" disabled={!canEditProject} onClick={openProjectEditor}>
<Pencil className="size-4" />
Bearbeiten
</Button>
@@ -1188,6 +1291,14 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
</div>
</CardHeader>
<CardContent className="space-y-3 px-4 pb-4">
{(project.unassigned_teamspace_session_count ?? 0) > 0 ? (
<Alert variant="warning">
<div className="font-medium">Nicht zugeordnete Sessions</div>
<div className="text-sm text-muted-foreground">
{project.unassigned_teamspace_session_count} Session(s) haben keinen Teamspace-Eintrag. Bitte über die Session-Bearbeitung neu zuordnen.
</div>
</Alert>
) : null}
<div className="flex flex-wrap gap-2 text-sm">
<Badge variant="secondary">Zeitraum {project.starts_on || "offen"} - {project.ends_on || "offen"}</Badge>
<Badge variant="secondary">Kontingent {projectBudgetMinutes ? formatMinutes(projectBudgetMinutes) : "offen"}</Badge>
@@ -1497,6 +1608,13 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
<div className="flex flex-wrap gap-1">
<Badge variant="outline" className="w-fit">{formatMinutes(session.rounded_minutes)}</Badge>
{session.recurring_billing_id ? <Badge variant="secondary">fix</Badge> : null}
{requiresProjectTeamspaceEntry ? (
session.project_teamspace_entry_id ? (
<Badge variant="info">{session.project_teamspace_project || "Teamspace"}</Badge>
) : (
<Badge variant="warning">ohne Teamspace</Badge>
)
) : null}
</div>
<BillingButtons
session={session}
@@ -1604,6 +1722,26 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
</div>
)}
{requiresProjectTeamspaceEntry ? (
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="ticket-session-teamspace-entry">Teamspace-Eintrag</label>
<select
id="ticket-session-teamspace-entry"
className="h-9 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
value={ticketSession.projectTeamspaceEntryId}
onChange={(event) => setTicketSession({ ...ticketSession, projectTeamspaceEntryId: event.currentTarget.value })}
required
>
<option value="">Bitte wählen</option>
{projectEntries.map((entry) => (
<option key={entry.id} value={entry.id}>
{entry.teamspace_project || (entry.is_default ? "Standard" : "Ohne Teamspace-ID")}
</option>
))}
</select>
</div>
) : null}
<div className="grid gap-3 lg:grid-cols-[150px_120px_120px_minmax(220px,1fr)_auto] lg:items-end">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="ticket-session-day">
@@ -1670,6 +1808,24 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
</p>
) : null}
<Dialog open={Boolean(pendingProjectDelete)} onOpenChange={(open) => !open && void keepEmptyProject()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Keine Sessions mehr vorhanden</DialogTitle>
<DialogDescription>Projekt löschen?</DialogDescription>
</DialogHeader>
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button type="button" variant="secondary" disabled={deletingEmptyProject} onClick={() => void keepEmptyProject()}>
Projekt behalten
</Button>
<Button type="button" variant="destructive" disabled={deletingEmptyProject} onClick={() => void deleteEmptyProject()}>
<Trash2 className="size-4" />
{deletingEmptyProject ? "Löscht..." : "Projekt löschen"}
</Button>
</div>
</DialogContent>
</Dialog>
<Dialog open={Boolean(editingSession)} onOpenChange={(open) => !open && setEditingSession(null)}>
<DialogContent className="max-h-[calc(100dvh-2rem)] w-[calc(100vw-2rem)] overflow-y-auto p-5 sm:max-w-2xl">
<DialogHeader className="pr-8">
@@ -1747,6 +1903,25 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
<option value="consulting">Consulting</option>
</select>
</div>
{editSessionWorkType === "consulting" && requiresProjectTeamspaceEntry ? (
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="session-teamspace-entry">Teamspace-Eintrag</label>
<select
id="session-teamspace-entry"
className="h-8 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
value={editSessionProjectTeamspaceEntryId}
onChange={(event) => setEditSessionProjectTeamspaceEntryId(event.currentTarget.value)}
required
>
<option value="">Bitte wählen</option>
{projectEntries.map((entry) => (
<option key={entry.id} value={entry.id}>
{entry.teamspace_project || (entry.is_default ? "Standard" : "Ohne Teamspace-ID")}
</option>
))}
</select>
</div>
) : null}
<div className="flex justify-end">
<Button type="submit" disabled={savingSessionDetails}>
<Save className="size-4" />
+204 -12
View File
@@ -1,6 +1,6 @@
import { FormEvent, useEffect, useMemo, useState } from "react";
import type { Dispatch, SetStateAction } from "react";
import { Pause, Play, Plus, RotateCcw, Square, Upload } from "lucide-react";
import { Pause, Play, Plus, RotateCcw, Search, Square, Upload } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -20,6 +20,7 @@ type ManualSessionFormState = {
ticketNumber: string;
organizationId: string;
organizationName: string | null;
projectTeamspaceEntryId: string;
activity: string;
workType: WorkType;
day: string;
@@ -37,6 +38,7 @@ function emptyManualSession(): ManualSessionFormState {
ticketNumber: "",
organizationId: "",
organizationName: null,
projectTeamspaceEntryId: "",
activity: "",
workType: "support",
day: currentDay(),
@@ -55,6 +57,14 @@ type FinishedSession = {
roundedMinutes: number;
};
function ticketTeamspaceEntries(ticket: TicketMeta | null | undefined) {
return ticket?.project_teamspace_entries ?? [];
}
function requiresTeamspaceEntry(ticket: TicketMeta | null | undefined) {
return Boolean(ticket?.is_project && ticketTeamspaceEntries(ticket).length > 1);
}
type TimerPageProps = {
timers: TimerEntry[];
setTimers: Dispatch<SetStateAction<TimerEntry[]>>;
@@ -69,6 +79,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
const [finishingId, setFinishingId] = useState<string | null>(null);
const [organizationId, setOrganizationId] = useState("");
const [organizationName, setOrganizationName] = useState<string | null>(null);
const [projectTeamspaceEntryId, setProjectTeamspaceEntryId] = useState("");
const [activity, setActivity] = useState("");
const [workType, setWorkType] = useState<WorkType>("support");
const [saving, setSaving] = useState(false);
@@ -76,11 +87,12 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
const [savingManualSession, setSavingManualSession] = useState(false);
const [manualLookupStatus, setManualLookupStatus] = useState<"idle" | "loading" | "found" | "not-found">("idle");
const [manualLookupTicket, setManualLookupTicket] = useState<TicketMeta | null>(null);
const [zammadLookupBusy, setZammadLookupBusy] = useState<string | null>(null);
const [recurringBillings, setRecurringBillings] = useState<RecurringBilling[]>([]);
const selectedTimer = timers.find((timer) => timer.id === selectedTimerId) ?? timers[0] ?? null;
const elapsedSeconds = useMemo(() => Math.floor(activeElapsedMs(selectedTimer, tick) / 1000), [selectedTimer, tick]);
const isValidTicket = ticketPattern.test(ticketNumber);
const isValidTicket = ticketPattern.test(ticketNumber.trim());
const isManualTicketValid = ticketPattern.test(manualSession.ticketNumber.trim());
const manualTicketDefaultsLocked = Boolean(manualLookupTicket?.organization_id && manualLookupTicket.work_type);
const manualTicketIsRecurringBilling =
@@ -151,6 +163,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
...current,
organizationId: result.ticket?.organization_id ?? "",
organizationName: result.ticket?.organization_name ?? result.ticket?.customer_name ?? null,
projectTeamspaceEntryId: "",
workType: result.ticket?.work_type ?? current.workType
};
});
@@ -169,7 +182,9 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
};
}, [manualSession.ticketNumber]);
async function addTimer() {
async function addTimer(source: "local" | "zammad" = "local") {
const normalizedTicketNumber = ticketNumber.trim();
if (!isValidTicket) {
toast.error("Ticketnummer prüfen", {
description: "Das Format muss Ticket#XXXXXX sein."
@@ -177,17 +192,40 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
return;
}
const ticketResult = await lookupTicket(ticketNumber).catch(() => ({ ticket: null }));
if (source === "zammad") {
setZammadLookupBusy("timer");
}
if (ticketHasRecurringBilling(ticketResult.ticket, recurringBillings) || isRecurringBillingTicketNumber(ticketNumber, recurringBillings)) {
setTimers((current) => current.filter((timer) => timer.ticketNumber !== ticketNumber));
const ticketResult = await lookupTicket(normalizedTicketNumber, source).catch((error) => {
if (source === "zammad") {
toast.error("Zammad-Suche fehlgeschlagen", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
}
return { ticket: null };
});
if (source === "zammad") {
setZammadLookupBusy(null);
if (!ticketResult.ticket) {
toast.error("Ticket nicht gefunden", {
description: "Zammad hat zu dieser Ticketnummer keinen Treffer geliefert."
});
return;
}
}
if (ticketHasRecurringBilling(ticketResult.ticket, recurringBillings) || isRecurringBillingTicketNumber(normalizedTicketNumber, recurringBillings)) {
setTimers((current) => current.filter((timer) => timer.ticketNumber !== normalizedTicketNumber));
toast.error("Fixe Abrechnung", {
description: "Für fixe Abrechnungen dürfen keine manuellen Sessions gestartet werden. Bitte die Definition bearbeiten."
});
return;
}
if (timers.some((timer) => timer.ticketNumber === ticketNumber)) {
if (timers.some((timer) => timer.ticketNumber === normalizedTicketNumber)) {
toast.error("Timer existiert bereits", {
description: "Für dieses Ticket läuft oder pausiert schon ein Timer."
});
@@ -197,7 +235,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
const now = Date.now();
const newTimer: TimerEntry = {
id: crypto.randomUUID(),
ticketNumber,
ticketNumber: normalizedTicketNumber,
organizationName: ticketResult.ticket?.organization_name ?? ticketResult.ticket?.customer_name ?? null,
workType: ticketResult.ticket?.work_type ?? null,
isProject: Boolean(ticketResult.ticket?.is_project),
@@ -214,6 +252,80 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
setTicketNumber("");
}
async function lookupManualTicketInZammad() {
const normalizedTicketNumber = manualSession.ticketNumber.trim();
if (!ticketPattern.test(normalizedTicketNumber)) {
toast.error("Ticketnummer prüfen", {
description: "Das Format muss Ticket#XXXXXX sein."
});
return;
}
setZammadLookupBusy("manual");
try {
const result = await lookupTicket(normalizedTicketNumber, "zammad");
if (!result.ticket) {
setManualLookupStatus("not-found");
toast.error("Ticket nicht gefunden", {
description: "Zammad hat zu dieser Ticketnummer keinen Treffer geliefert."
});
return;
}
setManualLookupTicket(result.ticket);
setManualLookupStatus("found");
setManualSession((current) => ({
...current,
organizationId: result.ticket?.organization_id ?? "",
organizationName: result.ticket?.organization_name ?? result.ticket?.customer_name ?? null,
projectTeamspaceEntryId: "",
workType: result.ticket?.work_type ?? current.workType
}));
toast.success("Zammad-Daten übernommen");
} catch (error) {
toast.error("Zammad-Suche fehlgeschlagen", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setZammadLookupBusy(null);
}
}
async function lookupFinishedTicketInZammad() {
if (!finished) {
return;
}
setZammadLookupBusy("finished");
try {
const result = await lookupTicket(finished.ticketNumber, "zammad");
if (!result.ticket) {
toast.error("Ticket nicht gefunden", {
description: "Zammad hat zu dieser Ticketnummer keinen Treffer geliefert."
});
return;
}
setFinished({ ...finished, ticket: result.ticket });
setOrganizationId(result.ticket.organization_id ?? "");
setOrganizationName(result.ticket.organization_name ?? result.ticket.customer_name ?? null);
setProjectTeamspaceEntryId("");
setWorkType(result.ticket.work_type ?? workType);
toast.success("Zammad-Daten übernommen");
} catch (error) {
toast.error("Zammad-Suche fehlgeschlagen", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setZammadLookupBusy(null);
}
}
function activateTimer(timerId: string) {
const now = Date.now();
setTimers((current) =>
@@ -268,6 +380,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
setOrganizationId(ticket?.organization_id ?? "");
setOrganizationName(ticket?.organization_name ?? null);
setProjectTeamspaceEntryId("");
setWorkType(ticket?.work_type ?? "support");
setActivity("");
setFinished({
@@ -306,6 +419,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
const existingTicketHasDefaults = Boolean(finished.ticket?.organization_id && finished.ticket?.work_type);
const effectiveOrganizationId = existingTicketHasDefaults ? finished.ticket!.organization_id! : organizationId;
const effectiveWorkType = existingTicketHasDefaults ? finished.ticket!.work_type! : workType;
const finishedRequiresTeamspaceEntry = requiresTeamspaceEntry(finished.ticket);
if (!effectiveOrganizationId) {
toast.error("Organisation wählen", {
@@ -314,6 +428,13 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
return;
}
if (finishedRequiresTeamspaceEntry && !projectTeamspaceEntryId) {
toast.error("Teamspace-Eintrag wählen", {
description: "Dieses Projekt hat mehrere Teamspace-Einträge."
});
return;
}
setSaving(true);
try {
@@ -324,7 +445,9 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
durationSeconds: finished.durationSeconds,
organizationId: effectiveOrganizationId,
activity,
workType: effectiveWorkType
workType: effectiveWorkType,
zammadLookup: Boolean(finished.ticket?.zammad_ticket_id),
projectTeamspaceEntryId: finishedRequiresTeamspaceEntry ? projectTeamspaceEntryId : null
});
toast.success("Session gespeichert", {
@@ -334,6 +457,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
setFinished(null);
setOrganizationId("");
setOrganizationName(null);
setProjectTeamspaceEntryId("");
setActivity("");
setWorkType("support");
} catch (error) {
@@ -367,6 +491,13 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
return;
}
if (requiresTeamspaceEntry(manualLookupTicket) && !manualSession.projectTeamspaceEntryId) {
toast.error("Teamspace-Eintrag wählen", {
description: "Dieses Projekt hat mehrere Teamspace-Einträge."
});
return;
}
const startedAt = new Date(`${manualSession.day}T${manualSession.startTime}:00`);
const endedAt = new Date(`${manualSession.day}T${manualSession.endTime}:00`);
@@ -392,7 +523,9 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
workType: manualSession.workType,
startedAt: startedAt.toISOString(),
endedAt: endedAt.toISOString(),
durationSeconds
durationSeconds,
zammadLookup: Boolean(manualLookupTicket?.zammad_ticket_id),
projectTeamspaceEntryId: requiresTeamspaceEntry(manualLookupTicket) ? manualSession.projectTeamspaceEntryId : null
});
toast.success("Session nachgetragen", {
description: manualLookupTicket?.is_project ? "Der Eintrag wurde dem Projekt zugeordnet." : "Der Eintrag ist in deiner Ticketliste offen."
@@ -433,7 +566,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
<HelpLink anchor="timer" label="Hilfe zu Timern" />
</div>
</CardHeader>
<CardContent className="grid gap-3 p-4 sm:grid-cols-[minmax(220px,320px)_auto] sm:items-end">
<CardContent className="grid gap-3 p-4 sm:grid-cols-[minmax(220px,320px)_auto_auto] sm:items-end">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="ticket-number">
Ticketnummer
@@ -456,6 +589,10 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
<Plus className="size-4" />
Timer starten
</Button>
<Button type="button" variant="secondary" disabled={!isValidTicket || zammadLookupBusy === "timer"} onClick={() => void addTimer("zammad")}>
<Search className="size-4" />
{zammadLookupBusy === "timer" ? "Sucht..." : "In Zammad suchen"}
</Button>
</CardContent>
</Card>
@@ -533,6 +670,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
ticketNumber: event.currentTarget.value,
organizationId: "",
organizationName: null,
projectTeamspaceEntryId: "",
workType: "support"
})
}
@@ -551,7 +689,15 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
{manualTicketIsRecurringBilling ? (
<p className="text-xs text-destructive">Fixe Abrechnung: Sessions kommen ausschließlich aus der Definition.</p>
) : null}
{manualLookupStatus === "not-found" ? <p className="text-xs text-muted-foreground">Neues Ticket. Organisation und Art bitte wählen.</p> : null}
{manualLookupStatus === "not-found" ? (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">Neues Ticket. Organisation und Art bitte wählen oder aus Zammad übernehmen.</p>
<Button type="button" size="sm" variant="secondary" disabled={zammadLookupBusy === "manual"} onClick={() => void lookupManualTicketInZammad()}>
<Search className="size-3.5" />
{zammadLookupBusy === "manual" ? "Sucht..." : "In Zammad suchen"}
</Button>
</div>
) : null}
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Organisation</label>
@@ -582,6 +728,25 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
<option value="consulting">Consulting</option>
</select>
</div>
{requiresTeamspaceEntry(manualLookupTicket) ? (
<div className="space-y-2 lg:col-span-3">
<label className="text-sm font-medium" htmlFor="manual-teamspace-entry">Teamspace-Eintrag</label>
<select
id="manual-teamspace-entry"
className="h-9 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
value={manualSession.projectTeamspaceEntryId}
onChange={(event) => setManualSession({ ...manualSession, projectTeamspaceEntryId: event.currentTarget.value })}
required
>
<option value="">Bitte wählen</option>
{ticketTeamspaceEntries(manualLookupTicket).map((entry) => (
<option key={entry.id} value={entry.id}>
{entry.teamspace_project || (entry.is_default ? "Standard" : "Ohne Teamspace-ID")}
</option>
))}
</select>
</div>
) : null}
</div>
<div className="grid gap-3 lg:grid-cols-[160px_120px_120px_minmax(220px,1fr)_auto] lg:items-end">
@@ -646,6 +811,14 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
</DialogHeader>
<form className="space-y-4" onSubmit={submitSession}>
{finished && !finished.ticket?.organization_id ? (
<div className="flex justify-end">
<Button type="button" size="sm" variant="secondary" disabled={zammadLookupBusy === "finished"} onClick={() => void lookupFinishedTicketInZammad()}>
<Search className="size-3.5" />
{zammadLookupBusy === "finished" ? "Sucht..." : "In Zammad suchen"}
</Button>
</div>
) : null}
{finished?.ticket?.organization_id && finished.ticket.work_type ? (
<div className="rounded-lg border bg-muted/40 p-3 text-sm">
<p className="font-medium">{finished.ticket.organization_name ?? finished.ticket.customer_name}</p>
@@ -694,6 +867,25 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
</select>
</div>
)}
{requiresTeamspaceEntry(finished?.ticket) ? (
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="finished-teamspace-entry">Teamspace-Eintrag</label>
<select
id="finished-teamspace-entry"
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
value={projectTeamspaceEntryId}
onChange={(event) => setProjectTeamspaceEntryId(event.currentTarget.value)}
required
>
<option value="">Bitte wählen</option>
{ticketTeamspaceEntries(finished?.ticket).map((entry) => (
<option key={entry.id} value={entry.id}>
{entry.teamspace_project || (entry.is_default ? "Standard" : "Ohne Teamspace-ID")}
</option>
))}
</select>
</div>
) : null}
<div className="flex justify-end">
<Button type="submit" disabled={saving}>
<Upload className="size-4" />
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env bash
set -euo pipefail
NODE_MAJOR_REQUIRED=22
NODESOURCE_SETUP_URL="https://deb.nodesource.com/setup_${NODE_MAJOR_REQUIRED}.x"
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
if [[ ! -f "${REPO_ROOT}/package.json" ]]; then
echo "Repo-Root nicht gefunden. Erwartet: ${REPO_ROOT}/package.json"
exit 1
fi
if ! command -v apt-get >/dev/null 2>&1; then
echo "Dieses Setup-Script unterstuetzt aktuell Debian/Ubuntu mit apt-get."
exit 1
fi
SUDO=()
if [[ "${EUID}" -ne 0 ]]; then
if ! command -v sudo >/dev/null 2>&1; then
echo "Bitte als root ausfuehren oder sudo installieren."
exit 1
fi
SUDO=(sudo)
fi
run_apt() {
if [[ "${#SUDO[@]}" -gt 0 ]]; then
"${SUDO[@]}" env DEBIAN_FRONTEND=noninteractive apt-get "$@"
else
DEBIAN_FRONTEND=noninteractive apt-get "$@"
fi
}
run_as_root_preserve_env() {
if [[ "${#SUDO[@]}" -gt 0 ]]; then
"${SUDO[@]}" -E "$@"
else
"$@"
fi
}
node_major() {
node -p 'Number(process.versions.node.split(".")[0])' 2>/dev/null || echo 0
}
echo "Installiere Basiswerkzeuge fuer die lokale Webapp..."
run_apt update
run_apt install -y ca-certificates curl gnupg git bash build-essential
CURRENT_NODE_MAJOR="$(node_major)"
if [[ "${CURRENT_NODE_MAJOR}" -lt "${NODE_MAJOR_REQUIRED}" ]]; then
echo "Installiere Node.js ${NODE_MAJOR_REQUIRED}.x ueber NodeSource..."
TMP_SETUP="$(mktemp)"
curl -fsSL "${NODESOURCE_SETUP_URL}" -o "${TMP_SETUP}"
run_as_root_preserve_env bash "${TMP_SETUP}"
rm -f "${TMP_SETUP}"
run_apt install -y nodejs
else
echo "Node.js $(node --version) ist bereits kompatibel."
fi
CURRENT_NODE_MAJOR="$(node_major)"
if [[ "${CURRENT_NODE_MAJOR}" -lt "${NODE_MAJOR_REQUIRED}" ]]; then
echo "Node.js >= ${NODE_MAJOR_REQUIRED} wird benoetigt, aktiv ist aber: $(node --version 2>/dev/null || echo nicht installiert)"
exit 1
fi
if ! command -v npm >/dev/null 2>&1; then
echo "npm wurde nicht gefunden. Bitte Node.js/npm Installation pruefen."
exit 1
fi
echo "Installiere npm-Abhaengigkeiten im Repo..."
cd "${REPO_ROOT}"
if [[ -f package-lock.json ]]; then
npm ci --include=dev
else
npm install --include=dev
fi
chmod +x "${REPO_ROOT}/scripts/start-local.sh"
echo
echo "Fertig. Zum Starten noch DATABASE_URL setzen und dann ausfuehren:"
echo " npm run serve:local"
+2 -2
View File
@@ -6,9 +6,9 @@ BACKEND_PORT="${BACKEND_PORT:-3001}"
export API_PROXY_TARGET="${API_PROXY_TARGET:-http://127.0.0.1:${BACKEND_PORT}}"
export NEXT_TELEMETRY_DISABLED=1
if [[ -f ".env" ]]; then
if [[ -f ".env_dev" ]]; then
set -a
source .env
source .env_dev
set +a
fi