Compare commits
2
Commits
4390f9a7da
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a760f423a4 | ||
|
|
dcc115a82c |
+94
-38
@@ -132,17 +132,17 @@ async function setAppSetting(key: string, value: string) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getOrganizationById(
|
async function getOrganizationByZammadId(
|
||||||
client: { query: (text: string, params?: unknown[]) => Promise<{ rows: OrganizationRow[]; rowCount: number | null }> },
|
client: { query: (text: string, params?: unknown[]) => Promise<{ rows: OrganizationRow[]; rowCount: number | null }> },
|
||||||
organizationId: number
|
zammadId: number
|
||||||
) {
|
) {
|
||||||
const result = await client.query(
|
const result = await client.query(
|
||||||
`
|
`
|
||||||
SELECT id, zammad_id, name, synced_at
|
SELECT zammad_id::text AS id, zammad_id::text AS zammad_id, name, synced_at
|
||||||
FROM organizations
|
FROM organizations
|
||||||
WHERE id = $1;
|
WHERE zammad_id = $1;
|
||||||
`,
|
`,
|
||||||
[organizationId]
|
[zammadId]
|
||||||
);
|
);
|
||||||
|
|
||||||
return result.rows[0] ?? null;
|
return result.rows[0] ?? null;
|
||||||
@@ -332,7 +332,7 @@ async function ensureRecurringSessionsForUserPeriod(userId: string, startIso: st
|
|||||||
rb.valid_until::text,
|
rb.valid_until::text,
|
||||||
rb.start_time::text
|
rb.start_time::text
|
||||||
FROM recurring_billings rb
|
FROM recurring_billings rb
|
||||||
JOIN organizations o ON o.id = rb.organization_id
|
JOIN organizations o ON o.zammad_id = rb.organization_id
|
||||||
WHERE rb.user_id = $1
|
WHERE rb.user_id = $1
|
||||||
AND rb.valid_from < $3::date
|
AND rb.valid_from < $3::date
|
||||||
AND (rb.valid_until IS NULL OR rb.valid_until >= $2::date);
|
AND (rb.valid_until IS NULL OR rb.valid_until >= $2::date);
|
||||||
@@ -558,7 +558,7 @@ async function getPeriodOverview(config: PeriodConfig, period: ParsedPeriod, use
|
|||||||
NULL::timestamptz AS closed_at
|
NULL::timestamptz AS closed_at
|
||||||
FROM tickets t
|
FROM tickets t
|
||||||
JOIN sessions s ON s.ticket_id = t.id
|
JOIN sessions s ON s.ticket_id = t.id
|
||||||
LEFT JOIN organizations o ON o.id = t.organization_id
|
LEFT JOIN organizations o ON o.zammad_id = t.organization_id
|
||||||
WHERE s.started_at >= $1::timestamptz AND s.started_at < $2::timestamptz
|
WHERE s.started_at >= $1::timestamptz AND s.started_at < $2::timestamptz
|
||||||
AND s.user_id = $3
|
AND s.user_id = $3
|
||||||
GROUP BY
|
GROUP BY
|
||||||
@@ -587,7 +587,7 @@ async function getPeriodOverview(config: PeriodConfig, period: ParsedPeriod, use
|
|||||||
s.rounded_minutes
|
s.rounded_minutes
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
JOIN tickets t ON t.id = s.ticket_id
|
JOIN tickets t ON t.id = s.ticket_id
|
||||||
LEFT JOIN organizations so ON so.id = s.organization_id
|
LEFT JOIN organizations so ON so.zammad_id = s.organization_id
|
||||||
WHERE s.started_at >= $1::timestamptz
|
WHERE s.started_at >= $1::timestamptz
|
||||||
AND s.started_at < $2::timestamptz
|
AND s.started_at < $2::timestamptz
|
||||||
AND s.user_id = $3
|
AND s.user_id = $3
|
||||||
@@ -681,8 +681,8 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
|
|||||||
s.recurring_billing_id
|
s.recurring_billing_id
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
JOIN tickets t ON t.id = s.ticket_id
|
JOIN tickets t ON t.id = s.ticket_id
|
||||||
LEFT JOIN organizations so ON so.id = s.organization_id
|
LEFT JOIN organizations so ON so.zammad_id = s.organization_id
|
||||||
LEFT JOIN organizations ot ON ot.id = t.organization_id
|
LEFT JOIN organizations ot ON ot.zammad_id = t.organization_id
|
||||||
WHERE s.started_at >= $1::timestamptz
|
WHERE s.started_at >= $1::timestamptz
|
||||||
AND s.started_at < $2::timestamptz
|
AND s.started_at < $2::timestamptz
|
||||||
AND s.user_id = $3
|
AND s.user_id = $3
|
||||||
@@ -904,8 +904,8 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
|
|||||||
s.rounded_minutes
|
s.rounded_minutes
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
JOIN tickets t ON t.id = s.ticket_id
|
JOIN tickets t ON t.id = s.ticket_id
|
||||||
LEFT JOIN organizations so ON so.id = s.organization_id
|
LEFT JOIN organizations so ON so.zammad_id = s.organization_id
|
||||||
LEFT JOIN organizations o ON o.id = t.organization_id
|
LEFT JOIN organizations o ON o.zammad_id = t.organization_id
|
||||||
WHERE s.started_at >= $1::timestamptz
|
WHERE s.started_at >= $1::timestamptz
|
||||||
AND s.started_at < $2::timestamptz
|
AND s.started_at < $2::timestamptz
|
||||||
AND s.user_id = $3
|
AND s.user_id = $3
|
||||||
@@ -1066,7 +1066,7 @@ async function getPeriodTicket(config: PeriodConfig, period: ParsedPeriod, ticke
|
|||||||
COALESCE(period_sessions.open_count, 0)::int AS ticket_open_count,
|
COALESCE(period_sessions.open_count, 0)::int AS ticket_open_count,
|
||||||
COALESCE(period_sessions.manual_session_count, 0)::int AS ticket_manual_session_count
|
COALESCE(period_sessions.manual_session_count, 0)::int AS ticket_manual_session_count
|
||||||
FROM tickets t
|
FROM tickets t
|
||||||
LEFT JOIN organizations o ON o.id = t.organization_id
|
LEFT JOIN organizations o ON o.zammad_id = t.organization_id
|
||||||
${periodClosureJoin}
|
${periodClosureJoin}
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT
|
SELECT
|
||||||
@@ -1096,13 +1096,13 @@ async function getPeriodTicket(config: PeriodConfig, period: ParsedPeriod, ticke
|
|||||||
(
|
(
|
||||||
SELECT name
|
SELECT name
|
||||||
FROM organizations
|
FROM organizations
|
||||||
WHERE id = sessions.organization_id
|
WHERE zammad_id = sessions.organization_id
|
||||||
) AS organization_name,
|
) AS organization_name,
|
||||||
COALESCE(
|
COALESCE(
|
||||||
(
|
(
|
||||||
SELECT name
|
SELECT name
|
||||||
FROM organizations
|
FROM organizations
|
||||||
WHERE id = sessions.organization_id
|
WHERE zammad_id = sessions.organization_id
|
||||||
),
|
),
|
||||||
customer_name
|
customer_name
|
||||||
) AS customer_name,
|
) AS customer_name,
|
||||||
@@ -1473,7 +1473,7 @@ app.get("/api/organizations", async (req, res) => {
|
|||||||
|
|
||||||
const result = await query<OrganizationRow>(
|
const result = await query<OrganizationRow>(
|
||||||
`
|
`
|
||||||
SELECT id, zammad_id, name, synced_at
|
SELECT zammad_id::text AS id, zammad_id::text AS zammad_id, name, synced_at
|
||||||
FROM organizations
|
FROM organizations
|
||||||
${where.length > 0 ? `WHERE ${where.join(" AND ")}` : ""}
|
${where.length > 0 ? `WHERE ${where.join(" AND ")}` : ""}
|
||||||
ORDER BY lower(name) ASC
|
ORDER BY lower(name) ASC
|
||||||
@@ -1596,30 +1596,30 @@ app.post("/api/admin/zammad/organizations/sync", requireAdmin, async (req, res)
|
|||||||
{
|
{
|
||||||
const staleResult =
|
const staleResult =
|
||||||
zammadIds.length > 0
|
zammadIds.length > 0
|
||||||
? await client.query<{ id: string }>(
|
? await client.query<{ zammad_id: string }>(
|
||||||
`
|
`
|
||||||
SELECT id
|
SELECT zammad_id::text AS zammad_id
|
||||||
FROM organizations
|
FROM organizations
|
||||||
WHERE NOT (zammad_id = ANY($1::bigint[]));
|
WHERE NOT (zammad_id = ANY($1::bigint[]));
|
||||||
`,
|
`,
|
||||||
[zammadIds]
|
[zammadIds]
|
||||||
)
|
)
|
||||||
: await client.query<{ id: string }>(
|
: await client.query<{ zammad_id: string }>(
|
||||||
`
|
`
|
||||||
SELECT id
|
SELECT zammad_id::text AS zammad_id
|
||||||
FROM organizations;
|
FROM organizations;
|
||||||
`
|
`
|
||||||
);
|
);
|
||||||
const staleIds = staleResult.rows.map((row) => row.id);
|
const staleZammadIds = staleResult.rows.map((row) => row.zammad_id);
|
||||||
|
|
||||||
if (staleIds.length > 0) {
|
if (staleZammadIds.length > 0) {
|
||||||
const ticketResult = await client.query(
|
const ticketResult = await client.query(
|
||||||
`
|
`
|
||||||
UPDATE tickets
|
UPDATE tickets
|
||||||
SET organization_id = NULL
|
SET organization_id = NULL
|
||||||
WHERE organization_id = ANY($1::bigint[]);
|
WHERE organization_id = ANY($1::bigint[]);
|
||||||
`,
|
`,
|
||||||
[staleIds]
|
[staleZammadIds]
|
||||||
);
|
);
|
||||||
const sessionResult = await client.query(
|
const sessionResult = await client.query(
|
||||||
`
|
`
|
||||||
@@ -1627,14 +1627,14 @@ app.post("/api/admin/zammad/organizations/sync", requireAdmin, async (req, res)
|
|||||||
SET organization_id = NULL
|
SET organization_id = NULL
|
||||||
WHERE organization_id = ANY($1::bigint[]);
|
WHERE organization_id = ANY($1::bigint[]);
|
||||||
`,
|
`,
|
||||||
[staleIds]
|
[staleZammadIds]
|
||||||
);
|
);
|
||||||
const deleteResult = await client.query(
|
const deleteResult = await client.query(
|
||||||
`
|
`
|
||||||
DELETE FROM organizations
|
DELETE FROM organizations
|
||||||
WHERE id = ANY($1::bigint[]);
|
WHERE zammad_id = ANY($1::bigint[]);
|
||||||
`,
|
`,
|
||||||
[staleIds]
|
[staleZammadIds]
|
||||||
);
|
);
|
||||||
|
|
||||||
unlinkedTickets = ticketResult.rowCount ?? 0;
|
unlinkedTickets = ticketResult.rowCount ?? 0;
|
||||||
@@ -1980,8 +1980,8 @@ app.get("/api/export/months/:month", requireUser, async (req, res) => {
|
|||||||
s.activity
|
s.activity
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
JOIN tickets t ON t.id = s.ticket_id
|
JOIN tickets t ON t.id = s.ticket_id
|
||||||
LEFT JOIN organizations so ON so.id = s.organization_id
|
LEFT JOIN organizations so ON so.zammad_id = s.organization_id
|
||||||
LEFT JOIN organizations ot ON ot.id = t.organization_id
|
LEFT JOIN organizations ot ON ot.zammad_id = t.organization_id
|
||||||
LEFT JOIN ticket_day_billings tdb
|
LEFT JOIN ticket_day_billings tdb
|
||||||
ON tdb.ticket_id = s.ticket_id
|
ON tdb.ticket_id = s.ticket_id
|
||||||
AND tdb.user_id = s.user_id
|
AND tdb.user_id = s.user_id
|
||||||
@@ -2149,7 +2149,7 @@ async function recurringBillingResponse(userId: string) {
|
|||||||
) AS slots
|
) AS slots
|
||||||
FROM recurring_billings rb
|
FROM recurring_billings rb
|
||||||
JOIN users owner ON owner.id = rb.user_id
|
JOIN users owner ON owner.id = rb.user_id
|
||||||
JOIN organizations o ON o.id = rb.organization_id
|
JOIN organizations o ON o.zammad_id = rb.organization_id
|
||||||
LEFT JOIN recurring_billing_slots rbs ON rbs.recurring_billing_id = rb.id
|
LEFT JOIN recurring_billing_slots rbs ON rbs.recurring_billing_id = rb.id
|
||||||
WHERE rb.user_id = $1
|
WHERE rb.user_id = $1
|
||||||
GROUP BY rb.id, owner.username, owner.display_name, o.name
|
GROUP BY rb.id, owner.username, owner.display_name, o.name
|
||||||
@@ -2170,7 +2170,7 @@ app.post("/api/recurring-billings", requireUser, async (req, res) => {
|
|||||||
const payload = parseRecurringBillingPayload(req.body);
|
const payload = parseRecurringBillingPayload(req.body);
|
||||||
|
|
||||||
const created = await withTransaction(async (client) => {
|
const created = await withTransaction(async (client) => {
|
||||||
const organization = await getOrganizationById(client, payload.organizationId);
|
const organization = await getOrganizationByZammadId(client, payload.organizationId);
|
||||||
|
|
||||||
if (!organization) {
|
if (!organization) {
|
||||||
return "organization-not-found" as const;
|
return "organization-not-found" as const;
|
||||||
@@ -2272,9 +2272,9 @@ app.patch("/api/recurring-billings/:billingId", requireUser, async (req, res) =>
|
|||||||
`
|
`
|
||||||
SELECT id
|
SELECT id
|
||||||
FROM recurring_billings
|
FROM recurring_billings
|
||||||
WHERE s.id = $1
|
WHERE id = $1
|
||||||
AND s.user_id = $2
|
AND user_id = $2
|
||||||
FOR UPDATE OF s;
|
FOR UPDATE;
|
||||||
`,
|
`,
|
||||||
[billingId, userId]
|
[billingId, userId]
|
||||||
);
|
);
|
||||||
@@ -2283,7 +2283,7 @@ app.patch("/api/recurring-billings/:billingId", requireUser, async (req, res) =>
|
|||||||
return { status: "not-found" as const };
|
return { status: "not-found" as const };
|
||||||
}
|
}
|
||||||
|
|
||||||
const organization = await getOrganizationById(client, payload.organizationId);
|
const organization = await getOrganizationByZammadId(client, payload.organizationId);
|
||||||
|
|
||||||
if (!organization) {
|
if (!organization) {
|
||||||
return { status: "organization-not-found" as const };
|
return { status: "organization-not-found" as const };
|
||||||
@@ -2579,7 +2579,7 @@ app.post("/api/sessions", requireUser, async (req, res) => {
|
|||||||
throw badRequest("organizationId is required");
|
throw badRequest("organizationId is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
const organization = await getOrganizationById(client, effectiveOrganizationId);
|
const organization = await getOrganizationByZammadId(client, effectiveOrganizationId);
|
||||||
|
|
||||||
if (!organization) {
|
if (!organization) {
|
||||||
throw badRequest("organizationId must reference an existing organization");
|
throw badRequest("organizationId must reference an existing organization");
|
||||||
@@ -2670,7 +2670,7 @@ app.get("/api/tickets/lookup", requireUser, async (req, res) => {
|
|||||||
COALESCE(o.name, t.customer_name) AS customer_name,
|
COALESCE(o.name, t.customer_name) AS customer_name,
|
||||||
t.work_type
|
t.work_type
|
||||||
FROM tickets t
|
FROM tickets t
|
||||||
LEFT JOIN organizations o ON o.id = t.organization_id
|
LEFT JOIN organizations o ON o.zammad_id = t.organization_id
|
||||||
WHERE t.ticket_number = $1;
|
WHERE t.ticket_number = $1;
|
||||||
`,
|
`,
|
||||||
[ticketNumber]
|
[ticketNumber]
|
||||||
@@ -2681,6 +2681,62 @@ app.get("/api/tickets/lookup", requireUser, async (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get("/api/tickets/search", requireUser, async (req, res) => {
|
||||||
|
const user = currentUser(req);
|
||||||
|
const rawQuery = typeof req.query.q === "string" ? req.query.q.trim() : "";
|
||||||
|
|
||||||
|
if (rawQuery.length < 2) {
|
||||||
|
res.json({ tickets: [] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedTicketQuery = rawQuery.replace(/^ticket#/iu, "").trim();
|
||||||
|
const searchPattern = `%${rawQuery}%`;
|
||||||
|
const ticketNumberPattern = `%${normalizedTicketQuery || rawQuery}%`;
|
||||||
|
|
||||||
|
const result = await query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
t.id AS ticket_id,
|
||||||
|
t.ticket_number,
|
||||||
|
t.organization_id,
|
||||||
|
COALESCE(o.name, t.customer_name, '') AS organization_name,
|
||||||
|
COALESCE(o.name, t.customer_name, '') AS customer_name,
|
||||||
|
t.work_type,
|
||||||
|
MAX(s.started_at) AS latest_started_at,
|
||||||
|
to_char(MAX(s.started_at) AT TIME ZONE 'Europe/Berlin', 'YYYY-MM') AS latest_period,
|
||||||
|
COUNT(s.id)::int AS session_count,
|
||||||
|
COALESCE(SUM(s.rounded_minutes), 0)::int AS total_minutes
|
||||||
|
FROM tickets t
|
||||||
|
JOIN sessions s ON s.ticket_id = t.id
|
||||||
|
LEFT JOIN organizations o ON o.zammad_id = t.organization_id
|
||||||
|
WHERE s.user_id = $1
|
||||||
|
AND (
|
||||||
|
t.ticket_number ILIKE $2
|
||||||
|
OR replace(t.ticket_number, 'Ticket#', '') ILIKE $3
|
||||||
|
OR COALESCE(o.name, '') ILIKE $2
|
||||||
|
OR COALESCE(t.customer_name, '') ILIKE $2
|
||||||
|
OR COALESCE(s.customer_name, '') ILIKE $2
|
||||||
|
OR s.activity ILIKE $2
|
||||||
|
)
|
||||||
|
GROUP BY t.id, t.ticket_number, t.organization_id, o.name, t.customer_name, t.work_type
|
||||||
|
ORDER BY
|
||||||
|
CASE
|
||||||
|
WHEN lower(t.ticket_number) = lower($4) THEN 0
|
||||||
|
WHEN lower(replace(t.ticket_number, 'Ticket#', '')) = lower($5) THEN 1
|
||||||
|
WHEN t.ticket_number ILIKE $3 THEN 2
|
||||||
|
ELSE 3
|
||||||
|
END,
|
||||||
|
MAX(s.started_at) DESC,
|
||||||
|
t.ticket_number ASC
|
||||||
|
LIMIT 12;
|
||||||
|
`,
|
||||||
|
[user.id, searchPattern, ticketNumberPattern, rawQuery, normalizedTicketQuery]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ tickets: result.rows });
|
||||||
|
});
|
||||||
|
|
||||||
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);
|
||||||
@@ -2689,7 +2745,7 @@ app.patch("/api/tickets/:ticketId", requireUser, async (req, res) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await withTransaction(async (client) => {
|
const result = await withTransaction(async (client) => {
|
||||||
const organization = await getOrganizationById(client, organizationId);
|
const organization = await getOrganizationByZammadId(client, organizationId);
|
||||||
|
|
||||||
if (!organization) {
|
if (!organization) {
|
||||||
return "organization-not-found" as const;
|
return "organization-not-found" as const;
|
||||||
@@ -3176,7 +3232,7 @@ app.patch("/api/sessions/:sessionId/details", requireUser, async (req, res) => {
|
|||||||
const roundedMinutes = Math.max(1, Math.round(durationSeconds / 60));
|
const roundedMinutes = Math.max(1, Math.round(durationSeconds / 60));
|
||||||
|
|
||||||
const updated = await withTransaction(async (client) => {
|
const updated = await withTransaction(async (client) => {
|
||||||
const organization = await getOrganizationById(client, organizationId);
|
const organization = await getOrganizationByZammadId(client, organizationId);
|
||||||
|
|
||||||
if (!organization) {
|
if (!organization) {
|
||||||
return "organization-not-found" as const;
|
return "organization-not-found" as const;
|
||||||
|
|||||||
@@ -97,7 +97,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,
|
||||||
organization_id BIGINT REFERENCES organizations(id),
|
organization_id BIGINT REFERENCES organizations(zammad_id),
|
||||||
customer_name TEXT,
|
customer_name TEXT,
|
||||||
work_type TEXT,
|
work_type TEXT,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
@@ -107,7 +107,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 organization_id BIGINT REFERENCES organizations(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(`
|
||||||
ALTER TABLE tickets
|
ALTER TABLE tickets
|
||||||
@@ -130,7 +130,7 @@ export async function migrate() {
|
|||||||
CREATE TABLE IF NOT EXISTS sessions (
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
ticket_id BIGINT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
|
ticket_id BIGINT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
|
||||||
organization_id BIGINT REFERENCES organizations(id),
|
organization_id BIGINT REFERENCES organizations(zammad_id),
|
||||||
customer_name TEXT NOT NULL,
|
customer_name TEXT NOT NULL,
|
||||||
activity TEXT NOT NULL,
|
activity TEXT NOT NULL,
|
||||||
work_type TEXT NOT NULL,
|
work_type TEXT NOT NULL,
|
||||||
@@ -151,7 +151,7 @@ export async function migrate() {
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS user_id BIGINT REFERENCES users(id);");
|
await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS user_id BIGINT REFERENCES users(id);");
|
||||||
await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS organization_id BIGINT REFERENCES organizations(id);");
|
await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS organization_id BIGINT REFERENCES organizations(zammad_id);");
|
||||||
await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS recurring_billing_id BIGINT;");
|
await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS recurring_billing_id BIGINT;");
|
||||||
await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS recurring_billing_slot_id BIGINT;");
|
await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS recurring_billing_slot_id BIGINT;");
|
||||||
await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS recurring_occurrence_date DATE;");
|
await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS recurring_occurrence_date DATE;");
|
||||||
@@ -173,7 +173,7 @@ export async function migrate() {
|
|||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
ticket_number TEXT,
|
ticket_number TEXT,
|
||||||
organization_id BIGINT NOT NULL REFERENCES organizations(id),
|
organization_id BIGINT NOT NULL REFERENCES organizations(zammad_id),
|
||||||
activity TEXT NOT NULL,
|
activity TEXT NOT NULL,
|
||||||
work_type TEXT NOT NULL,
|
work_type TEXT NOT NULL,
|
||||||
recurrence_type TEXT NOT NULL,
|
recurrence_type TEXT NOT NULL,
|
||||||
@@ -191,6 +191,82 @@ export async function migrate() {
|
|||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
await query(`
|
||||||
|
ALTER TABLE tickets DROP CONSTRAINT IF EXISTS tickets_organization_id_fkey;
|
||||||
|
ALTER TABLE sessions DROP CONSTRAINT IF EXISTS sessions_organization_id_fkey;
|
||||||
|
ALTER TABLE recurring_billings DROP CONSTRAINT IF EXISTS recurring_billings_organization_id_fkey;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await query(`
|
||||||
|
UPDATE tickets t
|
||||||
|
SET organization_id = o.zammad_id
|
||||||
|
FROM organizations o
|
||||||
|
WHERE t.organization_id = o.id
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM organizations current_org
|
||||||
|
WHERE current_org.zammad_id = t.organization_id
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await query(`
|
||||||
|
UPDATE sessions s
|
||||||
|
SET organization_id = o.zammad_id
|
||||||
|
FROM organizations o
|
||||||
|
WHERE s.organization_id = o.id
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM organizations current_org
|
||||||
|
WHERE current_org.zammad_id = s.organization_id
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await query(`
|
||||||
|
UPDATE recurring_billings rb
|
||||||
|
SET organization_id = o.zammad_id
|
||||||
|
FROM organizations o
|
||||||
|
WHERE rb.organization_id = o.id
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM organizations current_org
|
||||||
|
WHERE current_org.zammad_id = rb.organization_id
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await query(`
|
||||||
|
UPDATE tickets
|
||||||
|
SET organization_id = NULL
|
||||||
|
WHERE organization_id IS NOT NULL
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM organizations o
|
||||||
|
WHERE o.zammad_id = tickets.organization_id
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await query(`
|
||||||
|
UPDATE sessions
|
||||||
|
SET organization_id = NULL
|
||||||
|
WHERE organization_id IS NOT NULL
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM organizations o
|
||||||
|
WHERE o.zammad_id = sessions.organization_id
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await query(`
|
||||||
|
ALTER TABLE tickets
|
||||||
|
ADD CONSTRAINT tickets_organization_id_fkey
|
||||||
|
FOREIGN KEY (organization_id) REFERENCES organizations(zammad_id);
|
||||||
|
ALTER TABLE sessions
|
||||||
|
ADD CONSTRAINT sessions_organization_id_fkey
|
||||||
|
FOREIGN KEY (organization_id) REFERENCES organizations(zammad_id);
|
||||||
|
ALTER TABLE recurring_billings
|
||||||
|
ADD CONSTRAINT recurring_billings_organization_id_fkey
|
||||||
|
FOREIGN KEY (organization_id) REFERENCES organizations(zammad_id);
|
||||||
|
`);
|
||||||
|
|
||||||
await query("ALTER TABLE recurring_billings ALTER COLUMN ticket_number DROP NOT NULL;");
|
await query("ALTER TABLE recurring_billings ALTER COLUMN ticket_number DROP NOT NULL;");
|
||||||
await query("ALTER TABLE recurring_billings DROP CONSTRAINT IF EXISTS recurring_billings_ticket_number_format;");
|
await query("ALTER TABLE recurring_billings DROP CONSTRAINT IF EXISTS recurring_billings_ticket_number_format;");
|
||||||
await query("UPDATE recurring_billings SET recurrence_type = 'every_n_weeks' WHERE recurrence_type = 'every_n_days';");
|
await query("UPDATE recurring_billings SET recurrence_type = 'every_n_weeks' WHERE recurrence_type = 'every_n_days';");
|
||||||
|
|||||||
+119
-3
@@ -56,8 +56,8 @@ import {
|
|||||||
import { usePreferencesStore } from "@/stores/preferences/preferences-provider";
|
import { usePreferencesStore } from "@/stores/preferences/preferences-provider";
|
||||||
|
|
||||||
import { cn } from "./lib/utils";
|
import { cn } from "./lib/utils";
|
||||||
import { ApiError, getCurrentUser, getSetupStatus, logout, lookupTicket } from "./api";
|
import { ApiError, getCurrentUser, getSetupStatus, logout, lookupTicket, searchTickets } from "./api";
|
||||||
import { formatTimer } from "./format";
|
import { formatDateTime, formatMinutes, formatTimer } from "./format";
|
||||||
import { activeElapsedMs, pauseEntry, readStoredTimers, resumeEntry, storageKeyForUser, ticketPattern, type TimerEntry } from "./timers";
|
import { activeElapsedMs, pauseEntry, readStoredTimers, resumeEntry, storageKeyForUser, ticketPattern, type TimerEntry } from "./timers";
|
||||||
import { AdminUsersPage } from "./views/AdminUsersPage";
|
import { AdminUsersPage } from "./views/AdminUsersPage";
|
||||||
import { AnalysisPage } from "./views/AnalysisPage";
|
import { AnalysisPage } from "./views/AnalysisPage";
|
||||||
@@ -70,7 +70,7 @@ 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 } from "./types";
|
import type { AuthUser, PeriodType, TicketSearchResult } from "./types";
|
||||||
import type { Dispatch, DragEvent, SetStateAction } from "react";
|
import type { Dispatch, DragEvent, SetStateAction } from "react";
|
||||||
|
|
||||||
type NavMenuEntry =
|
type NavMenuEntry =
|
||||||
@@ -271,6 +271,118 @@ function QuickTimerStarter({ className, inputId, onStartTimer }: QuickTimerStart
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type HeaderTicketSearchProps = {
|
||||||
|
className?: string;
|
||||||
|
onNavigate: (to: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function HeaderTicketSearch({ className, onNavigate }: HeaderTicketSearchProps) {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [results, setResults] = useState<TicketSearchResult[]>([]);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const trimmedQuery = query.trim();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (trimmedQuery.length < 2) {
|
||||||
|
setResults([]);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const timeout = window.setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const result = await searchTickets(trimmedQuery);
|
||||||
|
|
||||||
|
if (!cancelled) {
|
||||||
|
setResults(result.tickets);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) {
|
||||||
|
setResults([]);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 220);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.clearTimeout(timeout);
|
||||||
|
};
|
||||||
|
}, [trimmedQuery]);
|
||||||
|
|
||||||
|
function openTicket(ticket: TicketSearchResult) {
|
||||||
|
onNavigate(`/analysis/month/${ticket.latest_period}/tickets/${ticket.ticket_id}`);
|
||||||
|
setQuery("");
|
||||||
|
setResults([]);
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function submit(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (results[0]) {
|
||||||
|
openTicket(results[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className={cn("relative min-w-0", className)} onSubmit={submit}>
|
||||||
|
<label className="sr-only" htmlFor="global-ticket-search">
|
||||||
|
Tickets suchen
|
||||||
|
</label>
|
||||||
|
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
id="global-ticket-search"
|
||||||
|
className="h-8 bg-background pl-8"
|
||||||
|
placeholder="Ticket, Kunde, Tätigkeit"
|
||||||
|
value={query}
|
||||||
|
onFocus={() => setOpen(true)}
|
||||||
|
onBlur={() => window.setTimeout(() => setOpen(false), 120)}
|
||||||
|
onChange={(event) => {
|
||||||
|
setQuery(event.currentTarget.value);
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{open && trimmedQuery.length >= 2 ? (
|
||||||
|
<div className="absolute left-0 top-[calc(100%+0.35rem)] z-50 w-[min(28rem,calc(100vw-2rem))] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-lg">
|
||||||
|
<div className="max-h-80 overflow-y-auto py-1">
|
||||||
|
{loading ? <p className="px-3 py-2 text-sm text-muted-foreground">Sucht...</p> : null}
|
||||||
|
{!loading && results.length === 0 ? <p className="px-3 py-2 text-sm text-muted-foreground">Keine Tickets gefunden.</p> : null}
|
||||||
|
{!loading
|
||||||
|
? results.map((ticket) => (
|
||||||
|
<button
|
||||||
|
key={`${ticket.latest_period}:${ticket.ticket_id}`}
|
||||||
|
type="button"
|
||||||
|
className="flex w-full cursor-pointer flex-col gap-1 px-3 py-2 text-left hover:bg-accent hover:text-accent-foreground"
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
openTicket(ticket);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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="shrink-0 text-xs text-muted-foreground">{formatMinutes(ticket.total_minutes)}</span>
|
||||||
|
</span>
|
||||||
|
<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)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type SidebarTimersProps = {
|
type SidebarTimersProps = {
|
||||||
timers: TimerEntry[];
|
timers: TimerEntry[];
|
||||||
selectedTimerId: string | null;
|
selectedTimerId: string | null;
|
||||||
@@ -916,10 +1028,14 @@ export function App() {
|
|||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<SidebarTrigger className="-ml-1" />
|
<SidebarTrigger className="-ml-1" />
|
||||||
<Separator orientation="vertical" className="mx-1 data-[orientation=vertical]:h-4 data-[orientation=vertical]:self-center" />
|
<Separator orientation="vertical" className="mx-1 data-[orientation=vertical]:h-4 data-[orientation=vertical]:self-center" />
|
||||||
|
{!isAdmin ? (
|
||||||
|
<HeaderTicketSearch className="hidden w-72 lg:block" onNavigate={navigate} />
|
||||||
|
) : (
|
||||||
<div className="hidden h-8 min-w-[160px] items-center gap-2 rounded-md px-2 text-sm text-muted-foreground lg:flex">
|
<div className="hidden h-8 min-w-[160px] items-center gap-2 rounded-md px-2 text-sm text-muted-foreground lg:flex">
|
||||||
<Search className="size-4" />
|
<Search className="size-4" />
|
||||||
<span>{currentUser.display_name}</span>
|
<span>{currentUser.display_name}</span>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
{runningTimer ? (
|
{runningTimer ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
StatisticsOverview,
|
StatisticsOverview,
|
||||||
TicketMeta,
|
TicketMeta,
|
||||||
TicketPeriod,
|
TicketPeriod,
|
||||||
|
TicketSearchResult,
|
||||||
WorkType
|
WorkType
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
@@ -300,6 +301,10 @@ export function lookupTicket(ticketNumber: string) {
|
|||||||
return request<{ ticket: TicketMeta | null }>(`/api/tickets/lookup?ticketNumber=${encodeURIComponent(ticketNumber)}`);
|
return request<{ ticket: TicketMeta | null }>(`/api/tickets/lookup?ticketNumber=${encodeURIComponent(ticketNumber)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function searchTickets(query: string) {
|
||||||
|
return request<{ tickets: TicketSearchResult[] }>(`/api/tickets/search?q=${encodeURIComponent(query)}`);
|
||||||
|
}
|
||||||
|
|
||||||
export function updateTicket(
|
export function updateTicket(
|
||||||
ticketId: string,
|
ticketId: string,
|
||||||
payload: {
|
payload: {
|
||||||
|
|||||||
@@ -61,6 +61,19 @@ export type TicketMeta = {
|
|||||||
work_type: WorkType | null;
|
work_type: WorkType | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TicketSearchResult = {
|
||||||
|
ticket_id: string;
|
||||||
|
ticket_number: string;
|
||||||
|
organization_id: string | null;
|
||||||
|
organization_name: string | null;
|
||||||
|
customer_name: string | null;
|
||||||
|
work_type: WorkType | null;
|
||||||
|
latest_started_at: string;
|
||||||
|
latest_period: string;
|
||||||
|
session_count: number;
|
||||||
|
total_minutes: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type TicketSummary = {
|
export type TicketSummary = {
|
||||||
id: string;
|
id: string;
|
||||||
ticket_number: string;
|
ticket_number: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user