Compare commits

...
9 Commits
16 changed files with 3143 additions and 205 deletions

No files matched your search

+1002 -50
View File
File diff suppressed because it is too large. Load diff
+98
View File
@@ -7,9 +7,25 @@ async function findMigrationAdmin() {
async function ensureClosureOwnership(table: string, column: string, primaryKeyColumns: string[], adminId: string | null) { async function ensureClosureOwnership(table: string, column: string, primaryKeyColumns: string[], adminId: string | null) {
await query(`ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS user_id BIGINT REFERENCES users(id);`); await query(`ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS user_id BIGINT REFERENCES users(id);`);
if (adminId) { if (adminId) {
await query(`UPDATE ${table} SET user_id = $1 WHERE user_id IS NULL;`, [adminId]); await query(`UPDATE ${table} SET user_id = $1 WHERE user_id IS NULL;`, [adminId]);
} }
const duplicateMatchers = [...primaryKeyColumns, "user_id"]
.map((key) => `older.${key} IS NOT DISTINCT FROM newer.${key}`)
.join(" AND ");
await query(`
DELETE FROM ${table} older
USING ${table} newer
WHERE ${duplicateMatchers}
AND (
older.closed_at < newer.closed_at
OR (older.closed_at = newer.closed_at AND older.ctid < newer.ctid)
);
`);
await query(` await query(`
DO $$ DO $$
BEGIN BEGIN
@@ -97,6 +113,7 @@ export async function migrate() {
CREATE TABLE IF NOT EXISTS tickets ( CREATE TABLE IF NOT EXISTS tickets (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
ticket_number TEXT NOT NULL UNIQUE, ticket_number TEXT NOT NULL UNIQUE,
title TEXT,
organization_id BIGINT REFERENCES organizations(zammad_id), organization_id BIGINT REFERENCES organizations(zammad_id),
customer_name TEXT, customer_name TEXT,
work_type TEXT, work_type TEXT,
@@ -107,6 +124,7 @@ export async function migrate() {
`); `);
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS customer_name TEXT;"); await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS customer_name 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(` await query(`
@@ -233,6 +251,46 @@ export async function migrate() {
); );
`); `);
await query(`
UPDATE tickets t
SET organization_id = old_org.zammad_id
FROM organizations old_org
WHERE t.organization_id = old_org.id
AND t.organization_id <> old_org.zammad_id
AND COALESCE(t.customer_name, '') = old_org.name;
`);
await query(`
UPDATE sessions s
SET organization_id = old_org.zammad_id
FROM organizations old_org
WHERE s.organization_id = old_org.id
AND s.organization_id <> old_org.zammad_id
AND COALESCE(s.customer_name, '') = old_org.name;
`);
await query(`
UPDATE recurring_billings rb
SET organization_id = old_org.zammad_id
FROM organizations old_org
WHERE rb.organization_id = old_org.id
AND rb.organization_id <> old_org.zammad_id
AND (
EXISTS (
SELECT 1
FROM sessions s
WHERE s.recurring_billing_id = rb.id
AND COALESCE(s.customer_name, '') = old_org.name
)
OR EXISTS (
SELECT 1
FROM tickets t
WHERE t.ticket_number = COALESCE(rb.ticket_number, 'Fix#' || rb.id)
AND COALESCE(t.customer_name, '') = old_org.name
)
);
`);
await query(` await query(`
UPDATE tickets UPDATE tickets
SET organization_id = NULL SET organization_id = NULL
@@ -379,6 +437,42 @@ export async function migrate() {
); );
`); `);
await query(`
CREATE TABLE IF NOT EXISTS projects (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
ticket_id BIGINT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
teamspace_project TEXT,
description TEXT NOT NULL DEFAULT '',
goals TEXT NOT NULL DEFAULT '',
starts_on DATE,
ends_on DATE,
hour_budget_minutes INTEGER,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (user_id, ticket_id),
CONSTRAINT projects_budget_non_negative CHECK (hour_budget_minutes IS NULL OR hour_budget_minutes >= 0),
CONSTRAINT projects_valid_range CHECK (starts_on IS NULL OR ends_on IS NULL OR ends_on >= starts_on)
);
`);
await query(`
CREATE TABLE IF NOT EXISTS ticket_todos (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
ticket_id BIGINT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
priority TEXT NOT NULL DEFAULT 'medium',
status TEXT NOT NULL DEFAULT 'open',
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT ticket_todos_priority_valid CHECK (priority IN ('low', 'medium', 'high', 'urgent')),
CONSTRAINT ticket_todos_status_valid CHECK (status IN ('open', 'done'))
);
`);
await ensureClosureOwnership("ticket_month_closures", "month", ["ticket_id", "month"], adminId); await ensureClosureOwnership("ticket_month_closures", "month", ["ticket_id", "month"], adminId);
await ensureClosureOwnership("month_closures", "month", ["month"], adminId); await ensureClosureOwnership("month_closures", "month", ["month"], adminId);
await ensureClosureOwnership("ticket_day_closures", "day", ["ticket_id", "day"], adminId); await ensureClosureOwnership("ticket_day_closures", "day", ["ticket_id", "day"], adminId);
@@ -394,6 +488,10 @@ export async function migrate() {
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);");
await query("CREATE INDEX IF NOT EXISTS idx_ticket_day_billings_user_day ON ticket_day_billings(user_id, day);"); await query("CREATE INDEX IF NOT EXISTS idx_ticket_day_billings_user_day ON ticket_day_billings(user_id, day);");
await query("CREATE INDEX IF NOT EXISTS idx_ticket_day_billing_ack_user_day ON ticket_day_billing_acknowledgements(user_id, day);"); await query("CREATE INDEX IF NOT EXISTS idx_ticket_day_billing_ack_user_day ON ticket_day_billing_acknowledgements(user_id, day);");
await query("CREATE INDEX IF NOT EXISTS idx_projects_user_ticket ON projects(user_id, ticket_id);");
await query("CREATE INDEX IF NOT EXISTS idx_projects_user_updated ON projects(user_id, updated_at DESC);");
await query("CREATE INDEX IF NOT EXISTS idx_ticket_todos_user_status_priority ON ticket_todos(user_id, status, priority);");
await query("CREATE INDEX IF NOT EXISTS idx_ticket_todos_ticket_user ON ticket_todos(ticket_id, user_id);");
await query("CREATE INDEX IF NOT EXISTS idx_recurring_billings_user_active ON recurring_billings(user_id, active);"); await query("CREATE INDEX IF NOT EXISTS idx_recurring_billings_user_active ON recurring_billings(user_id, active);");
await query("UPDATE recurring_billings SET active = true WHERE active = false;"); await query("UPDATE recurring_billings SET active = true WHERE active = false;");
await query(` await query(`
+76 -21
View File
@@ -6,8 +6,10 @@ import {
ChartNoAxesCombined, ChartNoAxesCombined,
CircleHelp, CircleHelp,
FileText, FileText,
FolderKanban,
GripVertical, GripVertical,
LayoutGrid, LayoutGrid,
ListTodo,
LogOut, LogOut,
Moon, Moon,
Pause, Pause,
@@ -65,12 +67,14 @@ import { FaqPage } from "./views/FaqPage";
import { LoginPage } from "./views/LoginPage"; import { LoginPage } from "./views/LoginPage";
import { MonthlyClosePage } from "./views/MonthlyClosePage"; import { MonthlyClosePage } from "./views/MonthlyClosePage";
import { ProfilePage } from "./views/ProfilePage"; import { ProfilePage } from "./views/ProfilePage";
import { ProjectsPage } from "./views/ProjectsPage";
import { RecurringBillingsPage } from "./views/RecurringBillingsPage"; import { RecurringBillingsPage } from "./views/RecurringBillingsPage";
import { StatisticsPage } from "./views/StatisticsPage"; import { StatisticsPage } from "./views/StatisticsPage";
import { SetupPage } from "./views/SetupPage"; import { SetupPage } from "./views/SetupPage";
import { TicketDetailPage } from "./views/TicketDetailPage"; import { TicketDetailPage } from "./views/TicketDetailPage";
import { TimerPage } from "./views/TimerPage"; import { TimerPage } from "./views/TimerPage";
import type { AuthUser, PeriodType, TicketSearchResult } from "./types"; import { TodosPage } from "./views/TodosPage";
import type { AuthUser, PeriodType, TicketDetailPeriodType, TicketSearchResult } from "./types";
import type { Dispatch, DragEvent, SetStateAction } from "react"; import type { Dispatch, DragEvent, SetStateAction } from "react";
type NavMenuEntry = type NavMenuEntry =
@@ -90,7 +94,7 @@ type OpenTicketNavItem = {
key: string; key: string;
ticketId: string; ticketId: string;
ticketNumber: string; ticketNumber: string;
periodType: PeriodType; periodType: TicketDetailPeriodType;
period: string; period: string;
path: string; path: string;
dirty: boolean; dirty: boolean;
@@ -100,6 +104,10 @@ function openTicketsStorageKey(userId: string) {
return `tickettracker.openTickets.${userId}`; return `tickettracker.openTickets.${userId}`;
} }
function pathWithoutSearch(value: string) {
return value.split("?")[0];
}
function readOpenTickets(userId: string): OpenTicketNavItem[] { function readOpenTickets(userId: string): OpenTicketNavItem[] {
try { try {
const raw = localStorage.getItem(openTicketsStorageKey(userId)); const raw = localStorage.getItem(openTicketsStorageKey(userId));
@@ -111,13 +119,13 @@ function readOpenTickets(userId: string): OpenTicketNavItem[] {
const parsed = JSON.parse(raw) as Partial<OpenTicketNavItem>[]; const parsed = JSON.parse(raw) as Partial<OpenTicketNavItem>[];
return parsed return parsed
.filter((item) => item.key && item.ticketId && item.ticketNumber && item.periodType && item.period && item.path) .filter((item) => item.key && item.ticketId && item.ticketNumber && item.path)
.map((item) => ({ .map((item) => ({
key: String(item.key), key: String(item.key),
ticketId: String(item.ticketId), ticketId: String(item.ticketId),
ticketNumber: String(item.ticketNumber), ticketNumber: String(item.ticketNumber),
periodType: item.periodType === "day" ? "day" : "month", periodType: item.periodType === "day" ? "day" : item.periodType === "month" ? "month" : "all",
period: String(item.period), period: item.period ? String(item.period) : "all",
path: String(item.path), path: String(item.path),
dirty: false dirty: false
})); }));
@@ -126,14 +134,30 @@ function readOpenTickets(userId: string): OpenTicketNavItem[] {
} }
} }
function routeFromPath(pathname: string) { function routeFromPath(pathWithSearch: string) {
const [pathname, search = ""] = pathWithSearch.split("?");
const searchParams = new URLSearchParams(search);
const ticketMatch = pathname.match(/^\/tickets\/(\d+)$/);
if (ticketMatch) {
return {
page: "ticket" as const,
periodType: "all" as const,
period: searchParams.get("month") ?? "all",
ticketId: ticketMatch[1],
};
}
const periodTicketMatch = pathname.match(/^\/analysis\/(month|day)\/([^/]+)\/tickets\/(\d+)$/); const periodTicketMatch = pathname.match(/^\/analysis\/(month|day)\/([^/]+)\/tickets\/(\d+)$/);
if (periodTicketMatch) { if (periodTicketMatch) {
const sourcePeriodType = periodTicketMatch[1] as PeriodType;
const sourcePeriod = periodTicketMatch[2];
return { return {
page: "ticket" as const, page: "ticket" as const,
periodType: periodTicketMatch[1] as PeriodType, periodType: "all" as const,
period: periodTicketMatch[2], period: sourcePeriodType === "day" ? sourcePeriod.slice(0, 7) : sourcePeriod,
ticketId: periodTicketMatch[3], ticketId: periodTicketMatch[3],
}; };
} }
@@ -143,7 +167,7 @@ function routeFromPath(pathname: string) {
if (legacyTicketMatch) { if (legacyTicketMatch) {
return { return {
page: "ticket" as const, page: "ticket" as const,
periodType: "month" as const, periodType: "all" as const,
period: legacyTicketMatch[1], period: legacyTicketMatch[1],
ticketId: legacyTicketMatch[2], ticketId: legacyTicketMatch[2],
}; };
@@ -157,6 +181,14 @@ function routeFromPath(pathname: string) {
return { page: "statistics" as const }; return { page: "statistics" as const };
} }
if (pathname.startsWith("/projects")) {
return { page: "projects" as const };
}
if (pathname.startsWith("/todos")) {
return { page: "todos" as const };
}
if (pathname.startsWith("/faq")) { if (pathname.startsWith("/faq")) {
return { page: "faq" as const }; return { page: "faq" as const };
} }
@@ -184,7 +216,7 @@ function useAppRouter() {
const [path, setPath] = useState("/timer"); const [path, setPath] = useState("/timer");
useEffect(() => { useEffect(() => {
const update = () => setPath(window.location.pathname); const update = () => setPath(`${window.location.pathname}${window.location.search}`);
update(); update();
window.addEventListener("popstate", update); window.addEventListener("popstate", update);
return () => window.removeEventListener("popstate", update); return () => window.removeEventListener("popstate", update);
@@ -192,7 +224,7 @@ function useAppRouter() {
function navigate(to: string) { function navigate(to: string) {
window.history.pushState({}, "", to); window.history.pushState({}, "", to);
setPath(window.location.pathname); setPath(`${window.location.pathname}${window.location.search}`);
} }
function navHandler(to: string) { function navHandler(to: string) {
@@ -318,7 +350,7 @@ function HeaderTicketSearch({ className, onNavigate }: HeaderTicketSearchProps)
}, [trimmedQuery]); }, [trimmedQuery]);
function openTicket(ticket: TicketSearchResult) { function openTicket(ticket: TicketSearchResult) {
onNavigate(`/analysis/month/${ticket.latest_period}/tickets/${ticket.ticket_id}`); onNavigate(`/tickets/${ticket.ticket_id}`);
setQuery(""); setQuery("");
setResults([]); setResults([]);
setOpen(false); setOpen(false);
@@ -367,11 +399,16 @@ function HeaderTicketSearch({ className, onNavigate }: HeaderTicketSearchProps)
}} }}
> >
<span className="flex min-w-0 items-center justify-between gap-3"> <span className="flex min-w-0 items-center justify-between gap-3">
<span className="truncate text-sm font-medium">{ticket.ticket_number}</span> <span className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-medium">{ticket.title || ticket.ticket_number}</span>
{ticket.is_project ? <span className="rounded-full border px-1.5 py-0.5 text-[10px] uppercase text-muted-foreground">Projekt</span> : null}
</span>
<span className="shrink-0 text-xs text-muted-foreground">{formatMinutes(ticket.total_minutes)}</span> <span className="shrink-0 text-xs text-muted-foreground">{formatMinutes(ticket.total_minutes)}</span>
</span> </span>
<span className="truncate text-xs text-muted-foreground"> <span className="truncate text-xs text-muted-foreground">
{ticket.organization_name || ticket.customer_name || "Keine Organisation"} · {ticket.session_count} Session(s) · zuletzt {formatDateTime(ticket.latest_started_at)} {ticket.title ? `${ticket.ticket_number} · ` : ""}
{ticket.project_name || ticket.organization_name || ticket.customer_name || "Keine Organisation"} · {ticket.session_count} Session(s)
{ticket.latest_started_at ? ` · zuletzt ${formatDateTime(ticket.latest_started_at)}` : ""}
</span> </span>
</button> </button>
)) ))
@@ -469,7 +506,8 @@ function SidebarTimers({ timers, selectedTimerId, setSelectedTimerId, setTimers,
</span> </span>
{timer.organizationName ? ( {timer.organizationName ? (
<span className="block truncate text-[11px] leading-4 text-muted-foreground"> <span className="block truncate text-[11px] leading-4 text-muted-foreground">
{timer.organizationName} {timer.isProject ? "Projekt · " : ""}
{timer.projectName || timer.organizationName}
</span> </span>
) : null} ) : null}
<span className="block font-mono text-[11px] font-semibold leading-4 tracking-normal text-muted-foreground"> <span className="block font-mono text-[11px] font-semibold leading-4 tracking-normal text-muted-foreground">
@@ -611,13 +649,17 @@ export function App() {
return { return {
id: timer.id, id: timer.id,
organizationName: result.ticket?.organization_name ?? result.ticket?.customer_name ?? null, organizationName: result.ticket?.organization_name ?? result.ticket?.customer_name ?? null,
workType: result.ticket?.work_type ?? null workType: result.ticket?.work_type ?? null,
isProject: Boolean(result.ticket?.is_project),
projectName: result.ticket?.project_name ?? null
}; };
} catch { } catch {
return { return {
id: timer.id, id: timer.id,
organizationName: null, organizationName: null,
workType: null workType: null,
isProject: false,
projectName: null
}; };
} }
}) })
@@ -639,6 +681,8 @@ export function App() {
...timer, ...timer,
organizationName: result.organizationName, organizationName: result.organizationName,
workType: result.workType, workType: result.workType,
isProject: result.isProject,
projectName: result.projectName,
ticketLookupDone: true ticketLookupDone: true
}; };
}) })
@@ -707,6 +751,8 @@ export function App() {
ticketNumber, ticketNumber,
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),
projectName: ticketResult.ticket?.project_name ?? null,
ticketLookupDone: true, ticketLookupDone: true,
startedAt: now, startedAt: now,
pausedTotalMs: 0, pausedTotalMs: 0,
@@ -717,7 +763,7 @@ export function App() {
setTimers((current) => [...current.map((timer) => pauseEntry(timer, now)), newTimer]); setTimers((current) => [...current.map((timer) => pauseEntry(timer, now)), newTimer]);
setSelectedTimerId(newTimer.id); setSelectedTimerId(newTimer.id);
toast.success("Timer gestartet", { toast.success("Timer gestartet", {
description: ticketNumber description: ticketResult.ticket?.is_project ? `${ticketNumber} ist ein Projekt` : ticketNumber
}); });
return true; return true;
} }
@@ -800,7 +846,7 @@ export function App() {
setOpenTickets((items) => items.filter((item) => item.key !== ticket.key)); setOpenTickets((items) => items.filter((item) => item.key !== ticket.key));
if (path === ticket.path) { if (pathWithoutSearch(path) === pathWithoutSearch(ticket.path)) {
navigate("/analysis"); navigate("/analysis");
} }
} }
@@ -847,6 +893,9 @@ export function App() {
{ type: "item" as const, href: "/timer", label: "Session starten", icon: Timer, active: path.startsWith("/timer") || path === "/" }, { type: "item" as const, href: "/timer", label: "Session starten", icon: Timer, active: path.startsWith("/timer") || path === "/" },
{ type: "item" as const, href: "/analysis", label: "Auswertung", icon: BarChart3, active: path.startsWith("/analysis") }, { type: "item" as const, href: "/analysis", label: "Auswertung", icon: BarChart3, active: path.startsWith("/analysis") },
{ type: "separator" as const, key: "after-analysis" }, { type: "separator" as const, key: "after-analysis" },
{ type: "item" as const, href: "/projects", label: "Projekte", icon: FolderKanban, active: path.startsWith("/projects") },
{ type: "item" as const, href: "/todos", label: "ToDos", icon: ListTodo, active: path.startsWith("/todos") },
{ type: "separator" as const, key: "after-work-items" },
{ type: "item" as const, href: "/monthly-close", label: "Monatsabschluss", icon: ClipboardCheck, active: path.startsWith("/monthly-close") }, { type: "item" as const, href: "/monthly-close", label: "Monatsabschluss", icon: ClipboardCheck, active: path.startsWith("/monthly-close") },
{ type: "separator" as const, key: "after-monthly-close" }, { type: "separator" as const, key: "after-monthly-close" },
{ type: "item" as const, href: "/statistics", label: "Statistiken", icon: ChartNoAxesCombined, active: path.startsWith("/statistics") }, { type: "item" as const, href: "/statistics", label: "Statistiken", icon: ChartNoAxesCombined, active: path.startsWith("/statistics") },
@@ -947,7 +996,7 @@ export function App() {
<SidebarGroupContent> <SidebarGroupContent>
<SidebarMenu> <SidebarMenu>
{openTickets.map((ticket) => { {openTickets.map((ticket) => {
const isActive = path === ticket.path; const isActive = pathWithoutSearch(path) === pathWithoutSearch(ticket.path);
return ( return (
<SidebarMenuItem key={ticket.key}> <SidebarMenuItem key={ticket.key}>
@@ -967,7 +1016,11 @@ export function App() {
<a <a
href={ticket.path} href={ticket.path}
className="flex min-w-0 flex-1 items-center gap-2 text-sm" className="flex min-w-0 flex-1 items-center gap-2 text-sm"
title={`${ticket.ticketNumber} (${ticket.periodType === "month" ? "Monat" : "Tag"} ${ticket.period})`} title={
ticket.periodType === "all"
? `${ticket.ticketNumber} (alle Sessions)`
: `${ticket.ticketNumber} (${ticket.periodType === "month" ? "Monat" : "Tag"} ${ticket.period})`
}
onClick={navHandler(ticket.path)} onClick={navHandler(ticket.path)}
> >
<span className={cn("size-2 shrink-0 rounded-full", ticket.dirty ? "bg-amber-400" : isActive ? "bg-primary-foreground" : "bg-muted-foreground/70")} /> <span className={cn("size-2 shrink-0 rounded-full", ticket.dirty ? "bg-amber-400" : isActive ? "bg-primary-foreground" : "bg-muted-foreground/70")} />
@@ -1106,6 +1159,8 @@ export function App() {
{!isAdmin && route.page === "analysis" ? <AnalysisPage onNavigate={navigate} /> : null} {!isAdmin && route.page === "analysis" ? <AnalysisPage onNavigate={navigate} /> : null}
{!isAdmin && route.page === "monthly-close" ? <MonthlyClosePage onNavigate={navigate} /> : null} {!isAdmin && route.page === "monthly-close" ? <MonthlyClosePage onNavigate={navigate} /> : null}
{!isAdmin && route.page === "statistics" ? <StatisticsPage onNavigate={navigate} /> : null} {!isAdmin && route.page === "statistics" ? <StatisticsPage onNavigate={navigate} /> : null}
{!isAdmin && route.page === "projects" ? <ProjectsPage onNavigate={navigate} onStartTimer={startQuickTimer} /> : null}
{!isAdmin && route.page === "todos" ? <TodosPage onNavigate={navigate} /> : null}
{!isAdmin && route.page === "recurring" ? <RecurringBillingsPage /> : null} {!isAdmin && route.page === "recurring" ? <RecurringBillingsPage /> : null}
{route.page === "faq" ? <FaqPage /> : null} {route.page === "faq" ? <FaqPage /> : null}
{route.page === "profile" ? <ProfilePage currentUser={currentUser} onUserUpdated={setCurrentUser} /> : null} {route.page === "profile" ? <ProfilePage currentUser={currentUser} onUserUpdated={setCurrentUser} /> : null}
+120
View File
@@ -5,11 +5,15 @@ import type {
Organization, Organization,
PeriodOverview, PeriodOverview,
PeriodType, PeriodType,
ProjectSummary,
RecurringBilling, RecurringBilling,
StatisticsOverview, StatisticsOverview,
TicketMeta, TicketMeta,
TicketPeriod, TicketPeriod,
TicketTodo,
TicketSearchResult, TicketSearchResult,
TodoPriority,
TodoStatus,
WorkType WorkType
} from "./types"; } from "./types";
@@ -290,6 +294,96 @@ export function deleteRecurringBilling(billingId: string) {
}); });
} }
export function getProjects() {
return request<{ projects: ProjectSummary[] }>("/api/projects");
}
export function getProject(projectId: string) {
return request<{ project: ProjectSummary }>(`/api/projects/${projectId}`);
}
export function createProject(payload: {
ticketNumber: string;
title?: string | null;
organizationId?: string | null;
teamspaceProject?: string | null;
description: string;
goals: string;
startsOn?: string | null;
endsOn?: string | null;
hourBudgetMinutes?: number | null;
}) {
return request<{ project: ProjectSummary }>("/api/projects", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function updateProject(
projectId: string,
payload: {
ticketNumber: string;
title?: string | null;
organizationId: string;
teamspaceProject?: string | null;
description: string;
goals: string;
startsOn?: string | null;
endsOn?: string | null;
hourBudgetMinutes?: number | null;
}
) {
return request<{ project: ProjectSummary }>(`/api/projects/${projectId}`, {
method: "PATCH",
body: JSON.stringify(payload)
});
}
export function getTodos(status?: TodoStatus | "all") {
const params = new URLSearchParams();
if (status && status !== "all") {
params.set("status", status);
}
return request<{ todos: TicketTodo[] }>(`/api/todos${params.size ? `?${params}` : ""}`);
}
export function createTicketTodo(
ticketId: string,
payload: {
title: string;
description: string;
priority: TodoPriority;
}
) {
return request<{ todo: TicketTodo }>(`/api/tickets/${ticketId}/todos`, {
method: "POST",
body: JSON.stringify(payload)
});
}
export function updateTodo(
todoId: string,
payload: {
title: string;
description: string;
priority: TodoPriority;
status: TodoStatus;
}
) {
return request<{ todo: TicketTodo }>(`/api/todos/${todoId}`, {
method: "PATCH",
body: JSON.stringify(payload)
});
}
export function deleteTodo(todoId: string) {
return request<{ deleted: { id: string } }>(`/api/todos/${todoId}`, {
method: "DELETE"
});
}
export function createSession(payload: CreateSessionPayload) { export function createSession(payload: CreateSessionPayload) {
return request("/api/sessions", { return request("/api/sessions", {
method: "POST", method: "POST",
@@ -309,6 +403,7 @@ export function updateTicket(
ticketId: string, ticketId: string,
payload: { payload: {
ticketNumber: string; ticketNumber: string;
title?: string | null;
organizationId: string; organizationId: string;
workType: WorkType; workType: WorkType;
} }
@@ -373,6 +468,31 @@ export function getTicketPeriod(type: PeriodType, period: string, ticketId: stri
return request<TicketPeriod>(`/api/periods/${periodPath(type)}/${period}/tickets/${ticketId}`); return request<TicketPeriod>(`/api/periods/${periodPath(type)}/${period}/tickets/${ticketId}`);
} }
export function getTicketDetails(
ticketId: string,
filters: {
month?: string;
status?: "all" | "open" | "billed" | "non_billable";
source?: "all" | "manual" | "recurring";
} = {}
) {
const params = new URLSearchParams();
if (filters.month && filters.month !== "all") {
params.set("month", filters.month);
}
if (filters.status && filters.status !== "all") {
params.set("status", filters.status);
}
if (filters.source && filters.source !== "all") {
params.set("source", filters.source);
}
return request<TicketPeriod>(`/api/tickets/${ticketId}/details${params.size ? `?${params}` : ""}`);
}
export function updateSessionBilling(sessionId: string, billingStatus: BillingStatus) { export function updateSessionBilling(sessionId: string, billingStatus: BillingStatus) {
return request(`/api/sessions/${sessionId}/billing`, { return request(`/api/sessions/${sessionId}/billing`, {
method: "PATCH", method: "PATCH",
+261
View File
@@ -0,0 +1,261 @@
import { CheckCircle2, Circle, Pencil, Plus, Save, Trash2 } from "lucide-react";
import { FormEvent, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { createTicketTodo, deleteTodo, updateTodo } from "../api";
import type { TicketTodo, TodoPriority, TodoStatus } from "../types";
const priorityLabels: Record<TodoPriority, string> = {
low: "Niedrig",
medium: "Normal",
high: "Hoch",
urgent: "Dringend"
};
const priorityClasses: Record<TodoPriority, string> = {
low: "border-sky-500/30 bg-sky-500/10 text-sky-300",
medium: "border-muted-foreground/25 bg-muted/50 text-muted-foreground",
high: "border-amber-500/30 bg-amber-500/10 text-amber-300",
urgent: "border-red-500/30 bg-red-500/10 text-red-300"
};
type TodoPanelProps = {
ticketId?: string;
todos: TicketTodo[];
onChanged: () => Promise<void> | void;
showCreate?: boolean;
showTicketMeta?: boolean;
onNavigate?: (to: string) => void;
};
export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showTicketMeta = false, onNavigate }: TodoPanelProps) {
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [priority, setPriority] = useState<TodoPriority>("medium");
const [saving, setSaving] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [editTitle, setEditTitle] = useState("");
const [editDescription, setEditDescription] = useState("");
const [editPriority, setEditPriority] = useState<TodoPriority>("medium");
const [busyId, setBusyId] = useState<string | null>(null);
async function submitTodo(event: FormEvent) {
event.preventDefault();
if (!ticketId) {
return;
}
setSaving(true);
try {
await createTicketTodo(ticketId, { title, description, priority });
setTitle("");
setDescription("");
setPriority("medium");
setCreateOpen(false);
toast.success("ToDo angelegt");
await onChanged();
} catch (error) {
toast.error("ToDo konnte nicht angelegt werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setSaving(false);
}
}
function startEdit(todo: TicketTodo) {
setEditingId(todo.id);
setEditTitle(todo.title);
setEditDescription(todo.description);
setEditPriority(todo.priority);
}
async function saveTodo(todo: TicketTodo, status: TodoStatus = todo.status) {
setBusyId(todo.id);
try {
await updateTodo(todo.id, {
title: editingId === todo.id ? editTitle : todo.title,
description: editingId === todo.id ? editDescription : todo.description,
priority: editingId === todo.id ? editPriority : todo.priority,
status
});
setEditingId(null);
await onChanged();
} catch (error) {
toast.error("ToDo konnte nicht gespeichert werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setBusyId(null);
}
}
async function removeTodo(todo: TicketTodo) {
const confirmed = window.confirm(`ToDo "${todo.title}" wirklich löschen?`);
if (!confirmed) {
return;
}
setBusyId(todo.id);
try {
await deleteTodo(todo.id);
toast.success("ToDo gelöscht");
await onChanged();
} catch (error) {
toast.error("ToDo konnte nicht gelöscht werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setBusyId(null);
}
}
return (
<div className="space-y-3">
{showCreate && ticketId ? (
<div className="flex justify-end">
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild>
<Button type="button" size="sm">
<Plus className="size-4" />
ToDo anlegen
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>ToDo anlegen</DialogTitle>
<DialogDescription>Neue Aufgabe zu diesem Ticket oder Projekt erfassen.</DialogDescription>
</DialogHeader>
<form className="space-y-4" onSubmit={submitTodo}>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor={`todo-title-${ticketId}`}>
Aufgabe
</label>
<Input id={`todo-title-${ticketId}`} value={title} onChange={(event) => setTitle(event.currentTarget.value)} autoFocus required />
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor={`todo-priority-${ticketId}`}>
Priorität
</label>
<select
id={`todo-priority-${ticketId}`}
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={priority}
onChange={(event) => setPriority(event.currentTarget.value as TodoPriority)}
>
{Object.entries(priorityLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor={`todo-description-${ticketId}`}>
Details
</label>
<Textarea
id={`todo-description-${ticketId}`}
className="min-h-24"
value={description}
onChange={(event) => setDescription(event.currentTarget.value)}
/>
</div>
<div className="flex justify-end">
<Button type="submit" disabled={saving}>
<Plus className="size-4" />
{saving ? "Speichert..." : "Anlegen"}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</div>
) : null}
<div className="space-y-2">
{todos.map((todo) => {
const isEditing = editingId === todo.id;
return (
<div key={todo.id} className={cn("rounded-md border bg-card/50 p-3", todo.status === "done" && "opacity-70")}>
{showTicketMeta ? (
<div className="mb-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<button type="button" className="font-medium text-foreground hover:underline" onClick={() => onNavigate?.(`/tickets/${todo.ticket_id}`)}>
{todo.ticket_number}
</button>
{todo.project_id ? <Badge variant="outline">Projekt</Badge> : null}
{todo.organization_name ? <span>{todo.organization_name}</span> : null}
</div>
) : null}
{isEditing ? (
<div className="space-y-2">
<Input value={editTitle} onChange={(event) => setEditTitle(event.currentTarget.value)} />
<Textarea className="min-h-16" value={editDescription} onChange={(event) => setEditDescription(event.currentTarget.value)} />
<div className="flex flex-wrap items-center justify-between gap-2">
<select
className="h-8 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={editPriority}
onChange={(event) => setEditPriority(event.currentTarget.value as TodoPriority)}
>
{Object.entries(priorityLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
<div className="flex gap-1">
<Button type="button" size="sm" variant="ghost" onClick={() => setEditingId(null)}>
Abbrechen
</Button>
<Button type="button" size="sm" disabled={busyId === todo.id} onClick={() => void saveTodo(todo)}>
<Save className="size-4" />
Speichern
</Button>
</div>
</div>
</div>
) : (
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<button
type="button"
className="flex min-w-0 flex-1 items-start gap-2 text-left"
disabled={busyId === todo.id}
onClick={() => void saveTodo(todo, todo.status === "done" ? "open" : "done")}
>
{todo.status === "done" ? <CheckCircle2 className="mt-0.5 size-4 shrink-0 text-emerald-400" /> : <Circle className="mt-0.5 size-4 shrink-0 text-muted-foreground" />}
<span className="min-w-0">
<span className={cn("block break-words text-sm font-medium", todo.status === "done" && "line-through")}>{todo.title}</span>
{todo.description ? <span className="mt-1 block whitespace-pre-wrap break-words text-xs text-muted-foreground">{todo.description}</span> : null}
</span>
</button>
<div className="flex shrink-0 items-center gap-1">
<span className={cn("rounded-full border px-2 py-0.5 text-xs font-medium", priorityClasses[todo.priority])}>{priorityLabels[todo.priority]}</span>
<Button type="button" size="icon" variant="ghost" className="size-8" onClick={() => startEdit(todo)}>
<Pencil className="size-4" />
</Button>
<Button type="button" size="icon" variant="ghost" className="size-8 text-destructive hover:text-destructive" onClick={() => void removeTodo(todo)}>
<Trash2 className="size-4" />
</Button>
</div>
</div>
)}
</div>
);
})}
</div>
{todos.length === 0 ? <p className="rounded-md border bg-muted/20 p-4 text-center text-sm text-muted-foreground">Keine ToDos vorhanden.</p> : null}
</div>
);
}
+37
View File
@@ -237,6 +237,43 @@ export const helpSections: HelpSection[] = [
"Die offenen Bewertungen findest du gebündelt im Monatsabschluss." "Die offenen Bewertungen findest du gebündelt im Monatsabschluss."
] ]
}, },
{
id: "projekte",
title: "Projekte",
description: "Projekte erweitern ein normales Ticket um Consulting-Projektinformationen und bleiben trotzdem über dieselbe Ticketdetailansicht bearbeitbar.",
quickItems: [
"Projekte werden über eine Ticketnummer angelegt.",
"Vorhandene Ticketdaten werden automatisch übernommen.",
"Projekt-Sessions sind immer Consulting."
],
details: [
"Der Navipunkt Projekte ist für Consulting-Arbeiten gedacht, die länger laufen oder ein Stundenkontingent haben.",
"Beim Anlegen gibst du die Ticketnummer ein. Existiert das Ticket bereits, übernimmt TicketTracker Organisation und Stammdaten automatisch.",
"Existiert das Ticket noch nicht, muss eine Organisation aus der synchronisierten Organisationsliste gewählt werden.",
"Ein Projekt-Ticket bleibt ein normales Ticket. Es kann über Suche, Auswertung, geöffnete Tickets und ToDos geöffnet werden.",
"Zusätzlich speicherst du Teamspace-Projekt, Beschreibung, Ziele, ungefähren Zeitraum und gesamtes Stundenkontingent.",
"Projekt-Tickets sind immer Consulting. Auch wenn Sessions später über den normalen Timer oder das Nachtragen erfasst werden, erkennt TicketTracker das Projekt und übernimmt Consulting.",
"Aus einem Projekt heraus kann direkt eine Session gestartet werden. Der Timer verhält sich wie jeder andere Timer und pausiert andere Timer automatisch."
]
},
{
id: "todos",
title: "ToDos",
description: "ToDos sind kleine Aufgabenlisten pro Ticket oder Projekt, damit offene Arbeit sichtbar bleibt.",
quickItems: [
"ToDos werden direkt im Ticket angelegt.",
"Die zentrale ToDo-Seite sammelt alle offenen Aufgaben.",
"Prioritäten helfen beim Sortieren der nächsten Schritte."
],
details: [
"Jedes Ticket kann ToDos enthalten, egal ob es ein Projekt oder ein normales Einzelticket ist.",
"In der Ticketdetailansicht legst du neue Aufgaben mit Titel, optionaler Beschreibung und Priorität an.",
"Die Prioritäten Niedrig, Normal, Hoch und Dringend helfen dabei, wichtige Punkte nicht zwischen Sessions zu verlieren.",
"ToDos lassen sich als erledigt markieren, wieder öffnen, bearbeiten oder löschen.",
"Die zentrale Seite ToDos zeigt Aufgaben aus allen deinen Tickets. Von dort öffnest du direkt das zugehörige Ticket.",
"ToDos sind benutzerbezogen. Andere Benutzer sehen ihre eigenen Aufgaben, auch wenn mehrere Benutzer am gleichen Ticket arbeiten."
]
},
{ {
id: "sessions-bearbeiten", id: "sessions-bearbeiten",
title: "Sessions Bearbeiten und Löschen", title: "Sessions Bearbeiten und Löschen",
+2
View File
@@ -5,6 +5,8 @@ export type TimerEntry = {
ticketNumber: string; ticketNumber: string;
organizationName?: string | null; organizationName?: string | null;
workType?: "support" | "consulting" | null; workType?: "support" | "consulting" | null;
isProject?: boolean;
projectName?: string | null;
ticketLookupDone?: boolean; ticketLookupDone?: boolean;
startedAt: number; startedAt: number;
pausedTotalMs: number; pausedTotalMs: number;
+68 -2
View File
@@ -1,6 +1,7 @@
export type WorkType = "support" | "consulting"; export type WorkType = "support" | "consulting";
export type BillingStatus = "billed" | "non_billable" | null; export type BillingStatus = "billed" | "non_billable" | null;
export type PeriodType = "month" | "day"; export type PeriodType = "month" | "day";
export type TicketDetailPeriodType = PeriodType | "all";
export type UserRole = "admin" | "user"; export type UserRole = "admin" | "user";
export type AuthUser = { export type AuthUser = {
@@ -55,28 +56,79 @@ export type RecurringBilling = {
export type TicketMeta = { export type TicketMeta = {
id: string; id: string;
ticket_number: string; ticket_number: string;
title: string | null;
organization_id: string | null; organization_id: string | null;
organization_name: string | null; organization_name: string | null;
customer_name: string | null; customer_name: string | null;
work_type: WorkType | null; work_type: WorkType | null;
project_id?: string | null;
is_project?: boolean;
project_name?: string | null;
}; };
export type TicketSearchResult = { export type TicketSearchResult = {
ticket_id: string; ticket_id: string;
ticket_number: string; ticket_number: string;
title: string | null;
organization_id: string | null; organization_id: string | null;
organization_name: string | null; organization_name: string | null;
customer_name: string | null; customer_name: string | null;
work_type: WorkType | null; work_type: WorkType | null;
latest_started_at: string; project_id: string | null;
is_project: boolean;
project_name: string | null;
latest_started_at: string | null;
latest_period: string; latest_period: string;
session_count: number; session_count: number;
total_minutes: number; total_minutes: number;
}; };
export type ProjectSummary = {
id: string;
ticket_id: string;
ticket_number: string;
title: string | null;
organization_id: string | null;
organization_name: string | null;
customer_name: string | null;
work_type: WorkType | null;
teamspace_project: string | null;
description: string;
goals: string;
starts_on: string | null;
ends_on: string | null;
hour_budget_minutes: number | null;
created_at: string;
updated_at: string;
session_count?: number;
billed_minutes?: number;
open_todos?: number;
done_todos?: number;
};
export type TodoPriority = "low" | "medium" | "high" | "urgent";
export type TodoStatus = "open" | "done";
export type TicketTodo = {
id: string;
ticket_id: string;
ticket_number?: string;
organization_name?: string | null;
project_id?: string | null;
teamspace_project?: string | null;
title: string;
description: string;
priority: TodoPriority;
status: TodoStatus;
completed_at: string | null;
created_at: string;
updated_at: string;
};
export type TicketSummary = { export type TicketSummary = {
id: string; id: string;
ticket_number: string; ticket_number: string;
title: string | null;
organization_id: string | null; organization_id: string | null;
organization_name: string | null; organization_name: string | null;
customer_name: string | null; customer_name: string | null;
@@ -140,6 +192,11 @@ export type TicketDayBilling = {
billed_minutes: number; billed_minutes: number;
}; };
export type TicketAvailableMonth = {
month: string;
session_count: number;
};
export type SessionEntry = { export type SessionEntry = {
id: string; id: string;
organization_id: string | null; organization_id: string | null;
@@ -160,13 +217,20 @@ export type SessionEntry = {
}; };
export type TicketPeriod = { export type TicketPeriod = {
periodType: PeriodType; periodType: TicketDetailPeriodType;
period: string; period: string;
closed: boolean; closed: boolean;
closedAt: string | null; closedAt: string | null;
filters?: {
month: string;
status: "all" | "open" | "billed" | "non_billable";
source: "all" | "manual" | "recurring";
};
availableMonths?: TicketAvailableMonth[];
ticket: { ticket: {
id: string; id: string;
ticket_number: string; ticket_number: string;
title: string | null;
organization_id: string | null; organization_id: string | null;
organization_name: string | null; organization_name: string | null;
customer_name: string | null; customer_name: string | null;
@@ -178,6 +242,8 @@ export type TicketPeriod = {
}; };
sessions: SessionEntry[]; sessions: SessionEntry[];
dayBillings: TicketDayBilling[]; dayBillings: TicketDayBilling[];
project?: ProjectSummary | null;
todos?: TicketTodo[];
canClose: boolean; canClose: boolean;
openCount: number; openCount: number;
ticketOpenCount: number; ticketOpenCount: number;
+16 -4
View File
@@ -437,6 +437,7 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
const matchesQuery = const matchesQuery =
!normalizedQuery || !normalizedQuery ||
ticket.ticket_number.toLocaleLowerCase("de").includes(normalizedQuery) || ticket.ticket_number.toLocaleLowerCase("de").includes(normalizedQuery) ||
(ticket.title ?? "").toLocaleLowerCase("de").includes(normalizedQuery) ||
organizationLabel(ticket).toLocaleLowerCase("de").includes(normalizedQuery); organizationLabel(ticket).toLocaleLowerCase("de").includes(normalizedQuery);
const matchesWorkType = ticketViewSettings.workType === "all" || ticket.work_type === ticketViewSettings.workType; const matchesWorkType = ticketViewSettings.workType === "all" || ticket.work_type === ticketViewSettings.workType;
const matchesOrganization = ticketViewSettings.organization === "all" || organizationKey(ticket) === ticketViewSettings.organization; const matchesOrganization = ticketViewSettings.organization === "all" || organizationKey(ticket) === ticketViewSettings.organization;
@@ -823,9 +824,10 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
<TableRow key={ticket.id}> <TableRow key={ticket.id}>
<TableCell> <TableCell>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span className="font-semibold">{ticket.ticket_number}</span> <span className="font-semibold">{ticket.title || ticket.ticket_number}</span>
<CopyTicketButton ticketNumber={ticket.ticket_number} /> <CopyTicketButton ticketNumber={ticket.ticket_number} />
</div> </div>
{ticket.title ? <p className="text-xs text-muted-foreground">{ticket.ticket_number}</p> : null}
</TableCell> </TableCell>
<TableCell>{ticket.customer_name ?? "-"}</TableCell> <TableCell>{ticket.customer_name ?? "-"}</TableCell>
<TableCell>{workTypeLabel(ticket.work_type)}</TableCell> <TableCell>{workTypeLabel(ticket.work_type)}</TableCell>
@@ -836,7 +838,11 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
<TicketStatus ticket={ticket} /> <TicketStatus ticket={ticket} />
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<Button variant="ghost" size="sm" onClick={() => onNavigate(`/analysis/${periodType}/${period}/tickets/${ticket.id}`)}> <Button
variant="ghost"
size="sm"
onClick={() => onNavigate(`/tickets/${ticket.id}?month=${periodType === "day" ? period.slice(0, 7) : period}`)}
>
Öffnen Öffnen
<ExternalLink className="size-4" /> <ExternalLink className="size-4" />
</Button> </Button>
@@ -870,9 +876,10 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<p className="truncate font-semibold">{ticket.ticket_number}</p> <p className="truncate font-semibold">{ticket.title || ticket.ticket_number}</p>
<CopyTicketButton ticketNumber={ticket.ticket_number} /> <CopyTicketButton ticketNumber={ticket.ticket_number} />
</div> </div>
{ticket.title ? <p className="text-xs text-muted-foreground">{ticket.ticket_number}</p> : null}
<p className="text-sm text-muted-foreground">{ticket.customer_name ?? "Keine Organisation"} · {workTypeLabel(ticket.work_type)} · {ticket.session_count} Session(s)</p> <p className="text-sm text-muted-foreground">{ticket.customer_name ?? "Keine Organisation"} · {workTypeLabel(ticket.work_type)} · {ticket.session_count} Session(s)</p>
</div> </div>
<TicketStatus ticket={ticket} /> <TicketStatus ticket={ticket} />
@@ -887,7 +894,12 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
<p className="font-medium">{ticket.billed_count}</p> <p className="font-medium">{ticket.billed_count}</p>
</div> </div>
</div> </div>
<Button className="w-full" size="sm" variant="secondary" onClick={() => onNavigate(`/analysis/${periodType}/${period}/tickets/${ticket.id}`)}> <Button
className="w-full"
size="sm"
variant="secondary"
onClick={() => onNavigate(`/tickets/${ticket.id}?month=${periodType === "day" ? period.slice(0, 7) : period}`)}
>
Öffnen Öffnen
<ExternalLink className="size-4" /> <ExternalLink className="size-4" />
</Button> </Button>
+2 -2
View File
@@ -103,7 +103,7 @@ function OpenTicketItem({ group, month, onNavigate }: { group: OpenTicketGroup;
<p className={`text-sm font-medium ${trackedTextClass}`}>{formatMinutes(group.totalMinutes)} offen zu bewerten</p> <p className={`text-sm font-medium ${trackedTextClass}`}>{formatMinutes(group.totalMinutes)} offen zu bewerten</p>
</div> </div>
<div className="flex justify-end"> <div className="flex justify-end">
<Button variant="secondary" size="sm" onClick={() => onNavigate(`/analysis/month/${month}/tickets/${group.ticketId}`)}> <Button variant="secondary" size="sm" onClick={() => onNavigate(`/tickets/${group.ticketId}?month=${month}`)}>
Ticket öffnen Ticket öffnen
<ExternalLink className="size-4" /> <ExternalLink className="size-4" />
</Button> </Button>
@@ -145,7 +145,7 @@ function CrmDayItem({
<Button variant={isAcknowledged ? "secondary" : "ghost"} size="sm" disabled={busy} onClick={() => onSetAcknowledgement(item, !isAcknowledged)}> <Button variant={isAcknowledged ? "secondary" : "ghost"} size="sm" disabled={busy} onClick={() => onSetAcknowledgement(item, !isAcknowledged)}>
{isAcknowledged ? "Zurücknehmen" : "Zur Kenntnis"} {isAcknowledged ? "Zurücknehmen" : "Zur Kenntnis"}
</Button> </Button>
<Button variant="secondary" size="sm" onClick={() => onNavigate(`/analysis/day/${item.day}/tickets/${item.ticket_id}`)}> <Button variant="secondary" size="sm" onClick={() => onNavigate(`/tickets/${item.ticket_id}?month=${item.day.slice(0, 7)}`)}>
Tag öffnen Tag öffnen
<ExternalLink className="size-4" /> <ExternalLink className="size-4" />
</Button> </Button>
+580
View File
@@ -0,0 +1,580 @@
import { CalendarDays, FolderKanban, Play, Plus, Save, Search, Target } from "lucide-react";
import { FormEvent, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { HelpLink } from "@/components/HelpLink";
import { OrganizationSelect } from "@/components/OrganizationSelect";
import { cn } from "@/lib/utils";
import { createProject, getProjects, lookupTicket, updateProject } from "../api";
import { currentMonth, formatMinutes } from "../format";
import { ticketPattern } from "../timers";
import type { ProjectSummary, TicketMeta } from "../types";
type ProjectsPageProps = {
onNavigate: (to: string) => void;
onStartTimer: (ticketNumber: string) => Promise<boolean>;
};
type ProjectForm = {
ticketNumber: string;
title: string;
organizationId: string;
organizationName: string | null;
teamspaceProject: string;
description: string;
goals: string;
startsOn: string;
endsOn: string;
hourBudget: string;
};
function emptyProjectForm(): ProjectForm {
return {
ticketNumber: "",
title: "",
organizationId: "",
organizationName: null,
teamspaceProject: "",
description: "",
goals: "",
startsOn: "",
endsOn: "",
hourBudget: ""
};
}
function formatHoursInput(minutes?: number | null) {
if (minutes === null || minutes === undefined) {
return "";
}
return new Intl.NumberFormat("de-DE", { maximumFractionDigits: 2 }).format(minutes / 60);
}
function parseHoursInput(value: string) {
const trimmed = value.trim();
if (!trimmed) {
return null;
}
const hours = Number(trimmed.replace(",", "."));
if (!Number.isFinite(hours) || hours < 0) {
return undefined;
}
return Math.round(hours * 60);
}
function formFromProject(project: ProjectSummary): ProjectForm {
return {
ticketNumber: project.ticket_number,
title: project.title ?? "",
organizationId: project.organization_id ?? "",
organizationName: project.organization_name ?? project.customer_name ?? null,
teamspaceProject: project.teamspace_project ?? "",
description: project.description,
goals: project.goals,
startsOn: project.starts_on ?? "",
endsOn: project.ends_on ?? "",
hourBudget: formatHoursInput(project.hour_budget_minutes)
};
}
type ProjectFormFieldsProps = {
form: ProjectForm;
setForm: (form: ProjectForm) => void;
organizationLocked: boolean;
lookupStatus: "idle" | "loading" | "found" | "not-found";
saving: boolean;
submitLabel: string;
onSubmit: (event: FormEvent) => void;
};
function ProjectFormFields({ form, setForm, organizationLocked, lookupStatus, saving, submitLabel, onSubmit }: ProjectFormFieldsProps) {
return (
<form className="space-y-4" onSubmit={onSubmit}>
<div className="grid gap-3 lg:grid-cols-[180px_minmax(240px,1fr)_180px]">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="project-ticket-number">
Ticket
</label>
<Input
id="project-ticket-number"
placeholder="Ticket#123456"
value={form.ticketNumber}
onChange={(event) =>
setForm({
...form,
ticketNumber: event.currentTarget.value,
organizationId: "",
organizationName: null
})
}
required
/>
{form.ticketNumber && !ticketPattern.test(form.ticketNumber.trim()) ? <p className="text-xs text-destructive">Format: Ticket#XXXXXX</p> : null}
{lookupStatus === "loading" ? <p className="text-xs text-muted-foreground">Suche Ticket...</p> : null}
{lookupStatus === "found" ? <p className="text-xs text-muted-foreground">Ticket gefunden. Daten werden übernommen.</p> : null}
{lookupStatus === "not-found" ? <p className="text-xs text-muted-foreground">Neues Ticket. Organisation bitte wählen.</p> : null}
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="project-title">
Titel
</label>
<Input id="project-title" value={form.title} onChange={(event) => setForm({ ...form, title: event.currentTarget.value })} />
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Art</label>
<div className="flex h-9 items-center rounded-lg border bg-muted/30 px-3 text-sm">Consulting</div>
</div>
</div>
<div className="grid gap-3 lg:grid-cols-[minmax(240px,1fr)_minmax(220px,1fr)]">
<div className="space-y-2">
<label className="text-sm font-medium">Organisation</label>
<OrganizationSelect
value={form.organizationId}
selectedName={form.organizationName}
disabled={organizationLocked}
onChange={(organization) => setForm({ ...form, organizationId: organization.id, organizationName: organization.name })}
required
/>
</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-3">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="project-starts-on">
Zeitraum von
</label>
<Input id="project-starts-on" type="date" value={form.startsOn} onChange={(event) => setForm({ ...form, startsOn: event.currentTarget.value })} />
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="project-ends-on">
Zeitraum bis
</label>
<Input id="project-ends-on" type="date" value={form.endsOn} onChange={(event) => setForm({ ...form, endsOn: event.currentTarget.value })} />
</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>
<div className="grid gap-3 lg:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="project-description">
Projektbeschreibung
</label>
<Textarea id="project-description" className="min-h-24" value={form.description} onChange={(event) => setForm({ ...form, description: event.currentTarget.value })} />
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="project-goals">
Ziele
</label>
<Textarea id="project-goals" className="min-h-24" value={form.goals} onChange={(event) => setForm({ ...form, goals: event.currentTarget.value })} />
</div>
</div>
<div className="flex justify-end">
<Button type="submit" disabled={saving}>
<Save className="size-4" />
{saving ? "Speichert..." : submitLabel}
</Button>
</div>
</form>
);
}
export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
const [projects, setProjects] = useState<ProjectSummary[]>([]);
const [loading, setLoading] = useState(true);
const [form, setForm] = useState<ProjectForm>(() => emptyProjectForm());
const [lookupStatus, setLookupStatus] = useState<"idle" | "loading" | "found" | "not-found">("idle");
const [lookupResult, setLookupResult] = useState<TicketMeta | null>(null);
const [saving, setSaving] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [editingProjectId, setEditingProjectId] = useState<string | null>(null);
const [editForm, setEditForm] = useState<ProjectForm>(() => emptyProjectForm());
const [savingProjectId, setSavingProjectId] = useState<string | null>(null);
async function load() {
setLoading(true);
try {
const result = await getProjects();
setProjects(result.projects);
} catch (error) {
toast.error("Projekte konnten nicht geladen werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setLoading(false);
}
}
useEffect(() => {
void load();
}, []);
useEffect(() => {
const normalizedTicketNumber = form.ticketNumber.trim();
if (!ticketPattern.test(normalizedTicketNumber)) {
setLookupStatus("idle");
setLookupResult(null);
return;
}
let cancelled = false;
setLookupStatus("loading");
const timeout = window.setTimeout(async () => {
try {
const result = await lookupTicket(normalizedTicketNumber);
if (cancelled) {
return;
}
setLookupResult(result.ticket);
setLookupStatus(result.ticket ? "found" : "not-found");
if (result.ticket?.organization_id) {
setForm((current) =>
current.ticketNumber.trim() === normalizedTicketNumber
? {
...current,
title: current.title || result.ticket?.title || "",
organizationId: result.ticket?.organization_id ?? "",
organizationName: result.ticket?.organization_name ?? result.ticket?.customer_name ?? null
}
: current
);
}
} catch {
if (!cancelled) {
setLookupStatus("idle");
setLookupResult(null);
}
}
}, 250);
return () => {
cancelled = true;
window.clearTimeout(timeout);
};
}, [form.ticketNumber]);
const organizationLocked = Boolean(lookupResult?.organization_id);
const openTodos = useMemo(() => projects.reduce((sum, project) => sum + Number(project.open_todos ?? 0), 0), [projects]);
const billedMinutes = useMemo(() => projects.reduce((sum, project) => sum + Number(project.billed_minutes ?? 0), 0), [projects]);
async function submitProject(event: FormEvent) {
event.preventDefault();
const ticketNumber = form.ticketNumber.trim();
if (!ticketPattern.test(ticketNumber)) {
toast.error("Ticketnummer prüfen", {
description: "Das Format muss Ticket#XXXXXX sein."
});
return;
}
if (!form.organizationId) {
toast.error("Organisation wählen", {
description: "Wenn das Ticket noch keine Stammdaten hat, muss die Organisation gesetzt werden."
});
return;
}
const hourBudgetMinutes = parseHoursInput(form.hourBudget);
if (hourBudgetMinutes === undefined) {
toast.error("Stundenkontingent prüfen", {
description: "Bitte Stunden als Zahl eintragen, z.B. 12,5."
});
return;
}
setSaving(true);
try {
const result = await createProject({
ticketNumber,
title: form.title || null,
organizationId: form.organizationId,
teamspaceProject: form.teamspaceProject,
description: form.description,
goals: form.goals,
startsOn: form.startsOn || null,
endsOn: form.endsOn || null,
hourBudgetMinutes
});
toast.success("Projekt angelegt", {
description: `${result.project.ticket_number} ist jetzt als Consulting-Projekt markiert.`
});
setForm(emptyProjectForm());
setCreateOpen(false);
setLookupResult(null);
setLookupStatus("idle");
await load();
} catch (error) {
toast.error("Projekt konnte nicht angelegt werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setSaving(false);
}
}
async function saveProject(project: ProjectSummary) {
const hourBudgetMinutes = parseHoursInput(editForm.hourBudget);
if (hourBudgetMinutes === undefined) {
toast.error("Stundenkontingent prüfen");
return;
}
if (!editForm.organizationId) {
toast.error("Organisation wählen");
return;
}
setSavingProjectId(project.id);
try {
await updateProject(project.id, {
ticketNumber: editForm.ticketNumber,
title: editForm.title || null,
organizationId: editForm.organizationId,
teamspaceProject: editForm.teamspaceProject,
description: editForm.description,
goals: editForm.goals,
startsOn: editForm.startsOn || null,
endsOn: editForm.endsOn || null,
hourBudgetMinutes
});
toast.success("Projekt gespeichert");
setEditingProjectId(null);
await load();
} catch (error) {
toast.error("Projekt konnte nicht gespeichert werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setSavingProjectId(null);
}
}
return (
<div className="space-y-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div>
<h2 className="text-xl font-semibold tracking-normal sm:text-2xl">Projekte</h2>
<p className="text-sm text-muted-foreground">Consulting-Projekte als Erweiterung bestehender oder neuer Tickets.</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="grid gap-2 text-sm sm:grid-cols-3">
<div className="rounded-md border bg-muted/20 px-3 py-2">
<span className="block text-muted-foreground">Projekte</span>
<span className="font-semibold">{projects.length}</span>
</div>
<div className="rounded-md border bg-muted/20 px-3 py-2">
<span className="block text-muted-foreground">Abgerechnet</span>
<span className="font-semibold">{formatMinutes(billedMinutes)}</span>
</div>
<div className="rounded-md border bg-muted/20 px-3 py-2">
<span className="block text-muted-foreground">Offene ToDos</span>
<span className="font-semibold">{openTodos}</span>
</div>
</div>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild>
<Button type="button">
<Plus className="size-4" />
Projekt anlegen
</Button>
</DialogTrigger>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-3xl">
<DialogHeader>
<DialogTitle>Projekt anlegen</DialogTitle>
<DialogDescription>Ticketnummer eingeben. Vorhandene Ticketdaten werden automatisch übernommen.</DialogDescription>
</DialogHeader>
<ProjectFormFields
form={form}
setForm={setForm}
organizationLocked={organizationLocked}
lookupStatus={lookupStatus}
saving={saving}
submitLabel="Projekt anlegen"
onSubmit={submitProject}
/>
</DialogContent>
</Dialog>
<HelpLink anchor="projekte" label="Hilfe zu Projekten" />
</div>
</div>
<div className="grid gap-3 xl:grid-cols-2">
{projects.map((project) => {
const isEditing = editingProjectId === project.id;
const usedPercent =
project.hour_budget_minutes && project.hour_budget_minutes > 0
? Math.min(100, Math.round(((project.billed_minutes ?? 0) / project.hour_budget_minutes) * 100))
: null;
return (
<Card key={project.id} className="overflow-hidden">
<CardHeader className="border-b bg-muted/25 p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<CardTitle className="truncate text-base">{project.title || project.ticket_number}</CardTitle>
<Badge variant="outline">Projekt</Badge>
</div>
<CardDescription className="truncate">
{project.ticket_number} · {project.teamspace_project || project.organization_name || "Ohne Teamspace-Projekt"}
</CardDescription>
</div>
<div className="flex shrink-0 gap-1">
<Button type="button" size="sm" variant="secondary" onClick={() => void onStartTimer(project.ticket_number)}>
<Play className="size-4" />
Session
</Button>
<Button type="button" size="sm" variant="secondary" onClick={() => onNavigate(`/tickets/${project.ticket_id}?month=${currentMonth()}`)}>
<Search className="size-4" />
Öffnen
</Button>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3 p-4">
{isEditing ? (
<div className="space-y-3">
<div className="grid gap-3 md:grid-cols-[160px_minmax(200px,1fr)_160px]">
<Input value={editForm.ticketNumber} onChange={(event) => setEditForm({ ...editForm, ticketNumber: event.currentTarget.value })} />
<OrganizationSelect
value={editForm.organizationId}
selectedName={editForm.organizationName}
onChange={(organization) => setEditForm({ ...editForm, organizationId: organization.id, organizationName: organization.name })}
required
/>
<div className="flex h-9 items-center rounded-lg border bg-muted/30 px-3 text-sm">Consulting</div>
</div>
<Input placeholder="Titel" value={editForm.title} onChange={(event) => setEditForm({ ...editForm, title: event.currentTarget.value })} />
<Input
placeholder="Teamspace-Projekt"
value={editForm.teamspaceProject}
onChange={(event) => setEditForm({ ...editForm, teamspaceProject: event.currentTarget.value })}
/>
<div className="grid gap-3 md:grid-cols-3">
<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 inputMode="decimal" placeholder="Kontingent h" value={editForm.hourBudget} onChange={(event) => setEditForm({ ...editForm, hourBudget: event.currentTarget.value })} />
</div>
<Textarea className="min-h-20" value={editForm.description} onChange={(event) => setEditForm({ ...editForm, description: event.currentTarget.value })} />
<Textarea className="min-h-20" value={editForm.goals} onChange={(event) => setEditForm({ ...editForm, goals: event.currentTarget.value })} />
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" onClick={() => setEditingProjectId(null)}>
Abbrechen
</Button>
<Button type="button" disabled={savingProjectId === project.id} onClick={() => void saveProject(project)}>
<Save className="size-4" />
Speichern
</Button>
</div>
</div>
) : (
<>
<div className="grid gap-2 text-sm sm:grid-cols-3">
<div className="rounded-md border bg-background p-2">
<span className="block text-xs text-muted-foreground">Ticket</span>
<span className="line-clamp-1 font-medium">{project.ticket_number}</span>
</div>
<div className="rounded-md border bg-background p-2">
<span className="block text-xs text-muted-foreground">Organisation</span>
<span className="line-clamp-1 font-medium">{project.organization_name ?? project.customer_name ?? "Keine Organisation"}</span>
</div>
<div className="rounded-md border bg-background p-2">
<span className="block text-xs text-muted-foreground">Zeitraum</span>
<span className="font-medium">{project.starts_on || "offen"} - {project.ends_on || "offen"}</span>
</div>
</div>
{usedPercent !== null ? (
<div className="space-y-1">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>Verbrauch</span>
<span>{usedPercent}% · {formatMinutes(project.billed_minutes ?? 0)}</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div className={cn("h-full rounded-full", usedPercent >= 90 ? "bg-amber-500" : "bg-emerald-500")} style={{ width: `${usedPercent}%` }} />
</div>
</div>
) : null}
<div className="grid gap-3 text-sm md:grid-cols-2">
<div>
<div className="mb-1 flex items-center gap-2 font-medium">
<FolderKanban className="size-4 text-muted-foreground" />
Beschreibung
</div>
<p className="whitespace-pre-wrap text-muted-foreground">{project.description || "Noch keine Beschreibung."}</p>
</div>
<div>
<div className="mb-1 flex items-center gap-2 font-medium">
<Target className="size-4 text-muted-foreground" />
Ziele
</div>
<p className="whitespace-pre-wrap text-muted-foreground">{project.goals || "Noch keine Ziele."}</p>
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-2 border-t pt-3">
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<Badge variant="secondary">{project.session_count ?? 0} Session(s)</Badge>
<Badge variant={(project.open_todos ?? 0) > 0 ? "warning" : "success"}>{project.open_todos ?? 0} offene ToDos</Badge>
<span className="inline-flex items-center gap-1">
<CalendarDays className="size-3.5" />
aktualisiert {new Date(project.updated_at).toLocaleDateString("de-DE")}
</span>
</div>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => {
setEditingProjectId(project.id);
setEditForm(formFromProject(project));
}}
>
Bearbeiten
</Button>
</div>
</>
)}
</CardContent>
</Card>
);
})}
</div>
{!loading && projects.length === 0 ? <p className="rounded-md border bg-muted/20 p-6 text-center text-sm text-muted-foreground">Noch keine Projekte angelegt.</p> : null}
</div>
);
}
+1 -1
View File
@@ -342,7 +342,7 @@ function TicketHotspot({ ticket, month, onNavigate }: { ticket: StatisticsTicket
<span className={deltaTextClass(ticket.crm_delta_minutes)}>{formatTeamspaceDelta(ticket.crm_delta_minutes)}</span> <span className={deltaTextClass(ticket.crm_delta_minutes)}>{formatTeamspaceDelta(ticket.crm_delta_minutes)}</span>
</div> </div>
</div> </div>
<Button variant="secondary" size="sm" onClick={() => onNavigate(`/analysis/month/${month}/tickets/${ticket.ticket_id}`)}> <Button variant="secondary" size="sm" onClick={() => onNavigate(`/tickets/${ticket.ticket_id}?month=${month}`)}>
Öffnen Öffnen
<ExternalLink className="size-4" /> <ExternalLink className="size-4" />
</Button> </Button>
+557 -118
View File
@@ -1,7 +1,8 @@
import { ArrowLeft, Pencil, Save, Trash2 } from "lucide-react"; import { ArrowLeft, ClipboardList, FolderKanban, Pencil, Plus, Save, Target, 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";
import { Alert } from "@/components/ui/alert"; import { Alert } from "@/components/ui/alert";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
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";
@@ -11,21 +12,25 @@ import { Textarea } from "@/components/ui/textarea";
import { CopyTicketButton } from "@/components/CopyTicketButton"; import { CopyTicketButton } from "@/components/CopyTicketButton";
import { HelpLink } from "@/components/HelpLink"; import { HelpLink } from "@/components/HelpLink";
import { OrganizationSelect } from "@/components/OrganizationSelect"; import { OrganizationSelect } from "@/components/OrganizationSelect";
import { TodoPanel } from "@/components/TodoPanel";
import { import {
createProject,
createSession, createSession,
deleteSession, deleteSession,
getTicketDetails,
getTicketPeriod, getTicketPeriod,
updateSessionBilling, updateSessionBilling,
updateSessionDetails, updateSessionDetails,
updateTicket, updateTicket,
updateTicketDayBilling updateTicketDayBilling,
updateProject
} from "../api"; } from "../api";
import { currentDay, formatDate, formatDateTime, formatMinutes, formatTimeRange } from "../format"; import { currentDay, currentMonth, formatDate, formatDateTime, formatMinutes, formatTimeRange } from "../format";
import type { BillingStatus, PeriodType, SessionEntry, TicketPeriod, WorkType } from "../types"; import type { BillingStatus, PeriodType, ProjectSummary, SessionEntry, TicketDetailPeriodType, TicketPeriod, WorkType } from "../types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
type TicketDetailPageProps = { type TicketDetailPageProps = {
periodType: PeriodType; periodType: TicketDetailPeriodType;
period: string; period: string;
ticketId: string; ticketId: string;
onNavigate: (to: string) => void; onNavigate: (to: string) => void;
@@ -33,7 +38,7 @@ type TicketDetailPageProps = {
key: string; key: string;
ticketId: string; ticketId: string;
ticketNumber: string; ticketNumber: string;
periodType: PeriodType; periodType: TicketDetailPeriodType;
period: string; period: string;
path: string; path: string;
dirty: boolean; dirty: boolean;
@@ -50,6 +55,18 @@ type TicketSessionFormState = {
endTime: string; endTime: string;
}; };
type ProjectEditFormState = {
title: string;
organizationId: string;
organizationName: string | null;
teamspaceProject: string;
description: string;
goals: string;
startsOn: string;
endsOn: string;
hourBudget: string;
};
function emptyTicketSessionForm(): TicketSessionFormState { function emptyTicketSessionForm(): TicketSessionFormState {
return { return {
organizationId: "", organizationId: "",
@@ -62,6 +79,20 @@ function emptyTicketSessionForm(): TicketSessionFormState {
}; };
} }
function projectEditForm(project: ProjectSummary): ProjectEditFormState {
return {
title: project.title ?? "",
organizationId: project.organization_id ?? "",
organizationName: project.organization_name ?? project.customer_name ?? null,
teamspaceProject: project.teamspace_project ?? "",
description: project.description,
goals: project.goals,
startsOn: project.starts_on ?? "",
endsOn: project.ends_on ?? "",
hourBudget: formatHoursInput(project.hour_budget_minutes)
};
}
function BillingButtons({ function BillingButtons({
session, session,
disabled, disabled,
@@ -151,11 +182,13 @@ function isPartialHoursInput(value: string) {
} }
export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onOpenTicketChange }: TicketDetailPageProps) { export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onOpenTicketChange }: TicketDetailPageProps) {
const initialGlobalMonthFilter = periodType === "all" && period !== "all" ? period : currentMonth();
const [data, setData] = useState<TicketPeriod | null>(null); const [data, setData] = useState<TicketPeriod | null>(null);
const [loadingId, setLoadingId] = useState<string | null>(null); const [loadingId, setLoadingId] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null); const [deletingId, setDeletingId] = useState<string | null>(null);
const [savingTicket, setSavingTicket] = useState(false); const [savingTicket, setSavingTicket] = useState(false);
const [editTicketNumber, setEditTicketNumber] = useState(""); const [editTicketNumber, setEditTicketNumber] = useState("");
const [editTicketTitle, setEditTicketTitle] = useState("");
const [editOrganizationId, setEditOrganizationId] = useState(""); const [editOrganizationId, setEditOrganizationId] = useState("");
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");
@@ -172,10 +205,36 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
const [savingDayBillingKey, setSavingDayBillingKey] = useState<string | null>(null); const [savingDayBillingKey, setSavingDayBillingKey] = useState<string | null>(null);
const [ticketSession, setTicketSession] = useState<TicketSessionFormState>(() => emptyTicketSessionForm()); const [ticketSession, setTicketSession] = useState<TicketSessionFormState>(() => emptyTicketSessionForm());
const [savingTicketSession, setSavingTicketSession] = useState(false); const [savingTicketSession, setSavingTicketSession] = useState(false);
const [globalMonthFilter, setGlobalMonthFilter] = useState(initialGlobalMonthFilter);
const [globalStatusFilter, setGlobalStatusFilter] = useState<"all" | "open" | "billed" | "non_billable">("all");
const [globalSourceFilter, setGlobalSourceFilter] = useState<"all" | "manual" | "recurring">("all");
const [creatingProject, setCreatingProject] = useState(false);
const [editingProject, setEditingProject] = useState(false);
const [savingProject, setSavingProject] = useState(false);
const [projectForm, setProjectForm] = useState<ProjectEditFormState>({
title: "",
organizationId: "",
organizationName: null,
teamspaceProject: "",
description: "",
goals: "",
startsOn: "",
endsOn: "",
hourBudget: ""
});
const [todoFilter, setTodoFilter] = useState<"open" | "all" | "done">("open");
async function load() { async function load() {
try { try {
setData(await getTicketPeriod(periodType, period, ticketId)); setData(
periodType === "all"
? await getTicketDetails(ticketId, {
month: globalMonthFilter,
status: globalStatusFilter,
source: globalSourceFilter
})
: await getTicketPeriod(periodType, period, ticketId)
);
} catch (error) { } catch (error) {
toast.error("Ticket konnte nicht geladen werden", { toast.error("Ticket konnte nicht geladen werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler" description: error instanceof Error ? error.message : "Unbekannter Fehler"
@@ -185,6 +244,14 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
useEffect(() => { useEffect(() => {
void load(); void load();
}, [periodType, period, ticketId, globalMonthFilter, globalStatusFilter, globalSourceFilter]);
useEffect(() => {
if (periodType !== "all") {
return;
}
setGlobalMonthFilter(period !== "all" ? period : currentMonth());
}, [periodType, period, ticketId]); }, [periodType, period, ticketId]);
useEffect(() => { useEffect(() => {
@@ -198,6 +265,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
} }
setEditTicketNumber(data.ticket.ticket_number); setEditTicketNumber(data.ticket.ticket_number);
setEditTicketTitle(data.ticket.title ?? "");
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");
@@ -210,8 +278,19 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
}, [data?.ticket]); }, [data?.ticket]);
const ticket = data?.ticket; const ticket = data?.ticket;
const project = data?.project ?? null;
const isProject = Boolean(project);
const isGlobalTicket = periodType === "all";
const isPeriodClosed = Boolean(data?.closed); const isPeriodClosed = Boolean(data?.closed);
const periodLabel = periodType === "month" ? "Monat" : "Tag"; const periodLabel = periodType === "month" ? "Monat" : periodType === "day" ? "Tag" : "Ticket";
const sessionsScopeLabel = isGlobalTicket
? globalMonthFilter === "all"
? "im gesamten Ticketverlauf"
: `im Monat ${globalMonthFilter}`
: `im ${periodLabel.toLowerCase()} ${period}`;
const teamspaceLabel = isGlobalTicket || periodType === "month" ? "Teamspace gesamt" : "Teamspace Tag";
const availableMonths = data?.availableMonths ?? [];
const selectedMonthHasSessions = availableMonths.some((month) => month.month === globalMonthFilter);
const dayBillingMap = useMemo(() => new Map((data?.dayBillings ?? []).map((billing) => [billing.day, billing.billed_minutes])), [data?.dayBillings]); const dayBillingMap = useMemo(() => new Map((data?.dayBillings ?? []).map((billing) => [billing.day, billing.billed_minutes])), [data?.dayBillings]);
const savedDayBillingInput = (day: string) => formatHoursInput(dayBillingMap.get(day)); const savedDayBillingInput = (day: string) => formatHoursInput(dayBillingMap.get(day));
const isDayBillingDirty = (day: string) => (dayBillingInputs[day] ?? "") !== savedDayBillingInput(day); const isDayBillingDirty = (day: string) => (dayBillingInputs[day] ?? "") !== savedDayBillingInput(day);
@@ -253,9 +332,17 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
const ticketDataDirty = Boolean( const ticketDataDirty = Boolean(
ticket && ticket &&
(editTicketNumber !== ticket.ticket_number || (editTicketNumber !== ticket.ticket_number ||
editTicketTitle !== (ticket.title ?? "") ||
editOrganizationId !== (ticket.organization_id ?? "") || editOrganizationId !== (ticket.organization_id ?? "") ||
editWorkType !== (ticket.work_type ?? "support")) editWorkType !== (ticket.work_type ?? "support"))
); );
const todos = data?.todos ?? [];
const visibleTodos = todoFilter === "all" ? todos : todos.filter((todo) => todo.status === todoFilter);
const todoCounts = {
open: todos.filter((todo) => todo.status === "open").length,
done: todos.filter((todo) => todo.status === "done").length,
all: todos.length
};
const hasUnsavedChanges = ticketDataDirty || sessionGroups.some((group) => isDayBillingDirty(group.key)); const hasUnsavedChanges = ticketDataDirty || sessionGroups.some((group) => isDayBillingDirty(group.key));
useEffect(() => { useEffect(() => {
@@ -263,16 +350,18 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
return; return;
} }
const ticketMonth = isGlobalTicket ? globalMonthFilter : periodType === "day" ? period.slice(0, 7) : period;
onOpenTicketChange?.({ onOpenTicketChange?.({
key: `${periodType}:${period}:${ticket.id}`, key: `ticket:${ticket.id}`,
ticketId: ticket.id, ticketId: ticket.id,
ticketNumber: ticket.ticket_number, ticketNumber: ticket.ticket_number,
periodType, periodType: "all",
period, period: ticketMonth,
path: `/analysis/${periodType}/${period}/tickets/${ticket.id}`, path: `/tickets/${ticket.id}?month=${ticketMonth}`,
dirty: hasUnsavedChanges dirty: hasUnsavedChanges
}); });
}, [ticket, periodType, period, hasUnsavedChanges, onOpenTicketChange]); }, [ticket, periodType, period, isGlobalTicket, globalMonthFilter, hasUnsavedChanges, onOpenTicketChange]);
async function setBilling(sessionId: string, billingStatus: BillingStatus) { async function setBilling(sessionId: string, billingStatus: BillingStatus) {
if (isPeriodClosed && billingStatus !== null) { if (isPeriodClosed && billingStatus !== null) {
@@ -300,6 +389,13 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
return; return;
} }
if (isPeriodClosed) {
toast.warning("Monat ist abgeschlossen", {
description: "Öffne den Monat zuerst wieder, um Teamspace-Zeiten zu ändern."
});
return;
}
const parsedMinutes = parseHoursInput(dayBillingInputs[day] ?? ""); const parsedMinutes = parseHoursInput(dayBillingInputs[day] ?? "");
if (parsedMinutes === undefined) { if (parsedMinutes === undefined) {
@@ -345,8 +441,9 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
try { try {
await updateTicket(ticket.id, { await updateTicket(ticket.id, {
ticketNumber: editTicketNumber, ticketNumber: editTicketNumber,
title: editTicketTitle || null,
organizationId: editOrganizationId, organizationId: editOrganizationId,
workType: editWorkType workType: isProject ? "consulting" : editWorkType
}); });
toast.success("Ticketdaten gespeichert", { toast.success("Ticketdaten gespeichert", {
description: "Organisation und Art wurden auf die vorhandenen Sessions übernommen." description: "Organisation und Art wurden auf die vorhandenen Sessions übernommen."
@@ -361,6 +458,99 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
} }
} }
async function createProjectFromTicket() {
if (!ticket) {
return;
}
if (!ticket.organization_id) {
toast.error("Organisation fehlt", {
description: "Speichere zuerst eine Organisation am Ticket, bevor daraus ein Projekt wird."
});
return;
}
setCreatingProject(true);
try {
await createProject({
ticketNumber: ticket.ticket_number,
title: ticket.title ?? null,
organizationId: ticket.organization_id,
teamspaceProject: ticket.title ?? "",
description: "",
goals: "",
startsOn: null,
endsOn: null,
hourBudgetMinutes: null
});
toast.success("Projekt angelegt", {
description: "Das Ticket ist jetzt als Consulting-Projekt markiert."
});
await load();
} catch (error) {
toast.error("Projekt konnte nicht angelegt werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setCreatingProject(false);
}
}
function openProjectEditor() {
if (!project) {
return;
}
setProjectForm(projectEditForm(project));
setEditingProject(true);
}
async function saveProjectData(event: FormEvent) {
event.preventDefault();
if (!project || !ticket) {
return;
}
if (!projectForm.organizationId) {
toast.error("Organisation wählen");
return;
}
const hourBudgetMinutes = parseHoursInput(projectForm.hourBudget);
if (hourBudgetMinutes === undefined) {
toast.error("Stundenkontingent prüfen", {
description: "Bitte Stunden als Zahl eintragen, z.B. 12,5."
});
return;
}
setSavingProject(true);
try {
await updateProject(project.id, {
ticketNumber: ticket.ticket_number,
title: projectForm.title || null,
organizationId: projectForm.organizationId,
teamspaceProject: projectForm.teamspaceProject || null,
description: projectForm.description,
goals: projectForm.goals,
startsOn: projectForm.startsOn || null,
endsOn: projectForm.endsOn || null,
hourBudgetMinutes
});
toast.success("Projekt gespeichert");
setEditingProject(false);
await load();
} catch (error) {
toast.error("Projekt konnte nicht gespeichert werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setSavingProject(false);
}
}
async function submitTicketSession(event: FormEvent) { async function submitTicketSession(event: FormEvent) {
event.preventDefault(); event.preventDefault();
@@ -369,7 +559,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
} }
const effectiveOrganizationId = ticket.organization_id ?? ticketSession.organizationId; const effectiveOrganizationId = ticket.organization_id ?? ticketSession.organizationId;
const effectiveWorkType = ticket.work_type ?? ticketSession.workType; const effectiveWorkType = isProject ? "consulting" : ticket.work_type ?? ticketSession.workType;
if (!effectiveOrganizationId) { if (!effectiveOrganizationId) {
toast.error("Organisation wählen"); toast.error("Organisation wählen");
@@ -464,7 +654,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
await updateSessionDetails(editingSession.id, { await updateSessionDetails(editingSession.id, {
organizationId: editSessionOrganizationId, organizationId: editSessionOrganizationId,
activity: editSessionActivity, activity: editSessionActivity,
workType: editSessionWorkType, workType: isProject ? "consulting" : editSessionWorkType,
startedAt: startedAt.toISOString(), startedAt: startedAt.toISOString(),
endedAt: endedAt.toISOString() endedAt: endedAt.toISOString()
}); });
@@ -521,26 +711,91 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
Zur Auswertung Zur Auswertung
</Button> </Button>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<h2 className="text-xl font-semibold tracking-normal sm:text-2xl">{ticket?.ticket_number ?? "Ticket"}</h2> <h2 className="text-xl font-semibold tracking-normal sm:text-2xl">{ticket?.title || ticket?.ticket_number || "Ticket"}</h2>
{ticket?.ticket_number ? <CopyTicketButton ticketNumber={ticket.ticket_number} /> : null} {ticket?.ticket_number ? <CopyTicketButton ticketNumber={ticket.ticket_number} /> : null}
{periodType === "month" && data?.closed ? <Badge variant="outline">Monat geschlossen</Badge> : null} {isProject ? <Badge variant="outline">Projekt</Badge> : null}
{(periodType === "month" || (isGlobalTicket && globalMonthFilter !== "all")) && data?.closed ? <Badge variant="outline">Monat geschlossen</Badge> : null}
</div> </div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Sessions im {periodLabel.toLowerCase()} {period} {ticket?.title ? `${ticket.ticket_number} · ` : ""}
Sessions {sessionsScopeLabel}
</p> </p>
</div> </div>
<Badge variant={data?.openCount === 0 ? "success" : "warning"}> <div className="flex flex-wrap items-center gap-2">
{data?.openCount === 0 ? "Bewertet" : `${data?.openCount ?? 0} offen`} {!isProject && ticket ? (
</Badge> <Button type="button" size="sm" variant="secondary" disabled={creatingProject} onClick={() => void createProjectFromTicket()}>
<Plus className="size-4" />
{creatingProject ? "Erstellt..." : "Als Projekt anlegen"}
</Button>
) : null}
<Badge variant={data?.openCount === 0 ? "success" : "warning"}>
{data?.openCount === 0 ? "Bewertet" : `${data?.openCount ?? 0} offen`}
</Badge>
</div>
</div> </div>
{periodType === "month" && data?.closed ? ( {(periodType === "month" || (isGlobalTicket && globalMonthFilter !== "all")) && data?.closed ? (
<Alert variant="success" className="py-3 text-sm"> <Alert variant="success" className="py-3 text-sm">
Dieser Monat ist abgeschlossen. Bewertungen und Änderungen sind geschützt, bis du den Monat im Monatsabschluss wieder öffnest. Dieser Monat ist abgeschlossen. Bewertungen und Änderungen sind geschützt, bis du den Monat im Monatsabschluss wieder öffnest.
</Alert> </Alert>
) : null} ) : null}
{isGlobalTicket ? (
<Card>
<CardHeader className="p-4">
<CardTitle>Filter</CardTitle>
<CardDescription>Grenzt die Sessionliste dieses Tickets ein. Die Ticketdaten selbst bleiben global.</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 px-4 pb-4 md:grid-cols-3">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="ticket-month-filter">Monat</label>
<select
id="ticket-month-filter"
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={globalMonthFilter}
onChange={(event) => setGlobalMonthFilter(event.currentTarget.value)}
>
<option value="all">Alle Monate</option>
{globalMonthFilter !== "all" && !selectedMonthHasSessions ? <option value={globalMonthFilter}>{globalMonthFilter} (0)</option> : null}
{availableMonths.map((month) => (
<option key={month.month} value={month.month}>
{month.month} ({month.session_count})
</option>
))}
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="ticket-status-filter">Bewertung</label>
<select
id="ticket-status-filter"
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={globalStatusFilter}
onChange={(event) => setGlobalStatusFilter(event.currentTarget.value as typeof globalStatusFilter)}
>
<option value="all">Alle Bewertungen</option>
<option value="open">Offen</option>
<option value="billed">Abgerechnet</option>
<option value="non_billable">Nicht abrechenbar</option>
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="ticket-source-filter">Quelle</label>
<select
id="ticket-source-filter"
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={globalSourceFilter}
onChange={(event) => setGlobalSourceFilter(event.currentTarget.value as typeof globalSourceFilter)}
>
<option value="all">Alle Quellen</option>
<option value="manual">Manuell</option>
<option value="recurring">Fixe Abrechnung</option>
</select>
</div>
</CardContent>
</Card>
) : null}
<Card> <Card>
<CardHeader className="p-4"> <CardHeader className="p-4">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
@@ -552,11 +807,15 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="px-4 pb-4"> <CardContent className="px-4 pb-4">
<form className="grid gap-3 lg:grid-cols-[180px_minmax(220px,1fr)_180px_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} onChange={(event) => setEditTicketNumber(event.currentTarget.value)} required />
</div> </div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="edit-ticket-title">Titel</label>
<Input id="edit-ticket-title" value={editTicketTitle} onChange={(event) => setEditTicketTitle(event.currentTarget.value)} placeholder="optional" />
</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>
{ticket?.customer_name && !ticket.organization_id ? ( {ticket?.customer_name && !ticket.organization_id ? (
@@ -578,11 +837,13 @@ 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={isProject}
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>
{isProject ? <p className="text-xs text-muted-foreground">Projekte sind immer Consulting.</p> : null}
</div> </div>
<Button type="submit" disabled={savingTicket}> <Button type="submit" disabled={savingTicket}>
<Save className="size-4" /> <Save className="size-4" />
@@ -592,6 +853,176 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
</CardContent> </CardContent>
</Card> </Card>
{project ? (
<Card>
<CardHeader className="p-4">
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle className="flex items-center gap-2">
<FolderKanban className="size-5 text-muted-foreground" />
Projekt
</CardTitle>
<CardDescription>{project.teamspace_project || "Kein Teamspace-Projekt hinterlegt"}</CardDescription>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button type="button" size="sm" variant="secondary" onClick={openProjectEditor}>
<Pencil className="size-4" />
Bearbeiten
</Button>
<HelpLink anchor="projekte" label="Hilfe zu Projekten" />
</div>
</div>
</CardHeader>
<CardContent className="space-y-3 px-4 pb-4">
<div className="flex flex-wrap gap-2 text-sm">
<Badge variant="secondary">Zeitraum {project.starts_on || "offen"} - {project.ends_on || "offen"}</Badge>
<Badge variant="secondary">Kontingent {project.hour_budget_minutes ? formatMinutes(project.hour_budget_minutes) : "offen"}</Badge>
{project.teamspace_project ? <Badge variant="outline">{project.teamspace_project}</Badge> : null}
</div>
<div className="grid gap-3 text-sm lg:grid-cols-2">
<div className="min-w-0">
<div className="mb-1 flex items-center gap-2 font-medium">
<FolderKanban className="size-4 text-muted-foreground" />
Beschreibung
</div>
<p className="line-clamp-3 whitespace-pre-wrap text-muted-foreground">{project.description || "Noch keine Beschreibung."}</p>
</div>
<div className="min-w-0">
<div className="mb-1 flex items-center gap-2 font-medium">
<Target className="size-4 text-muted-foreground" />
Ziele
</div>
<p className="line-clamp-3 whitespace-pre-wrap text-muted-foreground">{project.goals || "Noch keine Ziele."}</p>
</div>
</div>
</CardContent>
</Card>
) : null}
<Dialog open={editingProject} onOpenChange={setEditingProject}>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-3xl">
<DialogHeader>
<DialogTitle>Projekt bearbeiten</DialogTitle>
<DialogDescription>Projektinformationen zum geöffneten Ticket aktualisieren.</DialogDescription>
</DialogHeader>
<form className="space-y-4" onSubmit={saveProjectData}>
<div className="grid gap-3 lg:grid-cols-[minmax(220px,1fr)_minmax(220px,1fr)]">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="project-edit-title">Titel</label>
<Input
id="project-edit-title"
value={projectForm.title}
onChange={(event) => setProjectForm({ ...projectForm, title: event.currentTarget.value })}
placeholder="optional"
/>
</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_160px]">
<div className="space-y-2">
<label className="text-sm font-medium">Organisation</label>
<OrganizationSelect
value={projectForm.organizationId}
selectedName={projectForm.organizationName}
onChange={(organization) =>
setProjectForm({
...projectForm,
organizationId: organization.id,
organizationName: organization.name
})
}
required
/>
</div>
<div className="space-y-2">
<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 })} />
</div>
<div className="space-y-2">
<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 })} />
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="project-edit-budget">Kontingent h</label>
<Input
id="project-edit-budget"
inputMode="decimal"
value={projectForm.hourBudget}
onChange={(event) => setProjectForm({ ...projectForm, hourBudget: event.currentTarget.value })}
/>
</div>
</div>
<div className="grid gap-3 lg:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="project-edit-description">Projektbeschreibung</label>
<Textarea
id="project-edit-description"
className="min-h-28"
value={projectForm.description}
onChange={(event) => setProjectForm({ ...projectForm, description: event.currentTarget.value })}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="project-edit-goals">Ziele</label>
<Textarea
id="project-edit-goals"
className="min-h-28"
value={projectForm.goals}
onChange={(event) => setProjectForm({ ...projectForm, goals: event.currentTarget.value })}
/>
</div>
</div>
<div className="flex justify-end">
<Button type="submit" disabled={savingProject}>
<Save className="size-4" />
{savingProject ? "Speichert..." : "Projekt speichern"}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
<Card>
<CardHeader className="p-4">
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle className="flex items-center gap-2">
<ClipboardList className="size-5 text-muted-foreground" />
ToDos
</CardTitle>
<CardDescription>Aufgaben zu diesem Ticket oder Projekt verwalten.</CardDescription>
</div>
<div className="flex shrink-0 items-center gap-2">
<div className="flex rounded-md border bg-muted/20 p-0.5">
{(["open", "all", "done"] as const).map((value) => (
<Button
key={value}
type="button"
size="sm"
variant={todoFilter === value ? "default" : "ghost"}
className="h-7 px-2 text-xs"
onClick={() => setTodoFilter(value)}
>
{value === "open" ? `Offen ${todoCounts.open}` : value === "done" ? `Erledigt ${todoCounts.done}` : `Alle ${todoCounts.all}`}
</Button>
))}
</div>
<HelpLink anchor="todos" label="Hilfe zu ToDos" />
</div>
</div>
</CardHeader>
<CardContent className="px-4 pb-4">
<TodoPanel ticketId={ticket?.id} todos={visibleTodos} onChanged={load} />
</CardContent>
</Card>
<Card> <Card>
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-start sm:justify-between sm:space-y-0"> <CardHeader className="gap-3 p-4 sm:flex-row sm:items-start sm:justify-between sm:space-y-0">
<div> <div>
@@ -600,116 +1031,123 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-2 rounded-md border bg-muted/30 px-3 py-2 text-sm"> <div className="flex items-center gap-2 rounded-md border bg-muted/30 px-3 py-2 text-sm">
<span className="text-muted-foreground">{periodLabel} abgerechnet</span> <span className="text-muted-foreground">Abgerechnet</span>
<span className="font-semibold">{formatMinutes(periodTotalMinutes)}</span> <span className="font-semibold">{formatMinutes(periodTotalMinutes)}</span>
</div> </div>
<div className="flex items-center gap-2 rounded-md border bg-muted/30 px-3 py-2 text-sm"> <div className="flex items-center gap-2 rounded-md border bg-muted/30 px-3 py-2 text-sm">
<span className="text-muted-foreground">{periodType === "month" ? "Teamspace gesamt" : "Teamspace Tag"}</span> <span className="text-muted-foreground">{teamspaceLabel}</span>
<span className="font-semibold">{formatMinutes(periodTeamspaceMinutes)}</span> <span className="font-semibold">{formatMinutes(periodTeamspaceMinutes)}</span>
</div> </div>
<HelpLink anchor="ticketdetail" label="Hilfe zur Ticketdetailansicht" /> <HelpLink anchor="ticketdetail" label="Hilfe zur Ticketdetailansicht" />
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="px-4 pb-4"> <CardContent className="px-4 pb-4">
<div className="space-y-3"> <Accordion type="multiple" className="gap-2">
{sessionGroups.map((group) => ( {sessionGroups.map((group) => (
<div key={group.key} className="rounded-md border bg-background"> <AccordionItem key={group.key} value={group.key} className="rounded-md border bg-background px-3">
<div className="flex flex-col gap-2 border-b bg-muted/40 px-3 py-2 sm:flex-row sm:items-center sm:justify-between"> <AccordionTrigger className="gap-3 py-2 hover:no-underline">
<p className="font-medium">{group.label}</p> <div className="flex min-w-0 flex-1 flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
<div className="flex flex-wrap items-center gap-2"> <div className="min-w-0 text-left">
<Badge variant="outline">Tag abgerechnet {formatMinutes(group.totalMinutes)}</Badge> <p className="truncate font-medium">{group.label}</p>
{group.nonBillableMinutes > 0 ? <Badge variant="secondary">Nicht {formatMinutes(group.nonBillableMinutes)}</Badge> : null} <p className="text-xs text-muted-foreground">{group.sessions.length} Session(s)</p>
{group.openMinutes > 0 ? <Badge variant="warning">Offen {formatMinutes(group.openMinutes)}</Badge> : null}
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<label htmlFor={`crm-billing-${group.key}`}>Teamspace</label>
<Input
id={`crm-billing-${group.key}`}
type="text"
className="h-7 w-24 bg-background text-right"
autoComplete="off"
inputMode="decimal"
value={dayBillingInputs[group.key] ?? ""}
disabled={savingDayBillingKey === group.key}
placeholder="0"
onChange={(event) => {
const nextValue = event.currentTarget.value;
if (!isPartialHoursInput(nextValue)) {
return;
}
setDayBillingInputs((current) => ({
...current,
[group.key]: nextValue
}));
}}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
}
}}
/>
<span>h</span>
<Button
type="button"
size="sm"
className={cn("h-7 w-[104px]", !isDayBillingDirty(group.key) && "invisible")}
disabled={!isDayBillingDirty(group.key) || savingDayBillingKey === group.key}
onMouseDown={(event) => event.preventDefault()}
onClick={() => void saveDayBilling(group.key)}
>
<Save className="size-3.5" />
{savingDayBillingKey === group.key ? "Speichert..." : "Speichern"}
</Button>
</div> </div>
</div> <div className="flex flex-wrap items-center gap-2">
</div> <Badge variant="outline">Abgerechnet {formatMinutes(group.totalMinutes)}</Badge>
<div className="space-y-1.5 p-2 sm:p-3"> {group.nonBillableMinutes > 0 ? <Badge variant="secondary">Nicht {formatMinutes(group.nonBillableMinutes)}</Badge> : null}
{group.sessions.map((session) => ( {group.openMinutes > 0 ? <Badge variant="warning">Offen {formatMinutes(group.openMinutes)}</Badge> : null}
<div key={session.id} className="ml-3 rounded-md border bg-card/40 p-2 sm:ml-6"> <div className="flex items-center gap-2 text-xs text-muted-foreground" onClick={(event) => event.stopPropagation()}>
<div className="grid gap-2 lg:grid-cols-[140px_minmax(0,1fr)_90px_auto_auto] lg:items-center"> <label htmlFor={`crm-billing-${group.key}`}>Teamspace</label>
<div className="text-sm text-muted-foreground"> <Input
{formatTimeRange(session.started_at, session.ended_at)} id={`crm-billing-${group.key}`}
</div> type="text"
<div className="min-w-0"> className="h-7 w-24 bg-background text-right"
<p className="whitespace-pre-wrap break-words text-sm font-medium">{session.activity}</p> autoComplete="off"
</div> inputMode="decimal"
<div className="flex flex-wrap gap-1"> value={dayBillingInputs[group.key] ?? ""}
<Badge variant="outline" className="w-fit">{formatMinutes(session.rounded_minutes)}</Badge> disabled={isPeriodClosed || savingDayBillingKey === group.key}
{session.recurring_billing_id ? <Badge variant="secondary">fix</Badge> : null} placeholder="0"
</div> onChange={(event) => {
<BillingButtons const nextValue = event.currentTarget.value;
session={session}
disabled={isPeriodClosed} if (!isPartialHoursInput(nextValue)) {
loading={loadingId === session.id} return;
onChange={(value) => setBilling(session.id, value)} }
setDayBillingInputs((current) => ({
...current,
[group.key]: nextValue
}));
}}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
}
}}
/> />
<div className="flex gap-1 lg:justify-end"> <span>h</span>
<Button size="icon" variant="ghost" className="size-8" onClick={() => openSessionEditor(session)}> <Button
<Pencil className="size-4" /> type="button"
<span className="sr-only">Session bearbeiten</span> size="sm"
</Button> className={cn("h-7 w-[104px]", !isDayBillingDirty(group.key) && "invisible")}
<Button disabled={isPeriodClosed || !isDayBillingDirty(group.key) || savingDayBillingKey === group.key}
size="icon" onMouseDown={(event) => event.preventDefault()}
variant="ghost" onClick={() => void saveDayBilling(group.key)}
className="size-8 text-destructive hover:text-destructive" >
disabled={deletingId === session.id} <Save className="size-3.5" />
onClick={() => removeSession(session)} {savingDayBillingKey === group.key ? "Speichert..." : "Speichern"}
> </Button>
<Trash2 className="size-4" />
<span className="sr-only">Session löschen</span>
</Button>
</div>
</div> </div>
</div> </div>
))} </div>
</div> </AccordionTrigger>
</div> <AccordionContent className="pb-3">
<div className="space-y-1.5">
{group.sessions.map((session) => (
<div key={session.id} className="ml-3 rounded-md border bg-card/40 p-2 sm:ml-6">
<div className="grid gap-2 lg:grid-cols-[140px_minmax(0,1fr)_90px_auto_auto] lg:items-center">
<div className="text-sm text-muted-foreground">
{formatTimeRange(session.started_at, session.ended_at)}
</div>
<div className="min-w-0">
<p className="whitespace-pre-wrap break-words text-sm font-medium">{session.activity}</p>
</div>
<div className="flex flex-wrap gap-1">
<Badge variant="outline" className="w-fit">{formatMinutes(session.rounded_minutes)}</Badge>
{session.recurring_billing_id ? <Badge variant="secondary">fix</Badge> : null}
</div>
<BillingButtons
session={session}
disabled={isPeriodClosed}
loading={loadingId === session.id}
onChange={(value) => setBilling(session.id, value)}
/>
<div className="flex gap-1 lg:justify-end">
<Button size="icon" variant="ghost" className="size-8" onClick={() => openSessionEditor(session)}>
<Pencil className="size-4" />
<span className="sr-only">Session bearbeiten</span>
</Button>
<Button
size="icon"
variant="ghost"
className="size-8 text-destructive hover:text-destructive"
disabled={deletingId === session.id}
onClick={() => removeSession(session)}
>
<Trash2 className="size-4" />
<span className="sr-only">Session löschen</span>
</Button>
</div>
</div>
</div>
))}
</div>
</AccordionContent>
</AccordionItem>
))} ))}
</div> </Accordion>
{data?.sessions.length === 0 ? ( {data?.sessions.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">Keine Sessions für dieses Ticket im gewählten Zeitraum.</p> <p className="py-8 text-center text-sm text-muted-foreground">Keine Sessions für dieses Ticket im gewählten Filter.</p>
) : null} ) : null}
</CardContent> </CardContent>
</Card> </Card>
@@ -902,12 +1340,13 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
id="session-work-type" id="session-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={editSessionWorkType} value={editSessionWorkType}
disabled={Boolean(editingSession?.recurring_billing_id)} disabled={Boolean(editingSession?.recurring_billing_id) || isProject}
onChange={(event) => setEditSessionWorkType(event.currentTarget.value as WorkType)} onChange={(event) => setEditSessionWorkType(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>
{isProject ? <p className="text-xs text-muted-foreground">Projekt-Sessions bleiben Consulting.</p> : null}
</div> </div>
<div className="flex justify-end"> <div className="flex justify-end">
<Button type="submit" disabled={savingSessionDetails}> <Button type="submit" disabled={savingSessionDetails}>
+94 -7
View File
@@ -1,4 +1,4 @@
import { FormEvent, 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, Square, Upload } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -73,12 +73,74 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [manualSession, setManualSession] = useState<ManualSessionFormState>(() => emptyManualSession()); const [manualSession, setManualSession] = useState<ManualSessionFormState>(() => emptyManualSession());
const [savingManualSession, setSavingManualSession] = useState(false); const [savingManualSession, setSavingManualSession] = useState(false);
const [manualLookupStatus, setManualLookupStatus] = useState<"idle" | "loading" | "found" | "not-found">("idle");
const [manualLookupTicket, setManualLookupTicket] = useState<TicketMeta | null>(null);
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);
const isManualTicketValid = ticketPattern.test(manualSession.ticketNumber.trim());
const manualTicketDefaultsLocked = Boolean(manualLookupTicket?.organization_id && manualLookupTicket.work_type);
const runningTimer = timers.find((timer) => timer.phase === "running") ?? null; const runningTimer = timers.find((timer) => timer.phase === "running") ?? null;
useEffect(() => {
const normalizedTicketNumber = manualSession.ticketNumber.trim();
if (!normalizedTicketNumber) {
setManualLookupStatus("idle");
setManualLookupTicket(null);
return;
}
if (!ticketPattern.test(normalizedTicketNumber)) {
setManualLookupStatus("idle");
setManualLookupTicket(null);
return;
}
let cancelled = false;
setManualLookupStatus("loading");
setManualLookupTicket(null);
const timeout = window.setTimeout(async () => {
try {
const result = await lookupTicket(normalizedTicketNumber);
if (cancelled) {
return;
}
setManualLookupTicket(result.ticket);
setManualLookupStatus(result.ticket ? "found" : "not-found");
if (result.ticket) {
setManualSession((current) => {
if (current.ticketNumber.trim() !== normalizedTicketNumber) {
return current;
}
return {
...current,
organizationId: result.ticket?.organization_id ?? "",
organizationName: result.ticket?.organization_name ?? result.ticket?.customer_name ?? null,
workType: result.ticket?.work_type ?? current.workType
};
});
}
} catch {
if (!cancelled) {
setManualLookupStatus("idle");
setManualLookupTicket(null);
}
}
}, 250);
return () => {
cancelled = true;
window.clearTimeout(timeout);
};
}, [manualSession.ticketNumber]);
async function addTimer() { async function addTimer() {
if (!isValidTicket) { if (!isValidTicket) {
toast.error("Ticketnummer prüfen", { toast.error("Ticketnummer prüfen", {
@@ -101,6 +163,8 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
ticketNumber, ticketNumber,
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),
projectName: ticketResult.ticket?.project_name ?? null,
ticketLookupDone: true, ticketLookupDone: true,
startedAt: now, startedAt: now,
pausedTotalMs: 0, pausedTotalMs: 0,
@@ -231,7 +295,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
async function submitManualSession(event: FormEvent) { async function submitManualSession(event: FormEvent) {
event.preventDefault(); event.preventDefault();
if (!ticketPattern.test(manualSession.ticketNumber)) { if (!isManualTicketValid) {
toast.error("Ticketnummer prüfen", { toast.error("Ticketnummer prüfen", {
description: "Das Format muss Ticket#XXXXXX sein." description: "Das Format muss Ticket#XXXXXX sein."
}); });
@@ -262,7 +326,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
try { try {
await createSession({ await createSession({
ticketNumber: manualSession.ticketNumber, ticketNumber: manualSession.ticketNumber.trim(),
organizationId: manualSession.organizationId, organizationId: manualSession.organizationId,
activity: manualSession.activity, activity: manualSession.activity,
workType: manualSession.workType, workType: manualSession.workType,
@@ -271,7 +335,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
durationSeconds durationSeconds
}); });
toast.success("Session nachgetragen", { toast.success("Session nachgetragen", {
description: "Der Eintrag ist in deiner Auswertung offen." description: manualLookupTicket?.is_project ? "Der Eintrag wurde dem Projekt zugeordnet." : "Der Eintrag ist in deiner Auswertung offen."
}); });
setManualSession({ setManualSession({
...emptyManualSession(), ...emptyManualSession(),
@@ -343,7 +407,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
<CardDescription> <CardDescription>
{selectedTimer {selectedTimer
? selectedTimer.organizationName ? selectedTimer.organizationName
? `${selectedTimer.organizationName}${selectedTimer.workType ? ` · ${selectedTimer.workType === "support" ? "Support" : "Consulting"}` : ""}` ? `${selectedTimer.isProject ? "Projekt · " : ""}${selectedTimer.projectName || selectedTimer.organizationName}${selectedTimer.workType ? ` · ${selectedTimer.workType === "support" ? "Support" : "Consulting"}` : ""}`
: "Beim Umschalten wird dieser Timer aktiviert und alle anderen werden pausiert." : "Beim Umschalten wird dieser Timer aktiviert und alle anderen werden pausiert."
: "Starte einen Timer, um eine Session zu erfassen."} : "Starte einen Timer, um eine Session zu erfassen."}
</CardDescription> </CardDescription>
@@ -403,15 +467,35 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
id="manual-ticket-number" id="manual-ticket-number"
placeholder="Ticket#123456" placeholder="Ticket#123456"
value={manualSession.ticketNumber} value={manualSession.ticketNumber}
onChange={(event) => setManualSession({ ...manualSession, ticketNumber: event.currentTarget.value })} onChange={(event) =>
setManualSession({
...manualSession,
ticketNumber: event.currentTarget.value,
organizationId: "",
organizationName: null,
workType: "support"
})
}
required required
/> />
{manualSession.ticketNumber.length > 0 && !isManualTicketValid ? <p className="text-xs text-destructive">Format: Ticket#XXXXXX</p> : null}
{manualLookupStatus === "loading" ? <p className="text-xs text-muted-foreground">Prüfe vorhandenes Ticket...</p> : null}
{manualLookupStatus === "found" && manualTicketDefaultsLocked ? (
<p className="text-xs text-muted-foreground">
{manualLookupTicket?.is_project ? "Projekt gefunden. Organisation und Consulting wurden übernommen." : "Ticket gefunden. Organisation und Art wurden übernommen."}
</p>
) : null}
{manualLookupStatus === "found" && !manualTicketDefaultsLocked ? (
<p className="text-xs text-muted-foreground">Ticket gefunden, aber Stammdaten sind unvollständig. Bitte ergänzen.</p>
) : null}
{manualLookupStatus === "not-found" ? <p className="text-xs text-muted-foreground">Neues Ticket. Organisation und Art bitte wählen.</p> : 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>
<OrganizationSelect <OrganizationSelect
value={manualSession.organizationId} value={manualSession.organizationId}
selectedName={manualSession.organizationName} selectedName={manualSession.organizationName}
disabled={manualTicketDefaultsLocked}
onChange={(organization) => onChange={(organization) =>
setManualSession({ setManualSession({
...manualSession, ...manualSession,
@@ -428,6 +512,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
id="manual-work-type" id="manual-work-type"
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" 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.workType} value={manualSession.workType}
disabled={manualTicketDefaultsLocked}
onChange={(event) => setManualSession({ ...manualSession, workType: event.currentTarget.value as WorkType })} onChange={(event) => setManualSession({ ...manualSession, workType: event.currentTarget.value as WorkType })}
> >
<option value="support">Support</option> <option value="support">Support</option>
@@ -501,7 +586,9 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
{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>
<p className="text-muted-foreground">{finished.ticket.work_type === "support" ? "Support" : "Consulting"} wurde vom bestehenden Ticket übernommen.</p> <p className="text-muted-foreground">
{finished.ticket.is_project ? "Projekt erkannt. Consulting" : finished.ticket.work_type === "support" ? "Support" : "Consulting"} wurde vom bestehenden Ticket übernommen.
</p>
</div> </div>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
+102
View File
@@ -0,0 +1,102 @@
import { ClipboardList } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { HelpLink } from "@/components/HelpLink";
import { TodoPanel } from "@/components/TodoPanel";
import { getTodos } from "../api";
import type { TicketTodo, TodoStatus } from "../types";
type TodosPageProps = {
onNavigate: (to: string) => void;
};
export function TodosPage({ onNavigate }: TodosPageProps) {
const [todos, setTodos] = useState<TicketTodo[]>([]);
const [status, setStatus] = useState<TodoStatus | "all">("open");
const [loading, setLoading] = useState(true);
async function load() {
setLoading(true);
try {
const result = await getTodos(status);
setTodos(result.todos);
} catch (error) {
toast.error("ToDos konnten nicht geladen werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setLoading(false);
}
}
useEffect(() => {
void load();
}, [status]);
const counts = useMemo(
() => ({
open: todos.filter((todo) => todo.status === "open").length,
done: todos.filter((todo) => todo.status === "done").length,
urgent: todos.filter((todo) => todo.status === "open" && todo.priority === "urgent").length
}),
[todos]
);
return (
<div className="space-y-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div>
<h2 className="text-xl font-semibold tracking-normal sm:text-2xl">ToDos</h2>
<p className="text-sm text-muted-foreground">Offene Aufgaben aus Projekten und normalen Tickets an einem Ort.</p>
</div>
<div className="grid gap-2 text-sm sm:grid-cols-3">
<div className="rounded-md border bg-muted/20 px-3 py-2">
<span className="block text-muted-foreground">Offen</span>
<span className="font-semibold">{counts.open}</span>
</div>
<div className="rounded-md border bg-muted/20 px-3 py-2">
<span className="block text-muted-foreground">Dringend</span>
<span className="font-semibold">{counts.urgent}</span>
</div>
<div className="rounded-md border bg-muted/20 px-3 py-2">
<span className="block text-muted-foreground">Erledigt</span>
<span className="font-semibold">{counts.done}</span>
</div>
</div>
</div>
<Card>
<CardHeader className="p-4">
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle className="flex items-center gap-2">
<ClipboardList className="size-5 text-muted-foreground" />
Aufgabenliste
</CardTitle>
<CardDescription>ToDos werden im jeweiligen Ticket angelegt und hier gesammelt angezeigt.</CardDescription>
</div>
<HelpLink anchor="todos" label="Hilfe zu ToDos" />
</div>
<div className="flex flex-wrap gap-2 pt-2">
{(["open", "all", "done"] as const).map((value) => (
<button
key={value}
type="button"
className={`rounded-md border px-3 py-1.5 text-sm ${status === value ? "bg-primary text-primary-foreground" : "bg-background hover:bg-muted"}`}
onClick={() => setStatus(value)}
>
{value === "open" ? "Offen" : value === "done" ? "Erledigt" : "Alle"}
</button>
))}
</div>
</CardHeader>
<CardContent className="px-4 pb-4">
<TodoPanel todos={todos} showCreate={false} showTicketMeta onNavigate={onNavigate} onChanged={load} />
{!loading && todos.length === 0 ? null : null}
</CardContent>
</Card>
</div>
);
}
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env node
import "dotenv/config";
import pg from "pg";
const apply = process.argv.includes("--apply");
if (!process.env.DATABASE_URL) {
console.error("DATABASE_URL fehlt. Bitte .env setzen oder DATABASE_URL vor dem Aufruf exportieren.");
process.exit(1);
}
const updates = [
[
"tickets",
`
UPDATE tickets t
SET organization_id = old_org.zammad_id
FROM organizations old_org
WHERE t.organization_id = old_org.id
AND t.organization_id <> old_org.zammad_id
AND COALESCE(t.customer_name, '') = old_org.name
`
],
[
"sessions",
`
UPDATE sessions s
SET organization_id = old_org.zammad_id
FROM organizations old_org
WHERE s.organization_id = old_org.id
AND s.organization_id <> old_org.zammad_id
AND COALESCE(s.customer_name, '') = old_org.name
`
],
[
"recurring_billings",
`
UPDATE recurring_billings rb
SET organization_id = old_org.zammad_id
FROM organizations old_org
WHERE rb.organization_id = old_org.id
AND rb.organization_id <> old_org.zammad_id
AND (
EXISTS (
SELECT 1
FROM sessions s
WHERE s.recurring_billing_id = rb.id
AND COALESCE(s.customer_name, '') = old_org.name
)
OR EXISTS (
SELECT 1
FROM tickets t
WHERE t.ticket_number = COALESCE(rb.ticket_number, 'Fix#' || rb.id)
AND COALESCE(t.customer_name, '') = old_org.name
)
)
`
]
];
const remainingSql = `
SELECT 'tickets' AS table_name, COUNT(*)::int AS repairable
FROM tickets t
JOIN organizations old_org ON old_org.id = t.organization_id
WHERE t.organization_id IS NOT NULL
AND old_org.zammad_id <> t.organization_id
AND COALESCE(t.customer_name, '') = old_org.name
UNION ALL
SELECT 'sessions' AS table_name, COUNT(*)::int AS repairable
FROM sessions s
JOIN organizations old_org ON old_org.id = s.organization_id
WHERE s.organization_id IS NOT NULL
AND old_org.zammad_id <> s.organization_id
AND COALESCE(s.customer_name, '') = old_org.name
UNION ALL
SELECT 'recurring_billings' AS table_name, COUNT(*)::int AS repairable
FROM recurring_billings rb
JOIN organizations old_org ON old_org.id = rb.organization_id
WHERE old_org.zammad_id <> rb.organization_id
AND (
EXISTS (
SELECT 1
FROM sessions s
WHERE s.recurring_billing_id = rb.id
AND COALESCE(s.customer_name, '') = old_org.name
)
OR EXISTS (
SELECT 1
FROM tickets t
WHERE t.ticket_number = COALESCE(rb.ticket_number, 'Fix#' || rb.id)
AND COALESCE(t.customer_name, '') = old_org.name
)
);
`;
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const client = await pool.connect();
try {
await client.query("BEGIN");
const resultRows = [];
for (const [table, sql] of updates) {
const result = await client.query(sql);
resultRows.push({ table, [apply ? "updated" : "would_update"]: result.rowCount ?? 0 });
}
if (apply) {
await client.query("COMMIT");
console.log("Organisationszuordnungen wurden korrigiert.");
} else {
await client.query("ROLLBACK");
console.log("Dry-Run: Es wurde nichts gespeichert. Mit --apply wirklich ausfuehren.");
}
console.table(resultRows);
const remaining = await pool.query(remainingSql);
console.log("Noch eindeutig reparierbare Alt-Zuordnungen:");
console.table(remaining.rows);
} catch (error) {
await client.query("ROLLBACK").catch(() => undefined);
throw error;
} finally {
client.release();
await pool.end();
}