Compare commits
9
Commits
a760f423a4
...
3d9674bd5a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d9674bd5a | ||
|
|
239be9f218 | ||
|
|
3da436423f | ||
|
|
2792051a16 | ||
|
|
018c06dc3e | ||
|
|
b67dbad0b1 | ||
|
|
19cc37d42a | ||
|
|
4e77674cdf | ||
|
|
4f0020a61d |
No files matched your search
+1002
-50
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Executable
+127
@@ -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();
|
||||||
|
}
|
||||||
Reference in new issue
Block a user