Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07ef8ef025 | ||
|
|
c2063fe1ed |
No files matched your search
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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);");
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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">
|
||||||
@@ -2017,7 +2124,8 @@ function NotesWidget({ widget, editing, onUpdateConfig }: WidgetContentProps) {
|
|||||||
))}
|
))}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</div>
|
</div>
|
||||||
<Button type="button" size="icon" variant="ghost" className="size-7 shrink-0" onClick={() => scrollTabs(1)}>
|
</div>
|
||||||
|
<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}>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
try {
|
const payload = {
|
||||||
const result = await updateCurrentUser({
|
|
||||||
username,
|
username,
|
||||||
displayName,
|
displayName,
|
||||||
currentPassword: wantsPasswordChange ? currentPassword : undefined,
|
currentPassword: wantsPasswordChange ? currentPassword : undefined,
|
||||||
newPassword: wantsPasswordChange ? newPassword : undefined
|
newPassword: wantsPasswordChange ? newPassword : undefined,
|
||||||
});
|
...(currentUser.role === "user" && zammadTokenDirty ? { zammadApiToken } : {})
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await updateCurrentUser(payload);
|
||||||
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" />
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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: "",
|
||||||
|
teamspaceEntries: [
|
||||||
|
{
|
||||||
|
clientId: projectEntryId(),
|
||||||
|
teamspaceProject: "",
|
||||||
hourBudget: "",
|
hourBudget: "",
|
||||||
manualConsumption: ""
|
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">
|
</div>
|
||||||
<label className="text-sm font-medium" htmlFor="project-edit-budget">Kontingent h</label>
|
<ProjectTeamspaceEntriesEditor
|
||||||
<Input
|
entries={projectForm.teamspaceEntries}
|
||||||
id="project-edit-budget"
|
onChange={(teamspaceEntries) => setProjectForm({ ...projectForm, teamspaceEntries })}
|
||||||
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>
|
|
||||||
<p className="text-xs text-muted-foreground">Manueller Verbrauch wird nur im Projektverbrauch verrechnet, nicht in Auswertungen, Statistiken oder Monatsabschluss.</p>
|
|
||||||
<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" />
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
Executable
+88
@@ -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"
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user