Compare commits

..
2 Commits
Author SHA1 Message Date
michael 07ef8ef025 Weiter 2026-09-09 12:55:56 +02:00
michael c2063fe1ed Initial TicketTracker release 2026-09-09 09:42:48 +02:00
15 changed files with 2732 additions and 252 deletions

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
+13 -2
View File
@@ -15,6 +15,8 @@ export type AuthUser = {
display_name: string; display_name: string;
role: UserRole; role: UserRole;
active: boolean; active: boolean;
zammad_user_id: string | null;
has_zammad_api_token: boolean;
}; };
declare global { declare global {
@@ -73,7 +75,9 @@ function publicUser(user: AuthUser) {
username: user.username, username: user.username,
display_name: user.display_name, display_name: user.display_name,
role: user.role, role: user.role,
active: user.active active: user.active,
zammad_user_id: user.zammad_user_id ?? null,
has_zammad_api_token: Boolean(user.has_zammad_api_token)
}; };
} }
@@ -123,7 +127,14 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio
const tokenHash = hashToken(token); const tokenHash = hashToken(token);
const result = await query<AuthUser>( const result = await query<AuthUser>(
` `
SELECT u.id, u.username, u.display_name, u.role, u.active SELECT
u.id,
u.username,
u.display_name,
u.role,
u.active,
u.zammad_user_id::text AS zammad_user_id,
(u.zammad_api_token IS NOT NULL) AS has_zammad_api_token
FROM auth_sessions s FROM auth_sessions s
JOIN users u ON u.id = s.user_id JOIN users u ON u.id = s.user_id
WHERE s.token_hash = $1 WHERE s.token_hash = $1
+1246 -76
View File
File diff suppressed because it is too large. Load diff
+91
View File
@@ -60,6 +60,8 @@ export async function migrate() {
DROP CONSTRAINT IF EXISTS users_username_format, DROP CONSTRAINT IF EXISTS users_username_format,
ADD CONSTRAINT users_username_format CHECK (char_length(username) BETWEEN 3 AND 40 AND username !~ '\\s'); ADD CONSTRAINT users_username_format CHECK (char_length(username) BETWEEN 3 AND 40 AND username !~ '\\s');
`); `);
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_api_token TEXT;");
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_user_id BIGINT;");
await query(` await query(`
CREATE TABLE IF NOT EXISTS auth_sessions ( CREATE TABLE IF NOT EXISTS auth_sessions (
@@ -137,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 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 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 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(` await query(`
ALTER TABLE tickets ALTER TABLE tickets
DROP CONSTRAINT IF EXISTS ticket_number_format, DROP CONSTRAINT IF EXISTS ticket_number_format,
@@ -184,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_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_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 recurring_occurrence_key TEXT;");
await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS project_teamspace_entry_id BIGINT;");
if (adminId) { if (adminId) {
await query("UPDATE sessions SET user_id = $1 WHERE user_id IS NULL;", [adminId]); await query("UPDATE sessions SET user_id = $1 WHERE user_id IS NULL;", [adminId]);
} }
@@ -485,6 +494,37 @@ export async function migrate() {
END $$; END $$;
`); `);
await query(`
CREATE TABLE IF NOT EXISTS project_teamspace_entries (
id BIGSERIAL PRIMARY KEY,
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
teamspace_project TEXT,
is_default BOOLEAN NOT NULL DEFAULT false,
hour_budget_minutes INTEGER,
manual_consumption_minutes INTEGER NOT NULL DEFAULT 0,
display_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT project_teamspace_entries_budget_non_negative CHECK (hour_budget_minutes IS NULL OR hour_budget_minutes >= 0),
CONSTRAINT project_teamspace_entries_manual_non_negative CHECK (manual_consumption_minutes >= 0)
);
`);
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(` await query(`
UPDATE tickets t UPDATE tickets t
SET work_type = 'consulting' SET work_type = 'consulting'
@@ -506,6 +546,56 @@ export async function migrate() {
ON CONFLICT (user_id, ticket_id) DO NOTHING; ON CONFLICT (user_id, ticket_id) DO NOTHING;
`); `);
await query(`
INSERT INTO project_teamspace_entries (project_id, teamspace_project, is_default, hour_budget_minutes, manual_consumption_minutes, display_order)
SELECT p.id, p.teamspace_project, true, p.hour_budget_minutes, p.manual_consumption_minutes, 0
FROM projects p
WHERE NOT EXISTS (
SELECT 1
FROM project_teamspace_entries pte
WHERE pte.project_id = p.id
);
`);
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(` await query(`
CREATE TABLE IF NOT EXISTS ticket_todos ( CREATE TABLE IF NOT EXISTS ticket_todos (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
@@ -569,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_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_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_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 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_sessions_billing_status ON sessions(billing_status);");
await query("CREATE INDEX IF NOT EXISTS idx_ticket_day_closures_day ON ticket_day_closures(day);"); await query("CREATE INDEX IF NOT EXISTS idx_ticket_day_closures_day ON ticket_day_closures(day);");
+7
View File
@@ -510,9 +510,16 @@ Unter `Profil` kann jeder Benutzer seine eigenen Daten pflegen:
- Benutzername - Benutzername
- Voller Name - Voller Name
- Passwort - Passwort
- persoenlicher Zammad API-Token
Passwortaenderungen erfordern das aktuelle Passwort. Bleiben die Passwortfelder leer, werden nur Benutzername und voller Name gespeichert. Passwortaenderungen erfordern das aktuelle Passwort. Bleiben die Passwortfelder leer, werden nur Benutzername und voller Name gespeichert.
Normale Benutzer koennen im Profil einen persoenlichen Zammad API-Token hinterlegen. Beim Speichern eines neuen Tokens ruft TicketTracker Zammad ueber `/api/v1/users/me` auf und speichert die User-ID, die Zammad fuer diesen Token zurueckliefert. Die User-ID wird danach read-only im Profil angezeigt.
Die Pruefung findet nur statt, wenn der Token im Profil neu eingetragen oder entfernt wird. Wenn nur Benutzername, voller Name oder Passwort gespeichert werden, wird Zammad nicht erneut abgefragt.
Der persoenliche Zammad Token ist unabhaengig vom Admin-Token fuer den Organisations-Sync. Der gleiche Token kann technisch in beiden Bereichen eingetragen sein, wird aber getrennt gespeichert und getrennt verwendet. Im Admin-Profil gibt es keinen persoenlichen Zammad-Token.
## Adminbereich ## Adminbereich
Der feste Admin ist nur fuer die Systemverwaltung vorgesehen. Er kann: Der feste Admin ist nur fuer die Systemverwaltung vorgesehen. Er kann:
+29 -2
View File
@@ -26,6 +26,8 @@ type CreateSessionPayload = {
startedAt: string; startedAt: string;
endedAt: string; endedAt: string;
durationSeconds: number; durationSeconds: number;
zammadLookup?: boolean;
projectTeamspaceEntryId?: string | null;
}; };
type DeleteSessionResult = { type DeleteSessionResult = {
@@ -35,6 +37,8 @@ type DeleteSessionResult = {
started_at: string; started_at: string;
userTicketEmpty: boolean; userTicketEmpty: boolean;
ticketDeleted: boolean; ticketDeleted: boolean;
projectDeleteAvailable: boolean;
project_id: string | null;
}; };
}; };
@@ -94,6 +98,7 @@ export function updateCurrentUser(payload: {
displayName: string; displayName: string;
currentPassword?: string; currentPassword?: string;
newPassword?: string; newPassword?: string;
zammadApiToken?: string;
}) { }) {
return request<{ user: AuthUser }>("/api/auth/me", { return request<{ user: AuthUser }>("/api/auth/me", {
method: "PATCH", method: "PATCH",
@@ -337,7 +342,15 @@ export function createProject(payload: {
ticketNumber: string; ticketNumber: string;
title?: string | null; title?: string | null;
organizationId?: string | null; organizationId?: string | null;
zammadLookup?: boolean;
teamspaceProject?: string | null; teamspaceProject?: string | null;
teamspaceEntries?: Array<{
id?: string | null;
teamspaceProject?: string | null;
isDefault: boolean;
hourBudgetMinutes?: number | null;
manualConsumptionMinutes?: number | null;
}>;
description: string; description: string;
goals: string; goals: string;
startsOn?: string | null; startsOn?: string | null;
@@ -358,6 +371,13 @@ export function updateProject(
title?: string | null; title?: string | null;
organizationId: string; organizationId: string;
teamspaceProject?: string | null; teamspaceProject?: string | null;
teamspaceEntries?: Array<{
id?: string | null;
teamspaceProject?: string | null;
isDefault: boolean;
hourBudgetMinutes?: number | null;
manualConsumptionMinutes?: number | null;
}>;
description: string; description: string;
goals: string; goals: string;
startsOn?: string | null; startsOn?: string | null;
@@ -448,8 +468,14 @@ export function createSession(payload: CreateSessionPayload) {
}); });
} }
export function lookupTicket(ticketNumber: string) { export function lookupTicket(ticketNumber: string, source: "local" | "zammad" = "local") {
return request<{ ticket: TicketMeta | null }>(`/api/tickets/lookup?ticketNumber=${encodeURIComponent(ticketNumber)}`); 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) { export function searchTickets(query: string) {
@@ -479,6 +505,7 @@ export function updateSessionDetails(
activity: string; activity: string;
workType: WorkType; workType: WorkType;
removeProjectData?: boolean; removeProjectData?: boolean;
projectTeamspaceEntryId?: string | null;
startedAt: string; startedAt: string;
endedAt: string; endedAt: string;
} }
+7
View File
@@ -500,6 +500,7 @@ export const helpSections: HelpSection[] = [
quickItems: [ quickItems: [
"Benutzername und voller Name sind änderbar.", "Benutzername und voller Name sind änderbar.",
"Passwortänderung benötigt das aktuelle Passwort.", "Passwortänderung benötigt das aktuelle Passwort.",
"Normale Benutzer können ihren persönlichen Zammad API-Token hinterlegen.",
"Der volle Name wird in der Oberfläche angezeigt." "Der volle Name wird in der Oberfläche angezeigt."
], ],
details: [ details: [
@@ -507,6 +508,12 @@ export const helpSections: HelpSection[] = [
"Der volle Name erscheint in der App, zum Beispiel im Kopfbereich oder im Adminbereich.", "Der volle Name erscheint in der App, zum Beispiel im Kopfbereich oder im Adminbereich.",
"Wenn du dein Passwort ändern willst, musst du das aktuelle Passwort sowie das neue Passwort zweimal eingeben.", "Wenn du dein Passwort ändern willst, musst du das aktuelle Passwort sowie das neue Passwort zweimal eingeben.",
"Bleiben die Passwortfelder leer, werden nur Benutzername und voller Name gespeichert.", "Bleiben die Passwortfelder leer, werden nur Benutzername und voller Name gespeichert.",
"Normale Benutzer können im Profil einen persönlichen Zammad API-Token speichern.",
"Beim Speichern eines neuen Tokens fragt TicketTracker Zammad über /api/v1/users/me ab und speichert die zurückgelieferte Zammad User-ID.",
"Die gespeicherte Zammad User-ID wird danach read-only im Profil angezeigt.",
"Die Token-Prüfung läuft nur, wenn der Token im Profil geändert oder entfernt wird. Normale Profiländerungen prüfen Zammad nicht erneut.",
"Der persönliche Zammad Token ist vom Admin-Token für den Organisations-Sync getrennt. Es kann zufällig derselbe Token sein, wird aber separat gespeichert und verwendet.",
"Im Admin-Profil wird kein persönlicher Zammad-Token angezeigt.",
"Admins können Benutzer ebenfalls verwalten, aber jeder Benutzer kann die eigenen Daten jederzeit selbst pflegen." "Admins können Benutzer ebenfalls verwalten, aber jeder Benutzer kann die eigenen Daten jederzeit selbst pflegen."
] ]
}, },
+36
View File
@@ -10,6 +10,8 @@ export type AuthUser = {
display_name: string; display_name: string;
role: UserRole; role: UserRole;
active: boolean; active: boolean;
zammad_user_id: string | null;
has_zammad_api_token: boolean;
}; };
export type DashboardWidgetType = "today" | "month" | "openRatings" | "projectBudgets" | "todos" | "teamspace" | "favorites" | "divider" | "statistic" | "notes"; export type DashboardWidgetType = "today" | "month" | "openRatings" | "projectBudgets" | "todos" | "teamspace" | "favorites" | "divider" | "statistic" | "notes";
@@ -87,9 +89,20 @@ export type TicketMeta = {
organization_name: string | null; organization_name: string | null;
customer_name: string | null; customer_name: string | null;
work_type: WorkType | 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; project_id?: string | null;
is_project?: boolean; is_project?: boolean;
project_name?: string | null; 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; has_recurring_billing?: boolean;
}; };
@@ -112,6 +125,10 @@ export type TicketSearchResult = {
export type ProjectSummary = { export type ProjectSummary = {
id: string; id: string;
user_id?: string;
owner_username?: string | null;
owner_display_name?: string | null;
can_edit?: boolean;
ticket_id: string; ticket_id: string;
ticket_number: string; ticket_number: string;
title: string | null; title: string | null;
@@ -139,6 +156,21 @@ export type ProjectSummary = {
open_todos?: number; open_todos?: number;
overdue_todos?: number; overdue_todos?: number;
done_todos?: number; done_todos?: number;
teamspace_entry_count?: number;
teamspace_entries?: ProjectTeamspaceEntry[];
unassigned_teamspace_session_count?: number;
};
export type ProjectTeamspaceEntry = {
id: string;
project_id?: string;
teamspace_project: string | null;
is_default: boolean;
hour_budget_minutes: number | null;
manual_consumption_minutes: number;
teamspace_consumption_minutes?: number;
consumption_minutes?: number;
display_order?: number;
}; };
export type TodoPriority = "low" | "medium" | "high" | "urgent"; export type TodoPriority = "low" | "medium" | "high" | "urgent";
@@ -196,6 +228,8 @@ export type OpenSession = {
customer_name: string; customer_name: string;
activity: string; activity: string;
work_type: WorkType; work_type: WorkType;
project_teamspace_entry_id?: string | null;
project_teamspace_project?: string | null;
started_at: string; started_at: string;
rounded_minutes: number; rounded_minutes: number;
recurring_billing_id?: string | null; recurring_billing_id?: string | null;
@@ -244,6 +278,8 @@ export type SessionEntry = {
customer_name: string; customer_name: string;
activity: string; activity: string;
work_type: WorkType; work_type: WorkType;
project_teamspace_entry_id?: string | null;
project_teamspace_project?: string | null;
started_at: string; started_at: string;
ended_at: string; ended_at: string;
duration_seconds: number; duration_seconds: number;
+112 -4
View File
@@ -54,6 +54,7 @@ import type {
DashboardWidget, DashboardWidget,
DashboardWidgetType, DashboardWidgetType,
PeriodOverview, PeriodOverview,
ProjectTeamspaceEntry,
ProjectSummary, ProjectSummary,
RecurringBilling, RecurringBilling,
StatisticsOverview, StatisticsOverview,
@@ -571,6 +572,25 @@ function projectConsumptionPercent(project: ProjectSummary) {
return Math.round(((project.consumption_minutes ?? 0) / budgetMinutes) * 100); return Math.round(((project.consumption_minutes ?? 0) / budgetMinutes) * 100);
} }
function projectTeamspaceEntries(project: ProjectSummary): ProjectTeamspaceEntry[] {
if (project.teamspace_entries?.length) {
return project.teamspace_entries;
}
return [
{
id: `${project.id}-default`,
teamspace_project: project.teamspace_project,
is_default: true,
hour_budget_minutes: project.hour_budget_minutes,
manual_consumption_minutes: project.manual_consumption_minutes ?? 0,
teamspace_consumption_minutes: project.teamspace_consumption_minutes ?? 0,
consumption_minutes: project.consumption_minutes ?? project.teamspace_consumption_minutes ?? 0,
display_order: 0
}
];
}
function openRatingFilterGroup(filter: string) { function openRatingFilterGroup(filter: string) {
if (filter === "fixedBilling" || filter === "manualBilling") { if (filter === "fixedBilling" || filter === "manualBilling") {
return "source"; return "source";
@@ -1648,18 +1668,48 @@ function ProjectBudgetWidget({ widget, editing, projects, stats, onNavigate, onU
const consumptionMinutes = project.consumption_minutes ?? 0; const consumptionMinutes = project.consumption_minutes ?? 0;
const budgetMinutes = project.hour_budget_minutes ?? 0; const budgetMinutes = project.hour_budget_minutes ?? 0;
const percent = projectConsumptionPercent(project); const percent = projectConsumptionPercent(project);
const entries = projectTeamspaceEntries(project);
const hasMultipleTeamspaceEntries = entries.length > 1;
return ( return (
<button key={project.id} type="button" className="block w-full min-w-0 rounded-md px-2 py-1.5 text-left hover:bg-muted" onClick={() => onNavigate(`/tickets/${project.ticket_id}?month=${currentMonth()}`)}> <button key={project.id} type="button" className="block w-full min-w-0 rounded-md px-2 py-1.5 text-left hover:bg-muted" onClick={() => onNavigate(`/tickets/${project.ticket_id}?month=${currentMonth()}`)}>
<span className="block truncate text-xs font-medium">{project.title || project.ticket_number}</span> <span className="block truncate text-xs font-medium">{project.title || project.ticket_number}</span>
<span className="block truncate text-[11px] text-muted-foreground"> <span className="block truncate text-[11px] text-muted-foreground">
{project.ticket_number} · {budgetMinutes > 0 ? `${decimalHours(consumptionMinutes)} / ${decimalHours(budgetMinutes)} · ${percent ?? 0}%` : "ohne Kontingent"} {project.ticket_number} · {hasMultipleTeamspaceEntries ? `${entries.length} Teamspace · ` : ""}{budgetMinutes > 0 ? `${decimalHours(consumptionMinutes)} / ${decimalHours(budgetMinutes)} · ${percent ?? 0}%` : "ohne Kontingent"}
</span> </span>
{percent !== null ? ( {percent !== null ? (
<span className="mt-1 block h-1.5 overflow-hidden rounded-full bg-muted" title={`${percent}% Verbrauch`} aria-label={`${percent}% Verbrauch`}> <span className="mt-1 block h-1.5 overflow-hidden rounded-full bg-muted" title={`${percent}% Verbrauch`} aria-label={`${percent}% Verbrauch`}>
<span className={cn("block h-full rounded-full", consumptionBarClass(percent))} style={{ width: `${Math.min(100, percent)}%` }} /> <span className={cn("block h-full rounded-full", consumptionBarClass(percent))} style={{ width: `${Math.min(100, percent)}%` }} />
</span> </span>
) : null} ) : null}
{hasMultipleTeamspaceEntries ? (
<span className="mt-1 grid gap-1">
{entries.map((entry) => {
const entryBudgetMinutes = entry.hour_budget_minutes ?? 0;
const entryConsumptionMinutes = entry.consumption_minutes ?? 0;
const entryPercent = entryBudgetMinutes > 0 ? Math.round((entryConsumptionMinutes / entryBudgetMinutes) * 100) : null;
return (
<span key={entry.id} className="block">
<span className="flex min-w-0 items-center justify-between gap-2 text-[10px] text-muted-foreground">
<span className="truncate">
{entry.teamspace_project || (entry.is_default ? "Standard" : "Ohne Teamspace-ID")}
{entry.is_default ? " · Standard" : ""}
</span>
<span className="shrink-0">
{entryBudgetMinutes > 0 ? `${decimalHours(entryConsumptionMinutes)} / ${decimalHours(entryBudgetMinutes)}` : decimalHours(entryConsumptionMinutes)}
</span>
</span>
{entryPercent !== null ? (
<span className="mt-0.5 block h-1 overflow-hidden rounded-full bg-muted">
<span className={cn("block h-full rounded-full", consumptionBarClass(entryPercent))} style={{ width: `${Math.min(100, entryPercent)}%` }} />
</span>
) : null}
</span>
);
})}
</span>
) : null}
</button> </button>
); );
})} })}
@@ -1973,6 +2023,46 @@ function NotesWidget({ widget, editing, onUpdateConfig }: WidgetContentProps) {
const { notes, activeNoteId } = readWidgetNotes(widget.config); const { notes, activeNoteId } = readWidgetNotes(widget.config);
const activeNote = notes.find((note) => note.id === activeNoteId) ?? notes[0]; const activeNote = notes.find((note) => note.id === activeNoteId) ?? notes[0];
const tabsScrollerRef = useRef<HTMLDivElement | null>(null); const tabsScrollerRef = useRef<HTMLDivElement | null>(null);
const [tabScrollState, setTabScrollState] = useState({ canScrollLeft: false, canScrollRight: false });
function updateTabScrollState() {
const scroller = tabsScrollerRef.current;
if (!scroller) {
setTabScrollState({ canScrollLeft: false, canScrollRight: false });
return;
}
const maxScrollLeft = scroller.scrollWidth - scroller.clientWidth;
setTabScrollState({
canScrollLeft: scroller.scrollLeft > 1,
canScrollRight: scroller.scrollLeft < maxScrollLeft - 1
});
}
useEffect(() => {
updateTabScrollState();
const scroller = tabsScrollerRef.current;
if (!scroller) {
return;
}
const handleScroll = () => updateTabScrollState();
const handleResize = () => updateTabScrollState();
scroller.addEventListener("scroll", handleScroll, { passive: true });
window.addEventListener("resize", handleResize);
return () => {
scroller.removeEventListener("scroll", handleScroll);
window.removeEventListener("resize", handleResize);
};
}, [notes.length]);
useEffect(() => {
updateTabScrollState();
}, [activeNoteId, notes.length]);
function saveNotes(nextNotes: DashboardNote[], nextActiveNoteId = activeNoteId) { function saveNotes(nextNotes: DashboardNote[], nextActiveNoteId = activeNoteId) {
const safeActiveNoteId = nextNotes.some((note) => note.id === nextActiveNoteId) ? nextActiveNoteId : nextNotes[0]?.id; const safeActiveNoteId = nextNotes.some((note) => note.id === nextActiveNoteId) ? nextActiveNoteId : nextNotes[0]?.id;
@@ -2005,10 +2095,27 @@ function NotesWidget({ widget, editing, onUpdateConfig }: WidgetContentProps) {
return ( return (
<Tabs value={activeNoteId} onValueChange={(value) => onUpdateConfig({ activeNoteId: value })} className="flex h-full min-h-0 overflow-hidden gap-1"> <Tabs value={activeNoteId} onValueChange={(value) => onUpdateConfig({ activeNoteId: value })} className="flex h-full min-h-0 overflow-hidden gap-1">
<div className="flex h-8 min-w-0 shrink-0 items-center gap-1 overflow-hidden"> <div className="flex h-8 min-w-0 shrink-0 items-center gap-1 overflow-hidden">
<Button type="button" size="icon" variant="ghost" className="size-7 shrink-0" onClick={() => scrollTabs(-1)}> <Button type="button" size="icon" variant="ghost" className="size-7 shrink-0" disabled={!tabScrollState.canScrollLeft} onClick={() => scrollTabs(-1)}>
<ChevronLeft className="size-3.5" /> <ChevronLeft className="size-3.5" />
</Button> </Button>
<div ref={tabsScrollerRef} className="min-w-0 flex-1 overflow-x-auto overflow-y-hidden"> <div className="relative min-w-0 flex-1">
<div
className={cn(
"pointer-events-none absolute inset-y-0 left-0 z-10 w-8 bg-gradient-to-r from-card to-transparent transition-opacity",
tabScrollState.canScrollLeft ? "opacity-100" : "opacity-0"
)}
/>
<div
className={cn(
"pointer-events-none absolute inset-y-0 right-0 z-10 w-8 bg-gradient-to-l from-card to-transparent transition-opacity",
tabScrollState.canScrollRight ? "opacity-100" : "opacity-0"
)}
/>
<div
ref={tabsScrollerRef}
className="min-w-0 flex-1 overflow-x-auto overflow-y-hidden [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
onScroll={updateTabScrollState}
>
<TabsList variant="line" className="h-8 w-max min-w-max justify-start rounded-none p-0 pb-1"> <TabsList variant="line" className="h-8 w-max min-w-max justify-start rounded-none p-0 pb-1">
{notes.map((note) => ( {notes.map((note) => (
<TabsTrigger key={note.id} value={note.id} className="h-7 w-32 shrink-0 flex-none px-2 text-xs"> <TabsTrigger key={note.id} value={note.id} className="h-7 w-32 shrink-0 flex-none px-2 text-xs">
@@ -2016,8 +2123,9 @@ function NotesWidget({ widget, editing, onUpdateConfig }: WidgetContentProps) {
</TabsTrigger> </TabsTrigger>
))} ))}
</TabsList> </TabsList>
</div>
</div> </div>
<Button type="button" size="icon" variant="ghost" className="size-7 shrink-0" onClick={() => scrollTabs(1)}> <Button type="button" size="icon" variant="ghost" className="size-7 shrink-0" disabled={!tabScrollState.canScrollRight} onClick={() => scrollTabs(1)}>
<ChevronRight className="size-3.5" /> <ChevronRight className="size-3.5" />
</Button> </Button>
<Button type="button" size="icon" variant="ghost" className="size-7 shrink-0" onClick={addNote}> <Button type="button" size="icon" variant="ghost" className="size-7 shrink-0" onClick={addNote}>
+70 -7
View File
@@ -1,5 +1,5 @@
import { FormEvent, useEffect, useState } from "react"; import { FormEvent, useEffect, useState } from "react";
import { KeyRound, Save, UserCircle } from "lucide-react"; import { KeyRound, Save, Unplug, UserCircle, X } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@@ -19,11 +19,15 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
const [currentPassword, setCurrentPassword] = useState(""); const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState(""); const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState("");
const [zammadApiToken, setZammadApiToken] = useState("");
const [zammadTokenDirty, setZammadTokenDirty] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
useEffect(() => { useEffect(() => {
setUsername(currentUser.username); setUsername(currentUser.username);
setDisplayName(currentUser.display_name); setDisplayName(currentUser.display_name);
setZammadApiToken("");
setZammadTokenDirty(false);
}, [currentUser]); }, [currentUser]);
async function submit(event: FormEvent) { async function submit(event: FormEvent) {
@@ -45,17 +49,22 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
setSaving(true); setSaving(true);
const payload = {
username,
displayName,
currentPassword: wantsPasswordChange ? currentPassword : undefined,
newPassword: wantsPasswordChange ? newPassword : undefined,
...(currentUser.role === "user" && zammadTokenDirty ? { zammadApiToken } : {})
};
try { try {
const result = await updateCurrentUser({ const result = await updateCurrentUser(payload);
username,
displayName,
currentPassword: wantsPasswordChange ? currentPassword : undefined,
newPassword: wantsPasswordChange ? newPassword : undefined
});
onUserUpdated(result.user); onUserUpdated(result.user);
setCurrentPassword(""); setCurrentPassword("");
setNewPassword(""); setNewPassword("");
setConfirmPassword(""); setConfirmPassword("");
setZammadApiToken("");
setZammadTokenDirty(false);
toast.success("Profil gespeichert"); toast.success("Profil gespeichert");
} catch (error) { } catch (error) {
toast.error("Profil konnte nicht gespeichert werden", { toast.error("Profil konnte nicht gespeichert werden", {
@@ -146,6 +155,60 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
</div> </div>
</div> </div>
{currentUser.role === "user" ? (
<div className="rounded-md border bg-muted/30 p-3">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Unplug className="size-4 text-muted-foreground" />
<p className="text-sm font-medium">Zammad API</p>
</div>
<HelpLink anchor="profil" label="Hilfe zu Zammad im Profil" />
</div>
<div className="grid gap-3 md:grid-cols-[minmax(220px,1fr)_180px]">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="profile-zammad-token">
Persönlicher API-Token
</label>
<Input
id="profile-zammad-token"
type="password"
value={zammadApiToken}
placeholder={currentUser.has_zammad_api_token ? "••••••••••••••••" : "Token aus Zammad einfügen"}
autoComplete="off"
onChange={(event) => {
setZammadApiToken(event.currentTarget.value);
setZammadTokenDirty(true);
}}
/>
<p className="text-xs text-muted-foreground">
Die Zammad-User-ID wird nur geprüft, wenn du hier einen neuen Token speicherst.
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="profile-zammad-user-id">
Zammad User-ID
</label>
<Input id="profile-zammad-user-id" value={currentUser.zammad_user_id ?? "nicht verknüpft"} readOnly />
{currentUser.has_zammad_api_token ? (
<Button
type="button"
size="sm"
variant="secondary"
className="w-full"
onClick={() => {
setZammadApiToken("");
setZammadTokenDirty(true);
}}
>
<X className="size-4" />
Token entfernen
</Button>
) : null}
</div>
</div>
</div>
) : null}
<div className="flex justify-end"> <div className="flex justify-end">
<Button type="submit" disabled={saving}> <Button type="submit" disabled={saving}>
<Save className="size-4" /> <Save className="size-4" />
+380 -82
View File
@@ -1,4 +1,4 @@
import { ArrowDown, ArrowUp, CheckCircle2, MoreHorizontal, Pencil, Play, Plus, RotateCcw, Save, Search, SlidersHorizontal, Star } from "lucide-react"; import { ArrowDown, ArrowUp, CheckCircle2, MoreHorizontal, Pencil, Play, Plus, RotateCcw, Save, Search, SlidersHorizontal, Star, Trash2 } from "lucide-react";
import { FormEvent, useEffect, useMemo, useState } from "react"; import { FormEvent, useEffect, useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -17,7 +17,7 @@ import { addFavoriteTicket, createProject, getProjects, lookupTicket, updateProj
import { currentMonth, formatMinutes } from "../format"; import { currentMonth, formatMinutes } from "../format";
import { projectHasRecurringBilling } from "../recurring-billings"; import { projectHasRecurringBilling } from "../recurring-billings";
import { ticketPattern } from "../timers"; import { ticketPattern } from "../timers";
import type { ProjectSummary, TicketMeta } from "../types"; import type { ProjectSummary, ProjectTeamspaceEntry, TicketMeta } from "../types";
type ProjectsPageProps = { type ProjectsPageProps = {
onNavigate: (to: string) => void; onNavigate: (to: string) => void;
@@ -29,12 +29,18 @@ type ProjectForm = {
title: string; title: string;
organizationId: string; organizationId: string;
organizationName: string | null; organizationName: string | null;
teamspaceProject: string;
description: string; description: string;
startsOn: string; startsOn: string;
endsOn: string; endsOn: string;
teamspaceEntries: ProjectTeamspaceEntryForm[];
};
type ProjectTeamspaceEntryForm = {
clientId: string;
teamspaceProject: string;
hourBudget: string; hourBudget: string;
manualConsumption: string; manualConsumption: string;
isDefault: boolean;
}; };
type ProjectStatusFilter = "open" | "all" | "closed"; type ProjectStatusFilter = "open" | "all" | "closed";
@@ -88,6 +94,24 @@ const projectTodoOptions: Array<{ value: ProjectTodoFilter; label: string }> = [
const projectSortValues: ProjectSortBy[] = ["updated", "ticket", "title", "organization", "status", "consumption", "consumptionPercent", "remaining", "budget", "todos"]; const projectSortValues: ProjectSortBy[] = ["updated", "ticket", "title", "organization", "status", "consumption", "consumptionPercent", "remaining", "budget", "todos"];
function projectEntryId() {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `entry-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
function emptyProjectTeamspaceEntry(isDefault = false): ProjectTeamspaceEntryForm {
return {
clientId: projectEntryId(),
teamspaceProject: "",
hourBudget: "",
manualConsumption: "",
isDefault
};
}
function readStoredProjectViewSettings(): ProjectViewSettings { function readStoredProjectViewSettings(): ProjectViewSettings {
if (typeof window === "undefined") { if (typeof window === "undefined") {
return defaultProjectViewSettings; return defaultProjectViewSettings;
@@ -122,12 +146,10 @@ function emptyProjectForm(): ProjectForm {
title: "", title: "",
organizationId: "", organizationId: "",
organizationName: null, organizationName: null,
teamspaceProject: "",
description: "", description: "",
startsOn: "", startsOn: "",
endsOn: "", endsOn: "",
hourBudget: "", teamspaceEntries: [emptyProjectTeamspaceEntry(true)]
manualConsumption: ""
}; };
} }
@@ -147,6 +169,41 @@ function formatDecimalHours(minutes: number) {
return `${new Intl.NumberFormat("de-DE", { maximumFractionDigits: 2 }).format(minutes / 60)} h`; return `${new Intl.NumberFormat("de-DE", { maximumFractionDigits: 2 }).format(minutes / 60)} h`;
} }
function projectTeamspaceEntries(project: ProjectSummary): ProjectTeamspaceEntry[] {
if (project.teamspace_entries?.length) {
return project.teamspace_entries;
}
return [
{
id: `${project.id}-default`,
teamspace_project: project.teamspace_project,
is_default: true,
hour_budget_minutes: project.hour_budget_minutes,
manual_consumption_minutes: project.manual_consumption_minutes ?? 0,
teamspace_consumption_minutes: project.teamspace_consumption_minutes ?? 0,
consumption_minutes: project.consumption_minutes ?? project.teamspace_consumption_minutes ?? 0,
display_order: 0
}
];
}
function projectTeamspaceEntryCount(project: ProjectSummary) {
return Number(project.teamspace_entry_count ?? projectTeamspaceEntries(project).length);
}
function projectEntryConsumption(entry: ProjectTeamspaceEntry) {
return Number(entry.consumption_minutes ?? 0);
}
function projectEntryPercent(entry: ProjectTeamspaceEntry) {
if (!entry.hour_budget_minutes || entry.hour_budget_minutes <= 0) {
return null;
}
return Math.round((projectEntryConsumption(entry) / entry.hour_budget_minutes) * 100);
}
function projectConsumptionBarClass(percent: number) { function projectConsumptionBarClass(percent: number) {
if (percent >= 100) { if (percent >= 100) {
return "bg-blue-500"; return "bg-blue-500";
@@ -224,6 +281,7 @@ function projectMatchesQuery(project: ProjectSummary, query: string) {
project.title ?? "", project.title ?? "",
projectOrganizationLabel(project), projectOrganizationLabel(project),
project.teamspace_project ?? "", project.teamspace_project ?? "",
...projectTeamspaceEntries(project).map((entry) => entry.teamspace_project ?? ""),
project.description, project.description,
project.goals project.goals
]; ];
@@ -268,17 +326,24 @@ function compareProjects(left: ProjectSummary, right: ProjectSummary, sortBy: Pr
} }
function formFromProject(project: ProjectSummary): ProjectForm { function formFromProject(project: ProjectSummary): ProjectForm {
const entries = projectTeamspaceEntries(project).map((entry, index) => ({
clientId: entry.id || projectEntryId(),
teamspaceProject: entry.teamspace_project ?? "",
hourBudget: formatHoursInput(entry.hour_budget_minutes),
manualConsumption: formatHoursInput(entry.manual_consumption_minutes),
isDefault: entry.is_default || index === 0
}));
const hasDefault = entries.some((entry) => entry.isDefault);
return { return {
ticketNumber: project.ticket_number, ticketNumber: project.ticket_number,
title: project.title ?? "", title: project.title ?? "",
organizationId: project.organization_id ?? "", organizationId: project.organization_id ?? "",
organizationName: project.organization_name ?? project.customer_name ?? null, organizationName: project.organization_name ?? project.customer_name ?? null,
teamspaceProject: project.teamspace_project ?? "",
description: combinedProjectDescription(project), description: combinedProjectDescription(project),
startsOn: project.starts_on ?? "", startsOn: project.starts_on ?? "",
endsOn: project.ends_on ?? "", endsOn: project.ends_on ?? "",
hourBudget: formatHoursInput(project.hour_budget_minutes), teamspaceEntries: hasDefault ? entries : entries.map((entry, index) => ({ ...entry, isDefault: index === 0 }))
manualConsumption: formatHoursInput(project.manual_consumption_minutes)
}; };
} }
@@ -287,12 +352,142 @@ type ProjectFormFieldsProps = {
setForm: (form: ProjectForm) => void; setForm: (form: ProjectForm) => void;
organizationLocked: boolean; organizationLocked: boolean;
lookupStatus: "idle" | "loading" | "found" | "not-found"; lookupStatus: "idle" | "loading" | "found" | "not-found";
zammadLookupBusy?: boolean;
saving: boolean; saving: boolean;
submitLabel: string; submitLabel: string;
onZammadLookup?: () => void;
onSubmit: (event: FormEvent) => void; onSubmit: (event: FormEvent) => void;
}; };
function ProjectFormFields({ form, setForm, organizationLocked, lookupStatus, saving, submitLabel, onSubmit }: ProjectFormFieldsProps) { function normalizeProjectTeamspaceEntryForms(entries: ProjectTeamspaceEntryForm[]) {
const source = entries.length ? entries : [emptyProjectTeamspaceEntry(true)];
const defaultIndex = Math.max(0, source.findIndex((entry) => entry.isDefault));
return source.map((entry, index) => ({
...entry,
isDefault: index === defaultIndex
}));
}
function parseProjectTeamspaceEntryForms(entries: ProjectTeamspaceEntryForm[]) {
const normalizedEntries = normalizeProjectTeamspaceEntryForms(entries);
const parsedEntries = normalizedEntries.map((entry) => {
const hourBudgetMinutes = parseHoursInput(entry.hourBudget);
const manualConsumptionMinutes = parseHoursInput(entry.manualConsumption);
if (hourBudgetMinutes === undefined || manualConsumptionMinutes === undefined) {
return undefined;
}
return {
id: /^\d+$/.test(entry.clientId) ? entry.clientId : null,
teamspaceProject: entry.teamspaceProject.trim() || null,
isDefault: entry.isDefault,
hourBudgetMinutes,
manualConsumptionMinutes: manualConsumptionMinutes ?? 0
};
});
if (parsedEntries.some((entry) => entry === undefined)) {
return undefined;
}
return parsedEntries as Array<{
id?: string | null;
teamspaceProject: string | null;
isDefault: boolean;
hourBudgetMinutes: number | null;
manualConsumptionMinutes: number;
}>;
}
function defaultProjectTeamspacePayload(entries: ReturnType<typeof parseProjectTeamspaceEntryForms>) {
return entries?.find((entry) => entry.isDefault) ?? entries?.[0] ?? null;
}
function ProjectTeamspaceEntriesFields({
entries,
onChange
}: {
entries: ProjectTeamspaceEntryForm[];
onChange: (entries: ProjectTeamspaceEntryForm[]) => void;
}) {
const normalizedEntries = normalizeProjectTeamspaceEntryForms(entries);
function updateEntry(clientId: string, changes: Partial<ProjectTeamspaceEntryForm>) {
onChange(normalizeProjectTeamspaceEntryForms(normalizedEntries.map((entry) => (entry.clientId === clientId ? { ...entry, ...changes } : entry))));
}
function setDefault(clientId: string) {
onChange(normalizedEntries.map((entry) => ({ ...entry, isDefault: entry.clientId === clientId })));
}
function removeEntry(clientId: string) {
onChange(normalizeProjectTeamspaceEntryForms(normalizedEntries.filter((entry) => entry.clientId !== clientId)));
}
return (
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<label className="text-sm font-medium">Teamspace-Einträge</label>
<Button type="button" size="sm" variant="secondary" onClick={() => onChange([...normalizedEntries, emptyProjectTeamspaceEntry(false)])}>
<Plus className="size-3.5" />
Eintrag
</Button>
</div>
<div className="space-y-2">
{normalizedEntries.map((entry, index) => (
<div key={entry.clientId} className="grid gap-2 rounded-lg border bg-muted/10 p-3 lg:grid-cols-[minmax(180px,1fr)_130px_150px_120px_36px] lg:items-end">
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground" htmlFor={`project-teamspace-${entry.clientId}`}>
Teamspace-Projekt {entry.isDefault ? "(Standard)" : ""}
</label>
<Input
id={`project-teamspace-${entry.clientId}`}
value={entry.teamspaceProject}
onChange={(event) => updateEntry(entry.clientId, { teamspaceProject: event.currentTarget.value })}
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground" htmlFor={`project-budget-${entry.clientId}`}>
Budget h
</label>
<Input
id={`project-budget-${entry.clientId}`}
inputMode="decimal"
value={entry.hourBudget}
onChange={(event) => updateEntry(entry.clientId, { hourBudget: event.currentTarget.value })}
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground" htmlFor={`project-manual-${entry.clientId}`}>
Manueller Verbrauch h
</label>
<Input
id={`project-manual-${entry.clientId}`}
inputMode="decimal"
value={entry.manualConsumption}
onChange={(event) => updateEntry(entry.clientId, { manualConsumption: event.currentTarget.value })}
/>
</div>
<Button type="button" size="sm" variant={entry.isDefault ? "default" : "secondary"} onClick={() => setDefault(entry.clientId)}>
{entry.isDefault ? "Standard" : "Als Standard"}
</Button>
<Button type="button" size="icon" variant="ghost" disabled={normalizedEntries.length === 1} onClick={() => removeEntry(entry.clientId)}>
<Trash2 className="size-4 text-destructive" />
<span className="sr-only">Teamspace-Eintrag {index + 1} löschen</span>
</Button>
</div>
))}
</div>
<p className="text-xs text-muted-foreground">
Der Standard-Eintrag bekommt die aus Teamspace gebuchten Tageswerte. Manueller Verbrauch zählt nur im Projektverbrauch.
</p>
</div>
);
}
function ProjectFormFields({ form, setForm, organizationLocked, lookupStatus, zammadLookupBusy = false, saving, submitLabel, onZammadLookup, onSubmit }: ProjectFormFieldsProps) {
return ( return (
<form className="space-y-4" onSubmit={onSubmit}> <form className="space-y-4" onSubmit={onSubmit}>
<div className="grid gap-3 lg:grid-cols-[180px_minmax(240px,1fr)_180px]"> <div className="grid gap-3 lg:grid-cols-[180px_minmax(240px,1fr)_180px]">
@@ -317,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} {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 === "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 === "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>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium" htmlFor="project-title"> <label className="text-sm font-medium" htmlFor="project-title">
@@ -331,7 +536,7 @@ function ProjectFormFields({ form, setForm, organizationLocked, lookupStatus, sa
</div> </div>
</div> </div>
<div className="grid gap-3 lg:grid-cols-[minmax(240px,1fr)_minmax(220px,1fr)]"> <div className="grid gap-3 lg:grid-cols-[minmax(240px,1fr)_320px_320px]">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium">Organisation</label> <label className="text-sm font-medium">Organisation</label>
<OrganizationSelect <OrganizationSelect
@@ -342,15 +547,6 @@ function ProjectFormFields({ form, setForm, organizationLocked, lookupStatus, sa
required required
/> />
</div> </div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="teamspace-project">
Teamspace-Projekt
</label>
<Input id="teamspace-project" value={form.teamspaceProject} onChange={(event) => setForm({ ...form, teamspaceProject: event.currentTarget.value })} />
</div>
</div>
<div className="grid gap-3 lg:grid-cols-4">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium" htmlFor="project-starts-on"> <label className="text-sm font-medium" htmlFor="project-starts-on">
Zeitraum von Zeitraum von
@@ -363,26 +559,10 @@ function ProjectFormFields({ form, setForm, organizationLocked, lookupStatus, sa
</label> </label>
<Input id="project-ends-on" type="date" value={form.endsOn} onChange={(event) => setForm({ ...form, endsOn: event.currentTarget.value })} /> <Input id="project-ends-on" type="date" value={form.endsOn} onChange={(event) => setForm({ ...form, endsOn: event.currentTarget.value })} />
</div> </div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="project-budget">
Kontingent h
</label>
<Input id="project-budget" inputMode="decimal" value={form.hourBudget} onChange={(event) => setForm({ ...form, hourBudget: event.currentTarget.value })} />
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="project-manual-consumption">
Manueller Verbrauch h
</label>
<Input
id="project-manual-consumption"
inputMode="decimal"
value={form.manualConsumption}
onChange={(event) => setForm({ ...form, manualConsumption: event.currentTarget.value })}
/>
<p className="text-xs text-muted-foreground">Nur für Projektverbrauch, nicht für Auswertungen.</p>
</div>
</div> </div>
<ProjectTeamspaceEntriesFields entries={form.teamspaceEntries} onChange={(teamspaceEntries) => setForm({ ...form, teamspaceEntries })} />
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium" htmlFor="project-description"> <label className="text-sm font-medium" htmlFor="project-description">
Beschreibung/Ziele Beschreibung/Ziele
@@ -406,6 +586,8 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
const [form, setForm] = useState<ProjectForm>(() => emptyProjectForm()); const [form, setForm] = useState<ProjectForm>(() => emptyProjectForm());
const [lookupStatus, setLookupStatus] = useState<"idle" | "loading" | "found" | "not-found">("idle"); const [lookupStatus, setLookupStatus] = useState<"idle" | "loading" | "found" | "not-found">("idle");
const [lookupResult, setLookupResult] = useState<TicketMeta | null>(null); const [lookupResult, setLookupResult] = useState<TicketMeta | null>(null);
const [zammadLookupBusy, setZammadLookupBusy] = useState(false);
const [useZammadForCreate, setUseZammadForCreate] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [editingProjectId, setEditingProjectId] = useState<string | null>(null); const [editingProjectId, setEditingProjectId] = useState<string | null>(null);
@@ -442,6 +624,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
if (!ticketPattern.test(normalizedTicketNumber)) { if (!ticketPattern.test(normalizedTicketNumber)) {
setLookupStatus("idle"); setLookupStatus("idle");
setLookupResult(null); setLookupResult(null);
setUseZammadForCreate(false);
return; return;
} }
@@ -458,6 +641,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
setLookupResult(result.ticket); setLookupResult(result.ticket);
setLookupStatus(result.ticket ? "found" : "not-found"); setLookupStatus(result.ticket ? "found" : "not-found");
setUseZammadForCreate(false);
if (result.ticket?.organization_id) { if (result.ticket?.organization_id) {
setForm((current) => setForm((current) =>
@@ -568,6 +752,70 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
viewSettings.sortDirection !== defaultProjectViewSettings.sortDirection viewSettings.sortDirection !== defaultProjectViewSettings.sortDirection
].filter(Boolean).length; ].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) { function setProjectSort(sortBy: ProjectSortBy) {
setViewSettings((current) => ({ setViewSettings((current) => ({
...current, ...current,
@@ -610,24 +858,16 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
return; return;
} }
const hourBudgetMinutes = parseHoursInput(form.hourBudget); const teamspaceEntries = parseProjectTeamspaceEntryForms(form.teamspaceEntries);
const parsedManualConsumptionMinutes = parseHoursInput(form.manualConsumption);
if (hourBudgetMinutes === undefined) { if (!teamspaceEntries) {
toast.error("Stundenkontingent prüfen", { toast.error("Teamspace-Einträge prüfen", {
description: "Bitte Stunden als Zahl eintragen, z.B. 12,5." description: "Bitte Stunden als Zahl eintragen, z.B. 12,5."
}); });
return; return;
} }
if (parsedManualConsumptionMinutes === undefined) { const defaultTeamspaceEntry = defaultProjectTeamspacePayload(teamspaceEntries);
toast.error("Manuellen Verbrauch prüfen", {
description: "Bitte Stunden als Zahl eintragen, z.B. 12,5."
});
return;
}
const manualConsumptionMinutes = parsedManualConsumptionMinutes ?? 0;
setSaving(true); setSaving(true);
try { try {
@@ -635,13 +875,15 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
ticketNumber, ticketNumber,
title: form.title || null, title: form.title || null,
organizationId: form.organizationId, organizationId: form.organizationId,
teamspaceProject: form.teamspaceProject, zammadLookup: useZammadForCreate,
teamspaceProject: defaultTeamspaceEntry?.teamspaceProject ?? null,
teamspaceEntries,
description: form.description, description: form.description,
goals: "", goals: "",
startsOn: form.startsOn || null, startsOn: form.startsOn || null,
endsOn: form.endsOn || null, endsOn: form.endsOn || null,
hourBudgetMinutes, hourBudgetMinutes: defaultTeamspaceEntry?.hourBudgetMinutes ?? null,
manualConsumptionMinutes manualConsumptionMinutes: defaultTeamspaceEntry?.manualConsumptionMinutes ?? 0
}); });
toast.success("Projekt angelegt", { toast.success("Projekt angelegt", {
description: `${result.project.ticket_number} ist jetzt als Consulting-Projekt markiert.` description: `${result.project.ticket_number} ist jetzt als Consulting-Projekt markiert.`
@@ -650,6 +892,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
setCreateOpen(false); setCreateOpen(false);
setLookupResult(null); setLookupResult(null);
setLookupStatus("idle"); setLookupStatus("idle");
setUseZammadForCreate(false);
await load(); await load();
} catch (error) { } catch (error) {
toast.error("Projekt konnte nicht angelegt werden", { toast.error("Projekt konnte nicht angelegt werden", {
@@ -661,20 +904,16 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
} }
async function saveProject(project: ProjectSummary) { async function saveProject(project: ProjectSummary) {
const hourBudgetMinutes = parseHoursInput(editForm.hourBudget); const teamspaceEntries = parseProjectTeamspaceEntryForms(editForm.teamspaceEntries);
const parsedManualConsumptionMinutes = parseHoursInput(editForm.manualConsumption);
if (hourBudgetMinutes === undefined) { if (!teamspaceEntries) {
toast.error("Stundenkontingent prüfen"); toast.error("Teamspace-Einträge prüfen", {
description: "Bitte Stunden als Zahl eintragen, z.B. 12,5."
});
return; return;
} }
if (parsedManualConsumptionMinutes === undefined) { const defaultTeamspaceEntry = defaultProjectTeamspacePayload(teamspaceEntries);
toast.error("Manuellen Verbrauch prüfen");
return;
}
const manualConsumptionMinutes = parsedManualConsumptionMinutes ?? 0;
if (!editForm.organizationId) { if (!editForm.organizationId) {
toast.error("Organisation wählen"); toast.error("Organisation wählen");
@@ -687,13 +926,14 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
ticketNumber: editForm.ticketNumber, ticketNumber: editForm.ticketNumber,
title: editForm.title || null, title: editForm.title || null,
organizationId: editForm.organizationId, organizationId: editForm.organizationId,
teamspaceProject: editForm.teamspaceProject, teamspaceProject: defaultTeamspaceEntry?.teamspaceProject ?? null,
teamspaceEntries,
description: editForm.description, description: editForm.description,
goals: "", goals: "",
startsOn: editForm.startsOn || null, startsOn: editForm.startsOn || null,
endsOn: editForm.endsOn || null, endsOn: editForm.endsOn || null,
hourBudgetMinutes, hourBudgetMinutes: defaultTeamspaceEntry?.hourBudgetMinutes ?? null,
manualConsumptionMinutes manualConsumptionMinutes: defaultTeamspaceEntry?.manualConsumptionMinutes ?? 0
}); });
toast.success("Projekt gespeichert"); toast.success("Projekt gespeichert");
setEditingProjectId(null); setEditingProjectId(null);
@@ -765,6 +1005,31 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
<span className="font-semibold">{openTodos}</span> <span className="font-semibold">{openTodos}</span>
</div> </div>
</div> </div>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild>
<Button type="button" className="shrink-0">
<Plus className="size-4" />
Projekt
</Button>
</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-5xl">
<DialogHeader>
<DialogTitle>Projekt anlegen</DialogTitle>
<DialogDescription>Consulting-Projekt mit einem oder mehreren Teamspace-Einträgen anlegen.</DialogDescription>
</DialogHeader>
<ProjectFormFields
form={form}
setForm={setForm}
organizationLocked={organizationLocked}
lookupStatus={lookupStatus}
zammadLookupBusy={zammadLookupBusy}
saving={saving}
submitLabel="Projekt anlegen"
onZammadLookup={() => void lookupProjectTicketInZammad()}
onSubmit={submitProject}
/>
</DialogContent>
</Dialog>
<HelpLink anchor="projekte" label="Hilfe zu Projekten" className="shrink-0" /> <HelpLink anchor="projekte" label="Hilfe zu Projekten" className="shrink-0" />
</div> </div>
</div> </div>
@@ -915,9 +1180,12 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
</div> </div>
{filteredProjects.map((project) => { {filteredProjects.map((project) => {
const isEditing = editingProjectId === project.id; const isEditing = editingProjectId === project.id;
const teamspaceEntries = projectTeamspaceEntries(project);
const consumptionMinutes = project.consumption_minutes ?? 0; const consumptionMinutes = project.consumption_minutes ?? 0;
const remaining = projectRemaining(project); const remaining = projectRemaining(project);
const usedPercent = projectConsumptionPercent(project); const usedPercent = projectConsumptionPercent(project);
const hasMultipleTeamspaceEntries = teamspaceEntries.length > 1;
const canEditProject = project.can_edit !== false;
return ( return (
<div key={project.id} className="border-b last:border-b-0"> <div key={project.id} className="border-b last:border-b-0">
@@ -938,11 +1206,14 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
</div> </div>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{project.closed_at ? <Badge variant="secondary">geschlossen</Badge> : <Badge variant="success">offen</Badge>} {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>
<div className="min-w-0"> <div className="min-w-0">
<p className={cn("text-sm font-semibold", remaining !== null && remaining < 0 ? "text-destructive" : undefined)}> <p className={cn("text-sm font-semibold", remaining !== null && remaining < 0 ? "text-destructive" : undefined)}>
{formatDecimalHours(consumptionMinutes)} / {project.hour_budget_minutes ? formatDecimalHours(project.hour_budget_minutes) : "offen"} {formatDecimalHours(consumptionMinutes)} / {project.hour_budget_minutes ? formatDecimalHours(project.hour_budget_minutes) : "offen"}
</p> </p>
{hasMultipleTeamspaceEntries ? <p className="text-xs text-muted-foreground">{projectTeamspaceEntryCount(project)} Teamspace</p> : null}
</div> </div>
<div className="min-w-0 text-sm"> <div className="min-w-0 text-sm">
<p className="truncate font-medium xl:hidden">Verbrauch %</p> <p className="truncate font-medium xl:hidden">Verbrauch %</p>
@@ -978,7 +1249,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
Projekt öffnen Projekt öffnen
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
disabled={savingProjectStatusId === project.id} disabled={!canEditProject || savingProjectStatusId === project.id}
className="hover:cursor-pointer" className="hover:cursor-pointer"
onSelect={() => void setProjectClosed(project, !project.closed_at)} onSelect={() => void setProjectClosed(project, !project.closed_at)}
> >
@@ -986,6 +1257,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
{project.closed_at ? "Projekt öffnen" : "Projekt schließen"} {project.closed_at ? "Projekt öffnen" : "Projekt schließen"}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
disabled={!canEditProject}
className="hover:cursor-pointer" className="hover:cursor-pointer"
onSelect={() => { onSelect={() => {
setEditingProjectId(project.id); setEditingProjectId(project.id);
@@ -999,7 +1271,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
</DropdownMenu> </DropdownMenu>
</div> </div>
</div> </div>
<div className="px-4 pb-3"> <div className="space-y-2 px-4 pb-3">
{usedPercent !== null ? ( {usedPercent !== null ? (
<div className="h-1.5 overflow-hidden rounded-full bg-muted" title={`${usedPercent}% Verbrauch`} aria-label={`${usedPercent}% Verbrauch`}> <div className="h-1.5 overflow-hidden rounded-full bg-muted" title={`${usedPercent}% Verbrauch`} aria-label={`${usedPercent}% Verbrauch`}>
<div className={cn("h-full rounded-full", projectConsumptionBarClass(usedPercent))} style={{ width: `${Math.min(100, usedPercent)}%` }} /> <div className={cn("h-full rounded-full", projectConsumptionBarClass(usedPercent))} style={{ width: `${Math.min(100, usedPercent)}%` }} />
@@ -1009,6 +1281,40 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
<div className="h-full rounded-full bg-muted-foreground/10" style={{ width: "0%" }} /> <div className="h-full rounded-full bg-muted-foreground/10" style={{ width: "0%" }} />
</div> </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) => {
const entryPercent = projectEntryPercent(entry);
const entryConsumption = projectEntryConsumption(entry);
const label = entry.teamspace_project || (entry.is_default ? "Standard" : "Ohne Teamspace-ID");
return (
<div key={entry.id} className="min-w-0 rounded-md border bg-background/50 px-2 py-1">
<div className="mb-1 flex min-w-0 items-center justify-between gap-2 text-[11px]">
<span className="truncate text-muted-foreground">
{label}
{entry.is_default ? " · Standard" : ""}
</span>
<span className="shrink-0 font-medium">
{formatMinutes(entryConsumption)} / {entry.hour_budget_minutes ? formatMinutes(entry.hour_budget_minutes) : "offen"}
</span>
</div>
<div className="h-1 overflow-hidden rounded-full bg-muted">
<div
className={cn("h-full rounded-full", entryPercent === null ? "bg-muted-foreground/20" : projectConsumptionBarClass(entryPercent))}
style={{ width: `${entryPercent === null ? 0 : Math.min(100, entryPercent)}%` }}
/>
</div>
</div>
);
})}
</div>
) : null}
</div> </div>
{isEditing ? ( {isEditing ? (
<div className="border-t bg-muted/10 p-4"> <div className="border-t bg-muted/10 p-4">
@@ -1024,22 +1330,14 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
<div className="flex h-9 items-center rounded-lg border bg-muted/30 px-3 text-sm">Consulting</div> <div className="flex h-9 items-center rounded-lg border bg-muted/30 px-3 text-sm">Consulting</div>
</div> */} </div> */}
<Input placeholder="Titel" value={editForm.title} onChange={(event) => setEditForm({ ...editForm, title: event.currentTarget.value })} /> <Input placeholder="Titel" value={editForm.title} onChange={(event) => setEditForm({ ...editForm, title: event.currentTarget.value })} />
<Input <div className="grid gap-3 md:grid-cols-2">
placeholder="Teamspace-Projekt"
value={editForm.teamspaceProject}
onChange={(event) => setEditForm({ ...editForm, teamspaceProject: event.currentTarget.value })}
/>
<div className="grid gap-3 md:grid-cols-4">
<Input type="date" value={editForm.startsOn} onChange={(event) => setEditForm({ ...editForm, startsOn: event.currentTarget.value })} /> <Input type="date" value={editForm.startsOn} onChange={(event) => setEditForm({ ...editForm, startsOn: event.currentTarget.value })} />
<Input type="date" value={editForm.endsOn} onChange={(event) => setEditForm({ ...editForm, endsOn: event.currentTarget.value })} /> <Input type="date" value={editForm.endsOn} onChange={(event) => setEditForm({ ...editForm, endsOn: event.currentTarget.value })} />
<Input inputMode="decimal" placeholder="Kontingent h" value={editForm.hourBudget} onChange={(event) => setEditForm({ ...editForm, hourBudget: event.currentTarget.value })} />
<Input
inputMode="decimal"
placeholder="Manueller Verbrauch h"
value={editForm.manualConsumption}
onChange={(event) => setEditForm({ ...editForm, manualConsumption: event.currentTarget.value })}
/>
</div> </div>
<ProjectTeamspaceEntriesFields
entries={editForm.teamspaceEntries}
onChange={(teamspaceEntries) => setEditForm({ ...editForm, teamspaceEntries })}
/>
<Textarea className="min-h-24" value={editForm.description} onChange={(event) => setEditForm({ ...editForm, description: event.currentTarget.value })} /> <Textarea className="min-h-24" value={editForm.description} onChange={(event) => setEditForm({ ...editForm, description: event.currentTarget.value })} />
<div className="flex flex-wrap justify-end gap-2"> <div className="flex flex-wrap justify-end gap-2">
<Button <Button
+442 -65
View File
@@ -14,6 +14,7 @@ import { OrganizationSelect } from "@/components/OrganizationSelect";
import { TodoPanel } from "@/components/TodoPanel"; import { TodoPanel } from "@/components/TodoPanel";
import { import {
createSession, createSession,
deleteProject,
deleteSession, deleteSession,
addFavoriteTicket, addFavoriteTicket,
getDashboardPreference, getDashboardPreference,
@@ -29,7 +30,7 @@ import {
} from "../api"; } from "../api";
import { currentDay, currentMonth, formatDate, formatDateTime, formatMinutes, formatTimeRange } from "../format"; import { currentDay, currentMonth, formatDate, formatDateTime, formatMinutes, formatTimeRange } from "../format";
import { ticketHasRecurringBilling } from "../recurring-billings"; import { ticketHasRecurringBilling } from "../recurring-billings";
import type { BillingStatus, PeriodType, ProjectSummary, SessionEntry, TicketDetailPeriodType, TicketPeriod, WorkType } from "../types"; import type { BillingStatus, PeriodType, ProjectSummary, ProjectTeamspaceEntry, SessionEntry, TicketDetailPeriodType, TicketPeriod, WorkType } from "../types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
type TicketDetailPageProps = { type TicketDetailPageProps = {
@@ -53,6 +54,7 @@ type TicketDetailPageProps = {
type TicketSessionFormState = { type TicketSessionFormState = {
organizationId: string; organizationId: string;
organizationName: string | null; organizationName: string | null;
projectTeamspaceEntryId: string;
activity: string; activity: string;
workType: WorkType; workType: WorkType;
day: string; day: string;
@@ -64,18 +66,25 @@ type ProjectEditFormState = {
title: string; title: string;
organizationId: string; organizationId: string;
organizationName: string | null; organizationName: string | null;
teamspaceProject: string;
description: string; description: string;
startsOn: string; startsOn: string;
endsOn: string; endsOn: string;
teamspaceEntries: ProjectTeamspaceEntryForm[];
};
type ProjectTeamspaceEntryForm = {
clientId: string;
teamspaceProject: string;
hourBudget: string; hourBudget: string;
manualConsumption: string; manualConsumption: string;
isDefault: boolean;
}; };
function emptyTicketSessionForm(): TicketSessionFormState { function emptyTicketSessionForm(): TicketSessionFormState {
return { return {
organizationId: "", organizationId: "",
organizationName: null, organizationName: null,
projectTeamspaceEntryId: "",
activity: "", activity: "",
workType: "support", workType: "support",
day: currentDay(), day: currentDay(),
@@ -84,17 +93,51 @@ function emptyTicketSessionForm(): TicketSessionFormState {
}; };
} }
function projectEntryId() {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `entry-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
function projectTeamspaceEntries(project: ProjectSummary): ProjectTeamspaceEntry[] {
if (project.teamspace_entries?.length) {
return project.teamspace_entries;
}
return [
{
id: `${project.id}-default`,
teamspace_project: project.teamspace_project,
is_default: true,
hour_budget_minutes: project.hour_budget_minutes,
manual_consumption_minutes: project.manual_consumption_minutes ?? 0,
teamspace_consumption_minutes: project.teamspace_consumption_minutes ?? 0,
consumption_minutes: project.consumption_minutes ?? project.teamspace_consumption_minutes ?? 0,
display_order: 0
}
];
}
function projectEditForm(project: ProjectSummary): ProjectEditFormState { function projectEditForm(project: ProjectSummary): ProjectEditFormState {
const entries = projectTeamspaceEntries(project).map((entry, index) => ({
clientId: entry.id || projectEntryId(),
teamspaceProject: entry.teamspace_project ?? "",
hourBudget: formatHoursInput(entry.hour_budget_minutes),
manualConsumption: formatHoursInput(entry.manual_consumption_minutes),
isDefault: entry.is_default || index === 0
}));
const hasDefault = entries.some((entry) => entry.isDefault);
return { return {
title: project.title ?? "", title: project.title ?? "",
organizationId: project.organization_id ?? "", organizationId: project.organization_id ?? "",
organizationName: project.organization_name ?? project.customer_name ?? null, organizationName: project.organization_name ?? project.customer_name ?? null,
teamspaceProject: project.teamspace_project ?? "",
description: combinedProjectDescription(project), description: combinedProjectDescription(project),
startsOn: project.starts_on ?? "", startsOn: project.starts_on ?? "",
endsOn: project.ends_on ?? "", endsOn: project.ends_on ?? "",
hourBudget: formatHoursInput(project.hour_budget_minutes), teamspaceEntries: hasDefault ? entries : entries.map((entry, index) => ({ ...entry, isDefault: index === 0 }))
manualConsumption: formatHoursInput(project.manual_consumption_minutes)
}; };
} }
@@ -186,10 +229,147 @@ function parseHoursInput(value: string) {
return Math.round(hours * 60); return Math.round(hours * 60);
} }
function projectConsumptionBarClass(percent: number) {
if (percent >= 100) {
return "bg-blue-500";
}
if (percent >= 90) {
return "bg-red-500";
}
return "bg-emerald-500";
}
function projectConsumptionPercent(consumptionMinutes: number, budgetMinutes?: number | null) {
if (!budgetMinutes || budgetMinutes <= 0) {
return null;
}
return Math.round((consumptionMinutes / budgetMinutes) * 100);
}
function isPartialHoursInput(value: string) { function isPartialHoursInput(value: string) {
return /^\d*(?:[,.]\d*)?$/.test(value.trim()); return /^\d*(?:[,.]\d*)?$/.test(value.trim());
} }
function normalizeProjectTeamspaceEntryForms(entries: ProjectTeamspaceEntryForm[]) {
const source = entries.length
? entries
: [
{
clientId: projectEntryId(),
teamspaceProject: "",
hourBudget: "",
manualConsumption: "",
isDefault: true
}
];
const defaultIndex = Math.max(0, source.findIndex((entry) => entry.isDefault));
return source.map((entry, index) => ({
...entry,
isDefault: index === defaultIndex
}));
}
function parseProjectTeamspaceEntryForms(entries: ProjectTeamspaceEntryForm[]) {
const normalizedEntries = normalizeProjectTeamspaceEntryForms(entries);
const parsedEntries = normalizedEntries.map((entry) => {
const hourBudgetMinutes = parseHoursInput(entry.hourBudget);
const manualConsumptionMinutes = parseHoursInput(entry.manualConsumption);
if (hourBudgetMinutes === undefined || manualConsumptionMinutes === undefined) {
return undefined;
}
return {
id: /^\d+$/.test(entry.clientId) ? entry.clientId : null,
teamspaceProject: entry.teamspaceProject.trim() || null,
isDefault: entry.isDefault,
hourBudgetMinutes,
manualConsumptionMinutes: manualConsumptionMinutes ?? 0
};
});
if (parsedEntries.some((entry) => entry === undefined)) {
return undefined;
}
return parsedEntries as Array<{
id?: string | null;
teamspaceProject: string | null;
isDefault: boolean;
hourBudgetMinutes: number | null;
manualConsumptionMinutes: number;
}>;
}
function ProjectTeamspaceEntriesEditor({
entries,
onChange
}: {
entries: ProjectTeamspaceEntryForm[];
onChange: (entries: ProjectTeamspaceEntryForm[]) => void;
}) {
const normalizedEntries = normalizeProjectTeamspaceEntryForms(entries);
function updateEntry(clientId: string, changes: Partial<ProjectTeamspaceEntryForm>) {
onChange(normalizeProjectTeamspaceEntryForms(normalizedEntries.map((entry) => (entry.clientId === clientId ? { ...entry, ...changes } : entry))));
}
function removeEntry(clientId: string) {
onChange(normalizeProjectTeamspaceEntryForms(normalizedEntries.filter((entry) => entry.clientId !== clientId)));
}
return (
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<label className="text-sm font-medium">Teamspace-Einträge</label>
<Button type="button" size="sm" variant="secondary" onClick={() => onChange([...normalizedEntries, { clientId: projectEntryId(), teamspaceProject: "", hourBudget: "", manualConsumption: "", isDefault: false }])}>
<Plus className="size-3.5" />
Eintrag
</Button>
</div>
{normalizedEntries.map((entry, index) => (
<div key={entry.clientId} className="grid gap-2 rounded-lg border bg-muted/10 p-3 lg:grid-cols-[minmax(180px,1fr)_120px_150px_120px_36px] lg:items-end">
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground" htmlFor={`project-ticket-teamspace-${entry.clientId}`}>
Teamspace-Projekt {entry.isDefault ? "(Standard)" : ""}
</label>
<Input
id={`project-ticket-teamspace-${entry.clientId}`}
value={entry.teamspaceProject}
onChange={(event) => updateEntry(entry.clientId, { teamspaceProject: event.currentTarget.value })}
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground" htmlFor={`project-ticket-budget-${entry.clientId}`}>Budget h</label>
<Input id={`project-ticket-budget-${entry.clientId}`} inputMode="decimal" value={entry.hourBudget} onChange={(event) => updateEntry(entry.clientId, { hourBudget: event.currentTarget.value })} />
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground" htmlFor={`project-ticket-manual-${entry.clientId}`}>Manueller Verbrauch h</label>
<Input id={`project-ticket-manual-${entry.clientId}`} inputMode="decimal" value={entry.manualConsumption} onChange={(event) => updateEntry(entry.clientId, { manualConsumption: event.currentTarget.value })} />
</div>
<Button
type="button"
size="sm"
variant={entry.isDefault ? "default" : "secondary"}
onClick={() => onChange(normalizedEntries.map((candidate) => ({ ...candidate, isDefault: candidate.clientId === entry.clientId })))}
>
{entry.isDefault ? "Standard" : "Als Standard"}
</Button>
<Button type="button" size="icon" variant="ghost" disabled={normalizedEntries.length === 1} onClick={() => removeEntry(entry.clientId)}>
<Trash2 className="size-4 text-destructive" />
<span className="sr-only">Teamspace-Eintrag {index + 1} löschen</span>
</Button>
</div>
))}
<p className="text-xs text-muted-foreground">Der Standard-Eintrag erhält die gebuchten Teamspace-Tageswerte. Manuelle Stunden zählen nur im Projektverbrauch.</p>
</div>
);
}
export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onOpenTicketChange, returnPath }: TicketDetailPageProps) { export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onOpenTicketChange, returnPath }: TicketDetailPageProps) {
const initialGlobalMonthFilter = periodType === "all" && period !== "all" ? period : currentMonth(); const initialGlobalMonthFilter = periodType === "all" && period !== "all" ? period : currentMonth();
const [data, setData] = useState<TicketPeriod | null>(null); const [data, setData] = useState<TicketPeriod | null>(null);
@@ -204,10 +384,12 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
const [editOrganizationName, setEditOrganizationName] = useState<string | null>(null); const [editOrganizationName, setEditOrganizationName] = useState<string | null>(null);
const [editWorkType, setEditWorkType] = useState<WorkType>("support"); const [editWorkType, setEditWorkType] = useState<WorkType>("support");
const [editingSession, setEditingSession] = useState<SessionEntry | null>(null); const [editingSession, setEditingSession] = useState<SessionEntry | null>(null);
const [pendingProjectDelete, setPendingProjectDelete] = useState<{ projectId: string } | null>(null);
const [editSessionOrganizationId, setEditSessionOrganizationId] = useState(""); const [editSessionOrganizationId, setEditSessionOrganizationId] = useState("");
const [editSessionOrganizationName, setEditSessionOrganizationName] = useState<string | null>(null); const [editSessionOrganizationName, setEditSessionOrganizationName] = useState<string | null>(null);
const [editSessionActivity, setEditSessionActivity] = useState(""); const [editSessionActivity, setEditSessionActivity] = useState("");
const [editSessionWorkType, setEditSessionWorkType] = useState<WorkType>("support"); const [editSessionWorkType, setEditSessionWorkType] = useState<WorkType>("support");
const [editSessionProjectTeamspaceEntryId, setEditSessionProjectTeamspaceEntryId] = useState("");
const [editSessionDay, setEditSessionDay] = useState(""); const [editSessionDay, setEditSessionDay] = useState("");
const [editSessionStartTime, setEditSessionStartTime] = useState(""); const [editSessionStartTime, setEditSessionStartTime] = useState("");
const [editSessionEndTime, setEditSessionEndTime] = useState(""); const [editSessionEndTime, setEditSessionEndTime] = useState("");
@@ -221,17 +403,24 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
const [globalSourceFilter, setGlobalSourceFilter] = useState<"all" | "manual" | "recurring">("all"); const [globalSourceFilter, setGlobalSourceFilter] = useState<"all" | "manual" | "recurring">("all");
const [editingProject, setEditingProject] = useState(false); const [editingProject, setEditingProject] = useState(false);
const [savingProject, setSavingProject] = useState(false); const [savingProject, setSavingProject] = useState(false);
const [deletingEmptyProject, setDeletingEmptyProject] = useState(false);
const [savingProjectStatus, setSavingProjectStatus] = useState(false); const [savingProjectStatus, setSavingProjectStatus] = useState(false);
const [projectForm, setProjectForm] = useState<ProjectEditFormState>({ const [projectForm, setProjectForm] = useState<ProjectEditFormState>({
title: "", title: "",
organizationId: "", organizationId: "",
organizationName: null, organizationName: null,
teamspaceProject: "",
description: "", description: "",
startsOn: "", startsOn: "",
endsOn: "", endsOn: "",
hourBudget: "", teamspaceEntries: [
manualConsumption: "" {
clientId: projectEntryId(),
teamspaceProject: "",
hourBudget: "",
manualConsumption: "",
isDefault: true
}
]
}); });
const [todoFilter, setTodoFilter] = useState<"open" | "all" | "done">("open"); const [todoFilter, setTodoFilter] = useState<"open" | "all" | "done">("open");
const [expandedSessionIds, setExpandedSessionIds] = useState<string[]>([]); const [expandedSessionIds, setExpandedSessionIds] = useState<string[]>([]);
@@ -282,13 +471,15 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
setEditOrganizationId(data.ticket.organization_id ?? ""); setEditOrganizationId(data.ticket.organization_id ?? "");
setEditOrganizationName(data.ticket.organization_name ?? null); setEditOrganizationName(data.ticket.organization_name ?? null);
setEditWorkType(data.ticket.work_type ?? "support"); setEditWorkType(data.ticket.work_type ?? "support");
const loadedProjectEntries = data.project ? projectTeamspaceEntries(data.project) : [];
setTicketSession((current) => ({ setTicketSession((current) => ({
...current, ...current,
organizationId: data.ticket.organization_id ?? current.organizationId, organizationId: data.ticket.organization_id ?? current.organizationId,
organizationName: data.ticket.organization_name ?? current.organizationName, organizationName: data.ticket.organization_name ?? current.organizationName,
projectTeamspaceEntryId: loadedProjectEntries.length === 1 ? loadedProjectEntries[0].id : current.projectTeamspaceEntryId,
workType: data.ticket.work_type ?? current.workType workType: data.ticket.work_type ?? current.workType
})); }));
}, [data?.ticket]); }, [data?.ticket, data?.project]);
useEffect(() => { useEffect(() => {
setProjectDescriptionExpanded(false); setProjectDescriptionExpanded(false);
@@ -298,6 +489,14 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
const project = data?.project ?? null; const project = data?.project ?? null;
const isRecurringBillingTicket = ticketHasRecurringBilling(ticket); const isRecurringBillingTicket = ticketHasRecurringBilling(ticket);
const projectDescription = project ? combinedProjectDescription(project) : ""; 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 isProject = Boolean(project);
const isGlobalTicket = periodType === "all"; const isGlobalTicket = periodType === "all";
const isPeriodClosed = Boolean(data?.closed); const isPeriodClosed = Boolean(data?.closed);
@@ -498,6 +697,13 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
return; return;
} }
if (!canEditTicketData) {
toast.error("Projekt ist geteilt", {
description: "Nur der Zammad-Owner kann Ticket- und Projektdaten ändern."
});
return;
}
if (!editOrganizationId) { if (!editOrganizationId) {
toast.error("Organisation wählen"); toast.error("Organisation wählen");
return; return;
@@ -542,6 +748,13 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
return; return;
} }
if (project.can_edit === false) {
toast.error("Projekt ist geteilt", {
description: "Nur der Zammad-Owner kann die Projektdaten bearbeiten."
});
return;
}
setProjectForm(projectEditForm(project)); setProjectForm(projectEditForm(project));
setEditingProject(true); setEditingProject(true);
} }
@@ -553,29 +766,28 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
return; return;
} }
if (project.can_edit === false) {
toast.error("Projekt ist geteilt", {
description: "Nur der Zammad-Owner kann die Projektdaten bearbeiten."
});
return;
}
if (!projectForm.organizationId) { if (!projectForm.organizationId) {
toast.error("Organisation wählen"); toast.error("Organisation wählen");
return; return;
} }
const hourBudgetMinutes = parseHoursInput(projectForm.hourBudget); const teamspaceEntries = parseProjectTeamspaceEntryForms(projectForm.teamspaceEntries);
const parsedManualConsumptionMinutes = parseHoursInput(projectForm.manualConsumption);
if (hourBudgetMinutes === undefined) { if (!teamspaceEntries) {
toast.error("Stundenkontingent prüfen", { toast.error("Teamspace-Einträge prüfen", {
description: "Bitte Stunden als Zahl eintragen, z.B. 12,5." description: "Bitte Stunden als Zahl eintragen, z.B. 12,5."
}); });
return; return;
} }
if (parsedManualConsumptionMinutes === undefined) { const defaultTeamspaceEntry = teamspaceEntries.find((entry) => entry.isDefault) ?? teamspaceEntries[0];
toast.error("Manuellen Verbrauch prüfen", {
description: "Bitte Stunden als Zahl eintragen, z.B. 12,5."
});
return;
}
const manualConsumptionMinutes = parsedManualConsumptionMinutes ?? 0;
setSavingProject(true); setSavingProject(true);
try { try {
@@ -583,13 +795,14 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
ticketNumber: ticket.ticket_number, ticketNumber: ticket.ticket_number,
title: projectForm.title || null, title: projectForm.title || null,
organizationId: projectForm.organizationId, organizationId: projectForm.organizationId,
teamspaceProject: projectForm.teamspaceProject || null, teamspaceProject: defaultTeamspaceEntry.teamspaceProject,
teamspaceEntries,
description: projectForm.description, description: projectForm.description,
goals: "", goals: "",
startsOn: projectForm.startsOn || null, startsOn: projectForm.startsOn || null,
endsOn: projectForm.endsOn || null, endsOn: projectForm.endsOn || null,
hourBudgetMinutes, hourBudgetMinutes: defaultTeamspaceEntry.hourBudgetMinutes,
manualConsumptionMinutes manualConsumptionMinutes: defaultTeamspaceEntry.manualConsumptionMinutes
}); });
toast.success("Projekt gespeichert"); toast.success("Projekt gespeichert");
setEditingProject(false); setEditingProject(false);
@@ -608,6 +821,13 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
return; return;
} }
if (project.can_edit === false) {
toast.error("Projekt ist geteilt", {
description: "Nur der Zammad-Owner kann den Projektstatus ändern."
});
return;
}
setSavingProjectStatus(true); setSavingProjectStatus(true);
try { try {
await updateProjectStatus(project.id, closed); await updateProjectStatus(project.id, closed);
@@ -644,6 +864,13 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
return; 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 startedAt = new Date(`${ticketSession.day}T${ticketSession.startTime}:00`);
const endedAt = new Date(`${ticketSession.day}T${ticketSession.endTime}:00`); const endedAt = new Date(`${ticketSession.day}T${ticketSession.endTime}:00`);
@@ -657,6 +884,13 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
return; 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); const durationSeconds = Math.floor((endedAt.getTime() - startedAt.getTime()) / 1000);
setSavingTicketSession(true); setSavingTicketSession(true);
@@ -669,7 +903,8 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
workType: effectiveWorkType, workType: effectiveWorkType,
startedAt: startedAt.toISOString(), startedAt: startedAt.toISOString(),
endedAt: endedAt.toISOString(), endedAt: endedAt.toISOString(),
durationSeconds durationSeconds,
projectTeamspaceEntryId: effectiveWorkType === "consulting" && requiresProjectTeamspaceEntry ? ticketSession.projectTeamspaceEntryId : null
}); });
toast.success("Session nachgetragen", { toast.success("Session nachgetragen", {
description: "Der Eintrag wurde diesem Ticket zugeordnet. Betroffene Abschlüsse wurden wieder geöffnet." description: "Der Eintrag wurde diesem Ticket zugeordnet. Betroffene Abschlüsse wurden wieder geöffnet."
@@ -678,6 +913,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
...emptyTicketSessionForm(), ...emptyTicketSessionForm(),
organizationId: ticket.organization_id ?? "", organizationId: ticket.organization_id ?? "",
organizationName: ticket.organization_name ?? null, organizationName: ticket.organization_name ?? null,
projectTeamspaceEntryId: "",
workType: ticket.work_type ?? "support", workType: ticket.work_type ?? "support",
day: ticketSession.day day: ticketSession.day
}); });
@@ -697,6 +933,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
setEditSessionOrganizationName(session.organization_name ?? null); setEditSessionOrganizationName(session.organization_name ?? null);
setEditSessionActivity(session.activity); setEditSessionActivity(session.activity);
setEditSessionWorkType(session.work_type); setEditSessionWorkType(session.work_type);
setEditSessionProjectTeamspaceEntryId(session.project_teamspace_entry_id ?? "");
setEditSessionDay(toDateLocalValue(session.started_at)); setEditSessionDay(toDateLocalValue(session.started_at));
setEditSessionStartTime(toTimeLocalValue(session.started_at)); setEditSessionStartTime(toTimeLocalValue(session.started_at));
setEditSessionEndTime(toTimeLocalValue(session.ended_at)); setEditSessionEndTime(toTimeLocalValue(session.ended_at));
@@ -746,6 +983,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
activity: editSessionActivity, activity: editSessionActivity,
workType: editSessionWorkType, workType: editSessionWorkType,
removeProjectData, removeProjectData,
projectTeamspaceEntryId: editSessionWorkType === "consulting" && requiresProjectTeamspaceEntry ? editSessionProjectTeamspaceEntryId : null,
startedAt: startedAt.toISOString(), startedAt: startedAt.toISOString(),
endedAt: endedAt.toISOString() endedAt: endedAt.toISOString()
}); });
@@ -772,6 +1010,14 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
try { try {
const result = await deleteSession(session.id); 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) { if (result.deleted.ticketDeleted || result.deleted.userTicketEmpty) {
toast.success("Letzte Session gelöscht", { 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." description: result.deleted.ticketDeleted ? "Das leere Ticket wurde entfernt." : "Für dich gibt es zu diesem Ticket keine Einträge mehr."
@@ -793,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() { async function toggleTicketFavorite() {
if (!ticket?.ticket_number) { if (!ticket?.ticket_number) {
return; return;
@@ -922,7 +1194,11 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div> <div>
<CardTitle>Ticketdaten</CardTitle> <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> </div>
<HelpLink anchor="ticketdaten" label="Hilfe zu Ticketdaten" /> <HelpLink anchor="ticketdaten" label="Hilfe zu Ticketdaten" />
</div> </div>
@@ -931,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}> <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"> <div className="space-y-2">
<label className="text-sm font-medium" htmlFor="edit-ticket-number">Ticketnummer</label> <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>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium" htmlFor="edit-ticket-title">Titel</label> <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>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium" htmlFor="edit-ticket-organization">Organisation</label> <label className="text-sm font-medium" htmlFor="edit-ticket-organization">Organisation</label>
@@ -945,6 +1221,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
<OrganizationSelect <OrganizationSelect
value={editOrganizationId} value={editOrganizationId}
selectedName={editOrganizationName} selectedName={editOrganizationName}
disabled={!canEditTicketData}
onChange={(organization) => { onChange={(organization) => {
setEditOrganizationId(organization.id); setEditOrganizationId(organization.id);
setEditOrganizationName(organization.name); setEditOrganizationName(organization.name);
@@ -958,13 +1235,14 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
id="edit-ticket-work-type" 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" 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} value={editWorkType}
disabled={!canEditTicketData}
onChange={(event) => setEditWorkType(event.currentTarget.value as WorkType)} onChange={(event) => setEditWorkType(event.currentTarget.value as WorkType)}
> >
<option value="support">Support</option> <option value="support">Support</option>
<option value="consulting">Consulting</option> <option value="consulting">Consulting</option>
</select> </select>
</div> </div>
<Button type="submit" disabled={savingTicket}> <Button type="submit" disabled={!canEditTicketData || savingTicket}>
<Save className="size-4" /> <Save className="size-4" />
{savingTicket ? "Speichert..." : "Speichern"} {savingTicket ? "Speichert..." : "Speichern"}
</Button> </Button>
@@ -982,21 +1260,29 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
<FolderKanban className="size-5 text-muted-foreground" /> <FolderKanban className="size-5 text-muted-foreground" />
Projekt Projekt
{project.closed_at ? <Badge variant="secondary">geschlossen</Badge> : <Badge variant="success">offen</Badge>} {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> </CardTitle>
<CardDescription>{project.teamspace_project || "Kein Teamspace-Projekt hinterlegt"}</CardDescription> <CardDescription>
{!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>
</div> </div>
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center gap-1">
<Button <Button
type="button" type="button"
size="sm" size="sm"
variant="secondary" variant="secondary"
disabled={savingProjectStatus} disabled={!canEditProject || savingProjectStatus}
onClick={() => void setProjectClosed(!project.closed_at)} onClick={() => void setProjectClosed(!project.closed_at)}
> >
{project.closed_at ? <RotateCcw className="size-4" /> : <CheckCircle2 className="size-4" />} {project.closed_at ? <RotateCcw className="size-4" /> : <CheckCircle2 className="size-4" />}
{project.closed_at ? "Öffnen" : "Schließen"} {project.closed_at ? "Öffnen" : "Schließen"}
</Button> </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" /> <Pencil className="size-4" />
Bearbeiten Bearbeiten
</Button> </Button>
@@ -1005,13 +1291,63 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-3 px-4 pb-4"> <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"> <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">Zeitraum {project.starts_on || "offen"} - {project.ends_on || "offen"}</Badge>
<Badge variant="secondary">Kontingent {project.hour_budget_minutes ? formatMinutes(project.hour_budget_minutes) : "offen"}</Badge> <Badge variant="secondary">Kontingent {projectBudgetMinutes ? formatMinutes(projectBudgetMinutes) : "offen"}</Badge>
<Badge variant="secondary">Verbrauch {formatMinutes(project.consumption_minutes ?? 0)}</Badge> <Badge variant="secondary">Verbrauch {formatMinutes(projectConsumptionMinutes)}</Badge>
{project.manual_consumption_minutes > 0 ? <Badge variant="outline">Manuell {formatMinutes(project.manual_consumption_minutes)}</Badge> : null} {hasMultipleProjectTeamspaceEntries ? <Badge variant="outline">{projectEntries.length} Teamspace</Badge> : null}
{project.teamspace_project ? <Badge variant="outline">{project.teamspace_project}</Badge> : null}
</div> </div>
{hasMultipleProjectTeamspaceEntries ? (
<div className="space-y-2">
<div>
<div className="mb-1 flex items-center justify-between gap-2 text-xs text-muted-foreground">
<span>Gesamtverbrauch</span>
<span>{projectConsumptionUsagePercent !== null ? `${projectConsumptionUsagePercent}%` : "ohne Gesamtbudget"}</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
<div
className={cn("h-full rounded-full", projectConsumptionUsagePercent === null ? "bg-muted-foreground/20" : projectConsumptionBarClass(projectConsumptionUsagePercent))}
style={{ width: `${projectConsumptionUsagePercent === null ? 0 : Math.min(100, projectConsumptionUsagePercent)}%` }}
/>
</div>
</div>
<div className="grid gap-1.5 md:grid-cols-2">
{projectEntries.map((entry) => {
const consumption = Number(entry.consumption_minutes ?? 0);
const percent = projectConsumptionPercent(consumption, entry.hour_budget_minutes);
const label = entry.teamspace_project || (entry.is_default ? "Standard" : "Ohne Teamspace-ID");
return (
<div key={entry.id} className="rounded-md border bg-background/50 px-2 py-1">
<div className="mb-1 flex min-w-0 items-center justify-between gap-2 text-[11px]">
<span className="truncate text-muted-foreground">
{label}
{entry.is_default ? " · Standard" : ""}
</span>
<span className="shrink-0 font-medium">
{formatMinutes(consumption)} / {entry.hour_budget_minutes ? formatMinutes(entry.hour_budget_minutes) : "offen"}
</span>
</div>
<div className="h-1 overflow-hidden rounded-full bg-muted">
<div
className={cn("h-full rounded-full", percent === null ? "bg-muted-foreground/20" : projectConsumptionBarClass(percent))}
style={{ width: `${percent === null ? 0 : Math.min(100, percent)}%` }}
/>
</div>
</div>
);
})}
</div>
</div>
) : null}
<div className="text-sm"> <div className="text-sm">
<div className="min-w-0"> <div className="min-w-0">
<div className="mb-1 flex items-center gap-2 font-medium"> <div className="mb-1 flex items-center gap-2 font-medium">
@@ -1065,16 +1401,6 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
placeholder="optional" placeholder="optional"
/> />
</div> </div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="project-edit-teamspace">Teamspace-Projekt</label>
<Input
id="project-edit-teamspace"
value={projectForm.teamspaceProject}
onChange={(event) => setProjectForm({ ...projectForm, teamspaceProject: event.currentTarget.value })}
/>
</div>
</div>
<div className="grid gap-3 lg:grid-cols-[minmax(220px,1fr)_140px_140px_150px_170px]">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium">Organisation</label> <label className="text-sm font-medium">Organisation</label>
<OrganizationSelect <OrganizationSelect
@@ -1090,6 +1416,8 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
required required
/> />
</div> </div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium" htmlFor="project-edit-start">Von</label> <label className="text-sm font-medium" htmlFor="project-edit-start">Von</label>
<Input id="project-edit-start" type="date" value={projectForm.startsOn} onChange={(event) => setProjectForm({ ...projectForm, startsOn: event.currentTarget.value })} /> <Input id="project-edit-start" type="date" value={projectForm.startsOn} onChange={(event) => setProjectForm({ ...projectForm, startsOn: event.currentTarget.value })} />
@@ -1098,26 +1426,11 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
<label className="text-sm font-medium" htmlFor="project-edit-end">Bis</label> <label className="text-sm font-medium" htmlFor="project-edit-end">Bis</label>
<Input id="project-edit-end" type="date" value={projectForm.endsOn} onChange={(event) => setProjectForm({ ...projectForm, endsOn: event.currentTarget.value })} /> <Input id="project-edit-end" type="date" value={projectForm.endsOn} onChange={(event) => setProjectForm({ ...projectForm, endsOn: event.currentTarget.value })} />
</div> </div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="project-edit-budget">Kontingent h</label>
<Input
id="project-edit-budget"
inputMode="decimal"
value={projectForm.hourBudget}
onChange={(event) => setProjectForm({ ...projectForm, hourBudget: event.currentTarget.value })}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="project-edit-manual-consumption">Manueller Verbrauch h</label>
<Input
id="project-edit-manual-consumption"
inputMode="decimal"
value={projectForm.manualConsumption}
onChange={(event) => setProjectForm({ ...projectForm, manualConsumption: event.currentTarget.value })}
/>
</div>
</div> </div>
<p className="text-xs text-muted-foreground">Manueller Verbrauch wird nur im Projektverbrauch verrechnet, nicht in Auswertungen, Statistiken oder Monatsabschluss.</p> <ProjectTeamspaceEntriesEditor
entries={projectForm.teamspaceEntries}
onChange={(teamspaceEntries) => setProjectForm({ ...projectForm, teamspaceEntries })}
/>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium" htmlFor="project-edit-description">Beschreibung/Ziele</label> <label className="text-sm font-medium" htmlFor="project-edit-description">Beschreibung/Ziele</label>
<Textarea <Textarea
@@ -1295,6 +1608,13 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
<Badge variant="outline" className="w-fit">{formatMinutes(session.rounded_minutes)}</Badge> <Badge variant="outline" className="w-fit">{formatMinutes(session.rounded_minutes)}</Badge>
{session.recurring_billing_id ? <Badge variant="secondary">fix</Badge> : null} {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> </div>
<BillingButtons <BillingButtons
session={session} session={session}
@@ -1402,6 +1722,26 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
</div> </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="grid gap-3 lg:grid-cols-[150px_120px_120px_minmax(220px,1fr)_auto] lg:items-end">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium" htmlFor="ticket-session-day"> <label className="text-sm font-medium" htmlFor="ticket-session-day">
@@ -1468,6 +1808,24 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
</p> </p>
) : null} ) : 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)}> <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"> <DialogContent className="max-h-[calc(100dvh-2rem)] w-[calc(100vw-2rem)] overflow-y-auto p-5 sm:max-w-2xl">
<DialogHeader className="pr-8"> <DialogHeader className="pr-8">
@@ -1545,6 +1903,25 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
<option value="consulting">Consulting</option> <option value="consulting">Consulting</option>
</select> </select>
</div> </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"> <div className="flex justify-end">
<Button type="submit" disabled={savingSessionDetails}> <Button type="submit" disabled={savingSessionDetails}>
<Save className="size-4" /> <Save className="size-4" />
+204 -12
View File
@@ -1,6 +1,6 @@
import { FormEvent, useEffect, useMemo, useState } from "react"; import { FormEvent, useEffect, useMemo, useState } from "react";
import type { Dispatch, SetStateAction } 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 { toast } from "sonner";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -20,6 +20,7 @@ type ManualSessionFormState = {
ticketNumber: string; ticketNumber: string;
organizationId: string; organizationId: string;
organizationName: string | null; organizationName: string | null;
projectTeamspaceEntryId: string;
activity: string; activity: string;
workType: WorkType; workType: WorkType;
day: string; day: string;
@@ -37,6 +38,7 @@ function emptyManualSession(): ManualSessionFormState {
ticketNumber: "", ticketNumber: "",
organizationId: "", organizationId: "",
organizationName: null, organizationName: null,
projectTeamspaceEntryId: "",
activity: "", activity: "",
workType: "support", workType: "support",
day: currentDay(), day: currentDay(),
@@ -55,6 +57,14 @@ type FinishedSession = {
roundedMinutes: number; 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 = { type TimerPageProps = {
timers: TimerEntry[]; timers: TimerEntry[];
setTimers: Dispatch<SetStateAction<TimerEntry[]>>; setTimers: Dispatch<SetStateAction<TimerEntry[]>>;
@@ -69,6 +79,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
const [finishingId, setFinishingId] = useState<string | null>(null); const [finishingId, setFinishingId] = useState<string | null>(null);
const [organizationId, setOrganizationId] = useState(""); const [organizationId, setOrganizationId] = useState("");
const [organizationName, setOrganizationName] = useState<string | null>(null); const [organizationName, setOrganizationName] = useState<string | null>(null);
const [projectTeamspaceEntryId, setProjectTeamspaceEntryId] = useState("");
const [activity, setActivity] = useState(""); const [activity, setActivity] = useState("");
const [workType, setWorkType] = useState<WorkType>("support"); const [workType, setWorkType] = useState<WorkType>("support");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -76,11 +87,12 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
const [savingManualSession, setSavingManualSession] = useState(false); const [savingManualSession, setSavingManualSession] = useState(false);
const [manualLookupStatus, setManualLookupStatus] = useState<"idle" | "loading" | "found" | "not-found">("idle"); const [manualLookupStatus, setManualLookupStatus] = useState<"idle" | "loading" | "found" | "not-found">("idle");
const [manualLookupTicket, setManualLookupTicket] = useState<TicketMeta | null>(null); const [manualLookupTicket, setManualLookupTicket] = useState<TicketMeta | null>(null);
const [zammadLookupBusy, setZammadLookupBusy] = useState<string | null>(null);
const [recurringBillings, setRecurringBillings] = useState<RecurringBilling[]>([]); const [recurringBillings, setRecurringBillings] = useState<RecurringBilling[]>([]);
const selectedTimer = timers.find((timer) => timer.id === selectedTimerId) ?? timers[0] ?? null; const selectedTimer = timers.find((timer) => timer.id === selectedTimerId) ?? timers[0] ?? null;
const elapsedSeconds = useMemo(() => Math.floor(activeElapsedMs(selectedTimer, tick) / 1000), [selectedTimer, tick]); 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 isManualTicketValid = ticketPattern.test(manualSession.ticketNumber.trim());
const manualTicketDefaultsLocked = Boolean(manualLookupTicket?.organization_id && manualLookupTicket.work_type); const manualTicketDefaultsLocked = Boolean(manualLookupTicket?.organization_id && manualLookupTicket.work_type);
const manualTicketIsRecurringBilling = const manualTicketIsRecurringBilling =
@@ -151,6 +163,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
...current, ...current,
organizationId: result.ticket?.organization_id ?? "", organizationId: result.ticket?.organization_id ?? "",
organizationName: result.ticket?.organization_name ?? result.ticket?.customer_name ?? null, organizationName: result.ticket?.organization_name ?? result.ticket?.customer_name ?? null,
projectTeamspaceEntryId: "",
workType: result.ticket?.work_type ?? current.workType workType: result.ticket?.work_type ?? current.workType
}; };
}); });
@@ -169,7 +182,9 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
}; };
}, [manualSession.ticketNumber]); }, [manualSession.ticketNumber]);
async function addTimer() { async function addTimer(source: "local" | "zammad" = "local") {
const normalizedTicketNumber = ticketNumber.trim();
if (!isValidTicket) { if (!isValidTicket) {
toast.error("Ticketnummer prüfen", { toast.error("Ticketnummer prüfen", {
description: "Das Format muss Ticket#XXXXXX sein." description: "Das Format muss Ticket#XXXXXX sein."
@@ -177,17 +192,40 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
return; return;
} }
const ticketResult = await lookupTicket(ticketNumber).catch(() => ({ ticket: null })); if (source === "zammad") {
setZammadLookupBusy("timer");
}
if (ticketHasRecurringBilling(ticketResult.ticket, recurringBillings) || isRecurringBillingTicketNumber(ticketNumber, recurringBillings)) { const ticketResult = await lookupTicket(normalizedTicketNumber, source).catch((error) => {
setTimers((current) => current.filter((timer) => timer.ticketNumber !== ticketNumber)); 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", { toast.error("Fixe Abrechnung", {
description: "Für fixe Abrechnungen dürfen keine manuellen Sessions gestartet werden. Bitte die Definition bearbeiten." description: "Für fixe Abrechnungen dürfen keine manuellen Sessions gestartet werden. Bitte die Definition bearbeiten."
}); });
return; return;
} }
if (timers.some((timer) => timer.ticketNumber === ticketNumber)) { if (timers.some((timer) => timer.ticketNumber === normalizedTicketNumber)) {
toast.error("Timer existiert bereits", { toast.error("Timer existiert bereits", {
description: "Für dieses Ticket läuft oder pausiert schon ein Timer." 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 now = Date.now();
const newTimer: TimerEntry = { const newTimer: TimerEntry = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
ticketNumber, ticketNumber: normalizedTicketNumber,
organizationName: ticketResult.ticket?.organization_name ?? ticketResult.ticket?.customer_name ?? null, organizationName: ticketResult.ticket?.organization_name ?? ticketResult.ticket?.customer_name ?? null,
workType: ticketResult.ticket?.work_type ?? null, workType: ticketResult.ticket?.work_type ?? null,
isProject: Boolean(ticketResult.ticket?.is_project), isProject: Boolean(ticketResult.ticket?.is_project),
@@ -214,6 +252,80 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
setTicketNumber(""); 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) { function activateTimer(timerId: string) {
const now = Date.now(); const now = Date.now();
setTimers((current) => setTimers((current) =>
@@ -268,6 +380,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
setOrganizationId(ticket?.organization_id ?? ""); setOrganizationId(ticket?.organization_id ?? "");
setOrganizationName(ticket?.organization_name ?? null); setOrganizationName(ticket?.organization_name ?? null);
setProjectTeamspaceEntryId("");
setWorkType(ticket?.work_type ?? "support"); setWorkType(ticket?.work_type ?? "support");
setActivity(""); setActivity("");
setFinished({ setFinished({
@@ -306,6 +419,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
const existingTicketHasDefaults = Boolean(finished.ticket?.organization_id && finished.ticket?.work_type); const existingTicketHasDefaults = Boolean(finished.ticket?.organization_id && finished.ticket?.work_type);
const effectiveOrganizationId = existingTicketHasDefaults ? finished.ticket!.organization_id! : organizationId; const effectiveOrganizationId = existingTicketHasDefaults ? finished.ticket!.organization_id! : organizationId;
const effectiveWorkType = existingTicketHasDefaults ? finished.ticket!.work_type! : workType; const effectiveWorkType = existingTicketHasDefaults ? finished.ticket!.work_type! : workType;
const finishedRequiresTeamspaceEntry = requiresTeamspaceEntry(finished.ticket);
if (!effectiveOrganizationId) { if (!effectiveOrganizationId) {
toast.error("Organisation wählen", { toast.error("Organisation wählen", {
@@ -314,6 +428,13 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
return; return;
} }
if (finishedRequiresTeamspaceEntry && !projectTeamspaceEntryId) {
toast.error("Teamspace-Eintrag wählen", {
description: "Dieses Projekt hat mehrere Teamspace-Einträge."
});
return;
}
setSaving(true); setSaving(true);
try { try {
@@ -324,7 +445,9 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
durationSeconds: finished.durationSeconds, durationSeconds: finished.durationSeconds,
organizationId: effectiveOrganizationId, organizationId: effectiveOrganizationId,
activity, activity,
workType: effectiveWorkType workType: effectiveWorkType,
zammadLookup: Boolean(finished.ticket?.zammad_ticket_id),
projectTeamspaceEntryId: finishedRequiresTeamspaceEntry ? projectTeamspaceEntryId : null
}); });
toast.success("Session gespeichert", { toast.success("Session gespeichert", {
@@ -334,6 +457,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
setFinished(null); setFinished(null);
setOrganizationId(""); setOrganizationId("");
setOrganizationName(null); setOrganizationName(null);
setProjectTeamspaceEntryId("");
setActivity(""); setActivity("");
setWorkType("support"); setWorkType("support");
} catch (error) { } catch (error) {
@@ -367,6 +491,13 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
return; 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 startedAt = new Date(`${manualSession.day}T${manualSession.startTime}:00`);
const endedAt = new Date(`${manualSession.day}T${manualSession.endTime}: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, workType: manualSession.workType,
startedAt: startedAt.toISOString(), startedAt: startedAt.toISOString(),
endedAt: endedAt.toISOString(), endedAt: endedAt.toISOString(),
durationSeconds durationSeconds,
zammadLookup: Boolean(manualLookupTicket?.zammad_ticket_id),
projectTeamspaceEntryId: requiresTeamspaceEntry(manualLookupTicket) ? manualSession.projectTeamspaceEntryId : null
}); });
toast.success("Session nachgetragen", { toast.success("Session nachgetragen", {
description: manualLookupTicket?.is_project ? "Der Eintrag wurde dem Projekt zugeordnet." : "Der Eintrag ist in deiner Ticketliste offen." 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" /> <HelpLink anchor="timer" label="Hilfe zu Timern" />
</div> </div>
</CardHeader> </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"> <div className="space-y-2">
<label className="text-sm font-medium" htmlFor="ticket-number"> <label className="text-sm font-medium" htmlFor="ticket-number">
Ticketnummer Ticketnummer
@@ -456,6 +589,10 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
<Plus className="size-4" /> <Plus className="size-4" />
Timer starten Timer starten
</Button> </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> </CardContent>
</Card> </Card>
@@ -533,6 +670,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
ticketNumber: event.currentTarget.value, ticketNumber: event.currentTarget.value,
organizationId: "", organizationId: "",
organizationName: null, organizationName: null,
projectTeamspaceEntryId: "",
workType: "support" workType: "support"
}) })
} }
@@ -551,7 +689,15 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
{manualTicketIsRecurringBilling ? ( {manualTicketIsRecurringBilling ? (
<p className="text-xs text-destructive">Fixe Abrechnung: Sessions kommen ausschließlich aus der Definition.</p> <p className="text-xs text-destructive">Fixe Abrechnung: Sessions kommen ausschließlich aus der Definition.</p>
) : null} ) : 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>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium">Organisation</label> <label className="text-sm font-medium">Organisation</label>
@@ -582,6 +728,25 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
<option value="consulting">Consulting</option> <option value="consulting">Consulting</option>
</select> </select>
</div> </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>
<div className="grid gap-3 lg:grid-cols-[160px_120px_120px_minmax(220px,1fr)_auto] lg:items-end"> <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> </DialogHeader>
<form className="space-y-4" onSubmit={submitSession}> <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 ? ( {finished?.ticket?.organization_id && finished.ticket.work_type ? (
<div className="rounded-lg border bg-muted/40 p-3 text-sm"> <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> <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> </select>
</div> </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"> <div className="flex justify-end">
<Button type="submit" disabled={saving}> <Button type="submit" disabled={saving}>
<Upload className="size-4" /> <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 API_PROXY_TARGET="${API_PROXY_TARGET:-http://127.0.0.1:${BACKEND_PORT}}"
export NEXT_TELEMETRY_DISABLED=1 export NEXT_TELEMETRY_DISABLED=1
if [[ -f ".env" ]]; then if [[ -f ".env_dev" ]]; then
set -a set -a
source .env source .env_dev
set +a set +a
fi fi