Refine project and todo workflow
This commit is contained in:
1 parent
3da436423f
commit
239be9f218
9 files changed
+354
-168
No files matched your search
+49
-17
@@ -58,6 +58,19 @@ function optionalString(value: unknown) {
|
|||||||
return trimmed.length > 0 ? trimmed : null;
|
return trimmed.length > 0 ? trimmed : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function optionalNullableString(value: unknown) {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
throw badRequest("value must be a string or null");
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.length > 0 ? trimmed : null;
|
||||||
|
}
|
||||||
|
|
||||||
function optionalWorkType(value: unknown) {
|
function optionalWorkType(value: unknown) {
|
||||||
if (value === undefined || value === null || value === "") {
|
if (value === undefined || value === null || value === "") {
|
||||||
return null;
|
return null;
|
||||||
@@ -148,6 +161,7 @@ type ProjectRow = {
|
|||||||
id: string;
|
id: string;
|
||||||
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;
|
||||||
@@ -637,6 +651,7 @@ async function getPeriodOverview(config: PeriodConfig, period: ParsedPeriod, use
|
|||||||
SELECT
|
SELECT
|
||||||
t.id,
|
t.id,
|
||||||
t.ticket_number,
|
t.ticket_number,
|
||||||
|
t.title,
|
||||||
t.organization_id,
|
t.organization_id,
|
||||||
o.name AS organization_name,
|
o.name AS organization_name,
|
||||||
COALESCE(o.name, t.customer_name) AS customer_name,
|
COALESCE(o.name, t.customer_name) AS customer_name,
|
||||||
@@ -662,6 +677,7 @@ async function getPeriodOverview(config: PeriodConfig, period: ParsedPeriod, use
|
|||||||
GROUP BY
|
GROUP BY
|
||||||
t.id,
|
t.id,
|
||||||
t.ticket_number,
|
t.ticket_number,
|
||||||
|
t.title,
|
||||||
t.organization_id,
|
t.organization_id,
|
||||||
o.name,
|
o.name,
|
||||||
t.customer_name,
|
t.customer_name,
|
||||||
@@ -1154,6 +1170,7 @@ async function getPeriodTicket(config: PeriodConfig, period: ParsedPeriod, ticke
|
|||||||
SELECT
|
SELECT
|
||||||
t.id,
|
t.id,
|
||||||
t.ticket_number,
|
t.ticket_number,
|
||||||
|
t.title,
|
||||||
t.organization_id,
|
t.organization_id,
|
||||||
o.name AS organization_name,
|
o.name AS organization_name,
|
||||||
COALESCE(o.name, t.customer_name) AS customer_name,
|
COALESCE(o.name, t.customer_name) AS customer_name,
|
||||||
@@ -1275,6 +1292,7 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon
|
|||||||
SELECT
|
SELECT
|
||||||
t.id,
|
t.id,
|
||||||
t.ticket_number,
|
t.ticket_number,
|
||||||
|
t.title,
|
||||||
t.organization_id,
|
t.organization_id,
|
||||||
o.name AS organization_name,
|
o.name AS organization_name,
|
||||||
COALESCE(o.name, t.customer_name) AS customer_name,
|
COALESCE(o.name, t.customer_name) AS customer_name,
|
||||||
@@ -1402,6 +1420,7 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon
|
|||||||
p.id::text AS id,
|
p.id::text AS id,
|
||||||
p.ticket_id::text AS ticket_id,
|
p.ticket_id::text AS ticket_id,
|
||||||
t.ticket_number,
|
t.ticket_number,
|
||||||
|
t.title,
|
||||||
t.organization_id::text AS organization_id,
|
t.organization_id::text AS organization_id,
|
||||||
o.name AS organization_name,
|
o.name AS organization_name,
|
||||||
COALESCE(o.name, t.customer_name) AS customer_name,
|
COALESCE(o.name, t.customer_name) AS customer_name,
|
||||||
@@ -3044,6 +3063,7 @@ app.get("/api/tickets/search", requireUser, async (req, res) => {
|
|||||||
SELECT
|
SELECT
|
||||||
t.id AS ticket_id,
|
t.id AS ticket_id,
|
||||||
t.ticket_number,
|
t.ticket_number,
|
||||||
|
t.title,
|
||||||
t.organization_id,
|
t.organization_id,
|
||||||
COALESCE(o.name, t.customer_name, '') AS organization_name,
|
COALESCE(o.name, t.customer_name, '') AS organization_name,
|
||||||
COALESCE(o.name, t.customer_name, '') AS customer_name,
|
COALESCE(o.name, t.customer_name, '') AS customer_name,
|
||||||
@@ -3063,6 +3083,7 @@ app.get("/api/tickets/search", requireUser, async (req, res) => {
|
|||||||
AND (
|
AND (
|
||||||
t.ticket_number ILIKE $2
|
t.ticket_number ILIKE $2
|
||||||
OR replace(t.ticket_number, 'Ticket#', '') ILIKE $3
|
OR replace(t.ticket_number, 'Ticket#', '') ILIKE $3
|
||||||
|
OR COALESCE(t.title, '') ILIKE $2
|
||||||
OR COALESCE(o.name, '') ILIKE $2
|
OR COALESCE(o.name, '') ILIKE $2
|
||||||
OR COALESCE(t.customer_name, '') ILIKE $2
|
OR COALESCE(t.customer_name, '') ILIKE $2
|
||||||
OR (s.user_id = $1 AND COALESCE(s.customer_name, '') ILIKE $2)
|
OR (s.user_id = $1 AND COALESCE(s.customer_name, '') ILIKE $2)
|
||||||
@@ -3071,7 +3092,7 @@ app.get("/api/tickets/search", requireUser, async (req, res) => {
|
|||||||
OR COALESCE(p.description, '') ILIKE $2
|
OR COALESCE(p.description, '') ILIKE $2
|
||||||
OR COALESCE(p.goals, '') ILIKE $2
|
OR COALESCE(p.goals, '') ILIKE $2
|
||||||
)
|
)
|
||||||
GROUP BY t.id, t.ticket_number, t.organization_id, o.name, t.customer_name, t.work_type, p.id, p.teamspace_project
|
GROUP BY t.id, t.ticket_number, t.title, t.organization_id, o.name, t.customer_name, t.work_type, p.id, p.teamspace_project
|
||||||
ORDER BY
|
ORDER BY
|
||||||
CASE
|
CASE
|
||||||
WHEN lower(t.ticket_number) = lower($4) THEN 0
|
WHEN lower(t.ticket_number) = lower($4) THEN 0
|
||||||
@@ -3097,6 +3118,7 @@ app.get("/api/projects", requireUser, async (req, res) => {
|
|||||||
p.id::text AS id,
|
p.id::text AS id,
|
||||||
p.ticket_id::text AS ticket_id,
|
p.ticket_id::text AS ticket_id,
|
||||||
t.ticket_number,
|
t.ticket_number,
|
||||||
|
t.title,
|
||||||
t.organization_id::text AS organization_id,
|
t.organization_id::text AS organization_id,
|
||||||
o.name AS organization_name,
|
o.name AS organization_name,
|
||||||
COALESCE(o.name, t.customer_name) AS customer_name,
|
COALESCE(o.name, t.customer_name) AS customer_name,
|
||||||
@@ -3150,6 +3172,7 @@ app.get("/api/projects/:projectId", requireUser, async (req, res) => {
|
|||||||
p.id::text AS id,
|
p.id::text AS id,
|
||||||
p.ticket_id::text AS ticket_id,
|
p.ticket_id::text AS ticket_id,
|
||||||
t.ticket_number,
|
t.ticket_number,
|
||||||
|
t.title,
|
||||||
t.organization_id::text AS organization_id,
|
t.organization_id::text AS organization_id,
|
||||||
o.name AS organization_name,
|
o.name AS organization_name,
|
||||||
COALESCE(o.name, t.customer_name) AS customer_name,
|
COALESCE(o.name, t.customer_name) AS customer_name,
|
||||||
@@ -3182,6 +3205,7 @@ app.get("/api/projects/:projectId", requireUser, async (req, res) => {
|
|||||||
app.post("/api/projects", requireUser, async (req, res) => {
|
app.post("/api/projects", requireUser, async (req, res) => {
|
||||||
const userId = currentUser(req).id;
|
const userId = currentUser(req).id;
|
||||||
const ticketNumber = parseTicketNumber(req.body.ticketNumber);
|
const ticketNumber = parseTicketNumber(req.body.ticketNumber);
|
||||||
|
const title = optionalNullableString(req.body.title);
|
||||||
const requestOrganizationId = req.body.organizationId === undefined || req.body.organizationId === "" ? null : parseOrganizationId(req.body.organizationId);
|
const requestOrganizationId = req.body.organizationId === undefined || req.body.organizationId === "" ? null : parseOrganizationId(req.body.organizationId);
|
||||||
const teamspaceProject = optionalString(req.body.teamspaceProject);
|
const teamspaceProject = optionalString(req.body.teamspaceProject);
|
||||||
const description = optionalString(req.body.description) ?? "";
|
const description = optionalString(req.body.description) ?? "";
|
||||||
@@ -3198,11 +3222,12 @@ app.post("/api/projects", requireUser, async (req, res) => {
|
|||||||
const created = await withTransaction(async (client) => {
|
const created = await withTransaction(async (client) => {
|
||||||
const existingTicket = await client.query<{
|
const existingTicket = await client.query<{
|
||||||
id: string;
|
id: string;
|
||||||
|
title: string | null;
|
||||||
organization_id: string | null;
|
organization_id: string | null;
|
||||||
customer_name: string | null;
|
customer_name: string | null;
|
||||||
}>(
|
}>(
|
||||||
`
|
`
|
||||||
SELECT id, organization_id::text AS organization_id, customer_name
|
SELECT id, title, organization_id::text AS organization_id, customer_name
|
||||||
FROM tickets
|
FROM tickets
|
||||||
WHERE ticket_number = $1
|
WHERE ticket_number = $1
|
||||||
FOR UPDATE;
|
FOR UPDATE;
|
||||||
@@ -3224,16 +3249,17 @@ app.post("/api/projects", requireUser, async (req, res) => {
|
|||||||
|
|
||||||
const ticketResult = await client.query<{ id: string }>(
|
const ticketResult = await client.query<{ id: string }>(
|
||||||
`
|
`
|
||||||
INSERT INTO tickets (ticket_number, organization_id, customer_name, work_type)
|
INSERT INTO tickets (ticket_number, title, organization_id, customer_name, work_type)
|
||||||
VALUES ($1, $2, $3, 'consulting')
|
VALUES ($1, $2, $3, $4, 'consulting')
|
||||||
ON CONFLICT (ticket_number)
|
ON CONFLICT (ticket_number)
|
||||||
DO UPDATE SET
|
DO UPDATE SET
|
||||||
|
title = COALESCE(EXCLUDED.title, tickets.title),
|
||||||
organization_id = EXCLUDED.organization_id,
|
organization_id = EXCLUDED.organization_id,
|
||||||
customer_name = EXCLUDED.customer_name,
|
customer_name = EXCLUDED.customer_name,
|
||||||
work_type = 'consulting'
|
work_type = 'consulting'
|
||||||
RETURNING id;
|
RETURNING id;
|
||||||
`,
|
`,
|
||||||
[ticketNumber, organization.id, organization.name]
|
[ticketNumber, title, organization.id, organization.name]
|
||||||
);
|
);
|
||||||
|
|
||||||
const ticketId = ticketResult.rows[0].id;
|
const ticketId = ticketResult.rows[0].id;
|
||||||
@@ -3268,6 +3294,7 @@ app.post("/api/projects", requireUser, async (req, res) => {
|
|||||||
id::text,
|
id::text,
|
||||||
ticket_id::text,
|
ticket_id::text,
|
||||||
$9::text AS ticket_number,
|
$9::text AS ticket_number,
|
||||||
|
COALESCE($12::text, $13::text) AS title,
|
||||||
$10::text AS organization_id,
|
$10::text AS organization_id,
|
||||||
$11::text AS organization_name,
|
$11::text AS organization_name,
|
||||||
$11::text AS customer_name,
|
$11::text AS customer_name,
|
||||||
@@ -3281,7 +3308,7 @@ app.post("/api/projects", requireUser, async (req, res) => {
|
|||||||
created_at,
|
created_at,
|
||||||
updated_at;
|
updated_at;
|
||||||
`,
|
`,
|
||||||
[userId, ticketId, teamspaceProject, description, goals, startsOn, endsOn, hourBudgetMinutes, ticketNumber, organization.id, organization.name]
|
[userId, ticketId, teamspaceProject, description, goals, startsOn, endsOn, hourBudgetMinutes, ticketNumber, organization.id, organization.name, title, existingTicket.rows[0]?.title ?? null]
|
||||||
);
|
);
|
||||||
|
|
||||||
return projectResult.rows[0];
|
return projectResult.rows[0];
|
||||||
@@ -3312,6 +3339,7 @@ app.patch("/api/projects/:projectId", requireUser, async (req, res) => {
|
|||||||
const projectId = parsePositiveInteger(req.params.projectId, "projectId");
|
const projectId = parsePositiveInteger(req.params.projectId, "projectId");
|
||||||
const userId = currentUser(req).id;
|
const userId = currentUser(req).id;
|
||||||
const ticketNumber = parseTicketNumber(req.body.ticketNumber);
|
const ticketNumber = parseTicketNumber(req.body.ticketNumber);
|
||||||
|
const title = optionalNullableString(req.body.title);
|
||||||
const organizationId = parseOrganizationId(req.body.organizationId);
|
const organizationId = parseOrganizationId(req.body.organizationId);
|
||||||
const teamspaceProject = optionalString(req.body.teamspaceProject);
|
const teamspaceProject = optionalString(req.body.teamspaceProject);
|
||||||
const description = optionalString(req.body.description) ?? "";
|
const description = optionalString(req.body.description) ?? "";
|
||||||
@@ -3351,12 +3379,13 @@ app.patch("/api/projects/:projectId", requireUser, async (req, res) => {
|
|||||||
`
|
`
|
||||||
UPDATE tickets
|
UPDATE tickets
|
||||||
SET ticket_number = $1,
|
SET ticket_number = $1,
|
||||||
organization_id = $2,
|
title = $2,
|
||||||
customer_name = $3,
|
organization_id = $3,
|
||||||
|
customer_name = $4,
|
||||||
work_type = 'consulting'
|
work_type = 'consulting'
|
||||||
WHERE id = $4;
|
WHERE id = $5;
|
||||||
`,
|
`,
|
||||||
[ticketNumber, organization.id, organization.name, projectResult.rows[0].ticket_id]
|
[ticketNumber, title, organization.id, organization.name, projectResult.rows[0].ticket_id]
|
||||||
);
|
);
|
||||||
|
|
||||||
await client.query(
|
await client.query(
|
||||||
@@ -3387,6 +3416,7 @@ app.patch("/api/projects/:projectId", requireUser, async (req, res) => {
|
|||||||
id::text,
|
id::text,
|
||||||
ticket_id::text,
|
ticket_id::text,
|
||||||
$9::text AS ticket_number,
|
$9::text AS ticket_number,
|
||||||
|
$12::text AS title,
|
||||||
$10::text AS organization_id,
|
$10::text AS organization_id,
|
||||||
$11::text AS organization_name,
|
$11::text AS organization_name,
|
||||||
$11::text AS customer_name,
|
$11::text AS customer_name,
|
||||||
@@ -3400,7 +3430,7 @@ app.patch("/api/projects/:projectId", requireUser, async (req, res) => {
|
|||||||
created_at,
|
created_at,
|
||||||
updated_at;
|
updated_at;
|
||||||
`,
|
`,
|
||||||
[teamspaceProject, description, goals, startsOn, endsOn, hourBudgetMinutes, projectId, userId, ticketNumber, organization.id, organization.name]
|
[teamspaceProject, description, goals, startsOn, endsOn, hourBudgetMinutes, projectId, userId, ticketNumber, organization.id, organization.name, title]
|
||||||
);
|
);
|
||||||
|
|
||||||
return result.rows[0];
|
return result.rows[0];
|
||||||
@@ -3586,6 +3616,7 @@ app.get("/api/tickets/:ticketId/details", requireUser, async (req, res) => {
|
|||||||
app.patch("/api/tickets/:ticketId", requireUser, async (req, res) => {
|
app.patch("/api/tickets/:ticketId", requireUser, async (req, res) => {
|
||||||
const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId");
|
const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId");
|
||||||
const ticketNumber = parseTrackableTicketNumber(req.body.ticketNumber);
|
const ticketNumber = parseTrackableTicketNumber(req.body.ticketNumber);
|
||||||
|
const title = optionalNullableString(req.body.title);
|
||||||
const organizationId = parseOrganizationId(req.body.organizationId);
|
const organizationId = parseOrganizationId(req.body.organizationId);
|
||||||
const workType = parseWorkType(req.body.workType);
|
const workType = parseWorkType(req.body.workType);
|
||||||
|
|
||||||
@@ -3611,13 +3642,14 @@ app.patch("/api/tickets/:ticketId", requireUser, async (req, res) => {
|
|||||||
`
|
`
|
||||||
UPDATE tickets
|
UPDATE tickets
|
||||||
SET ticket_number = $1,
|
SET ticket_number = $1,
|
||||||
organization_id = $2,
|
title = $2,
|
||||||
customer_name = $3,
|
organization_id = $3,
|
||||||
work_type = $4
|
customer_name = $4,
|
||||||
WHERE id = $5
|
work_type = $5
|
||||||
RETURNING id, ticket_number, organization_id, customer_name, work_type;
|
WHERE id = $6
|
||||||
|
RETURNING id, ticket_number, title, organization_id, customer_name, work_type;
|
||||||
`,
|
`,
|
||||||
[ticketNumber, organization.id, organization.name, effectiveWorkType, ticketId]
|
[ticketNumber, title, organization.id, organization.name, effectiveWorkType, ticketId]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (ticketResult.rowCount === 0) {
|
if (ticketResult.rowCount === 0) {
|
||||||
|
|||||||
@@ -113,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,
|
||||||
@@ -123,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(`
|
||||||
|
|||||||
@@ -400,12 +400,13 @@ 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="flex min-w-0 items-center gap-2">
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
<span className="truncate text-sm font-medium">{ticket.ticket_number}</span>
|
<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}
|
{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>
|
||||||
<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.title ? `${ticket.ticket_number} · ` : ""}
|
||||||
{ticket.project_name || ticket.organization_name || ticket.customer_name || "Keine Organisation"} · {ticket.session_count} Session(s)
|
{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)}` : ""}
|
{ticket.latest_started_at ? ` · zuletzt ${formatDateTime(ticket.latest_started_at)}` : ""}
|
||||||
</span>
|
</span>
|
||||||
@@ -892,11 +893,12 @@ 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") },
|
||||||
{ 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: "item" as const, href: "/recurring", label: "Fixe Abrechnung", icon: Repeat, active: path.startsWith("/recurring") },
|
{ type: "item" as const, href: "/recurring", label: "Fixe Abrechnung", icon: Repeat, active: path.startsWith("/recurring") },
|
||||||
{ type: "separator" as const, key: "before-profile" },
|
{ type: "separator" as const, key: "before-profile" },
|
||||||
{ type: "item" as const, href: "/profile", label: "Profil", icon: UserCog, active: path.startsWith("/profile") },
|
{ type: "item" as const, href: "/profile", label: "Profil", icon: UserCog, active: path.startsWith("/profile") },
|
||||||
|
|||||||
@@ -304,6 +304,7 @@ export function getProject(projectId: string) {
|
|||||||
|
|
||||||
export function createProject(payload: {
|
export function createProject(payload: {
|
||||||
ticketNumber: string;
|
ticketNumber: string;
|
||||||
|
title?: string | null;
|
||||||
organizationId?: string | null;
|
organizationId?: string | null;
|
||||||
teamspaceProject?: string | null;
|
teamspaceProject?: string | null;
|
||||||
description: string;
|
description: string;
|
||||||
@@ -322,6 +323,7 @@ export function updateProject(
|
|||||||
projectId: string,
|
projectId: string,
|
||||||
payload: {
|
payload: {
|
||||||
ticketNumber: string;
|
ticketNumber: string;
|
||||||
|
title?: string | null;
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
teamspaceProject?: string | null;
|
teamspaceProject?: string | null;
|
||||||
description: string;
|
description: string;
|
||||||
@@ -401,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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { toast } from "sonner";
|
|||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -38,6 +39,7 @@ export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showT
|
|||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [priority, setPriority] = useState<TodoPriority>("medium");
|
const [priority, setPriority] = useState<TodoPriority>("medium");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
const [editTitle, setEditTitle] = useState("");
|
const [editTitle, setEditTitle] = useState("");
|
||||||
const [editDescription, setEditDescription] = useState("");
|
const [editDescription, setEditDescription] = useState("");
|
||||||
@@ -57,6 +59,7 @@ export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showT
|
|||||||
setTitle("");
|
setTitle("");
|
||||||
setDescription("");
|
setDescription("");
|
||||||
setPriority("medium");
|
setPriority("medium");
|
||||||
|
setCreateOpen(false);
|
||||||
toast.success("ToDo angelegt");
|
toast.success("ToDo angelegt");
|
||||||
await onChanged();
|
await onChanged();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -119,12 +122,25 @@ export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showT
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{showCreate && ticketId ? (
|
{showCreate && ticketId ? (
|
||||||
<form className="grid gap-3 lg:grid-cols-[minmax(220px,1fr)_160px_auto] lg:items-end" onSubmit={submitTodo}>
|
<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">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium" htmlFor={`todo-title-${ticketId}`}>
|
<label className="text-sm font-medium" htmlFor={`todo-title-${ticketId}`}>
|
||||||
Aufgabe
|
Aufgabe
|
||||||
</label>
|
</label>
|
||||||
<Input id={`todo-title-${ticketId}`} value={title} onChange={(event) => setTitle(event.currentTarget.value)} required />
|
<Input id={`todo-title-${ticketId}`} value={title} onChange={(event) => setTitle(event.currentTarget.value)} autoFocus required />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium" htmlFor={`todo-priority-${ticketId}`}>
|
<label className="text-sm font-medium" htmlFor={`todo-priority-${ticketId}`}>
|
||||||
@@ -143,17 +159,27 @@ export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showT
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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}>
|
<Button type="submit" disabled={saving}>
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
{saving ? "Speichert..." : "Anlegen"}
|
{saving ? "Speichert..." : "Anlegen"}
|
||||||
</Button>
|
</Button>
|
||||||
<Textarea
|
</div>
|
||||||
className="min-h-16 lg:col-span-3"
|
|
||||||
placeholder="Details optional"
|
|
||||||
value={description}
|
|
||||||
onChange={(event) => setDescription(event.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
</form>
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ 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;
|
||||||
@@ -68,6 +69,7 @@ export type TicketMeta = {
|
|||||||
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;
|
||||||
@@ -85,6 +87,7 @@ export type ProjectSummary = {
|
|||||||
id: string;
|
id: string;
|
||||||
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;
|
||||||
@@ -125,6 +128,7 @@ export type TicketTodo = {
|
|||||||
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;
|
||||||
@@ -226,6 +230,7 @@ export type TicketPeriod = {
|
|||||||
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;
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -874,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} />
|
||||||
|
|||||||
+157
-109
@@ -1,10 +1,11 @@
|
|||||||
import { CalendarDays, FolderKanban, Play, Save, Search, Target } from "lucide-react";
|
import { CalendarDays, FolderKanban, Play, Plus, Save, Search, Target } 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 { 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";
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { HelpLink } from "@/components/HelpLink";
|
import { HelpLink } from "@/components/HelpLink";
|
||||||
@@ -22,6 +23,7 @@ type ProjectsPageProps = {
|
|||||||
|
|
||||||
type ProjectForm = {
|
type ProjectForm = {
|
||||||
ticketNumber: string;
|
ticketNumber: string;
|
||||||
|
title: string;
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
organizationName: string | null;
|
organizationName: string | null;
|
||||||
teamspaceProject: string;
|
teamspaceProject: string;
|
||||||
@@ -35,6 +37,7 @@ type ProjectForm = {
|
|||||||
function emptyProjectForm(): ProjectForm {
|
function emptyProjectForm(): ProjectForm {
|
||||||
return {
|
return {
|
||||||
ticketNumber: "",
|
ticketNumber: "",
|
||||||
|
title: "",
|
||||||
organizationId: "",
|
organizationId: "",
|
||||||
organizationName: null,
|
organizationName: null,
|
||||||
teamspaceProject: "",
|
teamspaceProject: "",
|
||||||
@@ -73,6 +76,7 @@ function parseHoursInput(value: string) {
|
|||||||
function formFromProject(project: ProjectSummary): ProjectForm {
|
function formFromProject(project: ProjectSummary): ProjectForm {
|
||||||
return {
|
return {
|
||||||
ticketNumber: project.ticket_number,
|
ticketNumber: project.ticket_number,
|
||||||
|
title: project.title ?? "",
|
||||||
organizationId: project.organization_id ?? "",
|
organizationId: project.organization_id ?? "",
|
||||||
organizationName: project.organization_name ?? project.customer_name ?? null,
|
organizationName: project.organization_name ?? project.customer_name ?? null,
|
||||||
teamspaceProject: project.teamspace_project ?? "",
|
teamspaceProject: project.teamspace_project ?? "",
|
||||||
@@ -84,6 +88,120 @@ function formFromProject(project: ProjectSummary): ProjectForm {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
||||||
const [projects, setProjects] = useState<ProjectSummary[]>([]);
|
const [projects, setProjects] = useState<ProjectSummary[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -91,6 +209,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
|||||||
const [lookupStatus, setLookupStatus] = useState<"idle" | "loading" | "found" | "not-found">("idle");
|
const [lookupStatus, setLookupStatus] = useState<"idle" | "loading" | "found" | "not-found">("idle");
|
||||||
const [lookupResult, setLookupResult] = useState<TicketMeta | null>(null);
|
const [lookupResult, setLookupResult] = useState<TicketMeta | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [editingProjectId, setEditingProjectId] = useState<string | null>(null);
|
const [editingProjectId, setEditingProjectId] = useState<string | null>(null);
|
||||||
const [editForm, setEditForm] = useState<ProjectForm>(() => emptyProjectForm());
|
const [editForm, setEditForm] = useState<ProjectForm>(() => emptyProjectForm());
|
||||||
const [savingProjectId, setSavingProjectId] = useState<string | null>(null);
|
const [savingProjectId, setSavingProjectId] = useState<string | null>(null);
|
||||||
@@ -141,6 +260,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
|||||||
current.ticketNumber.trim() === normalizedTicketNumber
|
current.ticketNumber.trim() === normalizedTicketNumber
|
||||||
? {
|
? {
|
||||||
...current,
|
...current,
|
||||||
|
title: current.title || result.ticket?.title || "",
|
||||||
organizationId: result.ticket?.organization_id ?? "",
|
organizationId: result.ticket?.organization_id ?? "",
|
||||||
organizationName: result.ticket?.organization_name ?? result.ticket?.customer_name ?? null
|
organizationName: result.ticket?.organization_name ?? result.ticket?.customer_name ?? null
|
||||||
}
|
}
|
||||||
@@ -197,6 +317,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
|||||||
try {
|
try {
|
||||||
const result = await createProject({
|
const result = await createProject({
|
||||||
ticketNumber,
|
ticketNumber,
|
||||||
|
title: form.title || null,
|
||||||
organizationId: form.organizationId,
|
organizationId: form.organizationId,
|
||||||
teamspaceProject: form.teamspaceProject,
|
teamspaceProject: form.teamspaceProject,
|
||||||
description: form.description,
|
description: form.description,
|
||||||
@@ -209,6 +330,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
|||||||
description: `${result.project.ticket_number} ist jetzt als Consulting-Projekt markiert.`
|
description: `${result.project.ticket_number} ist jetzt als Consulting-Projekt markiert.`
|
||||||
});
|
});
|
||||||
setForm(emptyProjectForm());
|
setForm(emptyProjectForm());
|
||||||
|
setCreateOpen(false);
|
||||||
setLookupResult(null);
|
setLookupResult(null);
|
||||||
setLookupStatus("idle");
|
setLookupStatus("idle");
|
||||||
await load();
|
await load();
|
||||||
@@ -238,6 +360,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
|||||||
try {
|
try {
|
||||||
await updateProject(project.id, {
|
await updateProject(project.id, {
|
||||||
ticketNumber: editForm.ticketNumber,
|
ticketNumber: editForm.ticketNumber,
|
||||||
|
title: editForm.title || null,
|
||||||
organizationId: editForm.organizationId,
|
organizationId: editForm.organizationId,
|
||||||
teamspaceProject: editForm.teamspaceProject,
|
teamspaceProject: editForm.teamspaceProject,
|
||||||
description: editForm.description,
|
description: editForm.description,
|
||||||
@@ -265,6 +388,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
|||||||
<h2 className="text-xl font-semibold tracking-normal sm:text-2xl">Projekte</h2>
|
<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>
|
<p className="text-sm text-muted-foreground">Consulting-Projekte als Erweiterung bestehender oder neuer Tickets.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<div className="grid gap-2 text-sm sm:grid-cols-3">
|
<div className="grid gap-2 text-sm sm:grid-cols-3">
|
||||||
<div className="rounded-md border bg-muted/20 px-3 py-2">
|
<div className="rounded-md border bg-muted/20 px-3 py-2">
|
||||||
<span className="block text-muted-foreground">Projekte</span>
|
<span className="block text-muted-foreground">Projekte</span>
|
||||||
@@ -279,111 +403,32 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
|||||||
<span className="font-semibold">{openTodos}</span>
|
<span className="font-semibold">{openTodos}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
<Card>
|
<Button type="button">
|
||||||
<CardHeader className="p-4">
|
<Plus className="size-4" />
|
||||||
<div className="flex items-start justify-between gap-3">
|
Projekt anlegen
|
||||||
<div>
|
</Button>
|
||||||
<CardTitle>Projekt anlegen</CardTitle>
|
</DialogTrigger>
|
||||||
<CardDescription>Ticketnummer eingeben. Vorhandene Ticketdaten werden automatisch übernommen.</CardDescription>
|
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-3xl">
|
||||||
</div>
|
<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" />
|
<HelpLink anchor="projekte" label="Hilfe zu Projekten" />
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="px-4 pb-4">
|
|
||||||
<form className="space-y-4" onSubmit={submitProject}>
|
|
||||||
<div className="grid gap-3 lg:grid-cols-[180px_minmax(240px,1fr)_220px]">
|
|
||||||
<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. Organisation wird übernommen.</p> : null}
|
|
||||||
{lookupStatus === "not-found" ? <p className="text-xs text-muted-foreground">Neues Ticket. Organisation bitte wählen.</p> : null}
|
|
||||||
</div>
|
</div>
|
||||||
<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">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(220px,1fr)_140px_140px_160px]">
|
|
||||||
<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 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..." : "Projekt anlegen"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="grid gap-3 xl:grid-cols-2">
|
<div className="grid gap-3 xl:grid-cols-2">
|
||||||
{projects.map((project) => {
|
{projects.map((project) => {
|
||||||
@@ -399,10 +444,12 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
|||||||
<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 flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<CardTitle className="truncate text-base">{project.ticket_number}</CardTitle>
|
<CardTitle className="truncate text-base">{project.title || project.ticket_number}</CardTitle>
|
||||||
<Badge variant="outline">Projekt</Badge>
|
<Badge variant="outline">Projekt</Badge>
|
||||||
</div>
|
</div>
|
||||||
<CardDescription className="truncate">{project.teamspace_project || project.organization_name || "Ohne Teamspace-Projekt"}</CardDescription>
|
<CardDescription className="truncate">
|
||||||
|
{project.ticket_number} · {project.teamspace_project || project.organization_name || "Ohne Teamspace-Projekt"}
|
||||||
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 gap-1">
|
<div className="flex shrink-0 gap-1">
|
||||||
<Button type="button" size="sm" variant="secondary" onClick={() => void onStartTimer(project.ticket_number)}>
|
<Button type="button" size="sm" variant="secondary" onClick={() => void onStartTimer(project.ticket_number)}>
|
||||||
@@ -429,6 +476,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
|||||||
/>
|
/>
|
||||||
<div className="flex h-9 items-center rounded-lg border bg-muted/30 px-3 text-sm">Consulting</div>
|
<div className="flex h-9 items-center rounded-lg border bg-muted/30 px-3 text-sm">Consulting</div>
|
||||||
</div>
|
</div>
|
||||||
|
<Input placeholder="Titel" value={editForm.title} onChange={(event) => setEditForm({ ...editForm, title: event.currentTarget.value })} />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Teamspace-Projekt"
|
placeholder="Teamspace-Projekt"
|
||||||
value={editForm.teamspaceProject}
|
value={editForm.teamspaceProject}
|
||||||
@@ -454,6 +502,10 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="grid gap-2 text-sm sm:grid-cols-3">
|
<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">
|
<div className="rounded-md border bg-background p-2">
|
||||||
<span className="block text-xs text-muted-foreground">Organisation</span>
|
<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>
|
<span className="line-clamp-1 font-medium">{project.organization_name ?? project.customer_name ?? "Keine Organisation"}</span>
|
||||||
@@ -462,10 +514,6 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
|||||||
<span className="block text-xs text-muted-foreground">Zeitraum</span>
|
<span className="block text-xs text-muted-foreground">Zeitraum</span>
|
||||||
<span className="font-medium">{project.starts_on || "offen"} - {project.ends_on || "offen"}</span>
|
<span className="font-medium">{project.starts_on || "offen"} - {project.ends_on || "offen"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-md border bg-background p-2">
|
|
||||||
<span className="block text-xs text-muted-foreground">Kontingent</span>
|
|
||||||
<span className="font-medium">{project.hour_budget_minutes ? formatMinutes(project.hour_budget_minutes) : "offen"}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{usedPercent !== null ? (
|
{usedPercent !== null ? (
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { ArrowLeft, ClipboardList, FolderKanban, Pencil, Save, Target, 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";
|
||||||
@@ -13,6 +14,7 @@ import { HelpLink } from "@/components/HelpLink";
|
|||||||
import { OrganizationSelect } from "@/components/OrganizationSelect";
|
import { OrganizationSelect } from "@/components/OrganizationSelect";
|
||||||
import { TodoPanel } from "@/components/TodoPanel";
|
import { TodoPanel } from "@/components/TodoPanel";
|
||||||
import {
|
import {
|
||||||
|
createProject,
|
||||||
createSession,
|
createSession,
|
||||||
deleteSession,
|
deleteSession,
|
||||||
getTicketDetails,
|
getTicketDetails,
|
||||||
@@ -159,6 +161,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
|||||||
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");
|
||||||
@@ -178,6 +181,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
|||||||
const [globalMonthFilter, setGlobalMonthFilter] = useState(initialGlobalMonthFilter);
|
const [globalMonthFilter, setGlobalMonthFilter] = useState(initialGlobalMonthFilter);
|
||||||
const [globalStatusFilter, setGlobalStatusFilter] = useState<"all" | "open" | "billed" | "non_billable">("all");
|
const [globalStatusFilter, setGlobalStatusFilter] = useState<"all" | "open" | "billed" | "non_billable">("all");
|
||||||
const [globalSourceFilter, setGlobalSourceFilter] = useState<"all" | "manual" | "recurring">("all");
|
const [globalSourceFilter, setGlobalSourceFilter] = useState<"all" | "manual" | "recurring">("all");
|
||||||
|
const [creatingProject, setCreatingProject] = useState(false);
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
@@ -220,6 +224,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");
|
||||||
@@ -286,6 +291,7 @@ 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"))
|
||||||
);
|
);
|
||||||
@@ -387,6 +393,7 @@ 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: isProject ? "consulting" : editWorkType
|
workType: isProject ? "consulting" : editWorkType
|
||||||
});
|
});
|
||||||
@@ -403,6 +410,44 @@ 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function submitTicketSession(event: FormEvent) {
|
async function submitTicketSession(event: FormEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
@@ -563,17 +608,29 @@ 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}
|
||||||
|
{isProject ? <Badge variant="outline">Projekt</Badge> : null}
|
||||||
{(periodType === "month" || (isGlobalTicket && globalMonthFilter !== "all")) && data?.closed ? <Badge variant="outline">Monat geschlossen</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">Sessions {sessionsScopeLabel}</p>
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{ticket?.title ? `${ticket.ticket_number} · ` : ""}
|
||||||
|
Sessions {sessionsScopeLabel}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{!isProject && ticket ? (
|
||||||
|
<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"}>
|
<Badge variant={data?.openCount === 0 ? "success" : "warning"}>
|
||||||
{data?.openCount === 0 ? "Bewertet" : `${data?.openCount ?? 0} offen`}
|
{data?.openCount === 0 ? "Bewertet" : `${data?.openCount ?? 0} offen`}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{(periodType === "month" || (isGlobalTicket && globalMonthFilter !== "all")) && 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">
|
||||||
@@ -647,11 +704,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 ? (
|
||||||
@@ -703,29 +764,26 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
|||||||
<HelpLink anchor="projekte" label="Hilfe zu Projekten" />
|
<HelpLink anchor="projekte" label="Hilfe zu Projekten" />
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid gap-3 px-4 pb-4 lg:grid-cols-[1fr_1fr_220px]">
|
<CardContent className="space-y-3 px-4 pb-4">
|
||||||
<div className="rounded-md border bg-muted/20 p-3">
|
<div className="flex flex-wrap gap-2 text-sm">
|
||||||
<div className="mb-1 flex items-center gap-2 text-sm font-medium">
|
<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" />
|
<FolderKanban className="size-4 text-muted-foreground" />
|
||||||
Beschreibung
|
Beschreibung
|
||||||
</div>
|
</div>
|
||||||
<p className="whitespace-pre-wrap text-sm text-muted-foreground">{project.description || "Noch keine Beschreibung."}</p>
|
<p className="line-clamp-3 whitespace-pre-wrap text-muted-foreground">{project.description || "Noch keine Beschreibung."}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-md border bg-muted/20 p-3">
|
<div className="min-w-0">
|
||||||
<div className="mb-1 flex items-center gap-2 text-sm font-medium">
|
<div className="mb-1 flex items-center gap-2 font-medium">
|
||||||
<Target className="size-4 text-muted-foreground" />
|
<Target className="size-4 text-muted-foreground" />
|
||||||
Ziele
|
Ziele
|
||||||
</div>
|
</div>
|
||||||
<p className="whitespace-pre-wrap text-sm text-muted-foreground">{project.goals || "Noch keine Ziele."}</p>
|
<p className="line-clamp-3 whitespace-pre-wrap text-muted-foreground">{project.goals || "Noch keine Ziele."}</p>
|
||||||
</div>
|
|
||||||
<div className="space-y-2 rounded-md border bg-muted/20 p-3 text-sm">
|
|
||||||
<div>
|
|
||||||
<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>
|
|
||||||
<span className="block text-xs text-muted-foreground">Kontingent</span>
|
|
||||||
<span className="font-medium">{project.hour_budget_minutes ? formatMinutes(project.hour_budget_minutes) : "offen"}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -769,16 +827,20 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
|||||||
</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="min-w-0 text-left">
|
||||||
|
<p className="truncate font-medium">{group.label}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{group.sessions.length} Session(s)</p>
|
||||||
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Badge variant="outline">Tag abgerechnet {formatMinutes(group.totalMinutes)}</Badge>
|
<Badge variant="outline">Abgerechnet {formatMinutes(group.totalMinutes)}</Badge>
|
||||||
{group.nonBillableMinutes > 0 ? <Badge variant="secondary">Nicht {formatMinutes(group.nonBillableMinutes)}</Badge> : null}
|
{group.nonBillableMinutes > 0 ? <Badge variant="secondary">Nicht {formatMinutes(group.nonBillableMinutes)}</Badge> : null}
|
||||||
{group.openMinutes > 0 ? <Badge variant="warning">Offen {formatMinutes(group.openMinutes)}</Badge> : null}
|
{group.openMinutes > 0 ? <Badge variant="warning">Offen {formatMinutes(group.openMinutes)}</Badge> : null}
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
<div className="flex items-center gap-2 text-xs text-muted-foreground" onClick={(event) => event.stopPropagation()}>
|
||||||
<label htmlFor={`crm-billing-${group.key}`}>Teamspace</label>
|
<label htmlFor={`crm-billing-${group.key}`}>Teamspace</label>
|
||||||
<Input
|
<Input
|
||||||
id={`crm-billing-${group.key}`}
|
id={`crm-billing-${group.key}`}
|
||||||
@@ -822,7 +884,9 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5 p-2 sm:p-3">
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="pb-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
{group.sessions.map((session) => (
|
{group.sessions.map((session) => (
|
||||||
<div key={session.id} className="ml-3 rounded-md border bg-card/40 p-2 sm:ml-6">
|
<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="grid gap-2 lg:grid-cols-[140px_minmax(0,1fr)_90px_auto_auto] lg:items-center">
|
||||||
@@ -862,9 +926,10 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
|||||||
</div>
|
</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 Filter.</p>
|
<p className="py-8 text-center text-sm text-muted-foreground">Keine Sessions für dieses Ticket im gewählten Filter.</p>
|
||||||
|
|||||||
Reference in new issue
Block a user