This commit is contained in:
michael committed 2026-09-29 07:46:21 +02:00
1 parent 2105a0c5f7
commit c89d4825e2
30 files changed
+5325 -517

No files matched your search

+2 -1
View File
@@ -1,7 +1,8 @@
PP_PASSWORD='$ouzs4D$@RAi9' PP_PASSWORD='$ouzs4D$@RAi9'
DATABASE_URL=postgresql://tickettracker:${PP_PASSWORD}@172.22.0.2:5432/tickettracker_dev DATABASE_URL=postgresql://tickettracker:${PP_PASSWORD}@172.22.0.3:5432/tickettracker_dev
APP_PORT=3911 APP_PORT=3911
TICKETTRACKER_IMAGE=git.d-razz.de/michael/tickettracker:latest TICKETTRACKER_IMAGE=git.d-razz.de/michael/tickettracker:latest
COOKIE_SECURE=false COOKIE_SECURE=false
DB_POOL_SIZE=10 DB_POOL_SIZE=10
ZAMMAD_EMAIL_WEBHOOK_TOKEN='sPXJc87R@ySW3uTFKjGFMrti8BcDkNv8mN2g71hRkdg=' ZAMMAD_EMAIL_WEBHOOK_TOKEN='sPXJc87R@ySW3uTFKjGFMrti8BcDkNv8mN2g71hRkdg='
SECRET_ENCRYPTION_KEY='7qDJ1bcJprs$eSv57c2QXmQKh4kT^kb/w0vPBeJ4z^4='
+4 -1
View File
@@ -22,6 +22,7 @@ export type AuthUser = {
zammad_session_note_default_enabled: boolean; zammad_session_note_default_enabled: boolean;
zammad_time_unit_rounding_enabled: boolean; zammad_time_unit_rounding_enabled: boolean;
zammad_time_unit_rounding_minutes: number; zammad_time_unit_rounding_minutes: number;
zammad_atoss_email_sync_enabled: boolean;
zammad_article_type_id: number; zammad_article_type_id: number;
}; };
@@ -89,6 +90,7 @@ function publicUser(user: AuthUser) {
zammad_session_note_default_enabled: Boolean(user.zammad_session_note_default_enabled), zammad_session_note_default_enabled: Boolean(user.zammad_session_note_default_enabled),
zammad_time_unit_rounding_enabled: Boolean(user.zammad_time_unit_rounding_enabled), zammad_time_unit_rounding_enabled: Boolean(user.zammad_time_unit_rounding_enabled),
zammad_time_unit_rounding_minutes: Number(user.zammad_time_unit_rounding_minutes ?? 15), zammad_time_unit_rounding_minutes: Number(user.zammad_time_unit_rounding_minutes ?? 15),
zammad_atoss_email_sync_enabled: Boolean(user.zammad_atoss_email_sync_enabled ?? true),
zammad_article_type_id: Number(user.zammad_article_type_id ?? 6) zammad_article_type_id: Number(user.zammad_article_type_id ?? 6)
}; };
} }
@@ -152,7 +154,8 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio
u.zammad_session_note_default_enabled, u.zammad_session_note_default_enabled,
u.zammad_time_unit_rounding_enabled, u.zammad_time_unit_rounding_enabled,
u.zammad_time_unit_rounding_minutes, u.zammad_time_unit_rounding_minutes,
u.zammad_article_type_id u.zammad_atoss_email_sync_enabled,
COALESCE(u.zammad_article_type_id, 6) AS zammad_article_type_id
FROM auth_sessions s FROM auth_sessions s
JOIN users u ON u.id = s.user_id JOIN users u ON u.id = s.user_id
WHERE s.token_hash = $1 WHERE s.token_hash = $1
+1
View File
@@ -22,6 +22,7 @@ export const databaseExportTables = [
"recurring_billing_slots", "recurring_billing_slots",
"recurring_billing_exceptions", "recurring_billing_exceptions",
"sessions", "sessions",
"active_timers",
"ticket_month_closures", "ticket_month_closures",
"month_closures", "month_closures",
"ticket_day_closures", "ticket_day_closures",
+2373 -214
View File
File diff suppressed because it is too large. Load diff
+130 -6
View File
@@ -91,8 +91,11 @@ export async function migrate() {
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_session_note_default_enabled BOOLEAN NOT NULL DEFAULT false;"); await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_session_note_default_enabled BOOLEAN NOT NULL DEFAULT false;");
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_time_unit_rounding_enabled BOOLEAN NOT NULL DEFAULT false;"); await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_time_unit_rounding_enabled BOOLEAN NOT NULL DEFAULT false;");
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_time_unit_rounding_minutes INTEGER NOT NULL DEFAULT 15;"); await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_time_unit_rounding_minutes INTEGER NOT NULL DEFAULT 15;");
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_atoss_email_sync_enabled BOOLEAN NOT NULL DEFAULT true;");
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_article_type_id INTEGER DEFAULT 6;"); await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_article_type_id INTEGER DEFAULT 6;");
await query("ALTER TABLE users ALTER COLUMN zammad_article_type_id DROP NOT NULL;"); await query("UPDATE users SET zammad_article_type_id = 6 WHERE zammad_article_type_id IS NULL;");
await query("ALTER TABLE users ALTER COLUMN zammad_article_type_id SET DEFAULT 6;");
await query("ALTER TABLE users ALTER COLUMN zammad_article_type_id SET NOT NULL;");
await query(` await query(`
ALTER TABLE users ALTER TABLE users
DROP CONSTRAINT IF EXISTS users_zammad_time_unit_rounding_minutes_valid, DROP CONSTRAINT IF EXISTS users_zammad_time_unit_rounding_minutes_valid,
@@ -101,7 +104,7 @@ export async function migrate() {
await query(` await query(`
ALTER TABLE users ALTER TABLE users
DROP CONSTRAINT IF EXISTS users_zammad_article_type_id_valid, DROP CONSTRAINT IF EXISTS users_zammad_article_type_id_valid,
ADD CONSTRAINT users_zammad_article_type_id_valid CHECK (zammad_article_type_id IS NULL OR zammad_article_type_id IN (1, 2, 3, 4, 5, 6)); ADD CONSTRAINT users_zammad_article_type_id_valid CHECK (zammad_article_type_id IN (1, 2, 3, 4, 5, 6));
`); `);
await query(` await query(`
@@ -237,6 +240,86 @@ export async function migrate() {
await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS recurring_occurrence_date DATE;"); await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS recurring_occurrence_date DATE;");
await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS recurring_occurrence_key TEXT;"); await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS recurring_occurrence_key TEXT;");
await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS project_teamspace_entry_id BIGINT;"); await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS project_teamspace_entry_id BIGINT;");
await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS activity_html TEXT;");
await query(`
CREATE TABLE IF NOT EXISTS active_timers (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
ticket_number TEXT NOT NULL,
organization_name TEXT,
work_type TEXT,
is_project BOOLEAN NOT NULL DEFAULT false,
project_name TEXT,
ticket_lookup_done BOOLEAN NOT NULL DEFAULT false,
activity TEXT NOT NULL DEFAULT '',
activity_html TEXT,
billing_status TEXT,
started_at TIMESTAMPTZ NOT NULL,
paused_total_ms BIGINT NOT NULL DEFAULT 0,
paused_at TIMESTAMPTZ,
phase TEXT NOT NULL DEFAULT 'running',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT active_timers_ticket_number_format CHECK (ticket_number ~ '^Ticket#[0-9]{6}$'),
CONSTRAINT active_timers_work_type_valid CHECK (work_type IS NULL OR work_type IN ('support', 'consulting')),
CONSTRAINT active_timers_billing_status_valid CHECK (billing_status IS NULL OR billing_status IN ('billable', 'billed', 'non_billable')),
CONSTRAINT active_timers_phase_valid CHECK (phase IN ('running', 'paused')),
CONSTRAINT active_timers_paused_total_non_negative CHECK (paused_total_ms >= 0),
CONSTRAINT active_timers_paused_state_valid CHECK (
(phase = 'running' AND paused_at IS NULL)
OR (phase = 'paused' AND paused_at IS NOT NULL)
)
);
`);
await query("ALTER TABLE active_timers ADD COLUMN IF NOT EXISTS organization_name TEXT;");
await query("ALTER TABLE active_timers ADD COLUMN IF NOT EXISTS work_type TEXT;");
await query("ALTER TABLE active_timers ADD COLUMN IF NOT EXISTS is_project BOOLEAN NOT NULL DEFAULT false;");
await query("ALTER TABLE active_timers ADD COLUMN IF NOT EXISTS project_name TEXT;");
await query("ALTER TABLE active_timers ADD COLUMN IF NOT EXISTS ticket_lookup_done BOOLEAN NOT NULL DEFAULT false;");
await query("ALTER TABLE active_timers ADD COLUMN IF NOT EXISTS activity TEXT NOT NULL DEFAULT '';");
await query("ALTER TABLE active_timers ADD COLUMN IF NOT EXISTS activity_html TEXT;");
await query("ALTER TABLE active_timers ADD COLUMN IF NOT EXISTS billing_status TEXT;");
await query("ALTER TABLE active_timers ADD COLUMN IF NOT EXISTS paused_total_ms BIGINT NOT NULL DEFAULT 0;");
await query("ALTER TABLE active_timers ADD COLUMN IF NOT EXISTS paused_at TIMESTAMPTZ;");
await query("ALTER TABLE active_timers ADD COLUMN IF NOT EXISTS phase TEXT NOT NULL DEFAULT 'running';");
await query("ALTER TABLE active_timers ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT now();");
await query("ALTER TABLE active_timers ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT now();");
await query(`
ALTER TABLE active_timers
DROP CONSTRAINT IF EXISTS active_timers_ticket_number_format,
ADD CONSTRAINT active_timers_ticket_number_format CHECK (ticket_number ~ '^Ticket#[0-9]{6}$');
`);
await query(`
ALTER TABLE active_timers
DROP CONSTRAINT IF EXISTS active_timers_work_type_valid,
ADD CONSTRAINT active_timers_work_type_valid CHECK (work_type IS NULL OR work_type IN ('support', 'consulting'));
`);
await query(`
ALTER TABLE active_timers
DROP CONSTRAINT IF EXISTS active_timers_billing_status_valid,
ADD CONSTRAINT active_timers_billing_status_valid CHECK (billing_status IS NULL OR billing_status IN ('billable', 'billed', 'non_billable'));
`);
await query(`
ALTER TABLE active_timers
DROP CONSTRAINT IF EXISTS active_timers_phase_valid,
ADD CONSTRAINT active_timers_phase_valid CHECK (phase IN ('running', 'paused'));
`);
await query(`
ALTER TABLE active_timers
DROP CONSTRAINT IF EXISTS active_timers_paused_total_non_negative,
ADD CONSTRAINT active_timers_paused_total_non_negative CHECK (paused_total_ms >= 0);
`);
await query(`
ALTER TABLE active_timers
DROP CONSTRAINT IF EXISTS active_timers_paused_state_valid,
ADD CONSTRAINT active_timers_paused_state_valid CHECK (
(phase = 'running' AND paused_at IS NULL)
OR (phase = 'paused' AND paused_at IS NOT NULL)
);
`);
await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_active_timers_user_ticket ON active_timers(user_id, ticket_number);");
await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_active_timers_single_running ON active_timers(user_id) WHERE phase = 'running';");
await query("CREATE INDEX IF NOT EXISTS idx_active_timers_user_updated ON active_timers(user_id, updated_at);");
await query(` await query(`
ALTER TABLE sessions ALTER TABLE sessions
DROP CONSTRAINT IF EXISTS billing_status_valid, DROP CONSTRAINT IF EXISTS billing_status_valid,
@@ -263,6 +346,7 @@ export async function migrate() {
ON zammad_email_session_events(delivery_id) ON zammad_email_session_events(delivery_id)
WHERE delivery_id IS NOT NULL; WHERE delivery_id IS NOT NULL;
`); `);
await query("CREATE INDEX IF NOT EXISTS idx_zammad_email_session_events_session ON zammad_email_session_events(session_id);");
await query(` await query(`
CREATE TABLE IF NOT EXISTS zammad_session_article_queue ( CREATE TABLE IF NOT EXISTS zammad_session_article_queue (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
@@ -272,8 +356,10 @@ export async function migrate() {
ticket_number TEXT NOT NULL, ticket_number TEXT NOT NULL,
zammad_ticket_id BIGINT, zammad_ticket_id BIGINT,
activity TEXT NOT NULL, activity TEXT NOT NULL,
article_body TEXT,
article_content_type TEXT NOT NULL DEFAULT 'text/plain',
time_unit NUMERIC(10, 2) NOT NULL, time_unit NUMERIC(10, 2) NOT NULL,
zammad_article_type_id INTEGER DEFAULT 6, zammad_article_type_id INTEGER NOT NULL DEFAULT 6,
status TEXT NOT NULL DEFAULT 'pending', status TEXT NOT NULL DEFAULT 'pending',
attempt_count INTEGER NOT NULL DEFAULT 0, attempt_count INTEGER NOT NULL DEFAULT 0,
last_error TEXT, last_error TEXT,
@@ -285,12 +371,22 @@ export async function migrate() {
CONSTRAINT zammad_session_article_queue_status_valid CHECK (status IN ('pending', 'sent')) CONSTRAINT zammad_session_article_queue_status_valid CHECK (status IN ('pending', 'sent'))
); );
`); `);
await query("ALTER TABLE zammad_session_article_queue ADD COLUMN IF NOT EXISTS article_body TEXT;");
await query("ALTER TABLE zammad_session_article_queue ADD COLUMN IF NOT EXISTS article_content_type TEXT NOT NULL DEFAULT 'text/plain';");
await query("UPDATE zammad_session_article_queue SET article_body = activity WHERE article_body IS NULL;");
await query(`
ALTER TABLE zammad_session_article_queue
DROP CONSTRAINT IF EXISTS zammad_session_article_queue_content_type_valid,
ADD CONSTRAINT zammad_session_article_queue_content_type_valid CHECK (article_content_type IN ('text/plain', 'text/html'));
`);
await query("ALTER TABLE zammad_session_article_queue ADD COLUMN IF NOT EXISTS zammad_article_type_id INTEGER DEFAULT 6;"); await query("ALTER TABLE zammad_session_article_queue ADD COLUMN IF NOT EXISTS zammad_article_type_id INTEGER DEFAULT 6;");
await query("ALTER TABLE zammad_session_article_queue ALTER COLUMN zammad_article_type_id DROP NOT NULL;"); await query("UPDATE zammad_session_article_queue SET zammad_article_type_id = 6 WHERE zammad_article_type_id IS NULL;");
await query("ALTER TABLE zammad_session_article_queue ALTER COLUMN zammad_article_type_id SET DEFAULT 6;");
await query("ALTER TABLE zammad_session_article_queue ALTER COLUMN zammad_article_type_id SET NOT NULL;");
await query(` await query(`
ALTER TABLE zammad_session_article_queue ALTER TABLE zammad_session_article_queue
DROP CONSTRAINT IF EXISTS zammad_session_article_queue_type_id_valid, DROP CONSTRAINT IF EXISTS zammad_session_article_queue_type_id_valid,
ADD CONSTRAINT zammad_session_article_queue_type_id_valid CHECK (zammad_article_type_id IS NULL OR zammad_article_type_id IN (1, 2, 3, 4, 5, 6)); ADD CONSTRAINT zammad_session_article_queue_type_id_valid CHECK (zammad_article_type_id IN (1, 2, 3, 4, 5, 6));
`); `);
await query(` await query(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_zammad_session_article_queue_pending_session CREATE UNIQUE INDEX IF NOT EXISTS idx_zammad_session_article_queue_pending_session
@@ -546,14 +642,18 @@ export async function migrate() {
CREATE TABLE IF NOT EXISTS ticket_day_billings ( CREATE TABLE IF NOT EXISTS ticket_day_billings (
ticket_id BIGINT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE, ticket_id BIGINT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
project_teamspace_entry_id BIGINT,
day DATE NOT NULL, day DATE NOT NULL,
billed_minutes INTEGER NOT NULL, billed_minutes INTEGER NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (ticket_id, user_id, day),
CONSTRAINT ticket_day_billings_minutes_non_negative CHECK (billed_minutes >= 0) CONSTRAINT ticket_day_billings_minutes_non_negative CHECK (billed_minutes >= 0)
); );
`); `);
await query("ALTER TABLE ticket_day_billings ADD COLUMN IF NOT EXISTS project_teamspace_entry_id BIGINT;");
await query("ALTER TABLE ticket_day_billings DROP CONSTRAINT IF EXISTS ticket_day_billings_pkey;");
await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_ticket_day_billings_ticket_user_day_default ON ticket_day_billings(ticket_id, user_id, day) WHERE project_teamspace_entry_id IS NULL;");
await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_ticket_day_billings_ticket_user_day_entry ON ticket_day_billings(ticket_id, user_id, day, project_teamspace_entry_id) WHERE project_teamspace_entry_id IS NOT NULL;");
await query(` await query(`
CREATE TABLE IF NOT EXISTS ticket_day_billing_acknowledgements ( CREATE TABLE IF NOT EXISTS ticket_day_billing_acknowledgements (
@@ -610,6 +710,7 @@ export async function migrate() {
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
teamspace_project TEXT, teamspace_project TEXT,
teamspace_description TEXT NOT NULL DEFAULT '',
is_default BOOLEAN NOT NULL DEFAULT false, is_default BOOLEAN NOT NULL DEFAULT false,
hour_budget_minutes INTEGER, hour_budget_minutes INTEGER,
budget_sync_disabled BOOLEAN NOT NULL DEFAULT false, budget_sync_disabled BOOLEAN NOT NULL DEFAULT false,
@@ -622,9 +723,31 @@ export async function migrate() {
); );
`); `);
await query("ALTER TABLE project_teamspace_entries ADD COLUMN IF NOT EXISTS teamspace_description TEXT NOT NULL DEFAULT '';");
await query("ALTER TABLE project_teamspace_entries ADD COLUMN IF NOT EXISTS budget_sync_disabled BOOLEAN NOT NULL DEFAULT false;"); await query("ALTER TABLE project_teamspace_entries ADD COLUMN IF NOT EXISTS budget_sync_disabled BOOLEAN NOT NULL DEFAULT false;");
await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_project_teamspace_entries_default ON project_teamspace_entries(project_id) WHERE is_default;"); await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_project_teamspace_entries_default ON project_teamspace_entries(project_id) WHERE is_default;");
await query("CREATE INDEX IF NOT EXISTS idx_project_teamspace_entries_project_order ON project_teamspace_entries(project_id, display_order, id);"); await query("CREATE INDEX IF NOT EXISTS idx_project_teamspace_entries_project_order ON project_teamspace_entries(project_id, display_order, id);");
await query(`
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'ticket_day_billings_project_teamspace_entry_fkey'
AND confdeltype <> 'n'
) THEN
ALTER TABLE ticket_day_billings
DROP CONSTRAINT ticket_day_billings_project_teamspace_entry_fkey;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'ticket_day_billings_project_teamspace_entry_fkey'
) THEN
ALTER TABLE ticket_day_billings
ADD CONSTRAINT ticket_day_billings_project_teamspace_entry_fkey
FOREIGN KEY (project_teamspace_entry_id) REFERENCES project_teamspace_entries(id) ON DELETE SET NULL;
END IF;
END $$;
`);
await query(` await query(`
CREATE TABLE IF NOT EXISTS hidden_user_projects ( CREATE TABLE IF NOT EXISTS hidden_user_projects (
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -786,6 +909,7 @@ export async function migrate() {
await query("CREATE INDEX IF NOT EXISTS idx_sessions_billing_status ON sessions(billing_status);"); await query("CREATE INDEX IF NOT EXISTS idx_sessions_billing_status ON sessions(billing_status);");
await query("CREATE INDEX IF NOT EXISTS idx_ticket_day_closures_day ON ticket_day_closures(day);"); await query("CREATE INDEX IF NOT EXISTS idx_ticket_day_closures_day ON ticket_day_closures(day);");
await query("CREATE INDEX IF NOT EXISTS idx_ticket_day_billings_user_day ON ticket_day_billings(user_id, day);"); await query("CREATE INDEX IF NOT EXISTS idx_ticket_day_billings_user_day ON ticket_day_billings(user_id, day);");
await query("CREATE INDEX IF NOT EXISTS idx_ticket_day_billings_teamspace_entry ON ticket_day_billings(project_teamspace_entry_id);");
await query("CREATE INDEX IF NOT EXISTS idx_ticket_day_billing_ack_user_day ON ticket_day_billing_acknowledgements(user_id, day);"); await query("CREATE INDEX IF NOT EXISTS idx_ticket_day_billing_ack_user_day ON ticket_day_billing_acknowledgements(user_id, day);");
await query("CREATE INDEX IF NOT EXISTS idx_projects_user_ticket ON projects(user_id, ticket_id);"); await query("CREATE INDEX IF NOT EXISTS idx_projects_user_ticket ON projects(user_id, ticket_id);");
await query("CREATE INDEX IF NOT EXISTS idx_projects_user_updated ON projects(user_id, updated_at DESC);"); await query("CREATE INDEX IF NOT EXISTS idx_projects_user_updated ON projects(user_id, updated_at DESC);");
+36
View File
@@ -1,6 +1,7 @@
export const ticketPattern = /^Ticket#\d{6}$/; export const ticketPattern = /^Ticket#\d{6}$/;
export const trackableTicketPattern = /^(Ticket#\d{6}|Fix#\d+)$/; export const trackableTicketPattern = /^(Ticket#\d{6}|Fix#\d+)$/;
export const monthPattern = /^\d{4}-\d{2}$/; export const monthPattern = /^\d{4}-\d{2}$/;
export const weekPattern = /^\d{4}-W\d{2}$/;
export const dayPattern = /^\d{4}-\d{2}-\d{2}$/; export const dayPattern = /^\d{4}-\d{2}-\d{2}$/;
export const usernamePattern = /^\S{3,40}$/u; export const usernamePattern = /^\S{3,40}$/u;
@@ -87,6 +88,41 @@ export function parseDay(value: string) {
}; };
} }
export function parseWeek(value: string) {
if (!weekPattern.test(value)) {
throw badRequest("week must use YYYY-Www");
}
const year = Number(value.slice(0, 4));
const week = Number(value.slice(6, 8));
if (!Number.isInteger(year) || !Number.isInteger(week) || week < 1 || week > 53) {
throw badRequest("week is invalid");
}
const jan4 = new Date(Date.UTC(year, 0, 4));
const jan4Weekday = jan4.getUTCDay() || 7;
const weekStart = new Date(jan4);
weekStart.setUTCDate(jan4.getUTCDate() - jan4Weekday + 1 + (week - 1) * 7);
const checkThursday = new Date(weekStart);
checkThursday.setUTCDate(weekStart.getUTCDate() + 3);
if (checkThursday.getUTCFullYear() !== year) {
throw badRequest("week is invalid");
}
const next = new Date(weekStart);
next.setUTCDate(weekStart.getUTCDate() + 7);
return {
label: value,
start: weekStart.toISOString().slice(0, 10),
startIso: weekStart.toISOString(),
endIso: next.toISOString()
};
}
export function parsePositiveInteger(value: unknown, field: string) { export function parsePositiveInteger(value: unknown, field: string) {
const number = Number(value); const number = Number(value);
+1 -1
View File
@@ -8,7 +8,7 @@ services:
POSTGRES_USER: ${POSTGRES_USER:-tickettracker} POSTGRES_USER: ${POSTGRES_USER:-tickettracker}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD muss gesetzt sein} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD muss gesetzt sein}
volumes: volumes:
- tickettracker_postgres_data:/var/lib/postgresql/data - ${POSTGRES_DATA_PATH:-tickettracker_postgres_data}:/var/lib/postgresql/data
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""] test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""]
interval: 10s interval: 10s
+13 -13
View File
@@ -21,7 +21,7 @@ Es gibt drei zentrale Zeitebenen:
- Session-Zeit: Die gemessene oder manuell erfasste Arbeitszeit einer einzelnen Taetigkeit. - Session-Zeit: Die gemessene oder manuell erfasste Arbeitszeit einer einzelnen Taetigkeit.
- Teamspace-Zeit: Die Zeit, die fuer ein Ticket an einem konkreten Tag wirklich im CRM beziehungsweise Teamspace eingetragen wurde. - Teamspace-Zeit: Die Zeit, die fuer ein Ticket an einem konkreten Tag wirklich im CRM beziehungsweise Teamspace eingetragen wurde.
- Monatsabschluss: Der Abschluss eines ganzen Monats, sobald alle Sessions des Monats bewertet sind. - Monatsabschluss: Der Abschluss eines ganzen Monats, sobald alle Sessions des Monats final bewertet sind.
Wichtig: Tickets werden nicht geschlossen. Tickets dienen nur als Gruppierung fuer Sessions. Abgeschlossen wird ausschliesslich der Monat. Wichtig: Tickets werden nicht geschlossen. Tickets dienen nur als Gruppierung fuer Sessions. Abgeschlossen wird ausschliesslich der Monat.
@@ -161,7 +161,7 @@ Die oberen Karten zeigen:
- Abgerechnet: Summe der Sessions mit Bewertung `Abgerechnet` und, sofern vorhanden, die dazugehoerige Teamspace-Zeit als Vergleichswert - Abgerechnet: Summe der Sessions mit Bewertung `Abgerechnet` und, sofern vorhanden, die dazugehoerige Teamspace-Zeit als Vergleichswert
- Abrechenbar: Summe der Sessions mit Bewertung `Abrechenbar`, noch ohne Teamspace-Wirkung - Abrechenbar: Summe der Sessions mit Bewertung `Abrechenbar`, noch ohne Teamspace-Wirkung
- Nicht abrechenbar: Summe der Sessions mit Bewertung `Nicht abrechenbar` - Nicht abrechenbar: Summe der Sessions mit Bewertung `Nicht abrechenbar`
- Offen: Anzahl unbewerteter Sessions - Offen: Anzahl nicht final bewerteter Sessions, inklusive `Abrechenbar`
Der Verlauf der abgerechneten Zeit liegt auf der Seite `Statistiken`. Dort wird der Monatsverlauf gemeinsam mit Teamspace und Bewertungsstand betrachtet. Der Verlauf der abgerechneten Zeit liegt auf der Seite `Statistiken`. Dort wird der Monatsverlauf gemeinsam mit Teamspace und Bewertungsstand betrachtet.
@@ -185,8 +185,8 @@ Neben der Ticketnummer gibt es ein Copy-Icon. Ein Klick kopiert die Ticketnummer
Der Status bedeutet: Der Status bedeutet:
- `offen`: Es gibt noch unbewertete Sessions. - `offen`: Es gibt noch nicht final bewertete Sessions oder Sessions mit Status `Abrechenbar`.
- `bewertet`: Alle Sessions dieses Tickets im gewaehlten Zeitraum sind bewertet. - `bewertet`: Alle Sessions dieses Tickets im gewaehlten Zeitraum sind `Abgerechnet` oder `Nicht abrechenbar`.
### Filtern, Gruppieren und Sortieren ### Filtern, Gruppieren und Sortieren
@@ -326,7 +326,7 @@ Die Sessions sind pro Tag gruppiert. In jeder Tagesgruppe stehen:
- `Tag abgerechnet`: Summe der als `Abgerechnet` bewerteten Session-Zeit dieses Tages - `Tag abgerechnet`: Summe der als `Abgerechnet` bewerteten Session-Zeit dieses Tages
- `Abrechenbar`: Summe der als `Abrechenbar` bewerteten Session-Zeit dieses Tages - `Abrechenbar`: Summe der als `Abrechenbar` bewerteten Session-Zeit dieses Tages
- `Nicht`: Summe der als `Nicht abrechenbar` bewerteten Session-Zeit dieses Tages - `Nicht`: Summe der als `Nicht abrechenbar` bewerteten Session-Zeit dieses Tages
- `Offen`: Summe der noch unbewerteten Session-Zeit dieses Tages - `Offen`: Summe der noch nicht final bewerteten Session-Zeit dieses Tages, inklusive `Abrechenbar`
- `Teamspace`: Stunden, die fuer dieses Ticket an diesem Tag wirklich im CRM eingetragen wurden - `Teamspace`: Stunden, die fuer dieses Ticket an diesem Tag wirklich im CRM eingetragen wurden
Das Teamspace-Feld wird in Stunden eingetragen, zum Beispiel: Das Teamspace-Feld wird in Stunden eingetragen, zum Beispiel:
@@ -354,7 +354,7 @@ Jede Session kann bewertet werden als:
Ein erneuter Klick auf den bereits gewaehlten Zustand nimmt die Bewertung zurueck. Dadurch wird die Session wieder offen. Ein erneuter Klick auf den bereits gewaehlten Zustand nimmt die Bewertung zurueck. Dadurch wird die Session wieder offen.
Abrechenbare und nicht abrechenbare Sessions gelten als bewertet. Sie blockieren den Monatsabschluss nicht. `Abrechenbar` fliesst nicht in Teamspace-Statistiken ein; erst `Abgerechnet` zaehlt dort. `Abgerechnet` und `Nicht abrechenbar` schliessen die Bewertung ab. `Abrechenbar` ist nur ein Zwischenstatus, gilt fuer Abschluss und offene Bewertungen weiterhin als offen und blockiert den Monatsabschluss. In Teamspace-Statistiken zaehlt erst `Abgerechnet`.
Es gibt keinen Ticketabschluss. Tickets dienen nur als Gruppierung fuer Sessions. Abgeschlossen wird ausschliesslich der Monat. Es gibt keinen Ticketabschluss. Tickets dienen nur als Gruppierung fuer Sessions. Abgeschlossen wird ausschliesslich der Monat.
@@ -393,7 +393,7 @@ Der Monatsabschluss ist eine eigene Unterseite.
Ein Monat kann abgeschlossen werden, wenn: Ein Monat kann abgeschlossen werden, wenn:
- Alle Sessions im Monat bewertet sind. - Keine offenen Bewertungen mehr vorhanden sind. Sessions mit Status `Abrechenbar` gelten dabei weiterhin als offen.
Einen separaten Tagesabschluss gibt es nicht. Die Tagesansicht ist nur eine gefilterte Kontrolle einzelner Tage. Bewertungen und Teamspace-Tageswerte wirken in Monats- und Tagesansicht auf dieselben Daten, abgeschlossen wird aber ausschliesslich der Monat. Einen separaten Tagesabschluss gibt es nicht. Die Tagesansicht ist nur eine gefilterte Kontrolle einzelner Tage. Bewertungen und Teamspace-Tageswerte wirken in Monats- und Tagesansicht auf dieselben Daten, abgeschlossen wird aber ausschliesslich der Monat.
@@ -476,9 +476,9 @@ Farben:
- Nicht abrechenbar: indigo - Nicht abrechenbar: indigo
- Offen: gelb/orange - Offen: gelb/orange
`Abrechenbar` und `Nicht abrechenbar` zeigen bewertete Sessions, die nicht als abgerechnet gelten. Diese Sessions werden nicht mit Teamspace verglichen. `Abrechenbar` zeigt Sessions im Zwischenstatus, die noch offen bleiben. `Nicht abrechenbar` zeigt final bewertete Sessions, die nicht als abgerechnet gelten. Beide werden nicht mit Teamspace verglichen.
`Offen` zeigt Sessions ohne Bewertung. Diese blockieren den Monatsabschluss. `Offen` zeigt Sessions ohne finalen Status, also auch `Abrechenbar`. Diese blockieren den Monatsabschluss.
### Monatsverlauf und Abrechnungsqualitaet ### Monatsverlauf und Abrechnungsqualitaet
@@ -486,7 +486,7 @@ Diese Card kombiniert den Tagesverlauf und die Bewertungsqualitaet.
- Balken zeigen abgerechnete Session-Zeit. - Balken zeigen abgerechnete Session-Zeit.
- Die Linie zeigt Teamspace-Zeit. - Die Linie zeigt Teamspace-Zeit.
- Der Bewertungsstand trennt abgerechnete, abrechenbare, nicht abrechenbare und offene Sessions. - Der Bewertungsstand trennt abgerechnete, abrechenbare, nicht abrechenbare und offene Sessions. Abrechenbar bleibt dabei zusaetzlich offen.
- Der Teamspace-Abgleich zeigt die Gesamtdifferenz. - Der Teamspace-Abgleich zeigt die Gesamtdifferenz.
- Manuell und Fix trennen manuell erfasste abrechenbare Sessions von automatisch erzeugten abrechenbaren Sessions. - Manuell und Fix trennen manuell erfasste abrechenbare Sessions von automatisch erzeugten abrechenbaren Sessions.
@@ -549,7 +549,7 @@ Die Bewertungsabfrage beim Timerabschluss kann im Profil ein- oder ausgeschaltet
Die interne Zammad-Notiz beim Timerabschluss steuert, ob die Checkbox im Abschlussdialog standardmaessig aktiv ist. Ist sie aktiv, legt TicketTracker beim Speichern einen internen Zammad-Article zum Ticket an. Nur dann werden auch der Standard-Typ und die Zeitaufrundung im Profil angezeigt. Die interne Zammad-Notiz beim Timerabschluss steuert, ob die Checkbox im Abschlussdialog standardmaessig aktiv ist. Ist sie aktiv, legt TicketTracker beim Speichern einen internen Zammad-Article zum Ticket an. Nur dann werden auch der Standard-Typ und die Zeitaufrundung im Profil angezeigt.
Der Standard-Typ belegt den Zammad-Zeitbuchungstyp fuer neue interne Notizen vor. Beim Timerabschluss und beim Nachtragen kann der Typ vor dem Speichern noch geaendert werden. Der Standard-Typ belegt den Zammad-Zeitbuchungstyp fuer neue interne Notizen vor. Es muss immer eine Kategorie gewaehlt sein; falls noch kein Profilwert gespeichert ist, verwendet TicketTracker `Nicht kategorisiert`. Beim Timerabschluss und beim Nachtragen kann der Typ vor dem Speichern noch geaendert werden.
Fuer `time_unit` kann optional eine Aufrundung aktiviert werden. Dann wird die tatsaechlich getrackte Zeit immer auf 5, 10, 15 oder 30 Minuten aufgerundet und als Stundenwert an Zammad gesendet. Ohne Rundung wird der konkrete Stundenwert mit zwei Nachkommastellen gesendet. Fuer `time_unit` kann optional eine Aufrundung aktiviert werden. Dann wird die tatsaechlich getrackte Zeit immer auf 5, 10, 15 oder 30 Minuten aufgerundet und als Stundenwert an Zammad gesendet. Ohne Rundung wird der konkrete Stundenwert mit zwei Nachkommastellen gesendet.
@@ -597,12 +597,12 @@ Neue Organisationen werden hinzugefuegt, geaenderte Namen aktualisiert und nicht
3. Am Monatsende `Tickets` oeffnen. 3. Am Monatsende `Tickets` oeffnen.
4. Monat auswaehlen. 4. Monat auswaehlen.
5. Offene Sessions pruefen. 5. Offene Sessions pruefen.
6. Pro Ticket jede Session als `Abrechenbar`, `Abgerechnet` oder `Nicht abrechenbar` markieren. 6. Pro Ticket jede Session final als `Abgerechnet` oder `Nicht abrechenbar` markieren. `Abrechenbar` kann als Zwischenstatus stehen bleiben, blockiert dann aber den Abschluss.
7. Pro Tag und Ticket die wirklich in Teamspace eingetragene Zeit im Feld `Teamspace` pflegen. 7. Pro Tag und Ticket die wirklich in Teamspace eingetragene Zeit im Feld `Teamspace` pflegen.
8. `Monatsabschluss` oeffnen. 8. `Monatsabschluss` oeffnen.
9. Offene Bewertungen abarbeiten. 9. Offene Bewertungen abarbeiten.
10. Tage ohne Teamspace-Wert pruefen oder zur Kenntnis nehmen. 10. Tage ohne Teamspace-Wert pruefen oder zur Kenntnis nehmen.
11. Wenn keine Sessions im Monat mehr offen sind, Monat abschliessen. 11. Wenn keine Sessions im Monat mehr offen sind und keine Session mehr auf `Abrechenbar` steht, Monat abschliessen.
## Hinweise ## Hinweise
+147 -30
View File
@@ -67,10 +67,25 @@ 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, deleteTodo, getCurrentUser, getRecurringBillings, getSetupStatus, getTodos, logout, lookupTicket, searchTickets, updateTodo } from "./api"; import {
ApiError,
createActiveTimer,
deleteActiveTimer,
deleteTodo,
getActiveTimers,
getCurrentUser,
getRecurringBillings,
getSetupStatus,
getTodos,
logout,
lookupTicket,
searchTickets,
updateActiveTimer,
updateTodo
} from "./api";
import { currentDay, formatDateTime, formatMinutes, formatTimer } from "./format"; import { currentDay, formatDateTime, formatMinutes, formatTimer } from "./format";
import { isRecurringBillingTicketNumber, ticketHasRecurringBilling } from "./recurring-billings"; import { isRecurringBillingTicketNumber, ticketHasRecurringBilling } from "./recurring-billings";
import { activeElapsedMs, createClientId, pauseEntry, readStoredTimers, resumeEntry, storageKeyForUser, ticketPattern, type TimerEntry } from "./timers"; import { activeElapsedMs, readStoredTimers, storageKeyForUser, ticketPattern, type TimerEntry } from "./timers";
import { formatTodoPeriod, isTodoActiveOnDay, isTodoFixedOnDay, isTodoOverdue } from "./todos"; import { formatTodoPeriod, isTodoActiveOnDay, isTodoFixedOnDay, isTodoOverdue } from "./todos";
import { AdminUsersPage } from "./views/AdminUsersPage"; import { AdminUsersPage } from "./views/AdminUsersPage";
import { AnalysisPage } from "./views/AnalysisPage"; import { AnalysisPage } from "./views/AnalysisPage";
@@ -447,12 +462,12 @@ type SidebarTimersProps = {
timers: TimerEntry[]; timers: TimerEntry[];
selectedTimerId: string | null; selectedTimerId: string | null;
setSelectedTimerId: Dispatch<SetStateAction<string | null>>; setSelectedTimerId: Dispatch<SetStateAction<string | null>>;
setTimers: Dispatch<SetStateAction<TimerEntry[]>>;
tick: number; tick: number;
onToggleTimer: (timerId: string) => void;
onOpenTimer: () => void; onOpenTimer: () => void;
}; };
function SidebarTimers({ timers, selectedTimerId, setSelectedTimerId, setTimers, tick, onOpenTimer }: SidebarTimersProps) { function SidebarTimers({ timers, selectedTimerId, setSelectedTimerId, tick, onToggleTimer, onOpenTimer }: SidebarTimersProps) {
const sortedTimers = useMemo( const sortedTimers = useMemo(
() => () =>
[...timers].sort((first, second) => { [...timers].sort((first, second) => {
@@ -473,17 +488,8 @@ function SidebarTimers({ timers, selectedTimerId, setSelectedTimerId, setTimers,
return; return;
} }
const now = Date.now();
setSelectedTimerId(timerId); setSelectedTimerId(timerId);
setTimers((current) => onToggleTimer(timerId);
current.map((entry) => {
if (entry.id !== timerId) {
return pauseEntry(entry, now);
}
return timer.phase === "running" ? pauseEntry(entry, now) : resumeEntry(entry, now);
}),
);
} }
return ( return (
@@ -783,6 +789,7 @@ export function App() {
const [draggedOpenTicketKey, setDraggedOpenTicketKey] = useState<string | null>(null); const [draggedOpenTicketKey, setDraggedOpenTicketKey] = useState<string | null>(null);
const [sidebarTodos, setSidebarTodos] = useState<TicketTodo[]>([]); const [sidebarTodos, setSidebarTodos] = useState<TicketTodo[]>([]);
const openTicketsListRef = useRef<HTMLDivElement | null>(null); const openTicketsListRef = useRef<HTMLDivElement | null>(null);
const hydratingTimerIds = useRef(new Set<string>());
const navigate = useCallback( const navigate = useCallback(
(to: string) => { (to: string) => {
@@ -860,10 +867,48 @@ export function App() {
return; return;
} }
setTimers(readStoredTimers(currentUser.id)); let cancelled = false;
const userId = currentUser.id;
setTimersLoaded(false);
setTimers([]);
setOpenTickets(readOpenTickets(currentUser.id)); setOpenTickets(readOpenTickets(currentUser.id));
setTimerOwnerId(currentUser.id); setTimerOwnerId(currentUser.id);
async function loadTimers() {
try {
let serverTimers = (await getActiveTimers()).timers;
const storedTimers = readStoredTimers(userId);
if (serverTimers.length === 0 && storedTimers.length > 0) {
for (const timer of storedTimers) {
serverTimers = (await createActiveTimer(timer)).timers;
}
}
localStorage.removeItem(storageKeyForUser(userId));
if (!cancelled) {
setTimers(serverTimers);
}
} catch (error) {
if (!cancelled) {
setTimers(readStoredTimers(userId));
toast.error("Timer konnten nicht geladen werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
}
} finally {
if (!cancelled) {
setTimersLoaded(true); setTimersLoaded(true);
}
}
}
void loadTimers();
return () => {
cancelled = true;
};
}, [currentUser]); }, [currentUser]);
useEffect(() => { useEffect(() => {
@@ -887,8 +932,6 @@ export function App() {
return; return;
} }
localStorage.setItem(storageKeyForUser(currentUser.id), JSON.stringify(timers));
if (timers.length === 0) { if (timers.length === 0) {
setSelectedTimerId(null); setSelectedTimerId(null);
return; return;
@@ -899,6 +942,31 @@ export function App() {
} }
}, [currentUser, timers, selectedTimerId, timersLoaded, timerOwnerId]); }, [currentUser, timers, selectedTimerId, timersLoaded, timerOwnerId]);
const refreshActiveTimers = useCallback(async () => {
if (!currentUser || currentUser.role === "admin") {
return;
}
try {
const result = await getActiveTimers();
setTimers(result.timers);
} catch {
// Keep the currently displayed timers if the refresh fails.
}
}, [currentUser]);
useEffect(() => {
if (!currentUser || currentUser.role === "admin" || !timersLoaded || timerOwnerId !== currentUser.id) {
return;
}
window.addEventListener("focus", refreshActiveTimers);
return () => {
window.removeEventListener("focus", refreshActiveTimers);
};
}, [currentUser, refreshActiveTimers, timersLoaded, timerOwnerId]);
const loadSidebarTodos = useCallback(async () => { const loadSidebarTodos = useCallback(async () => {
if (!currentUser || currentUser.role === "admin") { if (!currentUser || currentUser.role === "admin") {
return; return;
@@ -938,15 +1006,17 @@ export function App() {
return; return;
} }
const timersToHydrate = timers.filter((timer) => !timer.ticketLookupDone); const timersToHydrate = timers.filter((timer) => !timer.ticketLookupDone && !hydratingTimerIds.current.has(timer.id));
if (timersToHydrate.length === 0) { if (timersToHydrate.length === 0) {
return; return;
} }
let cancelled = false; let cancelled = false;
timersToHydrate.forEach((timer) => hydratingTimerIds.current.add(timer.id));
async function hydrateTimers() { async function hydrateTimers() {
try {
const results = await Promise.all( const results = await Promise.all(
timersToHydrate.map(async (timer) => { timersToHydrate.map(async (timer) => {
try { try {
@@ -992,6 +1062,21 @@ export function App() {
}; };
}) })
); );
await Promise.all(
results.map((result) =>
updateActiveTimer(result.id, {
organizationName: result.organizationName,
workType: result.workType,
isProject: result.isProject,
projectName: result.projectName,
ticketLookupDone: true
}).catch(() => undefined)
)
);
} finally {
timersToHydrate.forEach((timer) => hydratingTimerIds.current.delete(timer.id));
}
} }
void hydrateTimers(); void hydrateTimers();
@@ -1006,6 +1091,25 @@ export function App() {
return () => window.clearInterval(interval); return () => window.clearInterval(interval);
}, []); }, []);
async function toggleActiveTimer(timerId: string) {
const timer = timers.find((entry) => entry.id === timerId);
if (!timer) {
return;
}
try {
const result = await updateActiveTimer(timerId, {
phase: timer.phase === "running" ? "paused" : "running"
});
setTimers(result.timers);
} catch (error) {
toast.error("Timer konnte nicht aktualisiert werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
}
}
useEffect(() => { useEffect(() => {
if (!currentUser || currentUser.role !== "admin") { if (!currentUser || currentUser.role !== "admin") {
return; return;
@@ -1041,7 +1145,9 @@ export function App() {
]); ]);
if (ticketHasRecurringBilling(ticketResult.ticket, recurringResult.recurringBillings) || isRecurringBillingTicketNumber(ticketNumber, recurringResult.recurringBillings)) { if (ticketHasRecurringBilling(ticketResult.ticket, recurringResult.recurringBillings) || isRecurringBillingTicketNumber(ticketNumber, recurringResult.recurringBillings)) {
setTimers((current) => current.filter((timer) => timer.ticketNumber !== ticketNumber)); const existingRecurringTimer = timers.find((timer) => timer.ticketNumber === ticketNumber);
const result = existingRecurringTimer ? await deleteActiveTimer(existingRecurringTimer.id).catch(() => null) : null;
setTimers((current) => result?.timers ?? current.filter((timer) => timer.ticketNumber !== ticketNumber));
toast.error("Fixe Abrechnung", { toast.error("Fixe Abrechnung", {
description: "Für fixe Abrechnungen dürfen keine manuellen Sessions gestartet werden. Bitte die Definition bearbeiten." description: "Für fixe Abrechnungen dürfen keine manuellen Sessions gestartet werden. Bitte die Definition bearbeiten."
}); });
@@ -1052,18 +1158,24 @@ export function App() {
const now = Date.now(); const now = Date.now();
if (existingTimer) { if (existingTimer) {
setTimers((current) => try {
current.map((timer) => (timer.id === existingTimer.id ? resumeEntry(timer, now) : pauseEntry(timer, now))) const result = await updateActiveTimer(existingTimer.id, { phase: "running" });
); setTimers(result.timers);
setSelectedTimerId(existingTimer.id); setSelectedTimerId(existingTimer.id);
toast.info("Bestehender Timer aktiviert", { toast.info("Bestehender Timer aktiviert", {
description: ticketNumber description: ticketNumber
}); });
return true; return true;
} catch (error) {
toast.error("Timer konnte nicht aktiviert werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
return false;
}
} }
const newTimer: TimerEntry = { try {
id: createClientId("timer"), const result = await createActiveTimer({
ticketNumber, ticketNumber,
organizationName: ticketResult.ticket?.organization_name ?? ticketResult.ticket?.customer_name ?? null, organizationName: ticketResult.ticket?.organization_name ?? ticketResult.ticket?.customer_name ?? null,
workType: ticketResult.ticket?.work_type ?? null, workType: ticketResult.ticket?.work_type ?? null,
@@ -1076,14 +1188,19 @@ export function App() {
activity: "", activity: "",
billingStatus: null, billingStatus: null,
phase: "running" phase: "running"
}; });
setTimers(result.timers);
setTimers((current) => [...current.map((timer) => pauseEntry(timer, now)), newTimer]); setSelectedTimerId(result.timers.find((timer) => timer.ticketNumber === ticketNumber)?.id ?? null);
setSelectedTimerId(newTimer.id);
toast.success("Timer gestartet", { toast.success("Timer gestartet", {
description: ticketResult.ticket?.is_project ? `${ticketNumber} ist ein Projekt` : ticketNumber description: ticketResult.ticket?.is_project ? `${ticketNumber} ist ein Projekt` : ticketNumber
}); });
return true; return true;
} catch (error) {
toast.error("Timer konnte nicht gestartet werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
return false;
}
} }
async function handleLogout() { async function handleLogout() {
@@ -1281,8 +1398,8 @@ export function App() {
timers={timers} timers={timers}
selectedTimerId={selectedTimerId} selectedTimerId={selectedTimerId}
setSelectedTimerId={setSelectedTimerId} setSelectedTimerId={setSelectedTimerId}
setTimers={setTimers}
tick={tick} tick={tick}
onToggleTimer={(timerId) => void toggleActiveTimer(timerId)}
onOpenTimer={() => navigate("/timer")} onOpenTimer={() => navigate("/timer")}
/> />
</> </>
@@ -1380,7 +1497,7 @@ export function App() {
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton tooltip="Worklog"> <SidebarMenuButton tooltip="Worklog">
<FolderGit /> <FolderGit />
<span className="text-xs text-muted-foreground">v1.0.1-rc1</span> <span className="text-xs text-muted-foreground">v1.0.10-rc1</span>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
+103 -5
View File
@@ -19,11 +19,13 @@ import type {
WorkType, WorkType,
ZammadSessionArticleQueueItem ZammadSessionArticleQueueItem
} from "./types"; } from "./types";
import type { TimerEntry, TimerPhase } from "./timers";
type CreateSessionPayload = { type CreateSessionPayload = {
ticketNumber: string; ticketNumber: string;
organizationId: string; organizationId: string;
activity: string; activity: string;
zammadArticleBody?: string | null;
workType: WorkType; workType: WorkType;
startedAt: string; startedAt: string;
endedAt: string; endedAt: string;
@@ -35,6 +37,22 @@ type CreateSessionPayload = {
zammadArticleTypeId?: number; zammadArticleTypeId?: number;
}; };
type CreateTimerPayload = Omit<TimerEntry, "id"> & {
id?: string;
};
type UpdateTimerPayload = Partial<{
organizationName: string | null;
workType: TimerEntry["workType"];
isProject: boolean;
projectName: string | null;
ticketLookupDone: boolean;
activity: string;
zammadArticleBody?: string | null;
billingStatus: TimerEntry["billingStatus"];
phase: TimerPhase;
}>;
type DeleteSessionResult = { type DeleteSessionResult = {
deleted: { deleted: {
id: string; id: string;
@@ -50,7 +68,7 @@ type DeleteSessionResult = {
export type CreateSessionResult = { export type CreateSessionResult = {
zammadArticle?: { zammadArticle?: {
requested: boolean; requested: boolean;
status: "not_requested" | "sent" | "queued"; status: "not_requested" | "sent" | "failed";
error: string | null; error: string | null;
articleId: number | null; articleId: number | null;
}; };
@@ -106,6 +124,25 @@ export type ZammadSyncJob = {
finishedAt: string | null; finishedAt: string | null;
}; };
export type TicketZammadMailSessionItem = {
articleId: number;
ticketNumber: string;
subject: string | null;
activity: string;
createdAt: string;
endedAt: string;
timeUnit: number;
durationSeconds: number;
};
export type TicketZammadMailSessionResult = {
checked: number;
created: number;
skipped: number;
items: TicketZammadMailSessionItem[];
errors: Array<{ articleId: number; error: string }>;
};
export type RestoreValidationResult = { export type RestoreValidationResult = {
valid: boolean; valid: boolean;
checkedTables: number; checkedTables: number;
@@ -215,6 +252,7 @@ export function updateCurrentUser(payload: {
zammadSessionNoteDefaultEnabled?: boolean; zammadSessionNoteDefaultEnabled?: boolean;
zammadTimeUnitRoundingEnabled?: boolean; zammadTimeUnitRoundingEnabled?: boolean;
zammadTimeUnitRoundingMinutes?: number; zammadTimeUnitRoundingMinutes?: number;
zammadAtossEmailSyncEnabled?: boolean;
zammadArticleTypeId?: number; zammadArticleTypeId?: number;
}) { }) {
return request<{ user: AuthUser }>("/api/auth/me", { return request<{ user: AuthUser }>("/api/auth/me", {
@@ -479,6 +517,20 @@ export function applyOwnZammadConsultingTicketsFromPreview(previewJobId: string,
}, onJobStarted); }, onJobStarted);
} }
export function previewZammadMailSessions(daysBack: number, perPage: number, onJobStarted?: (job: ZammadSyncJob) => void) {
return runZammadSyncJob("/api/zammad-sync/jobs/mail-sessions", {
dryRun: true,
daysBack,
perPage
}, onJobStarted);
}
export function applyZammadMailSessionsFromPreview(previewJobId: string, articleIds: number[], onJobStarted?: (job: ZammadSyncJob) => void) {
return runZammadSyncJob(`/api/zammad-sync/jobs/${encodeURIComponent(previewJobId)}/apply-mail-sessions`, {
articleIds
}, onJobStarted);
}
export function getRecurringBillings() { export function getRecurringBillings() {
return request<{ recurringBillings: RecurringBilling[] }>("/api/recurring-billings"); return request<{ recurringBillings: RecurringBilling[] }>("/api/recurring-billings");
} }
@@ -564,6 +616,7 @@ export function createProject(payload: {
teamspaceEntries?: Array<{ teamspaceEntries?: Array<{
id?: string | null; id?: string | null;
teamspaceProject?: string | null; teamspaceProject?: string | null;
teamspaceDescription?: string | null;
isDefault: boolean; isDefault: boolean;
hourBudgetMinutes?: number | null; hourBudgetMinutes?: number | null;
budgetSyncDisabled?: boolean; budgetSyncDisabled?: boolean;
@@ -592,6 +645,7 @@ export function updateProject(
teamspaceEntries?: Array<{ teamspaceEntries?: Array<{
id?: string | null; id?: string | null;
teamspaceProject?: string | null; teamspaceProject?: string | null;
teamspaceDescription?: string | null;
isDefault: boolean; isDefault: boolean;
hourBudgetMinutes?: number | null; hourBudgetMinutes?: number | null;
budgetSyncDisabled?: boolean; budgetSyncDisabled?: boolean;
@@ -699,6 +753,37 @@ export function createSession(payload: CreateSessionPayload) {
}); });
} }
export function resendSessionZammadArticle(sessionId: string, payload: { zammadArticleTypeId?: number } = {}) {
return request<CreateSessionResult>(`/api/sessions/${encodeURIComponent(sessionId)}/zammad-article`, {
method: "POST",
body: JSON.stringify(payload)
});
}
export function getActiveTimers() {
return request<{ timers: TimerEntry[] }>("/api/timers");
}
export function createActiveTimer(payload: CreateTimerPayload) {
return request<{ timers: TimerEntry[] }>("/api/timers", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function updateActiveTimer(timerId: string, payload: UpdateTimerPayload) {
return request<{ timers: TimerEntry[] }>(`/api/timers/${encodeURIComponent(timerId)}`, {
method: "PATCH",
body: JSON.stringify(payload)
});
}
export function deleteActiveTimer(timerId: string) {
return request<{ timers: TimerEntry[] }>(`/api/timers/${encodeURIComponent(timerId)}`, {
method: "DELETE"
});
}
export function getZammadSessionArticleQueue() { export function getZammadSessionArticleQueue() {
return request<{ queue: ZammadSessionArticleQueueItem[] }>("/api/zammad/session-article-queue"); return request<{ queue: ZammadSessionArticleQueueItem[] }>("/api/zammad/session-article-queue");
} }
@@ -749,6 +834,7 @@ export function updateSessionDetails(
payload: { payload: {
organizationId: string; organizationId: string;
activity: string; activity: string;
zammadArticleBody?: string | null;
workType: WorkType; workType: WorkType;
removeProjectData?: boolean; removeProjectData?: boolean;
projectTeamspaceEntryId?: string | null; projectTeamspaceEntryId?: string | null;
@@ -762,10 +848,10 @@ export function updateSessionDetails(
}); });
} }
export function updateTicketDayBilling(ticketId: string, day: string, billedMinutes: number | null) { export function updateTicketDayBilling(ticketId: string, day: string, billedMinutes: number | null, projectTeamspaceEntryId?: string | null) {
return request(`/api/tickets/${ticketId}/day-billings/${day}`, { return request(`/api/tickets/${ticketId}/day-billings/${day}`, {
method: "PATCH", method: "PATCH",
body: JSON.stringify({ billedMinutes }) body: JSON.stringify({ billedMinutes, projectTeamspaceEntryId: projectTeamspaceEntryId ?? null })
}); });
} }
@@ -792,8 +878,8 @@ export function getPeriodOverview(type: PeriodType, period: string) {
return request<PeriodOverview>(`/api/periods/${periodPath(type)}/${period}/overview`); return request<PeriodOverview>(`/api/periods/${periodPath(type)}/${period}/overview`);
} }
export function getStatisticsOverview(month: string) { export function getStatisticsOverview(period: string, type: "month" | "week" = "month") {
return request<StatisticsOverview>(`/api/statistics/months/${month}`); return request<StatisticsOverview>(`/api/statistics/${type === "week" ? "weeks" : "months"}/${period}`);
} }
export function getTicketPeriod(type: PeriodType, period: string, ticketId: string) { export function getTicketPeriod(type: PeriodType, period: string, ticketId: string) {
@@ -825,6 +911,18 @@ export function getTicketDetails(
return request<TicketPeriod>(`/api/tickets/${ticketId}/details${params.size ? `?${params}` : ""}`); return request<TicketPeriod>(`/api/tickets/${ticketId}/details${params.size ? `?${params}` : ""}`);
} }
export function previewTicketZammadMailSessions(ticketId: string, daysBack: number) {
const params = new URLSearchParams({ daysBack: String(daysBack) });
return request<TicketZammadMailSessionResult>(`/api/tickets/${ticketId}/zammad-mail-sessions?${params}`);
}
export function importTicketZammadMailSessions(ticketId: string, daysBack: number, articleIds: number[]) {
return request<TicketZammadMailSessionResult>(`/api/tickets/${ticketId}/zammad-mail-sessions`, {
method: "POST",
body: JSON.stringify({ daysBack, articleIds })
});
}
export function updateSessionBilling(sessionId: string, billingStatus: BillingStatus) { export function updateSessionBilling(sessionId: string, billingStatus: BillingStatus) {
return request(`/api/sessions/${sessionId}/billing`, { return request(`/api/sessions/${sessionId}/billing`, {
method: "PATCH", method: "PATCH",
@@ -0,0 +1,283 @@
import { Bold, Heading1, Heading2, Heading3, Italic, List, ListOrdered, RemoveFormatting, Strikethrough, Underline } from "lucide-react";
import { type ClipboardEvent, type KeyboardEvent, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
type RichActivityEditorProps = {
id: string;
value: string;
htmlValue: string;
disabled?: boolean;
placeholder?: string;
onChange: (value: string, htmlValue: string) => void;
};
function escapeHtml(value: string) {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function textToEditorHtml(value: string) {
return value
.split(/\r?\n/)
.map((line) => `<div>${line ? escapeHtml(line) : "<br>"}</div>`)
.join("");
}
function fileToDataUrl(file: File) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.addEventListener("load", () => resolve(String(reader.result ?? "")));
reader.addEventListener("error", () => reject(reader.error ?? new Error("Bild konnte nicht gelesen werden")));
reader.readAsDataURL(file);
});
}
function currentSelectionRange() {
const selection = window.getSelection();
return selection && selection.rangeCount > 0 ? selection.getRangeAt(0).cloneRange() : null;
}
function restoreSelectionRange(range: Range | null) {
if (!range) {
return;
}
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
}
function editorPlainText(element: HTMLElement) {
return element.innerText.replace(/\u00a0/g, " ");
}
function hasInlineImage(element: HTMLElement) {
return Boolean(element.querySelector("img[src^='data:image/']"));
}
function normalizedEditorHtml(element: HTMLElement) {
const html = element.innerHTML;
return html && (editorPlainText(element).trim() || hasInlineImage(element)) ? html : "";
}
function editorButtonClass(active = false) {
return cn(
"inline-flex size-8 items-center justify-center rounded-sm text-sm font-semibold text-zinc-100 outline-none transition-colors hover:bg-zinc-700 focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
active && "bg-zinc-700"
);
}
export function RichActivityEditor({
id,
value,
htmlValue,
disabled = false,
placeholder = "Tätigkeit beschreiben, Screenshot mit Strg+V einfügen",
onChange
}: RichActivityEditorProps) {
const editorRef = useRef<HTMLDivElement | null>(null);
const [selectedImage, setSelectedImage] = useState<HTMLImageElement | null>(null);
useEffect(() => {
const editor = editorRef.current;
if (!editor) {
return;
}
const nextHtml = htmlValue || (value ? textToEditorHtml(value) : "");
if (editor.innerHTML !== nextHtml) {
editor.innerHTML = nextHtml;
}
}, [htmlValue, value]);
function clearSelectedImage() {
selectedImage?.classList.remove("ring-2", "ring-ring", "ring-offset-2");
setSelectedImage(null);
}
function emitChange() {
const editor = editorRef.current;
if (!editor) {
return;
}
onChange(editorPlainText(editor), normalizedEditorHtml(editor));
}
function focusEditor() {
editorRef.current?.focus();
}
function runCommand(command: string, value?: string) {
if (disabled) {
return;
}
focusEditor();
document.execCommand("styleWithCSS", false, "false");
document.execCommand(command, false, value);
emitChange();
}
function selectImage(image: HTMLImageElement) {
clearSelectedImage();
image.classList.add("ring-2", "ring-ring", "ring-offset-2");
setSelectedImage(image);
const range = document.createRange();
range.selectNode(image);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
}
function removeSelectedImage() {
const image = selectedImage;
if (!image) {
return;
}
image.remove();
setSelectedImage(null);
emitChange();
}
function handleKeyDown(event: KeyboardEvent<HTMLDivElement>) {
if ((event.key === "Delete" || event.key === "Backspace") && selectedImage) {
event.preventDefault();
removeSelectedImage();
return;
}
if (event.key === "Tab" && !disabled) {
event.preventDefault();
document.execCommand("insertText", false, " ");
emitChange();
}
}
async function handlePaste(event: ClipboardEvent<HTMLDivElement>) {
if (disabled) {
return;
}
const imageFiles = Array.from(event.clipboardData.items)
.filter((item) => item.kind === "file" && item.type.startsWith("image/"))
.map((item) => item.getAsFile())
.filter((file): file is File => Boolean(file));
if (imageFiles.length === 0) {
const text = event.clipboardData.getData("text/plain");
if (text) {
event.preventDefault();
document.execCommand("styleWithCSS", false, "false");
document.execCommand("insertText", false, text);
emitChange();
}
return;
}
event.preventDefault();
clearSelectedImage();
let insertionRange = currentSelectionRange();
const text = event.clipboardData.getData("text/plain").trim();
if (text) {
document.execCommand("styleWithCSS", false, "false");
document.execCommand("insertText", false, `${text}\n`);
insertionRange = currentSelectionRange();
}
try {
for (const file of imageFiles) {
const dataUrl = await fileToDataUrl(file);
restoreSelectionRange(insertionRange);
document.execCommand("insertHTML", false, `<img src="${dataUrl}" style="display:block;max-width:100%;height:auto;margin-top:0.5rem;border-radius:0.375rem;"><br>`);
insertionRange = currentSelectionRange();
}
emitChange();
} catch (error) {
toast.error("Screenshot konnte nicht eingefügt werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
}
}
const toolbarDisabled = disabled;
return (
<div className={cn("overflow-hidden rounded-lg border border-input bg-transparent", disabled && "opacity-60")}>
<div className="flex flex-wrap items-center gap-1 border-b border-zinc-700 bg-zinc-900 px-2 py-1">
<button type="button" className={editorButtonClass()} disabled={toolbarDisabled} title="Fett" onMouseDown={(event) => event.preventDefault()} onClick={() => runCommand("bold")}>
<Bold className="size-4" />
</button>
<button type="button" className={editorButtonClass()} disabled={toolbarDisabled} title="Kursiv" onMouseDown={(event) => event.preventDefault()} onClick={() => runCommand("italic")}>
<Italic className="size-4" />
</button>
<button type="button" className={editorButtonClass()} disabled={toolbarDisabled} title="Unterstrichen" onMouseDown={(event) => event.preventDefault()} onClick={() => runCommand("underline")}>
<Underline className="size-4" />
</button>
<button type="button" className={editorButtonClass()} disabled={toolbarDisabled} title="Durchgestrichen" onMouseDown={(event) => event.preventDefault()} onClick={() => runCommand("strikeThrough")}>
<Strikethrough className="size-4" />
</button>
<button type="button" className={editorButtonClass()} disabled={toolbarDisabled} title="Formatierung entfernen" onMouseDown={(event) => event.preventDefault()} onClick={() => runCommand("removeFormat")}>
<RemoveFormatting className="size-4" />
</button>
<span className="mx-1 h-6 w-px bg-zinc-700" aria-hidden="true" />
<button type="button" className={editorButtonClass()} disabled={toolbarDisabled} title="Überschrift 1" onMouseDown={(event) => event.preventDefault()} onClick={() => runCommand("formatBlock", "h1")}>
<Heading1 className="size-4" />
</button>
<button type="button" className={editorButtonClass()} disabled={toolbarDisabled} title="Überschrift 2" onMouseDown={(event) => event.preventDefault()} onClick={() => runCommand("formatBlock", "h2")}>
<Heading2 className="size-4" />
</button>
<button type="button" className={editorButtonClass()} disabled={toolbarDisabled} title="Überschrift 3" onMouseDown={(event) => event.preventDefault()} onClick={() => runCommand("formatBlock", "h3")}>
<Heading3 className="size-4" />
</button>
<span className="mx-1 h-6 w-px bg-zinc-700" aria-hidden="true" />
<button type="button" className={editorButtonClass()} disabled={toolbarDisabled} title="Nummerierte Liste" onMouseDown={(event) => event.preventDefault()} onClick={() => runCommand("insertOrderedList")}>
<ListOrdered className="size-4" />
</button>
<button type="button" className={editorButtonClass()} disabled={toolbarDisabled} title="Liste" onMouseDown={(event) => event.preventDefault()} onClick={() => runCommand("insertUnorderedList")}>
<List className="size-4" />
</button>
</div>
<div
id={id}
ref={editorRef}
role="textbox"
aria-multiline="true"
contentEditable={!disabled}
suppressContentEditableWarning
className="min-h-24 w-full overflow-hidden bg-transparent px-2.5 py-2 text-base outline-none transition-colors empty:before:text-muted-foreground empty:before:content-[attr(data-placeholder)] focus-visible:ring-3 focus-visible:ring-ring/50 md:text-sm [&_h1]:text-2xl [&_h1]:font-semibold [&_h2]:text-xl [&_h2]:font-semibold [&_h3]:text-lg [&_h3]:font-semibold [&_img]:my-2 [&_img]:max-h-[28rem] [&_img]:max-w-full [&_img]:rounded-md [&_ol]:ml-5 [&_ol]:list-decimal [&_ul]:ml-5 [&_ul]:list-disc"
data-placeholder={placeholder}
onInput={emitChange}
onBlur={() => {
clearSelectedImage();
emitChange();
}}
onClick={(event) => {
const target = event.target;
if (target instanceof HTMLImageElement) {
selectImage(target);
return;
}
clearSelectedImage();
}}
onKeyDown={handleKeyDown}
onPaste={(event) => void handlePaste(event)}
/>
</div>
);
}
+11
View File
@@ -53,6 +53,17 @@ export function currentMonth() {
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`; return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
} }
export function currentWeek() {
const now = new Date();
const date = new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()));
const weekday = date.getUTCDay() || 7;
date.setUTCDate(date.getUTCDate() + 4 - weekday);
const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
const week = Math.ceil(((date.getTime() - yearStart.getTime()) / 86_400_000 + 1) / 7);
return `${date.getUTCFullYear()}-W${String(week).padStart(2, "0")}`;
}
export function currentDay() { export function currentDay() {
const now = new Date(); const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`; return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
+11 -11
View File
@@ -25,7 +25,7 @@ export const helpSections: HelpSection[] = [
"Eine Session ist immer einem Benutzer zugeordnet. In den normalen Auswertungen sieht jeder Benutzer nur seine eigenen Sessions.", "Eine Session ist immer einem Benutzer zugeordnet. In den normalen Auswertungen sieht jeder Benutzer nur seine eigenen Sessions.",
"Ticketdaten wie Organisation und Art können aus bestehenden Tickets übernommen werden, auch wenn ein anderer Benutzer das Ticket schon bearbeitet hat.", "Ticketdaten wie Organisation und Art können aus bestehenden Tickets übernommen werden, auch wenn ein anderer Benutzer das Ticket schon bearbeitet hat.",
"Consulting-Projekte können einen oder mehrere Teamspace-Einträge haben. Einer davon ist immer der Standard-Eintrag.", "Consulting-Projekte können einen oder mehrere Teamspace-Einträge haben. Einer davon ist immer der Standard-Eintrag.",
"Tickets werden nicht abgeschlossen. Sie gruppieren Sessions. Relevant für den Abschluss ist nur, ob alle Sessions eines Monats bewertet sind.", "Tickets werden nicht abgeschlossen. Sie gruppieren Sessions. Relevant für den Abschluss ist nur, ob alle Sessions eines Monats final bewertet sind.",
"Der Monatsabschluss sperrt den Monat gegen versehentliche Änderungen. Muss doch noch etwas korrigiert werden, kann der Monat wieder geöffnet werden." "Der Monatsabschluss sperrt den Monat gegen versehentliche Änderungen. Muss doch noch etwas korrigiert werden, kann der Monat wieder geöffnet werden."
] ]
}, },
@@ -47,14 +47,14 @@ export const helpSections: HelpSection[] = [
"Hat ein Consulting-Projekt mehrere Teamspace-Einträge, wählst du beim Speichern der Session den passenden Eintrag aus. Bei genau einem Teamspace-Eintrag ordnet TicketTracker automatisch zu.", "Hat ein Consulting-Projekt mehrere Teamspace-Einträge, wählst du beim Speichern der Session den passenden Eintrag aus. Bei genau einem Teamspace-Eintrag ordnet TicketTracker automatisch zu.",
"Vergessene Sessions trägst du auf der Seite Session starten im Bereich Session nachtragen nach. Wenn das Ticket bereits in der Ticketliste geöffnet ist, nutzt du in der Ticketdetailansicht ebenfalls Session nachtragen; dann ist die Zuordnung zum Ticket bereits vorbelegt.", "Vergessene Sessions trägst du auf der Seite Session starten im Bereich Session nachtragen nach. Wenn das Ticket bereits in der Ticketliste geöffnet ist, nutzt du in der Ticketdetailansicht ebenfalls Session nachtragen; dann ist die Zuordnung zum Ticket bereits vorbelegt.",
"Unter Tickets wählst du Monat oder Tag, öffnest ein Ticket über Öffnen und siehst dort alle Sessions nach Tag gruppiert.", "Unter Tickets wählst du Monat oder Tag, öffnest ein Ticket über Öffnen und siehst dort alle Sessions nach Tag gruppiert.",
"Im Ticket bewertest du jede Session mit Abrechenbar, Abgerechnet oder Nicht abrechenbar. Ein erneuter Klick auf den aktiven Status nimmt die Bewertung zurück.", "Im Ticket setzt du je Session den Status Abrechenbar, Abgerechnet oder Nicht abrechenbar. Abrechenbar bleibt eine offene Bewertung, bis daraus Abgerechnet oder Nicht abrechenbar wird.",
"Im Ticket steht pro Tag das Feld Teamspace. Dort trägst du die Stunden ein, die du für genau dieses Ticket an genau diesem Tag wirklich im CRM beziehungsweise Teamspace erfasst hast. Nach einer Änderung erscheint der Speichern-Button.", "Im Ticket steht pro Tag das Feld Teamspace. Dort trägst du die Stunden ein, die du für genau dieses Ticket an genau diesem Tag wirklich im CRM beziehungsweise Teamspace erfasst hast. Nach einer Änderung erscheint der Speichern-Button.",
"Die Teamspace-Zeit ist ein Tageswert pro Ticket und Benutzer. Monats- und Tagesansicht zeigen denselben Wert; er muss also nur einmal gepflegt werden.", "Die Teamspace-Zeit ist ein Tageswert pro Ticket und Benutzer. Monats- und Tagesansicht zeigen denselben Wert; er muss also nur einmal gepflegt werden.",
"Am Monatsende öffnest du Monatsabschluss. Dort findest du offene Bewertungen gruppiert nach Ticket, Tage ohne Teamspace-Wert und die Schaltfläche Monat abschließen.", "Am Monatsende öffnest du Monatsabschluss. Dort findest du offene Bewertungen gruppiert nach Ticket, Tage ohne Teamspace-Wert und die Schaltfläche Monat abschließen.",
"Offene Bewertungen sind Pflicht: Öffne das Ticket aus der Liste, bewerte die Sessions und kehre zum Monatsabschluss zurück.", "Offene Bewertungen sind Pflicht: Öffne das Ticket aus der Liste, bewerte die Sessions und kehre zum Monatsabschluss zurück.",
"Tage ohne Teamspace-Wert sind eine Prüfliste. Wenn die Arbeit wirklich nicht in Teamspace eingetragen werden soll, nutzt du Zur Kenntnis. Mit Zur Kenntnis anzeigen blendest du solche Einträge wieder ein und kannst die Kenntnisnahme zurücknehmen.", "Tage ohne Teamspace-Wert sind eine Prüfliste. Wenn die Arbeit wirklich nicht in Teamspace eingetragen werden soll, nutzt du Zur Kenntnis. Mit Zur Kenntnis anzeigen blendest du solche Einträge wieder ein und kannst die Kenntnisnahme zurücknehmen.",
"Kontrolliere vor dem Abschluss die Kennzahl Teamspace im Monatsabschluss. Idealerweise entspricht dieser Wert der Summe, die du im CRM beziehungsweise Teamspace für den Monat eingetragen hast.", "Kontrolliere vor dem Abschluss die Kennzahl Teamspace im Monatsabschluss. Idealerweise entspricht dieser Wert der Summe, die du im CRM beziehungsweise Teamspace für den Monat eingetragen hast.",
"Wenn alle Sessions bewertet sind und die Teamspace-Kontrolle passt, schließt du den Monat mit Monat abschließen. Danach sind Bewertungen und Änderungen für den Monat geschützt, bis der Monat wieder geöffnet wird." "Wenn keine offenen Bewertungen mehr vorhanden sind und die Teamspace-Kontrolle passt, schließt du den Monat mit Monat abschließen. Danach sind Bewertungen und Änderungen für den Monat geschützt, bis der Monat wieder geöffnet wird."
], ],
workflow: [ workflow: [
"Ticketfeld im Kopfbereich oder Session starten öffnen.", "Ticketfeld im Kopfbereich oder Session starten öffnen.",
@@ -239,16 +239,16 @@ export const helpSections: HelpSection[] = [
title: "Sessions Bewerten", title: "Sessions Bewerten",
description: "Bewertungen bestimmen, ob eine Session offen, abrechenbar, abgerechnet oder bewusst nicht abrechenbar ist.", description: "Bewertungen bestimmen, ob eine Session offen, abrechenbar, abgerechnet oder bewusst nicht abrechenbar ist.",
quickItems: [ quickItems: [
"Jede Session braucht genau eine Bewertung.", "Jede Session braucht vor dem Monatsabschluss einen finalen Status.",
"Abrechenbar, Abgerechnet und Nicht abrechenbar können durch erneuten Klick zurückgenommen werden.", "Abrechenbar, Abgerechnet und Nicht abrechenbar können durch erneuten Klick zurückgenommen werden.",
"Offene Bewertungen blockieren den Monatsabschluss." "Offene Bewertungen blockieren den Monatsabschluss."
], ],
details: [ details: [
"Abrechenbar bedeutet: Die Session ist bewertet, aber noch nicht als abgerechnet/Teamspace-relevant markiert.", "Abrechenbar bedeutet: Die Session ist als potenziell abrechenbar markiert, bleibt aber für Abschluss und offene Bewertungen noch offen.",
"Abgerechnet bedeutet: Die Session wurde für die Abrechnung beziehungsweise im CRM berücksichtigt.", "Abgerechnet bedeutet: Die Session wurde für die Abrechnung beziehungsweise im CRM berücksichtigt.",
"Nicht abrechenbar bedeutet: Die Session gehört zum Arbeitsnachweis, wird aber nicht abgerechnet.", "Nicht abrechenbar bedeutet: Die Session gehört zum Arbeitsnachweis, wird aber nicht abgerechnet.",
"Ein erneuter Klick auf den aktiven Bewertungsbutton entfernt die Bewertung und macht die Session wieder offen.", "Ein erneuter Klick auf den aktiven Bewertungsbutton entfernt die Bewertung und macht die Session wieder offen.",
"Abrechenbare und nicht abrechenbare Sessions zählen als bewertet. Sie blockieren den Monatsabschluss nicht.", "Nur Abgerechnet und Nicht abrechenbar schließen die Bewertung ab. Abrechenbar blockiert den Monatsabschluss weiterhin.",
"Wenn ein Monat abgeschlossen ist, sind Bewertungen gesperrt. Öffne den Monat zuerst wieder, wenn du etwas korrigieren musst.", "Wenn ein Monat abgeschlossen ist, sind Bewertungen gesperrt. Öffne den Monat zuerst wieder, wenn du etwas korrigieren musst.",
"Die offenen Bewertungen findest du gebündelt im Monatsabschluss." "Die offenen Bewertungen findest du gebündelt im Monatsabschluss."
] ]
@@ -345,7 +345,7 @@ export const helpSections: HelpSection[] = [
"Der Monat kann wieder geöffnet werden." "Der Monat kann wieder geöffnet werden."
], ],
details: [ details: [
"Ein Monat kann abgeschlossen werden, sobald alle Sessions dieses Monats bewertet sind.", "Ein Monat kann abgeschlossen werden, sobald keine offenen Bewertungen mehr vorhanden sind.",
"Tage ohne Teamspace-Wert blockieren den Abschluss nicht automatisch. Sie sind eine Prüfliste für CRM-Abgleich und Nacharbeit.", "Tage ohne Teamspace-Wert blockieren den Abschluss nicht automatisch. Sie sind eine Prüfliste für CRM-Abgleich und Nacharbeit.",
"Wenn eine Session bewusst nicht in Teamspace eingetragen wird, kann der fehlende Tageswert zur Kenntnis genommen werden.", "Wenn eine Session bewusst nicht in Teamspace eingetragen wird, kann der fehlende Tageswert zur Kenntnis genommen werden.",
"Zur Kenntnis genommene Einträge können über die Checkbox Zur Kenntnis anzeigen wieder eingeblendet werden.", "Zur Kenntnis genommene Einträge können über die Checkbox Zur Kenntnis anzeigen wieder eingeblendet werden.",
@@ -441,9 +441,9 @@ export const helpSections: HelpSection[] = [
"Die Klärung erfolgt in der Ticketdetailansicht." "Die Klärung erfolgt in der Ticketdetailansicht."
], ],
details: [ details: [
"Offen umfasst alle Sessions des Monats ohne Bewertung.", "Offen umfasst alle Sessions des Monats ohne finalen Status sowie Sessions mit dem Zwischenstatus Abrechenbar.",
"Diese Sessions müssen vor dem Monatsabschluss bewertet werden.", "Diese Sessions müssen vor dem Monatsabschluss auf Abgerechnet oder Nicht abrechenbar gesetzt werden.",
"Die offene Zeit zeigt, wie viele Minuten noch bewertet werden müssen.", "Die offene Zeit zeigt, wie viele Minuten noch final bewertet werden müssen.",
"Der Monatsabschluss listet offene Bewertungen gruppiert nach Ticket, damit du direkt in die passende Ticketdetailansicht springen kannst.", "Der Monatsabschluss listet offene Bewertungen gruppiert nach Ticket, damit du direkt in die passende Ticketdetailansicht springen kannst.",
"Wird eine Bewertung zurückgenommen, steigt die offene Anzahl wieder." "Wird eine Bewertung zurückgenommen, steigt die offene Anzahl wieder."
] ]
@@ -540,7 +540,7 @@ export const helpSections: HelpSection[] = [
"Der persönliche Zammad Token ist vom Admin-Token für den Organisations-Sync getrennt. Er wird für ticketbezogene Zammad-Abgleiche, Consulting-Importe und die Zuordnung zum Zammad-Owner verwendet.", "Der persönliche Zammad Token ist vom Admin-Token für den Organisations-Sync getrennt. Er wird für ticketbezogene Zammad-Abgleiche, Consulting-Importe und die Zuordnung zum Zammad-Owner verwendet.",
"Die Bewertungsabfrage beim Timerabschluss kann im Profil ein- oder ausgeschaltet werden. Sie erscheint nur, wenn der Timer noch auf --- steht.", "Die Bewertungsabfrage beim Timerabschluss kann im Profil ein- oder ausgeschaltet werden. Sie erscheint nur, wenn der Timer noch auf --- steht.",
"Die interne Zammad-Notiz beim Timerabschluss steuert, ob die Checkbox im Abschlussdialog standardmäßig aktiv ist. Nur dann werden Standard-Typ und Zeitaufrundung angezeigt.", "Die interne Zammad-Notiz beim Timerabschluss steuert, ob die Checkbox im Abschlussdialog standardmäßig aktiv ist. Nur dann werden Standard-Typ und Zeitaufrundung angezeigt.",
"Der Standard-Typ belegt die Zammad-type_id für interne Notizen vor und kann beim Timerabschluss oder Nachtragen noch geändert werden.", "Der Standard-Typ belegt die Zammad-Zeitbuchungskategorie für interne Notizen vor. Es gibt keine leere Auswahl; ohne gespeicherten Profilwert wird Nicht kategorisiert verwendet.",
"Für die an Zammad gesendete time_unit kann optional eine Aufrundung auf 5, 10, 15 oder 30 Minuten aktiviert werden. Ohne Rundung wird die konkrete Zeit mit zwei Nachkommastellen gesendet.", "Für die an Zammad gesendete time_unit kann optional eine Aufrundung auf 5, 10, 15 oder 30 Minuten aktiviert werden. Ohne Rundung wird die konkrete Zeit mit zwei Nachkommastellen gesendet.",
"Geparkte Zammad-Notizen listet interne Notizen, die wegen eines Zammad-Fehlers noch nicht übertragen wurden. Über Senden werden sie gesammelt nach Zammad gepusht.", "Geparkte Zammad-Notizen listet interne Notizen, die wegen eines Zammad-Fehlers noch nicht übertragen wurden. Über Senden werden sie gesammelt nach Zammad gepusht.",
"Im Admin-Profil wird kein persönlicher Zammad-Token angezeigt.", "Im Admin-Profil wird kein persönlicher Zammad-Token angezeigt.",
+97
View File
@@ -0,0 +1,97 @@
Datensatz aus api/v1/ticket_articles/
{
"id": 2,
"ticket_id": 2,
"type_id": 1,
"sender_id": 1,
"from": "",
"to": "",
"cc": null,
"subject": "Test Bestellung",
"reply_to": null,
"message_id": "",
"message_id_md5": "",
"in_reply_to": null,
"content_type": "text/html",
"body": "",
"internal": false,
"preferences": {
"send-auto-response": true,
"is-auto-response": false
},
"updated_by_id": 3,
"created_by_id": 3,
"origin_by_id": null,
"created_at": "2023-01-06T11:17:03.919Z",
"updated_at": "2023-01-06T11:17:03.919Z",
"detected_language": null,
"ai_stored_result_ids": [],
"attachments": [
{
"id": 3,
"store_file_id": 3,
"filename": "message.html",
"size": "5733",
"preferences": {
"content-alternative": true,
"original-format": true,
"Mime-Type": "text/html",
"Charset": "utf-8"
}
}
],
"body_rendering_error": false
}
Datensatz aus api/v1/ticket_articles/{id]}
{
"id": 2,
"ticket_id": 2,
"type_id": 1,
"sender_id": 1,
"from": "",
"to": "",
"cc": null,
"subject": "",
"reply_to": null,
"message_id": "",
"message_id_md5": "a9cc698b80f75fe08ca6d2dc6dec69ae",
"in_reply_to": null,
"content_type": "text/html",
"body": "",
"internal": false,
"preferences": {
"send-auto-response": true,
"is-auto-response": false
},
"updated_by_id": 3,
"created_by_id": 3,
"origin_by_id": null,
"created_at": "2023-01-06T11:17:03.919Z",
"updated_at": "2023-01-06T11:17:03.919Z",
"detected_language": null,
"ai_stored_result_ids": [],
"attachments": [
{
"id": 3,
"store_file_id": 3,
"filename": "message.html",
"size": "5733",
"preferences": {
"content-alternative": true,
"original-format": true,
"Mime-Type": "text/html",
"Charset": "utf-8"
}
}
],
"body_rendering_error": false,
"created_by": "",
"updated_by": "",
"type": "email",
"sender": "Agent",
"ai_stored_results": [],
"time_unit": null
}
+2
View File
@@ -11,6 +11,7 @@ export type TimerEntry = {
projectName?: string | null; projectName?: string | null;
ticketLookupDone?: boolean; ticketLookupDone?: boolean;
activity: string; activity: string;
zammadArticleBody?: string;
billingStatus: BillingStatus; billingStatus: BillingStatus;
startedAt: number; startedAt: number;
pausedTotalMs: number; pausedTotalMs: number;
@@ -66,6 +67,7 @@ export function normalizeTimers(timers: TimerEntry[]) {
const normalizedTimer = { const normalizedTimer = {
...timer, ...timer,
activity: typeof timer.activity === "string" ? timer.activity : "", activity: typeof timer.activity === "string" ? timer.activity : "",
zammadArticleBody: typeof timer.zammadArticleBody === "string" ? timer.zammadArticleBody : "",
billingStatus: timer.billingStatus === "billed" ? "billable" : timer.billingStatus === "billable" || timer.billingStatus === "non_billable" ? timer.billingStatus : null, billingStatus: timer.billingStatus === "billed" ? "billable" : timer.billingStatus === "billable" || timer.billingStatus === "non_billable" ? timer.billingStatus : null,
}; };
+8 -3
View File
@@ -17,6 +17,7 @@ export type AuthUser = {
zammad_session_note_default_enabled: boolean; zammad_session_note_default_enabled: boolean;
zammad_time_unit_rounding_enabled: boolean; zammad_time_unit_rounding_enabled: boolean;
zammad_time_unit_rounding_minutes: number; zammad_time_unit_rounding_minutes: number;
zammad_atoss_email_sync_enabled: boolean;
zammad_article_type_id: number; zammad_article_type_id: number;
}; };
@@ -76,7 +77,7 @@ export type ZammadSessionArticleQueueItem = {
zammad_ticket_id: string | null; zammad_ticket_id: string | null;
activity: string; activity: string;
time_unit: number; time_unit: number;
zammad_article_type_id: number | null; zammad_article_type_id: number;
attempt_count: number; attempt_count: number;
last_error: string | null; last_error: string | null;
last_attempt_at: string | null; last_attempt_at: string | null;
@@ -210,6 +211,7 @@ export type ProjectTeamspaceEntry = {
id: string; id: string;
project_id?: string; project_id?: string;
teamspace_project: string | null; teamspace_project: string | null;
teamspace_description?: string | null;
is_default: boolean; is_default: boolean;
hour_budget_minutes: number | null; hour_budget_minutes: number | null;
budget_sync_disabled?: boolean; budget_sync_disabled?: boolean;
@@ -313,6 +315,7 @@ export type PeriodOverview = {
export type TicketDayBilling = { export type TicketDayBilling = {
day: string; day: string;
project_teamspace_entry_id?: string | null;
billed_minutes: number; billed_minutes: number;
}; };
@@ -327,6 +330,7 @@ export type SessionEntry = {
organization_name: string | null; organization_name: string | null;
customer_name: string; customer_name: string;
activity: string; activity: string;
zammad_article_body?: string | null;
work_type: WorkType; work_type: WorkType;
project_teamspace_entry_id?: string | null; project_teamspace_entry_id?: string | null;
project_teamspace_project?: string | null; project_teamspace_project?: string | null;
@@ -340,9 +344,10 @@ export type SessionEntry = {
recurring_billing_id: string | null; recurring_billing_id: string | null;
recurring_billing_slot_id: string | null; recurring_billing_slot_id: string | null;
recurring_occurrence_date: string | null; recurring_occurrence_date: string | null;
zammad_source_article_id?: string | null;
zammad_article_status?: "pending" | "sent" | null; zammad_article_status?: "pending" | "sent" | null;
zammad_article_id?: string | null; zammad_article_id?: string | null;
zammad_article_type_id?: number | null; zammad_article_type_id?: number;
}; };
export type TicketPeriod = { export type TicketPeriod = {
@@ -473,7 +478,7 @@ export type StatisticsTeamspaceDay = {
}; };
export type StatisticsOverview = { export type StatisticsOverview = {
periodType: "month"; periodType: "month" | "week";
period: string; period: string;
closed: boolean; closed: boolean;
closedAt: string | null; closedAt: string | null;
+4 -4
View File
@@ -1632,7 +1632,7 @@ function WidgetContent(props: WidgetContentProps) {
/> />
) : null} ) : null}
<CompactList <CompactList
empty="Alles bewertet." empty="Keine offenen Bewertungen."
items={sessions.map((session) => ({ items={sessions.map((session) => ({
key: session.id, key: session.id,
title: session.ticket_number, title: session.ticket_number,
@@ -1722,9 +1722,9 @@ function DayControlWidget({ stats, projects, todos, onNavigate }: WidgetContentP
const checklistItems = [ const checklistItems = [
{ {
key: "ratings", key: "ratings",
label: "Sessions bewertet", label: "Bewertungen final",
done: todayOpenRatings.length === 0, done: todayOpenRatings.length === 0,
action: todayOpenRatings.length === 0 ? "Keine offenen Bewertungen." : `${todayOpenRatings.length} heutige Bewertung(en) setzen.` action: todayOpenRatings.length === 0 ? "Keine offenen Bewertungen." : `${todayOpenRatings.length} heutige Bewertung(en) finalisieren.`
}, },
{ {
key: "todos", key: "todos",
@@ -1831,7 +1831,7 @@ function DayControlWidget({ stats, projects, todos, onNavigate }: WidgetContentP
items={afterworkTickets.slice(0, 4).map((ticket) => ({ items={afterworkTickets.slice(0, 4).map((ticket) => ({
key: `today-ticket-${ticket.id}`, key: `today-ticket-${ticket.id}`,
title: ticket.title || ticket.ticket_number, title: ticket.title || ticket.ticket_number,
detail: `${ticket.ticket_number} · ${formatMinutes(ticket.total_minutes + ticket.billable_minutes + ticket.non_billable_minutes + ticket.open_minutes)} · ${ticket.session_count} Session(s)`, detail: `${ticket.ticket_number} · ${formatMinutes(ticket.total_minutes + ticket.non_billable_minutes + ticket.open_minutes)} · ${ticket.session_count} Session(s)`,
note: dayControlTodaySessionReason(ticket), note: dayControlTodaySessionReason(ticket),
onClick: () => onNavigate(`/tickets/${ticket.id}?month=${currentMonth()}`) onClick: () => onNavigate(`/tickets/${ticket.id}?month=${currentMonth()}`)
}))} }))}
+93 -27
View File
@@ -22,7 +22,7 @@ import { Input } from "@/components/ui/input";
import { CopyTicketButton } from "@/components/CopyTicketButton"; import { CopyTicketButton } from "@/components/CopyTicketButton";
import { HelpLink } from "@/components/HelpLink"; import { HelpLink } from "@/components/HelpLink";
import { acknowledgeMissingTicketDayBilling, closePeriod, getStatisticsOverview, reopenPeriod } from "../api"; import { acknowledgeMissingTicketDayBilling, closePeriod, getStatisticsOverview, reopenPeriod } from "../api";
import { currentMonth, formatDate, formatDateTime, formatMinutes } from "../format"; import { currentMonth, currentWeek, formatDate, formatDateTime, formatMinutes } from "../format";
import type { StatisticsCrmDay, StatisticsOpenSession, StatisticsOverview, StatisticsTeamspaceDay } from "../types"; import type { StatisticsCrmDay, StatisticsOpenSession, StatisticsOverview, StatisticsTeamspaceDay } from "../types";
type MonthlyClosePageProps = { type MonthlyClosePageProps = {
@@ -32,6 +32,17 @@ type MonthlyClosePageProps = {
const trackedTextClass = "text-sky-700 dark:text-sky-300"; const trackedTextClass = "text-sky-700 dark:text-sky-300";
const teamspaceTextClass = "text-emerald-700 dark:text-emerald-300"; const teamspaceTextClass = "text-emerald-700 dark:text-emerald-300";
const showAcknowledgedMissingCrmStorageKey = "tickettracker.monthlyClose.showAcknowledgedMissingCrm"; const showAcknowledgedMissingCrmStorageKey = "tickettracker.monthlyClose.showAcknowledgedMissingCrm";
const monthlyCloseViewModeStorageKey = "tickettracker.monthlyClose.viewMode";
type MonthlyCloseViewMode = "month" | "week";
function readMonthlyCloseViewMode(): MonthlyCloseViewMode {
if (typeof window === "undefined") {
return "month";
}
return window.localStorage.getItem(monthlyCloseViewModeStorageKey) === "week" ? "week" : "month";
}
function formatSignedMinutes(minutes: number) { function formatSignedMinutes(minutes: number) {
if (minutes === 0) { if (minutes === 0) {
@@ -63,6 +74,7 @@ type OpenTicketGroup = {
ticketId: string; ticketId: string;
ticketNumber: string; ticketNumber: string;
organizationName: string; organizationName: string;
month: string;
openCount: number; openCount: number;
totalMinutes: number; totalMinutes: number;
}; };
@@ -83,6 +95,7 @@ function groupOpenSessions(sessions: StatisticsOpenSession[]) {
ticketId: session.ticket_id, ticketId: session.ticket_id,
ticketNumber: session.ticket_number, ticketNumber: session.ticket_number,
organizationName: session.organization_name, organizationName: session.organization_name,
month: session.started_at.slice(0, 7),
openCount: 1, openCount: 1,
totalMinutes: session.rounded_minutes totalMinutes: session.rounded_minutes
}); });
@@ -91,7 +104,7 @@ function groupOpenSessions(sessions: StatisticsOpenSession[]) {
return Array.from(groups.values()).sort((left, right) => left.ticketNumber.localeCompare(right.ticketNumber, "de", { numeric: true, sensitivity: "base" })); return Array.from(groups.values()).sort((left, right) => left.ticketNumber.localeCompare(right.ticketNumber, "de", { numeric: true, sensitivity: "base" }));
} }
function OpenTicketItem({ group, month, onNavigate }: { group: OpenTicketGroup; month: string; onNavigate: (to: string) => void }) { function OpenTicketItem({ group, onNavigate }: { group: OpenTicketGroup; onNavigate: (to: string) => void }) {
return ( return (
<div className="rounded-md border bg-background px-3 py-2"> <div className="rounded-md border bg-background px-3 py-2">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
@@ -104,7 +117,7 @@ function OpenTicketItem({ group, month, onNavigate }: { group: OpenTicketGroup;
<p className="truncate text-sm text-muted-foreground">{group.organizationName}</p> <p className="truncate text-sm text-muted-foreground">{group.organizationName}</p>
<p className={`text-xs font-medium ${trackedTextClass}`}>{formatMinutes(group.totalMinutes)} offen zu bewerten</p> <p className={`text-xs font-medium ${trackedTextClass}`}>{formatMinutes(group.totalMinutes)} offen zu bewerten</p>
</div> </div>
<Button className="h-8 shrink-0" variant="secondary" size="sm" onClick={() => onNavigate(`/tickets/${group.ticketId}?month=${month}`)}> <Button className="h-8 shrink-0" variant="secondary" size="sm" onClick={() => onNavigate(`/tickets/${group.ticketId}?month=${group.month}`)}>
Ticket öffnen Ticket öffnen
<ExternalLink className="size-4" /> <ExternalLink className="size-4" />
</Button> </Button>
@@ -185,7 +198,9 @@ function TeamspaceDayItem({ item, onNavigate }: { item: StatisticsTeamspaceDay;
} }
export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) { export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
const [viewMode, setViewMode] = useState<MonthlyCloseViewMode>(() => readMonthlyCloseViewMode());
const [month, setMonth] = useState(currentMonth()); const [month, setMonth] = useState(currentMonth());
const [week, setWeek] = useState(currentWeek());
const [stats, setStats] = useState<StatisticsOverview | null>(null); const [stats, setStats] = useState<StatisticsOverview | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [closing, setClosing] = useState(false); const [closing, setClosing] = useState(false);
@@ -194,6 +209,11 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
const [showAcknowledgedMissingCrm, setShowAcknowledgedMissingCrm] = useState(false); const [showAcknowledgedMissingCrm, setShowAcknowledgedMissingCrm] = useState(false);
const [showAcknowledgedMissingCrmLoaded, setShowAcknowledgedMissingCrmLoaded] = useState(false); const [showAcknowledgedMissingCrmLoaded, setShowAcknowledgedMissingCrmLoaded] = useState(false);
const loadRequestId = useRef(0); const loadRequestId = useRef(0);
const period = viewMode === "month" ? month : week;
const periodLabel = viewMode === "month" ? "Monat" : "Woche";
const periodAttentionLabel = viewMode === "month" ? "diesen Monat" : "diese Woche";
const periodInLabel = viewMode === "month" ? "diesem Monat" : "dieser Woche";
const isMonthView = viewMode === "month";
async function load() { async function load() {
const requestId = loadRequestId.current + 1; const requestId = loadRequestId.current + 1;
@@ -201,14 +221,14 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
setLoading(true); setLoading(true);
try { try {
const result = await getStatisticsOverview(month); const result = await getStatisticsOverview(period, viewMode);
if (requestId === loadRequestId.current) { if (requestId === loadRequestId.current) {
setStats(result); setStats(result);
} }
} catch (error) { } catch (error) {
if (requestId === loadRequestId.current) { if (requestId === loadRequestId.current) {
toast.error("Monatsabschluss konnte nicht geladen werden", { toast.error("Abschlussansicht konnte nicht geladen werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler" description: error instanceof Error ? error.message : "Unbekannter Fehler"
}); });
} }
@@ -221,7 +241,11 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
useEffect(() => { useEffect(() => {
void load(); void load();
}, [month]); }, [period, viewMode]);
useEffect(() => {
window.localStorage.setItem(monthlyCloseViewModeStorageKey, viewMode);
}, [viewMode]);
useEffect(() => { useEffect(() => {
setShowAcknowledgedMissingCrm(window.localStorage.getItem(showAcknowledgedMissingCrmStorageKey) === "true"); setShowAcknowledgedMissingCrm(window.localStorage.getItem(showAcknowledgedMissingCrmStorageKey) === "true");
@@ -241,6 +265,22 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
setMonth(`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`); setMonth(`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`);
} }
function changeWeekBy(delta: number) {
const match = /^(\d{4})-W(\d{2})$/.exec(week);
const year = match ? Number(match[1]) : new Date().getFullYear();
const weekNumber = match ? Number(match[2]) : 1;
const jan4 = new Date(Date.UTC(year, 0, 4));
const jan4Weekday = jan4.getUTCDay() || 7;
const date = new Date(jan4);
date.setUTCDate(jan4.getUTCDate() - jan4Weekday + 1 + (weekNumber - 1 + delta) * 7);
const weekday = date.getUTCDay() || 7;
date.setUTCDate(date.getUTCDate() + 4 - weekday);
const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
const nextWeek = Math.ceil(((date.getTime() - yearStart.getTime()) / 86_400_000 + 1) / 7);
setWeek(`${date.getUTCFullYear()}-W${String(nextWeek).padStart(2, "0")}`);
}
async function finishMonth() { async function finishMonth() {
setClosing(true); setClosing(true);
try { try {
@@ -308,14 +348,14 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
const days = showAcknowledgedMissingCrm ? [...missingCrmDays, ...acknowledgedMissingCrmDays] : [...missingCrmDays]; const days = showAcknowledgedMissingCrm ? [...missingCrmDays, ...acknowledgedMissingCrmDays] : [...missingCrmDays];
return days.sort((left, right) => left.day.localeCompare(right.day) || left.ticket_number.localeCompare(right.ticket_number, "de", { numeric: true, sensitivity: "base" })); return days.sort((left, right) => left.day.localeCompare(right.day) || left.ticket_number.localeCompare(right.ticket_number, "de", { numeric: true, sensitivity: "base" }));
}, [acknowledgedMissingCrmDays, missingCrmDays, showAcknowledgedMissingCrm]); }, [acknowledgedMissingCrmDays, missingCrmDays, showAcknowledgedMissingCrm]);
const canClose = Boolean(stats && !stats.closed && stats.totals.sessions > 0 && openSessions.length === 0); const canClose = Boolean(isMonthView && stats && !stats.closed && stats.totals.sessions > 0 && openSessions.length === 0);
const checklist = useMemo( const checklist = useMemo(
() => [ () => [
{ {
label: "Offene Bewertungen", label: "Offene Bewertungen",
ok: openSessions.length === 0, ok: openSessions.length === 0,
value: openSessions.length, value: openSessions.length,
detail: openSessions.length === 0 ? "Alle Sessions sind bewertet." : `${openTicketGroups.length} Ticket(s) bearbeiten` detail: openSessions.length === 0 ? "Keine offenen Bewertungen." : `${openTicketGroups.length} Ticket(s) bearbeiten`
}, },
{ {
label: "Teamspace geprüft", label: "Teamspace geprüft",
@@ -324,13 +364,13 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
detail: missingCrmDays.length === 0 ? "Keine offenen Teamspace-Prüfpunkte." : `${acknowledgedMissingCrmDays.length} zur Kenntnis genommen` detail: missingCrmDays.length === 0 ? "Keine offenen Teamspace-Prüfpunkte." : `${acknowledgedMissingCrmDays.length} zur Kenntnis genommen`
}, },
{ {
label: "Monat abschließbar", label: isMonthView ? "Monat abschließbar" : "Wochenansicht",
ok: Boolean(stats?.closed || canClose), ok: isMonthView ? Boolean(stats?.closed || canClose) : true,
value: stats?.closed ? "geschlossen" : canClose ? "bereit" : "offen", value: isMonthView ? (stats?.closed ? "geschlossen" : canClose ? "bereit" : "offen") : "Ansicht",
detail: stats?.closed ? "Monat ist gesperrt." : canClose ? "Kann abgeschlossen werden." : "Bewertungen prüfen." detail: isMonthView ? (stats?.closed ? "Monat ist gesperrt." : canClose ? "Kann abgeschlossen werden." : "Bewertungen prüfen.") : "Abschluss bleibt monatlich."
} }
], ],
[acknowledgedMissingCrmDays.length, canClose, openSessions.length, openTicketGroups.length, missingCrmDays.length, stats?.closed] [acknowledgedMissingCrmDays.length, canClose, isMonthView, openSessions.length, openTicketGroups.length, missingCrmDays.length, stats?.closed]
); );
return ( return (
@@ -338,25 +378,46 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
<div className="flex flex-col gap-3 xl:flex-row xl:items-end xl:justify-between"> <div className="flex flex-col gap-3 xl:flex-row xl:items-end xl:justify-between">
<div> <div>
<h2 className="text-2xl font-semibold tracking-normal">Monatsabschluss</h2> <h2 className="text-2xl font-semibold tracking-normal">Monatsabschluss</h2>
<p className="text-sm text-muted-foreground">Offene Bewertungen, Teamspace-Abgleich und der eigentliche Monatsabschluss.</p> <p className="text-sm text-muted-foreground">Offene Bewertungen und Teamspace-Abgleich für {periodAttentionLabel}; der Abschluss selbst bleibt monatlich.</p>
</div>
<div className="grid gap-2 sm:grid-cols-[auto_minmax(220px,300px)_auto] sm:items-end">
<div className="space-y-1">
<span className="text-xs font-medium text-muted-foreground">Ansicht</span>
<div className="inline-flex h-9 rounded-md border bg-muted/20 p-1">
<Button type="button" size="sm" variant={viewMode === "week" ? "default" : "ghost"} className="h-7 px-3" onClick={() => setViewMode("week")}>
Woche
</Button>
<Button type="button" size="sm" variant={viewMode === "month" ? "default" : "ghost"} className="h-7 px-3" onClick={() => setViewMode("month")}>
Monat
</Button>
</div>
</div> </div>
<div className="grid gap-2 sm:grid-cols-[minmax(220px,300px)_auto] sm:items-end">
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<label className="text-xs font-medium text-muted-foreground" htmlFor="close-month">Monat</label> <label className="text-xs font-medium text-muted-foreground" htmlFor="close-period">{periodLabel}</label>
{loading ? <span className="text-xs text-muted-foreground">lädt...</span> : null} {loading ? <span className="text-xs text-muted-foreground">lädt...</span> : null}
</div> </div>
<div className="grid grid-cols-[2.25rem_minmax(0,1fr)_2.25rem] gap-1"> <div className="grid grid-cols-[2.25rem_minmax(0,1fr)_2.25rem] gap-1">
<Button type="button" size="icon" variant="secondary" className="h-9 w-9" onClick={() => changeMonthBy(-1)} aria-label="Monat zurück"> <Button type="button" size="icon" variant="secondary" className="h-9 w-9" onClick={() => (isMonthView ? changeMonthBy(-1) : changeWeekBy(-1))} aria-label={`${periodLabel} zurück`}>
<ChevronLeft className="size-4" /> <ChevronLeft className="size-4" />
</Button> </Button>
<Input id="close-month" className="h-9" type="month" value={month} onChange={(event) => setMonth(event.currentTarget.value)} /> <Input
<Button type="button" size="icon" variant="secondary" className="h-9 w-9" onClick={() => changeMonthBy(1)} aria-label="Monat vor"> id="close-period"
className="h-9"
type={isMonthView ? "month" : "week"}
value={period}
onChange={(event) => (isMonthView ? setMonth(event.currentTarget.value) : setWeek(event.currentTarget.value))}
/>
<Button type="button" size="icon" variant="secondary" className="h-9 w-9" onClick={() => (isMonthView ? changeMonthBy(1) : changeWeekBy(1))} aria-label={`${periodLabel} vor`}>
<ChevronRight className="size-4" /> <ChevronRight className="size-4" />
</Button> </Button>
</div> </div>
</div> </div>
{stats?.closed ? ( {!isMonthView ? (
<Badge variant="outline" className="h-9 justify-center px-3">
Abschluss nur monatlich
</Badge>
) : stats?.closed ? (
<Button size="sm" variant="secondary" disabled={reopening} onClick={reopenMonth}> <Button size="sm" variant="secondary" disabled={reopening} onClick={reopenMonth}>
<LockOpen className="size-4" /> <LockOpen className="size-4" />
{reopening ? "Öffnet..." : "Monat öffnen"} {reopening ? "Öffnet..." : "Monat öffnen"}
@@ -370,7 +431,7 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
</div> </div>
</div> </div>
{stats?.closed ? ( {isMonthView && stats?.closed ? (
<Alert variant="success" className="flex items-center gap-2 py-3"> <Alert variant="success" className="flex items-center gap-2 py-3">
<CheckCircle2 className="size-4 shrink-0" /> <CheckCircle2 className="size-4 shrink-0" />
<span>Dieser Monat wurde am {formatDateTime(stats.closedAt!)} abgeschlossen.</span> <span>Dieser Monat wurde am {formatDateTime(stats.closedAt!)} abgeschlossen.</span>
@@ -382,7 +443,12 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
<StatusCard label="Teamspace" value={formatMinutes(stats?.totals.crmBilledMinutes ?? 0)} detail={`Differenz ${formatTeamspaceDelta(stats?.totals.crmDeltaMinutes ?? 0)}`} /> <StatusCard label="Teamspace" value={formatMinutes(stats?.totals.crmBilledMinutes ?? 0)} detail={`Differenz ${formatTeamspaceDelta(stats?.totals.crmDeltaMinutes ?? 0)}`} />
<StatusCard label="Offene Bewertungen" value={openSessions.length} detail={`${openTicketGroups.length} Ticket(s)`} tone={openSessions.length > 0 ? "warn" : "ok"} /> <StatusCard label="Offene Bewertungen" value={openSessions.length} detail={`${openTicketGroups.length} Ticket(s)`} tone={openSessions.length > 0 ? "warn" : "ok"} />
<StatusCard label="Ohne Teamspace" value={missingCrmDays.length} detail={`${acknowledgedMissingCrmDays.length} zur Kenntnis genommen`} tone={missingCrmDays.length > 0 ? "warn" : "ok"} /> <StatusCard label="Ohne Teamspace" value={missingCrmDays.length} detail={`${acknowledgedMissingCrmDays.length} zur Kenntnis genommen`} tone={missingCrmDays.length > 0 ? "warn" : "ok"} />
<StatusCard label="Status" value={stats?.closed ? "geschlossen" : canClose ? "bereit" : "offen"} detail={stats?.closed ? "Monat ist gesperrt" : canClose ? "kann abgeschlossen werden" : "Prüfpunkte bearbeiten"} tone={stats?.closed || canClose ? "ok" : "warn"} /> <StatusCard
label="Status"
value={isMonthView ? (stats?.closed ? "geschlossen" : canClose ? "bereit" : "offen") : "Ansicht"}
detail={isMonthView ? (stats?.closed ? "Monat ist gesperrt" : canClose ? "kann abgeschlossen werden" : "Prüfpunkte bearbeiten") : "Abschluss bleibt monatlich"}
tone={isMonthView ? (stats?.closed || canClose ? "ok" : "warn") : "neutral"}
/>
</div> </div>
<Card> <Card>
@@ -413,7 +479,7 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div> <div>
<CardTitle>Aufräumen</CardTitle> <CardTitle>Aufräumen</CardTitle>
<CardDescription>Alles, was für diesen Monat noch Aufmerksamkeit braucht.</CardDescription> <CardDescription>Alles, was für {periodAttentionLabel} noch Aufmerksamkeit braucht.</CardDescription>
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<div className="inline-flex h-8 items-center gap-2 rounded-md border bg-background px-3 text-sm"> <div className="inline-flex h-8 items-center gap-2 rounded-md border bg-background px-3 text-sm">
@@ -435,7 +501,7 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
Offene Bewertungen Offene Bewertungen
</div> </div>
{openTicketGroups.map((group) => ( {openTicketGroups.map((group) => (
<OpenTicketItem key={group.ticketId} group={group} month={month} onNavigate={onNavigate} /> <OpenTicketItem key={group.ticketId} group={group} onNavigate={onNavigate} />
))} ))}
{openTicketGroups.length === 0 ? <p className="rounded-md border bg-muted/20 p-3 text-sm text-muted-foreground">Keine offenen Bewertungen.</p> : null} {openTicketGroups.length === 0 ? <p className="rounded-md border bg-muted/20 p-3 text-sm text-muted-foreground">Keine offenen Bewertungen.</p> : null}
</section> </section>
@@ -479,7 +545,7 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
))} ))}
</div> </div>
) : ( ) : (
<p className="rounded-md border bg-muted/20 p-3 text-sm text-muted-foreground">Für diesen Monat sind noch keine Teamspace-Werte zu abgerechneten Ticket-Tagen vorhanden.</p> <p className="rounded-md border bg-muted/20 p-3 text-sm text-muted-foreground">Für {periodAttentionLabel} sind noch keine Teamspace-Werte zu abgerechneten Ticket-Tagen vorhanden.</p>
)} )}
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
@@ -490,8 +556,8 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
<CardContent className="grid place-items-center gap-2 p-8 text-center"> <CardContent className="grid place-items-center gap-2 p-8 text-center">
<Clock3 className="size-8 text-muted-foreground" /> <Clock3 className="size-8 text-muted-foreground" />
<div> <div>
<p className="font-medium">Keine Sessions in diesem Monat</p> <p className="font-medium">Keine Sessions in {periodInLabel}</p>
<p className="text-sm text-muted-foreground">Ein Monatsabschluss ist erst sinnvoll, wenn Sessions vorhanden sind.</p> <p className="text-sm text-muted-foreground">Die Kontrollansicht ist erst sinnvoll, wenn Sessions vorhanden sind.</p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
+19
View File
@@ -25,6 +25,7 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
const [zammadApiToken, setZammadApiToken] = useState(""); const [zammadApiToken, setZammadApiToken] = useState("");
const [zammadTokenDirty, setZammadTokenDirty] = useState(false); const [zammadTokenDirty, setZammadTokenDirty] = useState(false);
const [zammadEmailSessionWebhookEnabled, setZammadEmailSessionWebhookEnabled] = useState(currentUser.zammad_email_session_webhook_enabled); const [zammadEmailSessionWebhookEnabled, setZammadEmailSessionWebhookEnabled] = useState(currentUser.zammad_email_session_webhook_enabled);
const [zammadAtossEmailSyncEnabled, setZammadAtossEmailSyncEnabled] = useState(currentUser.zammad_atoss_email_sync_enabled);
const [sessionBillingPromptEnabled, setSessionBillingPromptEnabled] = useState(currentUser.session_billing_prompt_enabled); const [sessionBillingPromptEnabled, setSessionBillingPromptEnabled] = useState(currentUser.session_billing_prompt_enabled);
const [zammadSessionNoteDefaultEnabled, setZammadSessionNoteDefaultEnabled] = useState(currentUser.zammad_session_note_default_enabled); const [zammadSessionNoteDefaultEnabled, setZammadSessionNoteDefaultEnabled] = useState(currentUser.zammad_session_note_default_enabled);
const [zammadTimeUnitRoundingEnabled, setZammadTimeUnitRoundingEnabled] = useState(currentUser.zammad_time_unit_rounding_enabled); const [zammadTimeUnitRoundingEnabled, setZammadTimeUnitRoundingEnabled] = useState(currentUser.zammad_time_unit_rounding_enabled);
@@ -41,6 +42,7 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
setZammadApiToken(""); setZammadApiToken("");
setZammadTokenDirty(false); setZammadTokenDirty(false);
setZammadEmailSessionWebhookEnabled(currentUser.zammad_email_session_webhook_enabled); setZammadEmailSessionWebhookEnabled(currentUser.zammad_email_session_webhook_enabled);
setZammadAtossEmailSyncEnabled(currentUser.zammad_atoss_email_sync_enabled);
setSessionBillingPromptEnabled(currentUser.session_billing_prompt_enabled); setSessionBillingPromptEnabled(currentUser.session_billing_prompt_enabled);
setZammadSessionNoteDefaultEnabled(currentUser.zammad_session_note_default_enabled); setZammadSessionNoteDefaultEnabled(currentUser.zammad_session_note_default_enabled);
setZammadTimeUnitRoundingEnabled(currentUser.zammad_time_unit_rounding_enabled); setZammadTimeUnitRoundingEnabled(currentUser.zammad_time_unit_rounding_enabled);
@@ -125,6 +127,7 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
...(currentUser.role === "user" ...(currentUser.role === "user"
? { ? {
zammadEmailSessionWebhookEnabled, zammadEmailSessionWebhookEnabled,
zammadAtossEmailSyncEnabled,
sessionBillingPromptEnabled, sessionBillingPromptEnabled,
zammadSessionNoteDefaultEnabled, zammadSessionNoteDefaultEnabled,
zammadTimeUnitRoundingEnabled, zammadTimeUnitRoundingEnabled,
@@ -299,6 +302,22 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
onCheckedChange={setZammadEmailSessionWebhookEnabled} onCheckedChange={setZammadEmailSessionWebhookEnabled}
/> />
</div> </div>
<div className="mt-3 flex items-center justify-between gap-3 rounded-md border bg-background p-3">
<div className="flex items-center gap-2">
<MailPlus className="size-4 text-muted-foreground" />
<div>
<label className="text-sm font-medium" htmlFor="profile-zammad-atoss-email-sync">
ATOSS eMails syncen
</label>
<p className="text-xs text-muted-foreground">Wenn aus, werden eMails an support.atc@atoss.com nicht übernommen.</p>
</div>
</div>
<Switch
id="profile-zammad-atoss-email-sync"
checked={zammadAtossEmailSyncEnabled}
onCheckedChange={setZammadAtossEmailSyncEnabled}
/>
</div>
<div className="mt-3 flex items-center justify-between gap-3 rounded-md border bg-background p-3"> <div className="mt-3 flex items-center justify-between gap-3 rounded-md border bg-background p-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ClipboardCheck className="size-4 text-muted-foreground" /> <ClipboardCheck className="size-4 text-muted-foreground" />
+190 -3
View File
@@ -27,6 +27,8 @@ import {
zammadPriorityFilterOptions, zammadPriorityFilterOptions,
zammadPriorityLabel, zammadPriorityLabel,
zammadPrioritySortRank, zammadPrioritySortRank,
zammadId,
zammadStateId,
ZammadStateBadge, ZammadStateBadge,
zammadStateFilterOptions, zammadStateFilterOptions,
zammadStateLabel, zammadStateLabel,
@@ -60,6 +62,7 @@ type ProjectTeamspaceEntryForm = {
type ProjectSortBy = "updated" | "ticket" | "title" | "organization" | "status" | "zammadStatus" | "zammadPriority" | "consumption" | "consumptionPercent" | "remaining" | "budget"; type ProjectSortBy = "updated" | "ticket" | "title" | "organization" | "status" | "zammadStatus" | "zammadPriority" | "consumption" | "consumptionPercent" | "remaining" | "budget";
type ProjectSortDirection = "asc" | "desc"; type ProjectSortDirection = "asc" | "desc";
type ProjectGroupBy = "none" | "organization" | "status" | "zammadStatus" | "zammadPriority" | "budgetState" | "teamspace";
type ProjectFilterOption = MultiFilterOption; type ProjectFilterOption = MultiFilterOption;
type ProjectViewSettings = { type ProjectViewSettings = {
@@ -67,6 +70,8 @@ type ProjectViewSettings = {
filterValues: string[]; filterValues: string[];
organizationValues: string[]; organizationValues: string[];
standardTeamspaceOnly: boolean; standardTeamspaceOnly: boolean;
groupBy: ProjectGroupBy;
groupDirection: ProjectSortDirection;
sortBy: ProjectSortBy; sortBy: ProjectSortBy;
sortDirection: ProjectSortDirection; sortDirection: ProjectSortDirection;
}; };
@@ -78,6 +83,8 @@ const defaultProjectViewSettings: ProjectViewSettings = {
filterValues: ["open"], filterValues: ["open"],
organizationValues: [], organizationValues: [],
standardTeamspaceOnly: false, standardTeamspaceOnly: false,
groupBy: "none",
groupDirection: "asc",
sortBy: "updated", sortBy: "updated",
sortDirection: "desc" sortDirection: "desc"
}; };
@@ -97,6 +104,7 @@ const projectStaticFilterOptions: ProjectFilterOption[] = [
const projectStaticFilterValues = new Set(projectStaticFilterOptions.map((option) => option.value)); const projectStaticFilterValues = new Set(projectStaticFilterOptions.map((option) => option.value));
const projectSortValues: ProjectSortBy[] = ["updated", "ticket", "title", "organization", "status", "zammadStatus", "zammadPriority", "consumption", "consumptionPercent", "remaining", "budget"]; const projectSortValues: ProjectSortBy[] = ["updated", "ticket", "title", "organization", "status", "zammadStatus", "zammadPriority", "consumption", "consumptionPercent", "remaining", "budget"];
const projectGroupValues: ProjectGroupBy[] = ["none", "organization", "status", "zammadStatus", "zammadPriority", "budgetState", "teamspace"];
const projectSortOptions: Array<{ value: ProjectSortBy; label: string }> = [ const projectSortOptions: Array<{ value: ProjectSortBy; label: string }> = [
{ value: "updated", label: "Aktualisierung" }, { value: "updated", label: "Aktualisierung" },
@@ -112,6 +120,16 @@ const projectSortOptions: Array<{ value: ProjectSortBy; label: string }> = [
{ value: "budget", label: "Kontingent" } { value: "budget", label: "Kontingent" }
]; ];
const projectGroupOptions: Array<{ value: ProjectGroupBy; label: string }> = [
{ value: "none", label: "Keine Gruppierung" },
{ value: "organization", label: "Organisation" },
{ value: "status", label: "TT-Status" },
{ value: "zammadStatus", label: "Zammad-Status" },
{ value: "zammadPriority", label: "Zammad-Prio" },
{ value: "budgetState", label: "Kontingentstatus" },
{ value: "teamspace", label: "Standard-Teamspace" }
];
function projectEntryId() { function projectEntryId() {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID(); return crypto.randomUUID();
@@ -262,6 +280,8 @@ function readStoredProjectViewSettings(): ProjectViewSettings {
filterValues, filterValues,
organizationValues: legacyProjectOrganizationValues(parsed as Partial<ProjectViewSettings> & Record<string, unknown>), organizationValues: legacyProjectOrganizationValues(parsed as Partial<ProjectViewSettings> & Record<string, unknown>),
standardTeamspaceOnly: legacyStandardTeamspaceOnly(parsed as Partial<ProjectViewSettings> & Record<string, unknown>), standardTeamspaceOnly: legacyStandardTeamspaceOnly(parsed as Partial<ProjectViewSettings> & Record<string, unknown>),
groupBy: projectGroupValues.find((value) => value === parsed.groupBy) ?? defaultProjectViewSettings.groupBy,
groupDirection: parsed.groupDirection === "desc" ? "desc" : defaultProjectViewSettings.groupDirection,
sortBy: projectSortValues.find((value) => value === parsed.sortBy) ?? defaultProjectViewSettings.sortBy, sortBy: projectSortValues.find((value) => value === parsed.sortBy) ?? defaultProjectViewSettings.sortBy,
sortDirection: parsed.sortDirection === "asc" ? "asc" : defaultProjectViewSettings.sortDirection sortDirection: parsed.sortDirection === "asc" ? "asc" : defaultProjectViewSettings.sortDirection
}; };
@@ -579,6 +599,117 @@ function compareProjects(left: ProjectSummary, right: ProjectSummary, sortBy: Pr
return comparison * factor || compareText(left.ticket_number, right.ticket_number); return comparison * factor || compareText(left.ticket_number, right.ticket_number);
} }
type ProjectGroupMeta = {
key: string;
label: string;
sortLabel: string;
rank: number;
};
type ProjectGroup = ProjectGroupMeta & {
projects: ProjectSummary[];
consumptionMinutes: number;
remainingMinutes: number;
};
function projectBudgetStateGroup(project: ProjectSummary, standardTeamspaceOnly: boolean): ProjectGroupMeta {
const budgetMinutes = projectBudget(project, standardTeamspaceOnly) ?? 0;
const remaining = projectRemaining(project, standardTeamspaceOnly);
if (budgetMinutes <= 0) {
return { key: "budgetState:none", label: "Ohne Kontingent", sortLabel: "Ohne Kontingent", rank: 40 };
}
if (remaining !== null && remaining < 0) {
return { key: "budgetState:over", label: "Überzogen", sortLabel: "Überzogen", rank: 10 };
}
if (remaining === 0) {
return { key: "budgetState:used", label: "Aufgebraucht", sortLabel: "Aufgebraucht", rank: 30 };
}
return { key: "budgetState:remaining", label: "Mit Rest", sortLabel: "Mit Rest", rank: 20 };
}
function projectGroupMeta(project: ProjectSummary, groupBy: ProjectGroupBy, standardTeamspaceOnly: boolean): ProjectGroupMeta {
if (groupBy === "organization") {
const label = projectOrganizationLabel(project);
return { key: `organization:${projectOrganizationKey(project)}`, label, sortLabel: label, rank: 0 };
}
if (groupBy === "status") {
return project.closed_at
? { key: "status:closed", label: "Geschlossen", sortLabel: "Geschlossen", rank: 20 }
: { key: "status:open", label: "Offen", sortLabel: "Offen", rank: 10 };
}
if (groupBy === "zammadStatus") {
const id = zammadStateId(project.zammad_state_id);
const label = id ? zammadStateLabel(id) : "Ohne Zammad-Status";
return { key: `zammadStatus:${id ?? "none"}`, label, sortLabel: label, rank: zammadStateSortRank(id) };
}
if (groupBy === "zammadPriority") {
const id = zammadId(project.zammad_priority_id);
const label = id ? zammadPriorityLabel(id) : "Ohne Zammad-Prio";
return { key: `zammadPriority:${id ?? "none"}`, label, sortLabel: label, rank: zammadPrioritySortRank(id) };
}
if (groupBy === "budgetState") {
return projectBudgetStateGroup(project, standardTeamspaceOnly);
}
if (groupBy === "teamspace") {
const label = defaultProjectTeamspaceEntry(project)?.teamspace_project || "Ohne Teamspace-ID";
return { key: `teamspace:${label.toLocaleLowerCase("de-DE")}`, label, sortLabel: label, rank: 0 };
}
return { key: "all", label: "Alle Projekte", sortLabel: "Alle Projekte", rank: 0 };
}
function groupProjects(projects: ProjectSummary[], groupBy: ProjectGroupBy, groupDirection: ProjectSortDirection, standardTeamspaceOnly: boolean): ProjectGroup[] {
if (groupBy === "none") {
return [
{
key: "all",
label: "Alle Projekte",
sortLabel: "Alle Projekte",
rank: 0,
projects,
consumptionMinutes: projects.reduce((sum, project) => sum + projectConsumption(project, standardTeamspaceOnly), 0),
remainingMinutes: projects.reduce((sum, project) => sum + Math.max(projectRemaining(project, standardTeamspaceOnly) ?? 0, 0), 0)
}
];
}
const factor = groupDirection === "asc" ? 1 : -1;
const groups = new Map<string, ProjectGroup>();
for (const project of projects) {
const meta = projectGroupMeta(project, groupBy, standardTeamspaceOnly);
const group = groups.get(meta.key);
if (group) {
group.projects.push(project);
group.consumptionMinutes += projectConsumption(project, standardTeamspaceOnly);
group.remainingMinutes += Math.max(projectRemaining(project, standardTeamspaceOnly) ?? 0, 0);
} else {
groups.set(meta.key, {
...meta,
projects: [project],
consumptionMinutes: projectConsumption(project, standardTeamspaceOnly),
remainingMinutes: Math.max(projectRemaining(project, standardTeamspaceOnly) ?? 0, 0)
});
}
}
return Array.from(groups.values()).sort((left, right) => ((left.rank - right.rank) || compareText(left.sortLabel, right.sortLabel)) * factor);
}
function formFromProject(project: ProjectSummary): ProjectForm { function formFromProject(project: ProjectSummary): ProjectForm {
const entries = projectTeamspaceEntries(project).map((entry, index) => ({ const entries = projectTeamspaceEntries(project).map((entry, index) => ({
clientId: entry.id || projectEntryId(), clientId: entry.id || projectEntryId(),
@@ -967,12 +1098,18 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
() => filteredProjects.reduce((sum, project) => sum + Math.max(projectRemaining(project, viewSettings.standardTeamspaceOnly) ?? 0, 0), 0), () => filteredProjects.reduce((sum, project) => sum + Math.max(projectRemaining(project, viewSettings.standardTeamspaceOnly) ?? 0, 0), 0),
[filteredProjects, viewSettings.standardTeamspaceOnly] [filteredProjects, viewSettings.standardTeamspaceOnly]
); );
const projectGroups = useMemo(
() => groupProjects(filteredProjects, viewSettings.groupBy, viewSettings.groupDirection, viewSettings.standardTeamspaceOnly),
[filteredProjects, viewSettings.groupBy, viewSettings.groupDirection, viewSettings.standardTeamspaceOnly]
);
const closedProjectsLoaded = projectShouldLoadClosed(viewSettings.filterValues); const closedProjectsLoaded = projectShouldLoadClosed(viewSettings.filterValues);
const activeFilterCount = [ const activeFilterCount = [
viewSettings.query.trim() !== "", viewSettings.query.trim() !== "",
JSON.stringify(viewSettings.filterValues) !== JSON.stringify(defaultProjectViewSettings.filterValues), JSON.stringify(viewSettings.filterValues) !== JSON.stringify(defaultProjectViewSettings.filterValues),
viewSettings.organizationValues.length > 0, viewSettings.organizationValues.length > 0,
viewSettings.standardTeamspaceOnly, viewSettings.standardTeamspaceOnly,
viewSettings.groupBy !== defaultProjectViewSettings.groupBy,
viewSettings.groupDirection !== defaultProjectViewSettings.groupDirection,
viewSettings.sortBy !== defaultProjectViewSettings.sortBy, viewSettings.sortBy !== defaultProjectViewSettings.sortBy,
viewSettings.sortDirection !== defaultProjectViewSettings.sortDirection viewSettings.sortDirection !== defaultProjectViewSettings.sortDirection
].filter(Boolean).length; ].filter(Boolean).length;
@@ -1286,7 +1423,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
</Button> </Button>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="grid gap-3 px-4 pb-4 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-5"> <CardContent className="grid gap-3 px-4 pb-4 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-8">
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground" htmlFor="project-search"> <label className="text-xs font-medium text-muted-foreground" htmlFor="project-search">
Suche Suche
@@ -1331,6 +1468,44 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
<span className="block truncate text-xs">Hauptbalken</span> <span className="block truncate text-xs">Hauptbalken</span>
</span> </span>
</label> </label>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">Gruppierung</label>
<Select value={viewSettings.groupBy} onValueChange={(value) => setViewSettings((current) => ({ ...current, groupBy: value as ProjectGroupBy }))}>
<SelectTrigger size="sm" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{projectGroupOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">Gruppenrichtung</label>
<div className="grid grid-cols-2 rounded-md border bg-background p-0.5">
<Button
type="button"
size="sm"
variant={viewSettings.groupDirection === "asc" ? "secondary" : "ghost"}
className="h-7 px-2"
onClick={() => setViewSettings((current) => ({ ...current, groupDirection: "asc" }))}
>
<ArrowUp className="size-3.5" />
Auf
</Button>
<Button
type="button"
size="sm"
variant={viewSettings.groupDirection === "desc" ? "secondary" : "ghost"}
className="h-7 px-2"
onClick={() => setViewSettings((current) => ({ ...current, groupDirection: "desc" }))}
>
<ArrowDown className="size-3.5" />
Ab
</Button>
</div>
</div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">Sortierung</label> <label className="text-xs font-medium text-muted-foreground">Sortierung</label>
<Select value={viewSettings.sortBy} onValueChange={(value) => setViewSettings((current) => ({ ...current, sortBy: value as ProjectSortBy }))}> <Select value={viewSettings.sortBy} onValueChange={(value) => setViewSettings((current) => ({ ...current, sortBy: value as ProjectSortBy }))}>
@@ -1345,7 +1520,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
</Select> </Select>
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">Richtung</label> <label className="text-xs font-medium text-muted-foreground">Sortierrichtung</label>
<div className="grid grid-cols-2 rounded-md border bg-background p-0.5"> <div className="grid grid-cols-2 rounded-md border bg-background p-0.5">
<Button <Button
type="button" type="button"
@@ -1432,7 +1607,17 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
{sortHeader("Verbrauch %", "consumptionPercent")} {sortHeader("Verbrauch %", "consumptionPercent")}
<span className="text-right">Aktion</span> <span className="text-right">Aktion</span>
</div> </div>
{filteredProjects.map((project) => { {projectGroups.map((group) => (
<div key={group.key} className={cn(viewSettings.groupBy !== "none" ? "border-b last:border-b-0" : undefined)}>
{viewSettings.groupBy !== "none" ? (
<div className="flex flex-col gap-1 border-b bg-muted/15 px-4 py-2 sm:flex-row sm:items-center sm:justify-between">
<h4 className="min-w-0 truncate text-sm font-medium">{group.label}</h4>
<p className="text-xs text-muted-foreground">
{group.projects.length} Projekt(e) · {formatMinutes(group.consumptionMinutes)} Verbrauch · {formatMinutes(group.remainingMinutes)} Rest offen
</p>
</div>
) : null}
{group.projects.map((project) => {
const isEditing = editingProjectId === project.id; const isEditing = editingProjectId === project.id;
const budgetedTeamspaceEntries = budgetedProjectTeamspaceEntries(project); const budgetedTeamspaceEntries = budgetedProjectTeamspaceEntries(project);
const consumptionMinutes = projectConsumption(project, viewSettings.standardTeamspaceOnly); const consumptionMinutes = projectConsumption(project, viewSettings.standardTeamspaceOnly);
@@ -1642,6 +1827,8 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
</div> </div>
); );
})} })}
</div>
))}
</Card> </Card>
{!loading && filteredProjects.length === 0 ? ( {!loading && filteredProjects.length === 0 ? (
+1 -1
View File
@@ -430,7 +430,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
const nonBillableMinutes = totals?.nonBillableMinutes ?? 0; const nonBillableMinutes = totals?.nonBillableMinutes ?? 0;
const openSessions = totals?.openSessions ?? 0; const openSessions = totals?.openSessions ?? 0;
const openMinutes = totals?.openMinutes ?? 0; const openMinutes = totals?.openMinutes ?? 0;
const evaluatedSessions = billedSessions + billableSessions + nonBillableSessions; const evaluatedSessions = billedSessions + nonBillableSessions;
const crmCoverage = percentage(totals?.crmBilledMinutes ?? 0, totals?.minutes ?? 0); const crmCoverage = percentage(totals?.crmBilledMinutes ?? 0, totals?.minutes ?? 0);
const maxOrganizationMinutes = Math.max(...(stats?.organizations ?? []).map((organization) => organization.total_minutes), 1); const maxOrganizationMinutes = Math.max(...(stats?.organizations ?? []).map((organization) => organization.total_minutes), 1);
const topOrganization = stats?.organizations[0] ?? null; const topOrganization = stats?.organizations[0] ?? null;
File diff suppressed because it is too large. Load diff
+302 -75
View File
@@ -1,4 +1,4 @@
import { FormEvent, useEffect, useMemo, useState } from "react"; import { type ClipboardEvent, FormEvent, useEffect, useMemo, useRef, useState } from "react";
import type { Dispatch, SetStateAction } from "react"; import type { Dispatch, SetStateAction } from "react";
import { FolderGit, Pause, Play, Plus, RotateCcw, Search, Square, Upload } from "lucide-react"; import { FolderGit, Pause, Play, Plus, RotateCcw, Search, Square, Upload } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -11,11 +11,12 @@ 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";
import { OrganizationSelect } from "@/components/OrganizationSelect"; import { OrganizationSelect } from "@/components/OrganizationSelect";
import { createSession, getRecurringBillings, lookupTicket } from "../api"; import { RichActivityEditor } from "@/components/RichActivityEditor";
import { createActiveTimer, createSession, deleteActiveTimer, getRecurringBillings, lookupTicket, updateActiveTimer } from "../api";
import type { CreateSessionResult } from "../api"; import type { CreateSessionResult } from "../api";
import { formatTimer } from "../format"; import { formatTimer } from "../format";
import { isRecurringBillingTicketNumber, ticketHasRecurringBilling } from "../recurring-billings"; import { isRecurringBillingTicketNumber, ticketHasRecurringBilling } from "../recurring-billings";
import { activeElapsedMs, createClientId, pauseEntry, resumeEntry, ticketPattern, type TimerEntry } from "../timers"; import { activeElapsedMs, ticketPattern, type TimerEntry } from "../timers";
import type { AuthUser, BillingStatus, RecurringBilling, TicketMeta, WorkType } from "../types"; import type { AuthUser, BillingStatus, RecurringBilling, TicketMeta, WorkType } from "../types";
import { zammadArticleTypeFromValue, zammadArticleTypeOptions, zammadArticleTypeValue, type ZammadArticleTypeId } from "../zammad-article-types"; import { zammadArticleTypeFromValue, zammadArticleTypeOptions, zammadArticleTypeValue, type ZammadArticleTypeId } from "../zammad-article-types";
@@ -25,6 +26,7 @@ type ManualSessionFormState = {
organizationName: string | null; organizationName: string | null;
projectTeamspaceEntryId: string; projectTeamspaceEntryId: string;
activity: string; activity: string;
zammadArticleBody: string;
billingStatus: BillingStatus; billingStatus: BillingStatus;
createZammadArticle: boolean; createZammadArticle: boolean;
zammadArticleTypeId: ZammadArticleTypeId; zammadArticleTypeId: ZammadArticleTypeId;
@@ -46,6 +48,7 @@ function emptyManualSession(): ManualSessionFormState {
organizationName: null, organizationName: null,
projectTeamspaceEntryId: "", projectTeamspaceEntryId: "",
activity: "", activity: "",
zammadArticleBody: "",
billingStatus: null, billingStatus: null,
createZammadArticle: false, createZammadArticle: false,
zammadArticleTypeId: 6, zammadArticleTypeId: 6,
@@ -61,6 +64,7 @@ type FinishedSession = {
ticketNumber: string; ticketNumber: string;
ticket: TicketMeta | null; ticket: TicketMeta | null;
activity: string; activity: string;
zammadArticleBody: string;
billingStatus: BillingStatus; billingStatus: BillingStatus;
promptBillingStatus: boolean; promptBillingStatus: boolean;
createZammadArticle: boolean; createZammadArticle: boolean;
@@ -81,6 +85,10 @@ function teamspaceEntryOptionLabel(entry: { teamspace_project: string | null; is
return entry.is_default ? `${label} (Standard)` : label; return entry.is_default ? `${label} (Standard)` : label;
} }
function teamspaceEntryDescription(entry: { teamspace_description?: string | null } | null | undefined) {
return entry?.teamspace_description?.trim() || "";
}
function requiresTeamspaceEntry(ticket: TicketMeta | null | undefined) { function requiresTeamspaceEntry(ticket: TicketMeta | null | undefined) {
return Boolean(ticket?.is_project && ticketTeamspaceEntries(ticket).length > 1); return Boolean(ticket?.is_project && ticketTeamspaceEntries(ticket).length > 1);
} }
@@ -126,13 +134,161 @@ function notifyZammadArticleResult(result: CreateSessionResult) {
return; return;
} }
if (result.zammadArticle?.status === "queued") { if (result.zammadArticle?.status === "failed") {
toast.warning("Zammad-Notiz geparkt", { toast.error("Zammad-Notiz nicht übertragen", {
description: result.zammadArticle.error ?? "Die Session wurde gespeichert. Die Notiz kann im Profil später erneut gesendet werden." description: result.zammadArticle.error ?? "Die Session wurde gespeichert, aber Zammad hat die Notiz nicht angenommen."
}); });
} }
} }
function escapeHtml(value: string) {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function textToEditorHtml(value: string) {
return value
.split(/\r?\n/)
.map((line) => `<div>${line ? escapeHtml(line) : "<br>"}</div>`)
.join("");
}
function fileToDataUrl(file: File) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.addEventListener("load", () => resolve(String(reader.result ?? "")));
reader.addEventListener("error", () => reject(reader.error ?? new Error("Bild konnte nicht gelesen werden")));
reader.readAsDataURL(file);
});
}
function currentSelectionRange() {
const selection = window.getSelection();
return selection && selection.rangeCount > 0 ? selection.getRangeAt(0).cloneRange() : null;
}
function restoreSelectionRange(range: Range | null) {
if (!range) {
return;
}
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
}
function editorPlainText(element: HTMLElement) {
return element.innerText.replace(/\u00a0/g, " ").trim();
}
function hasInlineImage(element: HTMLElement) {
return Boolean(element.querySelector("img[src^='data:image/']"));
}
function normalizedEditorHtml(element: HTMLElement) {
const html = element.innerHTML.trim();
return html && (editorPlainText(element) || hasInlineImage(element)) ? html : "";
}
function InlineImageActivityEditor({
id,
value,
htmlValue,
onChange
}: {
id: string;
value: string;
htmlValue: string;
onChange: (value: string, htmlValue: string) => void;
}) {
const editorRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const editor = editorRef.current;
if (!editor) {
return;
}
const nextHtml = htmlValue || (value ? textToEditorHtml(value) : "");
if (editor.innerHTML !== nextHtml) {
editor.innerHTML = nextHtml;
}
}, [htmlValue, value]);
function emitChange() {
const editor = editorRef.current;
if (!editor) {
return;
}
onChange(editorPlainText(editor), normalizedEditorHtml(editor));
}
async function handlePaste(event: ClipboardEvent<HTMLDivElement>) {
const imageFiles = Array.from(event.clipboardData.items)
.filter((item) => item.kind === "file" && item.type.startsWith("image/"))
.map((item) => item.getAsFile())
.filter((file): file is File => Boolean(file));
if (imageFiles.length === 0) {
const text = event.clipboardData.getData("text/plain");
if (text) {
event.preventDefault();
document.execCommand("insertText", false, text);
emitChange();
}
return;
}
event.preventDefault();
let insertionRange = currentSelectionRange();
const text = event.clipboardData.getData("text/plain").trim();
if (text) {
document.execCommand("insertText", false, `${text}\n`);
insertionRange = currentSelectionRange();
}
try {
for (const file of imageFiles) {
const dataUrl = await fileToDataUrl(file);
restoreSelectionRange(insertionRange);
document.execCommand("insertHTML", false, `<img src="${dataUrl}" style="display:block;max-width:100%;height:auto;margin-top:0.5rem;border-radius:0.375rem;"><br>`);
insertionRange = currentSelectionRange();
}
emitChange();
} catch (error) {
toast.error("Screenshot konnte nicht eingefügt werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
}
}
return (
<div
id={id}
ref={editorRef}
role="textbox"
aria-multiline="true"
contentEditable
suppressContentEditableWarning
className="min-h-24 w-full overflow-hidden rounded-lg border border-input bg-transparent px-2.5 py-2 text-base outline-none transition-colors empty:before:text-muted-foreground empty:before:content-[attr(data-placeholder)] focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 md:text-sm [&_img]:my-2 [&_img]:max-h-[28rem] [&_img]:max-w-full [&_img]:rounded-md"
data-placeholder="Tätigkeit beschreiben, Screenshot mit Strg+V einfügen"
onInput={emitChange}
onBlur={emitChange}
onPaste={(event) => void handlePaste(event)}
/>
);
}
export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimerId, tick, currentUser }: TimerPageProps) { export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimerId, tick, currentUser }: TimerPageProps) {
const [ticketNumber, setTicketNumber] = useState(""); const [ticketNumber, setTicketNumber] = useState("");
const [finished, setFinished] = useState<FinishedSession | null>(null); const [finished, setFinished] = useState<FinishedSession | null>(null);
@@ -148,6 +304,8 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
const [manualLookupTicket, setManualLookupTicket] = useState<TicketMeta | null>(null); const [manualLookupTicket, setManualLookupTicket] = useState<TicketMeta | null>(null);
const [zammadLookupBusy, setZammadLookupBusy] = useState<string | null>(null); const [zammadLookupBusy, setZammadLookupBusy] = useState<string | null>(null);
const [recurringBillings, setRecurringBillings] = useState<RecurringBilling[]>([]); const [recurringBillings, setRecurringBillings] = useState<RecurringBilling[]>([]);
const activitySaveTimeouts = useRef(new Map<string, number>());
const activityDrafts = useRef(new Map<string, { activity: string; zammadArticleBody: string }>());
const selectedTimer = timers.find((timer) => timer.id === selectedTimerId) ?? timers[0] ?? null; const selectedTimer = timers.find((timer) => timer.id === selectedTimerId) ?? timers[0] ?? null;
const elapsedSeconds = useMemo(() => Math.floor(activeElapsedMs(selectedTimer, tick) / 1000), [selectedTimer, tick]); const elapsedSeconds = useMemo(() => Math.floor(activeElapsedMs(selectedTimer, tick) / 1000), [selectedTimer, tick]);
@@ -157,6 +315,8 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
const manualTicketIsRecurringBilling = const manualTicketIsRecurringBilling =
ticketHasRecurringBilling(manualLookupTicket, recurringBillings) || isRecurringBillingTicketNumber(manualSession.ticketNumber.trim(), recurringBillings); ticketHasRecurringBilling(manualLookupTicket, recurringBillings) || isRecurringBillingTicketNumber(manualSession.ticketNumber.trim(), recurringBillings);
const runningTimer = timers.find((timer) => timer.phase === "running") ?? null; const runningTimer = timers.find((timer) => timer.phase === "running") ?? null;
const selectedManualTeamspaceEntry = ticketTeamspaceEntries(manualLookupTicket).find((entry) => entry.id === manualSession.projectTeamspaceEntryId) ?? null;
const selectedFinishedTeamspaceEntry = ticketTeamspaceEntries(finished?.ticket).find((entry) => entry.id === projectTeamspaceEntryId) ?? null;
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -261,6 +421,15 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
}); });
}, [currentUser.zammad_article_type_id, currentUser.zammad_session_note_default_enabled]); }, [currentUser.zammad_article_type_id, currentUser.zammad_session_note_default_enabled]);
useEffect(() => {
return () => {
for (const timeout of activitySaveTimeouts.current.values()) {
window.clearTimeout(timeout);
}
activitySaveTimeouts.current.clear();
};
}, []);
async function addTimer(source: "local" | "zammad" = "local") { async function addTimer(source: "local" | "zammad" = "local") {
const normalizedTicketNumber = ticketNumber.trim(); const normalizedTicketNumber = ticketNumber.trim();
@@ -312,8 +481,8 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
} }
const now = Date.now(); const now = Date.now();
const newTimer: TimerEntry = { try {
id: createClientId("timer"), const result = await createActiveTimer({
ticketNumber: normalizedTicketNumber, ticketNumber: normalizedTicketNumber,
organizationName: ticketResult.ticket?.organization_name ?? ticketResult.ticket?.customer_name ?? null, organizationName: ticketResult.ticket?.organization_name ?? ticketResult.ticket?.customer_name ?? null,
workType: ticketResult.ticket?.work_type ?? null, workType: ticketResult.ticket?.work_type ?? null,
@@ -326,11 +495,15 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
activity: "", activity: "",
billingStatus: null, billingStatus: null,
phase: "running" phase: "running"
}; });
setTimers(result.timers);
setTimers((current) => [...current.map((timer) => pauseEntry(timer, now)), newTimer]); setSelectedTimerId(result.timers.find((timer) => timer.ticketNumber === normalizedTicketNumber)?.id ?? null);
setSelectedTimerId(newTimer.id);
setTicketNumber(""); setTicketNumber("");
} catch (error) {
toast.error("Timer konnte nicht gestartet werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
}
} }
async function lookupManualTicketInZammad() { async function lookupManualTicketInZammad() {
@@ -407,36 +580,67 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
} }
} }
function activateTimer(timerId: string) { async function activateTimer(timerId: string) {
const now = Date.now(); try {
setTimers((current) => const result = await updateActiveTimer(timerId, { phase: "running" });
current.map((timer) => { setTimers(result.timers);
if (timer.id === timerId) {
return resumeEntry(timer, now);
}
return pauseEntry(timer, now);
})
);
setSelectedTimerId(timerId); setSelectedTimerId(timerId);
} catch (error) {
toast.error("Timer konnte nicht aktiviert werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
}
} }
function pauseTimer(timerId: string) { async function pauseTimer(timerId: string) {
const now = Date.now(); try {
setTimers((current) => current.map((timer) => (timer.id === timerId ? pauseEntry(timer, now) : timer))); const result = await updateActiveTimer(timerId, { phase: "paused" });
setTimers(result.timers);
setSelectedTimerId(timerId); setSelectedTimerId(timerId);
} catch (error) {
toast.error("Timer konnte nicht pausiert werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
}
} }
function resetTimer(timerId: string) { async function resetTimer(timerId: string) {
setTimers((current) => current.filter((timer) => timer.id !== timerId)); try {
const result = await deleteActiveTimer(timerId);
setTimers(result.timers);
} catch (error) {
toast.error("Timer konnte nicht entfernt werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
}
} }
function updateTimerActivity(timerId: string, nextActivity: string) { function updateTimerActivity(timerId: string, nextActivity: string, zammadArticleBody: string) {
setTimers((current) => current.map((timer) => (timer.id === timerId ? { ...timer, activity: nextActivity } : timer))); activityDrafts.current.set(timerId, { activity: nextActivity, zammadArticleBody });
setTimers((current) => current.map((timer) => (timer.id === timerId ? { ...timer, activity: nextActivity, zammadArticleBody } : timer)));
const existingTimeout = activitySaveTimeouts.current.get(timerId);
if (existingTimeout) {
window.clearTimeout(existingTimeout);
}
const timeout = window.setTimeout(() => {
activitySaveTimeouts.current.delete(timerId);
void updateActiveTimer(timerId, { activity: nextActivity, zammadArticleBody: zammadArticleBody || null }).catch((error) => {
console.error(error);
});
}, 700);
activitySaveTimeouts.current.set(timerId, timeout);
} }
function updateTimerBillingStatus(timerId: string, billingStatus: BillingStatus) { function updateTimerBillingStatus(timerId: string, billingStatus: BillingStatus) {
setTimers((current) => current.map((timer) => (timer.id === timerId ? { ...timer, billingStatus } : timer))); setTimers((current) => current.map((timer) => (timer.id === timerId ? { ...timer, billingStatus } : timer)));
void updateActiveTimer(timerId, { billingStatus }).then((result) => setTimers(result.timers)).catch((error) => {
toast.error("Bewertung konnte nicht gespeichert werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
});
} }
async function finishTimer(timerId: string) { async function finishTimer(timerId: string) {
@@ -446,31 +650,31 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
return; return;
} }
const timerActivity = timer.activity; const activityDraft = activityDrafts.current.get(timer.id);
const timerActivity = activityDraft?.activity ?? timer.activity;
if (!timerActivity.trim()) { const timerZammadArticleBody = activityDraft?.zammadArticleBody ?? timer.zammadArticleBody ?? "";
setSelectedTimerId(timer.id);
toast.error("Tätigkeit erfassen", {
description: "Bitte dokumentiere die Tätigkeit vor dem Beenden der Session."
});
return;
}
const now = Date.now(); const now = Date.now();
const stoppedTimer = pauseEntry(timer, now); const durationSeconds = Math.floor(activeElapsedMs(timer, now) / 1000);
const durationSeconds = Math.floor(activeElapsedMs(stoppedTimer, now) / 1000);
const roundedMinutes = Math.max(1, Math.round(durationSeconds / 60)); const roundedMinutes = Math.max(1, Math.round(durationSeconds / 60));
setTimers((current) => current.map((entry) => (entry.id === timerId ? pauseEntry(entry, now) : entry)));
setSelectedTimerId(timer.id);
setFinishingId(timer.id); setFinishingId(timer.id);
try { try {
const timerResult = await updateActiveTimer(timer.id, {
phase: "paused",
activity: timerActivity,
zammadArticleBody: timerZammadArticleBody || null
});
setTimers(timerResult.timers);
setSelectedTimerId(timer.id);
const result = await lookupTicket(timer.ticketNumber); const result = await lookupTicket(timer.ticketNumber);
const ticket = result.ticket; const ticket = result.ticket;
if (ticketHasRecurringBilling(ticket, recurringBillings) || isRecurringBillingTicketNumber(timer.ticketNumber, recurringBillings)) { if (ticketHasRecurringBilling(ticket, recurringBillings) || isRecurringBillingTicketNumber(timer.ticketNumber, recurringBillings)) {
setTimers((current) => current.filter((entry) => entry.id !== timer.id)); const timerResult = await deleteActiveTimer(timer.id).catch(() => null);
setTimers((current) => timerResult?.timers ?? current.filter((entry) => entry.id !== timer.id));
toast.error("Fixe Abrechnung", { toast.error("Fixe Abrechnung", {
description: "Für fixe Abrechnungen dürfen keine manuellen Sessions gespeichert werden. Bitte die Definition bearbeiten." description: "Für fixe Abrechnungen dürfen keine manuellen Sessions gespeichert werden. Bitte die Definition bearbeiten."
}); });
@@ -486,11 +690,12 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
ticketNumber: timer.ticketNumber, ticketNumber: timer.ticketNumber,
ticket, ticket,
activity: timerActivity, activity: timerActivity,
zammadArticleBody: timerZammadArticleBody,
billingStatus: timer.billingStatus, billingStatus: timer.billingStatus,
promptBillingStatus: currentUser.session_billing_prompt_enabled && timer.billingStatus === null, promptBillingStatus: currentUser.session_billing_prompt_enabled && timer.billingStatus === null,
createZammadArticle: currentUser.zammad_session_note_default_enabled, createZammadArticle: currentUser.zammad_session_note_default_enabled && timerActivity.trim().length > 0,
zammadArticleTypeId: currentUser.zammad_article_type_id, zammadArticleTypeId: currentUser.zammad_article_type_id,
startedAt: new Date(stoppedTimer.startedAt).toISOString(), startedAt: new Date(timer.startedAt).toISOString(),
endedAt: new Date(now).toISOString(), endedAt: new Date(now).toISOString(),
durationSeconds, durationSeconds,
roundedMinutes roundedMinutes
@@ -511,13 +716,6 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
return; return;
} }
if (!finished.activity.trim()) {
toast.error("Tätigkeit erfassen", {
description: "Bitte dokumentiere die Tätigkeit vor dem Speichern der Session."
});
return;
}
if (ticketHasRecurringBilling(finished.ticket, recurringBillings) || isRecurringBillingTicketNumber(finished.ticketNumber, recurringBillings)) { if (ticketHasRecurringBilling(finished.ticket, recurringBillings) || isRecurringBillingTicketNumber(finished.ticketNumber, recurringBillings)) {
toast.error("Fixe Abrechnung", { toast.error("Fixe Abrechnung", {
description: "Für fixe Abrechnungen dürfen keine manuellen Sessions gespeichert werden. Bitte die Definition bearbeiten." description: "Für fixe Abrechnungen dürfen keine manuellen Sessions gespeichert werden. Bitte die Definition bearbeiten."
@@ -555,11 +753,12 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
durationSeconds: finished.durationSeconds, durationSeconds: finished.durationSeconds,
organizationId: effectiveOrganizationId, organizationId: effectiveOrganizationId,
activity: finished.activity, activity: finished.activity,
zammadArticleBody: finished.zammadArticleBody || null,
workType: effectiveWorkType, workType: effectiveWorkType,
zammadLookup: Boolean(finished.ticket?.zammad_ticket_id), zammadLookup: Boolean(finished.ticket?.zammad_ticket_id),
projectTeamspaceEntryId: finishedRequiresTeamspaceEntry ? projectTeamspaceEntryId : null, projectTeamspaceEntryId: finishedRequiresTeamspaceEntry ? projectTeamspaceEntryId : null,
billingStatus: finished.billingStatus, billingStatus: finished.billingStatus,
createZammadArticle: finished.createZammadArticle, createZammadArticle: finished.createZammadArticle && (finished.activity.trim().length > 0 || Boolean(finished.zammadArticleBody)),
zammadArticleTypeId: finished.zammadArticleTypeId zammadArticleTypeId: finished.zammadArticleTypeId
}); });
@@ -567,7 +766,15 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
description: `${finished.ticketNumber} wurde mit ${finished.roundedMinutes} Minute(n) erfasst.` description: `${finished.ticketNumber} wurde mit ${finished.roundedMinutes} Minute(n) erfasst.`
}); });
notifyZammadArticleResult(result); notifyZammadArticleResult(result);
try {
const timerResult = await deleteActiveTimer(finished.timerId);
setTimers(timerResult.timers);
} catch (error) {
setTimers((current) => current.filter((timer) => timer.id !== finished.timerId)); setTimers((current) => current.filter((timer) => timer.id !== finished.timerId));
toast.warning("Session gespeichert, Timer lokal entfernt", {
description: error instanceof Error ? error.message : "Der Timer konnte serverseitig nicht entfernt werden."
});
}
setFinished(null); setFinished(null);
setOrganizationId(""); setOrganizationId("");
setOrganizationName(null); setOrganizationName(null);
@@ -619,8 +826,8 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
return; return;
} }
if (endedAt <= startedAt) { if (endedAt < startedAt) {
toast.error("Ende muss nach Beginn liegen"); toast.error("Ende darf nicht vor Beginn liegen");
return; return;
} }
@@ -633,6 +840,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
ticketNumber: manualSession.ticketNumber.trim(), ticketNumber: manualSession.ticketNumber.trim(),
organizationId: manualSession.organizationId, organizationId: manualSession.organizationId,
activity: manualSession.activity, activity: manualSession.activity,
zammadArticleBody: manualSession.zammadArticleBody || null,
workType: manualSession.workType, workType: manualSession.workType,
startedAt: startedAt.toISOString(), startedAt: startedAt.toISOString(),
endedAt: endedAt.toISOString(), endedAt: endedAt.toISOString(),
@@ -750,13 +958,13 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
<label className="text-sm font-medium" htmlFor="timer-activity"> <label className="text-sm font-medium" htmlFor="timer-activity">
Tätigkeit Tätigkeit
</label> </label>
<Textarea <RichActivityEditor
id="timer-activity" id="timer-activity"
className="min-h-32"
value={selectedTimer?.activity ?? ""} value={selectedTimer?.activity ?? ""}
htmlValue={selectedTimer?.zammadArticleBody ?? ""}
disabled={!selectedTimer} disabled={!selectedTimer}
placeholder={selectedTimer ? "Beschreibe, woran du in dieser Session arbeitest." : "Timer auswählen oder starten."} placeholder={selectedTimer ? "Beschreibe, woran du in dieser Session arbeitest." : "Timer auswählen oder starten."}
onChange={(event) => selectedTimer && updateTimerActivity(selectedTimer.id, event.currentTarget.value)} onChange={(activity, zammadArticleBody) => selectedTimer && updateTimerActivity(selectedTimer.id, activity, zammadArticleBody)}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -780,12 +988,12 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
<div className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap"> <div className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap">
{selectedTimer?.phase === "running" ? ( {selectedTimer?.phase === "running" ? (
<Button variant="secondary" disabled={!selectedTimer} onClick={() => selectedTimer && pauseTimer(selectedTimer.id)}> <Button variant="secondary" disabled={!selectedTimer} onClick={() => selectedTimer && void pauseTimer(selectedTimer.id)}>
<Pause className="size-4" /> <Pause className="size-4" />
Pausieren Pausieren
</Button> </Button>
) : ( ) : (
<Button disabled={!selectedTimer} onClick={() => selectedTimer && activateTimer(selectedTimer.id)}> <Button disabled={!selectedTimer} onClick={() => selectedTimer && void activateTimer(selectedTimer.id)}>
<Play className="size-4" /> <Play className="size-4" />
Aktivieren Aktivieren
</Button> </Button>
@@ -795,7 +1003,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
<Square className="size-4" /> <Square className="size-4" />
{finishingId === selectedTimer?.id ? "Prüft..." : "Beenden"} {finishingId === selectedTimer?.id ? "Prüft..." : "Beenden"}
</Button> </Button>
<Button variant="ghost" disabled={!selectedTimer} onClick={() => selectedTimer && resetTimer(selectedTimer.id)}> <Button variant="ghost" disabled={!selectedTimer} onClick={() => selectedTimer && void resetTimer(selectedTimer.id)}>
<RotateCcw className="size-4" /> <RotateCcw className="size-4" />
Entfernen Entfernen
</Button> </Button>
@@ -903,6 +1111,9 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
</option> </option>
))} ))}
</select> </select>
{teamspaceEntryDescription(selectedManualTeamspaceEntry) ? (
<p className="text-xs text-muted-foreground">{teamspaceEntryDescription(selectedManualTeamspaceEntry)}</p>
) : null}
</div> </div>
) : null} ) : null}
</div> </div>
@@ -948,12 +1159,11 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium" htmlFor="manual-activity">Tätigkeit</label> <label className="text-sm font-medium" htmlFor="manual-activity">Tätigkeit</label>
<Textarea <RichActivityEditor
id="manual-activity" id="manual-activity"
className="min-h-20"
value={manualSession.activity} value={manualSession.activity}
onChange={(event) => setManualSession({ ...manualSession, activity: event.currentTarget.value })} htmlValue={manualSession.zammadArticleBody}
required onChange={(activity, zammadArticleBody) => setManualSession((current) => ({ ...current, activity, zammadArticleBody }))}
/> />
</div> </div>
<label className="flex min-h-10 items-start gap-2 rounded-md border bg-background p-3 text-sm lg:col-span-5"> <label className="flex min-h-10 items-start gap-2 rounded-md border bg-background p-3 text-sm lg:col-span-5">
@@ -1044,36 +1254,50 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
<label className="text-sm font-medium" htmlFor="finished-activity"> <label className="text-sm font-medium" htmlFor="finished-activity">
Tätigkeit Tätigkeit
</label> </label>
<Textarea <RichActivityEditor
id="finished-activity" id="finished-activity"
className="min-h-24"
value={finished?.activity ?? ""} value={finished?.activity ?? ""}
onChange={(event) => { htmlValue={finished?.zammadArticleBody ?? ""}
const nextActivity = event.currentTarget.value; onChange={(activity, zammadArticleBody) => {
setFinished((current) => (current ? { ...current, activity: nextActivity } : current)); setFinished((current) => current ? {
...current,
activity,
zammadArticleBody,
createZammadArticle: activity.trim().length > 0 || zammadArticleBody ? current.createZammadArticle : false
} : current);
}} }}
required
/> />
</div> </div>
{finished ? ( {finished ? (
<div className="space-y-3 rounded-md border bg-muted/30 p-3"> <div className="space-y-3 rounded-md border bg-muted/30 p-3">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
{(() => {
const hasZammadContent = finished.activity.trim().length > 0 || Boolean(finished.zammadArticleBody);
return (
<>
<Checkbox <Checkbox
id="finished-zammad-article" id="finished-zammad-article"
checked={finished.createZammadArticle} checked={finished.createZammadArticle && hasZammadContent}
onCheckedChange={(checked) => setFinished((current) => (current ? { ...current, createZammadArticle: checked === true } : current))} disabled={!hasZammadContent}
onCheckedChange={(checked) => setFinished((current) => (current ? { ...current, createZammadArticle: checked === true && (current.activity.trim().length > 0 || Boolean(current.zammadArticleBody)) } : current))}
/> />
<div className="space-y-1"> <div className="space-y-1">
<label className="text-sm font-medium" htmlFor="finished-zammad-article"> <label className="text-sm font-medium" htmlFor="finished-zammad-article">
Tätigkeit als interne Zammad-Notiz übertragen Tätigkeit als interne Zammad-Notiz übertragen
</label> </label>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Erstellt einen internen Zammad-Article mit Tätigkeit und getrackter Zeit. {hasZammadContent
? "Erstellt einen internen Zammad-Article mit Tätigkeit und getrackter Zeit."
: "Ohne Tätigkeit wird keine Zammad-Notiz erstellt."}
</p> </p>
{!finished.ticket?.zammad_ticket_id ? ( {!finished.ticket?.zammad_ticket_id ? (
<p className="text-xs text-muted-foreground">Falls die Zammad-ID noch fehlt, wird sie beim Speichern oder späteren Nachsenden erneut gesucht.</p> <p className="text-xs text-muted-foreground">Falls die Zammad-ID noch fehlt, wird sie beim Speichern oder späteren Nachsenden erneut gesucht.</p>
) : null} ) : null}
</div> </div>
</>
);
})()}
</div> </div>
{finished.createZammadArticle ? ( {finished.createZammadArticle ? (
<div className="space-y-2"> <div className="space-y-2">
@@ -1142,6 +1366,9 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
</option> </option>
))} ))}
</select> </select>
{teamspaceEntryDescription(selectedFinishedTeamspaceEntry) ? (
<p className="text-xs text-muted-foreground">{teamspaceEntryDescription(selectedFinishedTeamspaceEntry)}</p>
) : null}
</div> </div>
) : null} ) : null}
<div className="flex justify-end"> <div className="flex justify-end">
+406 -36
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState, useSyncExternalStore } from "react"; import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { AlertCircle, CheckCircle2, DatabaseZap, DownloadCloud, RefreshCw } from "lucide-react"; import { AlertCircle, CheckCircle2, DatabaseZap, DownloadCloud, MailPlus, RefreshCw } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
@@ -7,13 +7,16 @@ 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 { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
applyExistingZammadTicketsFromPreview, applyExistingZammadTicketsFromPreview,
applyOwnZammadConsultingTicketsFromPreview, applyOwnZammadConsultingTicketsFromPreview,
applyZammadMailSessionsFromPreview,
previewExistingZammadTickets, previewExistingZammadTickets,
previewOwnZammadConsultingTickets, previewOwnZammadConsultingTickets,
previewZammadMailSessions,
waitForZammadSyncJob, waitForZammadSyncJob,
type ZammadSyncExistingFields, type ZammadSyncExistingFields,
type ZammadSyncChange, type ZammadSyncChange,
@@ -61,10 +64,18 @@ type ZammadSyncPageState = {
importPreviewJobId: string | null; importPreviewJobId: string | null;
importRunningJobId: string | null; importRunningJobId: string | null;
importRunningJobKind: RunningJobKind | null; importRunningJobKind: RunningJobKind | null;
mailResult: ZammadSyncResult | null;
mailPreviewJobId: string | null;
mailRunningJobId: string | null;
mailRunningJobKind: RunningJobKind | null;
mailDaysBack: number;
mailPerPage: number;
existingSelection: SelectionMap; existingSelection: SelectionMap;
importSelection: SelectionMap; importSelection: SelectionMap;
mailSelection: SelectionMap;
syncingExisting: boolean; syncingExisting: boolean;
importingConsulting: boolean; importingConsulting: boolean;
syncingMail: boolean;
}; };
function emptyZammadSyncPageState(): ZammadSyncPageState { function emptyZammadSyncPageState(): ZammadSyncPageState {
@@ -78,10 +89,18 @@ function emptyZammadSyncPageState(): ZammadSyncPageState {
importPreviewJobId: null, importPreviewJobId: null,
importRunningJobId: null, importRunningJobId: null,
importRunningJobKind: null, importRunningJobKind: null,
mailResult: null,
mailPreviewJobId: null,
mailRunningJobId: null,
mailRunningJobKind: null,
mailDaysBack: 30,
mailPerPage: 300,
existingSelection: {}, existingSelection: {},
importSelection: {}, importSelection: {},
mailSelection: {},
syncingExisting: false, syncingExisting: false,
importingConsulting: false importingConsulting: false,
syncingMail: false
}; };
} }
@@ -162,6 +181,40 @@ function runningJobKind(value: unknown): RunningJobKind | null {
return value === "preview" || value === "apply" ? value : null; return value === "preview" || value === "apply" ? value : null;
} }
function parseMailDaysBack(value: unknown) {
const parsed = Number(value);
return Number.isInteger(parsed) && parsed >= 1 && parsed <= 3650 ? parsed : 30;
}
function berlinDateParts(date: Date) {
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit"
}).formatToParts(date);
const value = (type: string) => Number(parts.find((part) => part.type === type)?.value);
return {
year: value("year"),
month: value("month"),
day: value("day")
};
}
function mailSyncCutoffDate(daysBack: number) {
const current = berlinDateParts(new Date());
const date = new Date(Date.UTC(current.year, current.month - 1, current.day));
date.setUTCDate(date.getUTCDate() - Math.max(0, daysBack - 1));
return date.toISOString().slice(0, 10);
}
function parseMailPerPage(value: unknown) {
const parsed = Number(value);
return Number.isInteger(parsed) && parsed >= 1 && parsed <= 1000 ? parsed : 300;
}
function loadZammadSyncPageState(userId: string): ZammadSyncPageState | null { function loadZammadSyncPageState(userId: string): ZammadSyncPageState | null {
const raw = getLocalStorageValue(zammadSyncStorageKey(userId)); const raw = getLocalStorageValue(zammadSyncStorageKey(userId));
@@ -180,6 +233,8 @@ function loadZammadSyncPageState(userId: string): ZammadSyncPageState | null {
const existingRunningJobKind = runningJobKind(parsed.existingRunningJobKind); const existingRunningJobKind = runningJobKind(parsed.existingRunningJobKind);
const importRunningJobId = nullableString(parsed.importRunningJobId); const importRunningJobId = nullableString(parsed.importRunningJobId);
const importRunningJobKind = runningJobKind(parsed.importRunningJobKind); const importRunningJobKind = runningJobKind(parsed.importRunningJobKind);
const mailRunningJobId = nullableString(parsed.mailRunningJobId);
const mailRunningJobKind = runningJobKind(parsed.mailRunningJobKind);
return { return {
fields: syncFields(parsed.fields), fields: syncFields(parsed.fields),
@@ -191,10 +246,18 @@ function loadZammadSyncPageState(userId: string): ZammadSyncPageState | null {
importPreviewJobId: nullableString(parsed.importPreviewJobId), importPreviewJobId: nullableString(parsed.importPreviewJobId),
importRunningJobId, importRunningJobId,
importRunningJobKind, importRunningJobKind,
mailResult: syncResult(parsed.mailResult),
mailPreviewJobId: nullableString(parsed.mailPreviewJobId),
mailRunningJobId,
mailRunningJobKind,
mailDaysBack: parseMailDaysBack(parsed.mailDaysBack),
mailPerPage: parseMailPerPage(parsed.mailPerPage),
existingSelection: selectionMap(parsed.existingSelection), existingSelection: selectionMap(parsed.existingSelection),
importSelection: selectionMap(parsed.importSelection), importSelection: selectionMap(parsed.importSelection),
mailSelection: selectionMap(parsed.mailSelection),
syncingExisting: Boolean(existingRunningJobId && existingRunningJobKind), syncingExisting: Boolean(existingRunningJobId && existingRunningJobKind),
importingConsulting: Boolean(importRunningJobId && importRunningJobKind) importingConsulting: Boolean(importRunningJobId && importRunningJobKind),
syncingMail: Boolean(mailRunningJobId && mailRunningJobKind)
}; };
} catch { } catch {
return null; return null;
@@ -213,12 +276,17 @@ function selectableItems(result: ZammadSyncResult) {
return result.items.filter((item) => item.action === "update" || item.action === "create"); return result.items.filter((item) => item.action === "update" || item.action === "create");
} }
function selectionKey(item: ZammadSyncItem) {
const articleId = item.zammad?.articleId;
return typeof articleId === "number" || typeof articleId === "string" ? `article:${articleId}` : item.ticketNumber;
}
function itemIsSelected(item: ZammadSyncItem, selection: SelectionMap) { function itemIsSelected(item: ZammadSyncItem, selection: SelectionMap) {
return (item.action === "update" || item.action === "create") && selection[item.ticketNumber] !== false; return (item.action === "update" || item.action === "create") && selection[selectionKey(item)] !== false;
} }
function selectionFromResult(result: ZammadSyncResult): SelectionMap { function selectionFromResult(result: ZammadSyncResult): SelectionMap {
return Object.fromEntries(selectableItems(result).map((item) => [item.ticketNumber, true])); return Object.fromEntries(selectableItems(result).map((item) => [selectionKey(item), true]));
} }
function selectedTicketNumbers(result: ZammadSyncResult | null, selection: SelectionMap) { function selectedTicketNumbers(result: ZammadSyncResult | null, selection: SelectionMap) {
@@ -231,6 +299,25 @@ function selectedTicketNumbers(result: ZammadSyncResult | null, selection: Selec
.map((item) => item.ticketNumber); .map((item) => item.ticketNumber);
} }
function selectedArticleIds(result: ZammadSyncResult | null, selection: SelectionMap) {
if (!result) {
return [];
}
return selectableItems(result)
.filter((item) => itemIsSelected(item, selection))
.map((item) => Number(item.zammad?.articleId))
.filter((articleId) => Number.isInteger(articleId) && articleId > 0);
}
function existingApplyDescription(result: ZammadSyncResult) {
return `${result.updated} aktualisiert, ${result.skipped} ohne Transfer.`;
}
function importApplyDescription(result: ZammadSyncResult) {
return `${result.created} angelegt, ${result.skipped} ohne Transfer.`;
}
function formatMinutes(value: unknown) { function formatMinutes(value: unknown) {
if (typeof value !== "number") { if (typeof value !== "number") {
return formatValue(value); return formatValue(value);
@@ -243,6 +330,28 @@ function formatMinutes(value: unknown) {
return `${new Intl.NumberFormat("de-DE", { maximumFractionDigits: value < 600 ? 1 : 0 }).format(value / 60)} h`; return `${new Intl.NumberFormat("de-DE", { maximumFractionDigits: value < 600 ? 1 : 0 }).format(value / 60)} h`;
} }
function formatDurationSeconds(value: unknown) {
const seconds = Number(value);
if (!Number.isFinite(seconds)) {
return formatValue(value);
}
const minutes = Math.round(seconds / 60);
return `${new Intl.NumberFormat("de-DE").format(minutes)} min`;
}
function formatTimeUnit(value: unknown) {
const hours = Number(value);
if (!Number.isFinite(hours)) {
return formatValue(value);
}
const minutes = Math.round(hours * 60);
return `${new Intl.NumberFormat("de-DE", { maximumFractionDigits: 2 }).format(hours)} h / ${new Intl.NumberFormat("de-DE").format(minutes)} min`;
}
function numericValue(value: unknown) { function numericValue(value: unknown) {
const number = Number(value); const number = Number(value);
return Number.isInteger(number) ? number : null; return Number.isInteger(number) ? number : null;
@@ -316,6 +425,10 @@ function formatValue(value: unknown): string {
} }
function formatChangeValue(field: string, value: unknown) { function formatChangeValue(field: string, value: unknown) {
if (field === "Dauer") {
return formatDurationSeconds(value);
}
if (field === "Teamspace-Budget") { if (field === "Teamspace-Budget") {
return formatMinutes(value); return formatMinutes(value);
} }
@@ -725,6 +838,17 @@ function zammadDetails(item: ZammadSyncItem) {
return ["kein Treffer"]; return ["kein Treffer"];
} }
if (zammad.articleId) {
return [
`Article ${formatValue(zammad.articleId)}`,
`Ticket-ID ${formatValue(zammad.ticketId)}`,
`Typ eMail`,
`Erstellt ${formatValue(zammad.createdAt)}`,
`time_unit ${formatTimeUnit(zammad.timeUnit)}`,
`Betreff ${formatValue(zammad.subject)}`
];
}
return [ return [
`ID ${formatValue(zammad.ticketId)}`, `ID ${formatValue(zammad.ticketId)}`,
`Gruppe ${formatZammadGroup(zammad.groupId)}`, `Gruppe ${formatZammadGroup(zammad.groupId)}`,
@@ -795,18 +919,25 @@ function ResultSummary({
) : null} ) : null}
<div className="max-h-[520px] overflow-auto rounded-md border"> <div className="max-h-[520px] overflow-auto rounded-md border">
<Table> <Table className="w-full table-fixed">
<colgroup>
{selection ? <col className="w-12" /> : null}
<col className="w-40" />
<col className="w-64" />
<col className="w-[34rem]" />
<col className="w-[36rem]" />
</colgroup>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
{selection ? ( {selection ? (
<TableHead className="w-10"> <TableHead>
<Checkbox checked={allSelected} disabled={selectable.length === 0} onCheckedChange={(checked) => onToggleAll?.(checked === true)} /> <Checkbox checked={allSelected} disabled={selectable.length === 0} onCheckedChange={(checked) => onToggleAll?.(checked === true)} />
</TableHead> </TableHead>
) : null} ) : null}
<TableHead className="min-w-32">Ticket</TableHead> <TableHead>Ticket</TableHead>
<TableHead className="min-w-32">Aktion</TableHead> <TableHead>Aktion</TableHead>
<TableHead className="min-w-72">Daten aus Zammad</TableHead> <TableHead>Daten aus Zammad</TableHead>
<TableHead className="min-w-80">Änderungen in TicketTracker</TableHead> <TableHead>Änderungen in TicketTracker</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -823,27 +954,27 @@ function ResultSummary({
const visibleChanges = visibleTicketTrackerChanges(item); const visibleChanges = visibleTicketTrackerChanges(item);
return ( return (
<TableRow key={`${result.mode}-${item.ticketNumber}-${item.action}`}> <TableRow key={`${result.mode}-${selectionKey(item)}-${item.action}`}>
{selection ? ( {selection ? (
<TableCell> <TableCell>
<Checkbox <Checkbox
checked={itemIsSelected(item, selection)} checked={itemIsSelected(item, selection)}
disabled={!isSelectable} disabled={!isSelectable}
onCheckedChange={(checked) => onToggle?.(item.ticketNumber, checked === true)} onCheckedChange={(checked) => onToggle?.(selectionKey(item), checked === true)}
/> />
</TableCell> </TableCell>
) : null} ) : null}
<TableCell className="font-medium">{item.ticketNumber}</TableCell> <TableCell className="truncate font-medium" title={item.ticketNumber}>{item.ticketNumber}</TableCell>
<TableCell> <TableCell>
<div className="space-y-1"> <div className="min-w-0 space-y-1">
<Badge variant={action.variant}>{action.label}</Badge> <Badge variant={action.variant}>{action.label}</Badge>
{item.reason ? <p className="text-xs text-muted-foreground">{item.reason}</p> : null} {item.reason ? <p className="truncate text-xs text-muted-foreground" title={item.reason}>{item.reason}</p> : null}
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="max-w-xl space-y-1 text-xs text-muted-foreground"> <div className="min-w-0 space-y-1 text-xs text-muted-foreground">
{zammadDetails(item).map((detail) => ( {zammadDetails(item).map((detail) => (
<div key={detail} className="break-words"> <div key={detail} className="truncate" title={detail}>
{detail} {detail}
</div> </div>
))} ))}
@@ -851,15 +982,21 @@ function ResultSummary({
</TableCell> </TableCell>
<TableCell> <TableCell>
{visibleChanges.length > 0 ? ( {visibleChanges.length > 0 ? (
<div className="space-y-1 text-xs"> <div className="min-w-0 space-y-1 text-xs">
{visibleChanges.map((change) => ( {visibleChanges.map((change) => {
<div key={`${item.ticketNumber}-${change.field}`} className="break-words"> const fromValue = formatChangeValue(change.field, change.from);
const toValue = formatChangeValue(change.field, change.to);
const title = `${change.field}: ${fromValue} -> ${toValue}`;
return (
<div key={`${item.ticketNumber}-${change.field}`} className="truncate" title={title}>
<span className="font-medium">{change.field}:</span>{" "} <span className="font-medium">{change.field}:</span>{" "}
<span className="text-muted-foreground">{formatChangeValue(change.field, change.from)}</span> <span className="text-muted-foreground">{fromValue}</span>
<span className="text-muted-foreground"> -&gt; </span> <span className="text-muted-foreground"> -&gt; </span>
<span>{formatChangeValue(change.field, change.to)}</span> <span>{toValue}</span>
</div> </div>
))} );
})}
</div> </div>
) : ( ) : (
<span className="text-xs text-muted-foreground">Keine Änderung.</span> <span className="text-xs text-muted-foreground">Keine Änderung.</span>
@@ -894,15 +1031,23 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
importResult, importResult,
importRunningJobId, importRunningJobId,
importRunningJobKind, importRunningJobKind,
mailResult,
mailRunningJobId,
mailRunningJobKind,
mailDaysBack,
mailPerPage,
existingSelection, existingSelection,
importSelection, importSelection,
mailSelection,
syncingExisting, syncingExisting,
importingConsulting importingConsulting,
syncingMail
} = pageState; } = pageState;
const [storageReady, setStorageReady] = useState(false); const [storageReady, setStorageReady] = useState(false);
const displayedExistingResult = useMemo(() => applyFieldsToExistingPreview(existingResult, fields), [existingResult, fields]); const displayedExistingResult = useMemo(() => applyFieldsToExistingPreview(existingResult, fields), [existingResult, fields]);
const selectedExistingCount = useMemo(() => selectedTicketNumbers(displayedExistingResult, existingSelection).length, [displayedExistingResult, existingSelection]); const selectedExistingCount = useMemo(() => selectedTicketNumbers(displayedExistingResult, existingSelection).length, [displayedExistingResult, existingSelection]);
const selectedImportCount = useMemo(() => selectedTicketNumbers(importResult, importSelection).length, [importResult, importSelection]); const selectedImportCount = useMemo(() => selectedTicketNumbers(importResult, importSelection).length, [importResult, importSelection]);
const selectedMailCount = useMemo(() => selectedArticleIds(mailResult, mailSelection).length, [mailResult, mailSelection]);
const zammadReady = currentUser.has_zammad_api_token && Boolean(currentUser.zammad_user_id); const zammadReady = currentUser.has_zammad_api_token && Boolean(currentUser.zammad_user_id);
useEffect(() => { useEffect(() => {
@@ -952,7 +1097,7 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
})); }));
} else { } else {
setZammadSyncPageState({ setZammadSyncPageState({
existingResult: result, existingResult: null,
existingPreviewJobId: null, existingPreviewJobId: null,
existingSelection: {}, existingSelection: {},
existingRunningJobId: null, existingRunningJobId: null,
@@ -1005,7 +1150,7 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
})); }));
} else { } else {
setZammadSyncPageState({ setZammadSyncPageState({
importResult: result, importResult: null,
importPreviewJobId: null, importPreviewJobId: null,
importSelection: {}, importSelection: {},
importRunningJobId: null, importRunningJobId: null,
@@ -1034,6 +1179,59 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
}; };
}, [importRunningJobId, importRunningJobKind, storageReady]); }, [importRunningJobId, importRunningJobKind, storageReady]);
useEffect(() => {
if (!storageReady || !mailRunningJobId || !mailRunningJobKind) {
return;
}
let cancelled = false;
void waitForZammadSyncJob(mailRunningJobId)
.then((result) => {
if (cancelled) {
return;
}
if (mailRunningJobKind === "preview") {
setZammadSyncPageState((current) => ({
mailResult: result,
mailPreviewJobId: mailRunningJobId,
mailSelection: Object.keys(current.mailSelection).length ? current.mailSelection : selectionFromResult(result),
mailRunningJobId: null,
mailRunningJobKind: null,
syncingMail: false
}));
} else {
setZammadSyncPageState({
mailResult: null,
mailPreviewJobId: null,
mailSelection: {},
mailRunningJobId: null,
mailRunningJobKind: null,
syncingMail: false
});
}
})
.catch((error) => {
if (cancelled) {
return;
}
setZammadSyncPageState({
mailRunningJobId: null,
mailRunningJobKind: null,
syncingMail: false
});
toast.error("Zammad-Mail-Sync konnte nicht fortgesetzt werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
});
return () => {
cancelled = true;
};
}, [mailRunningJobId, mailRunningJobKind, storageReady]);
function setField(key: keyof ZammadSyncExistingFields, checked: boolean) { function setField(key: keyof ZammadSyncExistingFields, checked: boolean) {
setZammadSyncPageState((current) => ({ setZammadSyncPageState((current) => ({
fields: { ...current.fields, [key]: checked, ...(key === "budget" && !checked ? { overrideBudget: false } : {}) } fields: { ...current.fields, [key]: checked, ...(key === "budget" && !checked ? { overrideBudget: false } : {}) }
@@ -1044,10 +1242,13 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
setZammadSyncPageState({ setZammadSyncPageState({
syncingExisting: false, syncingExisting: false,
importingConsulting: false, importingConsulting: false,
syncingMail: false,
existingRunningJobId: null, existingRunningJobId: null,
existingRunningJobKind: null, existingRunningJobKind: null,
importRunningJobId: null, importRunningJobId: null,
importRunningJobKind: null importRunningJobKind: null,
mailRunningJobId: null,
mailRunningJobKind: null
}); });
toast.info("Laufstatus zurückgesetzt"); toast.info("Laufstatus zurückgesetzt");
} }
@@ -1058,7 +1259,14 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
} }
const syncFields = zammadSyncPageState.fields; const syncFields = zammadSyncPageState.fields;
setZammadSyncPageState({ syncingExisting: true, existingRunningJobId: null, existingRunningJobKind: null }); setZammadSyncPageState({
existingResult: null,
existingPreviewJobId: null,
existingSelection: {},
syncingExisting: true,
existingRunningJobId: null,
existingRunningJobKind: null
});
try { try {
const { jobId, result } = await previewExistingZammadTickets(syncFields, (job: ZammadSyncJob) => { const { jobId, result } = await previewExistingZammadTickets(syncFields, (job: ZammadSyncJob) => {
setZammadSyncPageState({ syncingExisting: true, existingRunningJobId: job.id, existingRunningJobKind: "preview" }); setZammadSyncPageState({ syncingExisting: true, existingRunningJobId: job.id, existingRunningJobKind: "preview" });
@@ -1107,14 +1315,14 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
setZammadSyncPageState({ syncingExisting: true, existingRunningJobId: job.id, existingRunningJobKind: "apply" }); setZammadSyncPageState({ syncingExisting: true, existingRunningJobId: job.id, existingRunningJobKind: "apply" });
}); });
setZammadSyncPageState({ setZammadSyncPageState({
existingResult: result, existingResult: null,
existingPreviewJobId: null, existingPreviewJobId: null,
existingSelection: {}, existingSelection: {},
existingRunningJobId: null, existingRunningJobId: null,
existingRunningJobKind: null existingRunningJobKind: null
}); });
toast.success("Auswahl synchronisiert", { toast.success("Auswahl synchronisiert", {
description: `${result.updated} aktualisiert, ${result.skipped} ohne Transfer.` description: existingApplyDescription(result)
}); });
} catch (error) { } catch (error) {
toast.error("Zammad-Sync fehlgeschlagen", { toast.error("Zammad-Sync fehlgeschlagen", {
@@ -1130,7 +1338,14 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
return; return;
} }
setZammadSyncPageState({ importingConsulting: true, importRunningJobId: null, importRunningJobKind: null }); setZammadSyncPageState({
importResult: null,
importPreviewJobId: null,
importSelection: {},
importingConsulting: true,
importRunningJobId: null,
importRunningJobKind: null
});
try { try {
const { jobId, result } = await previewOwnZammadConsultingTickets((job: ZammadSyncJob) => { const { jobId, result } = await previewOwnZammadConsultingTickets((job: ZammadSyncJob) => {
setZammadSyncPageState({ importingConsulting: true, importRunningJobId: job.id, importRunningJobKind: "preview" }); setZammadSyncPageState({ importingConsulting: true, importRunningJobId: job.id, importRunningJobKind: "preview" });
@@ -1177,14 +1392,14 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
setZammadSyncPageState({ importingConsulting: true, importRunningJobId: job.id, importRunningJobKind: "apply" }); setZammadSyncPageState({ importingConsulting: true, importRunningJobId: job.id, importRunningJobKind: "apply" });
}); });
setZammadSyncPageState({ setZammadSyncPageState({
importResult: result, importResult: null,
importPreviewJobId: null, importPreviewJobId: null,
importSelection: {}, importSelection: {},
importRunningJobId: null, importRunningJobId: null,
importRunningJobKind: null importRunningJobKind: null
}); });
toast.success("Auswahl importiert", { toast.success("Auswahl importiert", {
description: `${result.created} angelegt, ${result.skipped} ohne Transfer.` description: importApplyDescription(result)
}); });
} catch (error) { } catch (error) {
toast.error("Zammad-Import fehlgeschlagen", { toast.error("Zammad-Import fehlgeschlagen", {
@@ -1195,6 +1410,87 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
} }
} }
async function previewMailSync() {
if (zammadSyncPageState.syncingMail) {
return;
}
const daysBack = parseMailDaysBack(zammadSyncPageState.mailDaysBack);
const perPage = parseMailPerPage(zammadSyncPageState.mailPerPage);
setZammadSyncPageState({
mailResult: null,
mailPreviewJobId: null,
mailSelection: {},
mailDaysBack: daysBack,
mailPerPage: perPage,
syncingMail: true,
mailRunningJobId: null,
mailRunningJobKind: null
});
try {
const { jobId, result } = await previewZammadMailSessions(daysBack, perPage, (job: ZammadSyncJob) => {
setZammadSyncPageState({ syncingMail: true, mailRunningJobId: job.id, mailRunningJobKind: "preview" });
});
setZammadSyncPageState({
mailResult: result,
mailPreviewJobId: jobId,
mailSelection: selectionFromResult(result),
mailRunningJobId: null,
mailRunningJobKind: null
});
toast.success("Mail-Vorschau erstellt", {
description: `${result.created} Mail-Session(s) können angelegt werden.`
});
} catch (error) {
toast.error("Mail-Vorschau fehlgeschlagen", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setZammadSyncPageState({ syncingMail: false, mailRunningJobId: null, mailRunningJobKind: null });
}
}
async function applyMailSync() {
if (zammadSyncPageState.syncingMail) {
return;
}
const articleIds = selectedArticleIds(zammadSyncPageState.mailResult, zammadSyncPageState.mailSelection);
if (articleIds.length === 0) {
toast.info("Keine Mail-Sessions ausgewählt");
return;
}
if (!zammadSyncPageState.mailPreviewJobId) {
toast.error("Keine Mail-Vorschau im Zwischenspeicher");
return;
}
setZammadSyncPageState({ syncingMail: true, mailRunningJobId: null, mailRunningJobKind: null });
try {
const { result } = await applyZammadMailSessionsFromPreview(zammadSyncPageState.mailPreviewJobId, articleIds, (job: ZammadSyncJob) => {
setZammadSyncPageState({ syncingMail: true, mailRunningJobId: job.id, mailRunningJobKind: "apply" });
});
setZammadSyncPageState({
mailResult: null,
mailPreviewJobId: null,
mailSelection: {},
mailRunningJobId: null,
mailRunningJobKind: null
});
toast.success("Mail-Sessions importiert", {
description: `${result.created} angelegt, ${result.skipped} ohne Transfer.`
});
} catch (error) {
toast.error("Mail-Sync fehlgeschlagen", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setZammadSyncPageState({ syncingMail: false, mailRunningJobId: null, mailRunningJobKind: null });
}
}
function toggleExisting(ticketNumber: string, checked: boolean) { function toggleExisting(ticketNumber: string, checked: boolean) {
setZammadSyncPageState((current) => ({ setZammadSyncPageState((current) => ({
existingSelection: { ...current.existingSelection, [ticketNumber]: checked } existingSelection: { ...current.existingSelection, [ticketNumber]: checked }
@@ -1209,7 +1505,7 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
} }
setZammadSyncPageState({ setZammadSyncPageState({
existingSelection: Object.fromEntries(selectableItems(currentResult).map((item) => [item.ticketNumber, checked])) existingSelection: Object.fromEntries(selectableItems(currentResult).map((item) => [selectionKey(item), checked]))
}); });
} }
@@ -1225,7 +1521,23 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
} }
setZammadSyncPageState({ setZammadSyncPageState({
importSelection: Object.fromEntries(selectableItems(zammadSyncPageState.importResult).map((item) => [item.ticketNumber, checked])) importSelection: Object.fromEntries(selectableItems(zammadSyncPageState.importResult).map((item) => [selectionKey(item), checked]))
});
}
function toggleMail(key: string, checked: boolean) {
setZammadSyncPageState((current) => ({
mailSelection: { ...current.mailSelection, [key]: checked }
}));
}
function toggleMailAll(checked: boolean) {
if (!zammadSyncPageState.mailResult) {
return;
}
setZammadSyncPageState({
mailSelection: Object.fromEntries(selectableItems(zammadSyncPageState.mailResult).map((item) => [selectionKey(item), checked]))
}); });
} }
@@ -1340,6 +1652,64 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
) : null} ) : null}
</CardContent> </CardContent>
</Card> </Card>
<Card>
<CardHeader className="p-4">
<CardTitle className="flex items-center gap-2">
<MailPlus className="size-5 text-muted-foreground" />
Mail-Sync
</CardTitle>
<CardDescription>Legt fest, wie weit und mit welcher Seitengröße Mail-Session-Importe suchen.</CardDescription>
</CardHeader>
<CardContent className="space-y-4 px-4 pb-4">
<div className="flex flex-wrap items-end gap-3">
<label className="grid w-48 gap-1 text-sm font-medium">
Tage rückwirkend
<Input
type="number"
min={1}
max={3650}
value={mailDaysBack}
onChange={(event) => setZammadSyncPageState({ mailDaysBack: parseMailDaysBack(event.currentTarget.value) })}
/>
</label>
<label className="grid w-48 gap-1 text-sm font-medium">
Abgleich ab
<Input type="date" value={mailSyncCutoffDate(mailDaysBack)} readOnly tabIndex={-1} />
</label>
<label className="grid w-48 gap-1 text-sm font-medium">
PER_PAGE
<Input
type="number"
min={1}
max={1000}
value={mailPerPage}
onChange={(event) => setZammadSyncPageState({ mailPerPage: parseMailPerPage(event.currentTarget.value) })}
/>
</label>
</div>
<div className="flex flex-wrap justify-end gap-2">
{syncingMail ? (
<Button type="button" variant="outline" onClick={resetRunningState}>
Laufstatus zurücksetzen
</Button>
) : null}
{mailResult?.mode === "preview" ? (
<Button type="button" disabled={!zammadReady || syncingMail || selectedMailCount === 0} onClick={() => void applyMailSync()}>
<CheckCircle2 className="size-4" />
{syncingMail ? "Importiert..." : `Auswahl übernehmen (${selectedMailCount})`}
</Button>
) : null}
<Button type="button" variant={mailResult?.mode === "preview" ? "outline" : "default"} disabled={!zammadReady || syncingMail} onClick={() => void previewMailSync()}>
<MailPlus className="size-4" />
{syncingMail ? "Bereitet vor..." : "Vorschau erstellen"}
</Button>
</div>
{mailResult ? (
<ResultSummary result={mailResult} selection={mailResult.mode === "preview" ? mailSelection : undefined} onToggle={toggleMail} onToggleAll={toggleMailAll} />
) : null}
</CardContent>
</Card>
</div> </div>
); );
} }
Executable
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env bash
# Holt alle Zammad-Articles der letzten X Tage, gefiltert nach type_id und created_by_id.
#
# Aufruf: ZAMMAD_TOKEN=xxx ./zammad_articles.sh [TAGE] [CREATED_BY_ID] [TYPE_ID]
# Beispiel: ZAMMAD_TOKEN=xxx ./zammad_articles.sh 2 1764 1 > mails.json
#
# Voraussetzungen: curl, jq, API-Token mit Admin-Rechten (für /ticket_articles)
set -euo pipefail
BASE_URL="${ZAMMAD_URL:-https://aikava.zammad.com}"
TOKEN="${ZAMMAD_TOKEN:?Bitte ZAMMAD_TOKEN setzen}"
DAYS="${1:-2}"
CREATED_BY="${2:-1764}"
TYPE_ID="${3:-1}"
PER_PAGE=1000
MAX_PAGE=1000000 # Schutz gegen Endlosschleifen
# Call-Zähler über eine Temp-Datei, da fetch_page oft in $(...) läuft
CALLS_FILE=$(mktemp)
trap 'rm -f "$CALLS_FILE"' EXIT
calls() { wc -l < "$CALLS_FILE" | tr -d ' '; }
# Stichtag in UTC (GNU date oder BSD/macOS date)
# CALENDAR=1: ab lokaler Mitternacht vor (TAGE-1) Tagen, also TAGE=2 -> gestern 00:00 bis jetzt
# sonst: rollierend, jetzt minus TAGE*24h
if date -u -d "now" >/dev/null 2>&1; then
if [ "${CALENDAR:-0}" = "1" ]; then
TS=$(date -d "$((DAYS - 1)) days ago 00:00" +%s)
else
TS=$(date -d "${DAYS} days ago" +%s)
fi
CUTOFF=$(date -u -d "@${TS}" +%Y-%m-%dT%H:%M:%S)
else
if [ "${CALENDAR:-0}" = "1" ]; then
TS=$(date -v-"$((DAYS - 1))"d -v0H -v0M -v0S +%s)
else
TS=$(date -v-"${DAYS}"d +%s)
fi
CUTOFF=$(date -u -r "${TS}" +%Y-%m-%dT%H:%M:%S)
fi
log() { echo "$*" >&2; }
fetch_page() {
echo "$1" >> "$CALLS_FILE"
curl -sf -H "Authorization: Token token=${TOKEN}" \
"${BASE_URL}/api/v1/ticket_articles?page=$1&per_page=${PER_PAGE}"
}
page_len() { fetch_page "$1" | jq 'length'; }
log "Stichtag: ${CUTOFF}Z | created_by_id=${CREATED_BY} | type_id=${TYPE_ID}"
# 1) Letzte nicht-leere Seite per Binärsuche finden
if [ "$(page_len 1)" -eq 0 ]; then
log "Keine Articles gefunden. (API-Calls: $(calls))"
echo "[]"
exit 0
fi
lo=1; hi=2
while [ "$hi" -le "$MAX_PAGE" ] && [ "$(page_len "$hi")" -gt 0 ]; do
lo=$hi; hi=$((hi * 2))
done
while [ $((hi - lo)) -gt 1 ]; do
mid=$(( (lo + hi) / 2 ))
if [ "$(page_len "$mid")" -gt 0 ]; then lo=$mid; else hi=$mid; fi
done
SEARCH_CALLS=$(calls)
log "Letzte Seite: ${lo} (Binärsuche: ${SEARCH_CALLS} Calls)"
# 2) Rückwärts lesen bis zum Stichtag und filtern
results="[]"
p=$lo
while [ "$p" -ge 1 ]; do
batch=$(fetch_page "$p")
matches=$(jq --arg c "$CUTOFF" --argjson u "$CREATED_BY" --argjson t "$TYPE_ID" \
'[.[] | select(.created_at >= $c and .type_id == $t and .created_by_id == $u)]' <<<"$batch")
results=$(jq -s 'add' <(echo "$results") <(echo "$matches"))
oldest=$(jq -r '.[0].created_at // empty' <<<"$batch")
log "Seite ${p}: $(jq 'length' <<<"$matches") Treffer (ältester Article: ${oldest})"
[[ -z "$oldest" || "$oldest" < "$CUTOFF" ]] && break
p=$((p - 1))
done
TOTAL_CALLS=$(calls)
log "Gesamt: $(jq 'length' <<<"$results") Articles"
log "API-Calls: ${TOTAL_CALLS} (Binärsuche: ${SEARCH_CALLS}, Rückwärts lesen: $((TOTAL_CALLS - SEARCH_CALLS)))"
jq 'sort_by(.id)' <<<"$results"
+37
View File
@@ -0,0 +1,37 @@
[
{
"id": 66368,
"ticket_id": 3122,
"type_id": 1,
"sender_id": 1,
"from": "XX",
"to": "XX",
"cc": "XX",
"subject": "XX",
"reply_to": null,
"message_id": "XX",
"message_id_md5": "XX",
"in_reply_to": "XX",
"content_type": "text/html",
"body": "",
"internal": false,
"preferences": {
"subtype": "reply",
"email_address_id": 7,
"delivery_retry": 1,
"delivery_channel_id": 24,
"delivery_status_message": null,
"delivery_status": "success",
"delivery_status_date": "2026-09-25T10:55:51.214Z"
},
"updated_by_id": 77,
"created_by_id": 77,
"origin_by_id": null,
"created_at": "2026-09-25T10:55:48.201Z",
"updated_at": "2026-09-25T10:55:51.215Z",
"detected_language": null,
"ai_stored_result_ids": [],
"attachments": [],
"body_rendering_error": false
}
]
View File
Whitespace-only changes.
+211
View File
@@ -0,0 +1,211 @@
[
{
"id": 66333,
"ticket_id": 6910,
"type_id": 1,
"sender_id": 1,
"from": "\"Michael Böhmländer via Zammad Helpdesk Support\" <consulting@aikava.de>",
"to": "tobias.ritter@primed-halberstadt.de",
"cc": "",
"subject": "Primed Halberstadt Medizintechnik GmbH / WF + neuer Standort Tschechien / 17459",
"reply_to": null,
"message_id": "<20260925061659.6910.ebeeffdd-6e76-46c8-a41e-36c38f4a3f5d@aikava.zammad.com>",
"message_id_md5": "fa072c137010607ae161cc50262d5fa5",
"in_reply_to": "<FR0P281MB2831BA848411868C36FA216BB9812@FR0P281MB2831.DEUP281.PROD.OUTLOOK.COM>",
"content_type": "text/html",
"body": "Hallo Herr Ritter,<div><br></div><div>ich bin bisher noch nicht zum testen gekommen. Ich habe mich eingeloggt und den QR Code gescannt. Beim anschließenden Login mit</div><div><br></div><div>Username</div><div>PasswordTOTP (so wie es in der Seite steht; also Passwort direkt gefolgt vom Token aus der App)</div><div><br></div><div>erhalte ich aber Login failed. Habe ich etwas falsch gemacht?<br><br><div data-signature=\"true\" data-signature-id=\"3\"><div><b>Michael Böhmländer</b></div><div>Senior Software Consultant<br>Geplante Abwesenheiten:<br>\n</div><div><br></div><div><b>AIKAVA GmbH</b></div><div>Galgenfeld 7 | 93413 Cham</div><div><br></div><div>T: +49 9971 88999 40</div><div><a href=\"mailto:consulting.atoss@aikava.de\" title=\"mailto:consulting.atoss@aikava.de\">consulting@aikava.de</a></div><div>\n<a href=\"https://aikava.de/\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">aikava.de</a><br>\n</div><div><br></div><div><p>Amtsgericht Regensburg HRB 19554<br></p></div><div>Geschäftsführung Marco Christener</div><div><b><br></b></div><div><i>This e-mail may contain confidential and/or privileged information. If you are not the intended recipient or have received this e-mail in error please notify the sender immediately and delete this e-mail.</i></div></div><div><br><br></div><div><blockquote type=\"cite\">Am Donnerstag, 24. September 2026 um 13:00:24, schrieb Tobias Ritter:<br><br><div>\n<p>Sehr geehrter Hr. Böhmländer,</p><p>&nbsp;</p><p>hat die Anmeldung VPN und ATOSS geklappt ?</p><p>&nbsp;</p><p>Sonst hätten Sie sich sicher gemeldet.</p><p>&nbsp;</p><p>Ich habe ein paar Infos in die Cloud getan.</p><p>&nbsp;</p><p>Bitte entnehmen Sie</p><p>&nbsp;</p><p>KeyUserEinstellgn Checkliste - Primed Halberstadt Medizintechnik GmbH Projekt 20260924</p><p>&nbsp;</p><div>\n<div>\n<div>\n<p>Mit freundlichen Grüßen / Kind regards</p><p>Tobias Ritter</p><p>Betriebsleiter</p><p><span>B: +49 3941- 668 710 | <a href=\"mailto:tobias.ritter@primed-halberstadt.de\">tobias.ritter@primed-halberstadt.de</a></span></p><p>PRIMED Halberstadt Medizintechnik GmbH | <a href=\"http://www.primed-halberstadt.de/\" title=\"www.primed-halberstadt.de\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">www.primed-halberstadt.de</a></p></div></div></div><p>&nbsp;</p><div>\n<div>\n<span class=\"js-signatureMarker\"></span><p><b>Von:</b><span> Ritter, Tobias <br>\n<b>Gesendet:</b> Mittwoch, 23. September 2026 16:07<br>\n<b>An:</b> 'Michael Böhmländer via Zammad Helpdesk Support' &lt;consulting@aikava.de&gt;<br>\n<b>Betreff:</b> AW: [Ticket#946896] Primed Halberstadt Medizintechnik GmbH / WF + neuer Standort Tschechien / 17459</span></p></div></div><p>&nbsp;</p><p>Hallo Hr. Böhmländer,</p><p>&nbsp;</p><p>danke für die Nachfrage.</p><p>Ich komme mit der Zusammenstellung der Informationen nicht so voran, wie ich mir das wünschte. Sorry.</p><p>Aber ein wenig habe ich vorbereiten können.</p><p>&nbsp;</p><p>Zugang:</p><p>Bitte einen VPN-Tunnel via Sophos Connect Client aufbauen:</p><p>Dazu melden Sie sich zuerst am Gateway / VPN-Portal an, um das Zertifikat und den Client herunter zu laden.</p><p>Außerdem werden Sie aufgefordert, einen OTP-Token als 2FA einzurichten (Authenticator..).</p><p>&nbsp;</p><p>Die Zugangsdaten erhalten Sie separat.</p><p>&nbsp;</p><p>Wenn diese Anmeldung erfolgreich war und der Tunnel steht, ist es so konfiguriert, dass Sie auf den ATOSS-Dienst via Browser Port 8080 zugreifen können.</p><p>&nbsp;</p><p>Weiter Infos erhalten Sie separat.</p><p>&nbsp;</p><p>Der Link zum VPN-Gateway ist:</p><p>&nbsp;</p><p>Bitte im VPN Portal: <a href=\"https://gw-primed.primed-halberstadt.de:4442/\" rel=\"nofollow noreferrer noopener\" title=\"https://gw-primed.primed-halberstadt.de:4442/\" target=\"_blank\"> https://deu01.safelinks.protection.outlook.com/?url=https%3A%2F%2Fgw-primed.primed-halberstadt.de%3A4442%2F&amp;data=05%7C02%7CTobias.Ritter%40primed-halberstadt.de%7C79dd9da709a6417f9efa08df1486fb3a%7Ccdf1a6302e144edca1dfd90a5e006008%7C0%7C0%7C639252242147688576%7CUnknown%7CTWFpbGZsb3d8eyJFbXB0eU1hcGkiOnRydWUsIlYiOiIwLjAuMDAwMCIsIlAiOiJXaW4zMiIsIkFOIjoiTWFpbCIsIldUIjoyfQ%3D%3D%7C0%7C%7C%7C&amp;sdata=CAsI%2FABdwPu9EtaSgccfmUe3RLF6zyCL4ociily5oQo%3D&amp;reserved=0</a> den Token einrichten. Mit einen Software-Authenticator (bsp: Google Authenticator, Microsoft Authenticator, etc. -&gt; also Handy geht auch.)</p><p>&nbsp;</p><p>Bitte testen Sie dies. Für Rückfragen stehe ich zur Verfügung.</p><p>&nbsp;</p><p>Ein Hinweis: Alle Änderungen im ATOSS möchte ich begleiten. Bitte sprechen Sie sie mit mir ab.</p><p>&nbsp;</p><p>Mit freundlichen Grüßen / Kind regards</p><p>Tobias Ritter</p><p>Betriebsleiter</p><p><span>B: +49 3941- 668 710 | <a href=\"mailto:tobias.ritter@primed-halberstadt.de\">tobias.ritter@primed-halberstadt.de</a></span></p><p>PRIMED Halberstadt Medizintechnik GmbH | <a href=\"http://www.primed-halberstadt.de/\" title=\"www.primed-halberstadt.de\" rel=\"nofollow noreferLine truncated
"internal": false,
"preferences": {
"subtype": "reply",
"email_address_id": 7,
"delivery_retry": 1,
"delivery_channel_id": 24,
"delivery_status_message": null,
"delivery_status": "success",
"delivery_status_date": "2026-09-25T06:17:03.054Z"
},
"updated_by_id": 1764,
"created_by_id": 1764,
"origin_by_id": null,
"created_at": "2026-09-25T06:16:58.989Z",
"updated_at": "2026-09-25T06:17:03.056Z",
"detected_language": null,
"ai_stored_result_ids": [],
"attachments": [],
"body_rendering_error": false
},
{
"id": 66336,
"ticket_id": 6916,
"type_id": 1,
"sender_id": 1,
"from": "\"Michael Böhmländer via Zammad Helpdesk Support\" <support@aikava.de>",
"to": "kirchhof@hwk-berlin.de",
"cc": "",
"subject": "Verarbeitungsanpassung",
"reply_to": null,
"message_id": "<20260925062752.6916.7844e20a-2b4e-4bd5-819e-91042a70d7bb@aikava.zammad.com>",
"message_id_md5": "abf1595d00cb49f18dd00cff2f45446e",
"in_reply_to": "<VI0PR09MB8591D4B56733592E7AF7FAE988812@VI0PR09MB8591.eurprd09.prod.outlook.com>",
"content_type": "text/html",
"body": "Hallo Frau Kirchhof,<div><br></div><div>ich habe an den Verarbeitungen nichts gemacht. Das einzige was ich gemacht habe ist, dass ich die (inzwischen nachgetragene Gehenstempelung) testweise am 28.06.2026 entfernt habe und eine Neuberechnung durchgeführt habe. Hier habe ich festgestellt, dass alles so funktioniert wie es soll.</div><div><br></div><div>Den Screenshot, den Sie sehen, wo das Datum der 22.9. ist, ist aus meinem lokalen Testsystem (Mitarbeiter value 2 value 1). Das ist kein Bild aus Ihrem System.</div><div><br></div><div>Viele Grüße<br><br><div data-signature=\"true\" data-signature-id=\"2\"><div>\n<div><b>Michael Böhmländer</b></div>\n<div>Senior Software Consultant<br>Geplante Abwesenheiten:</div>\n</div><div><br></div><div><b>AIKAVA GmbH</b></div><div>Galgenfeld 7 | 93413 Cham</div><div><b><br></b></div><div>T: +49 9971 88999 40</div><div>\n<a href=\"mailto:support.atoss@aikava.de\" title=\"mailto:support.atoss@aikava.de\">support@aikava.de</a><br>\n</div><div>\n<a href=\"https://aikava.de/\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">aikava.de</a><br>\n</div><div><br></div><div><p>Amtsgericht Regensburg HRB 19554<br></p></div><div>Geschäftsführung Marco Christener</div><div><i><br></i></div><div><i>This e-mail may contain confidential and/or privileged information. If you are not the intended recipient or have received this e-mail in error please notify the sender immediately and delete this e-mail.</i></div></div><div><br><br></div><div><blockquote type=\"cite\">Am Donnerstag, 24. September 2026 um 12:35:48, schrieb Christiane Kirchhof:<br><br><div>\n<p>Ein Nachtrag zu meiner Mail von eben.</p><p>Da hier bei Testen und Nachstellen eine rote Fehlermeldung erzeugt wird, fragen wir uns, ob Sie die Regel ( s. Ihre untere Mail) schon ausgeschaltet haben?</p><p>Grundsätzlich sind wir auch weiterhin für eine Fehlermeldung, haben aber Bedenken, dass das Entfernen dieses Regel dann wieder Folgefehler nach sich zieht.</p><p>Leider hatten wir in der Vergangenheit oft sehr unschöne Erfahrungen gemacht…</p><p>&nbsp;</p><p>Oder haben Sie gar nichts gemacht und es funktioniert jetzt dennoch?</p><p>&nbsp;</p><table cellspacing=\"0\" cellpadding=\"0\" style=\"width: 660px;\"><tbody><tr><td valign=\"top\" style=\"width: 660px; font-size: 10pt;\"><span><br>Mit freundlichen Grüßen</span></td></tr><tr style=\"font-size: 11pt;\"><td valign=\"top\" style=\"width: 660px; font-size: 11pt; color: rgb(7, 48, 112);\"><span style=\"color: rgb(7, 48, 112);\"><span style=\" color: rgb(35, 255, 0);\"><span style=\"color: rgb(7, 48, 112);\"><span style=\"color: rgb(7, 48, 112);\">Christiane </span></span></span><span style=\"background-color: rgb(255, 255, 255); color: rgb(35, 255, 0);\"><span style=\"color: rgb(7, 48, 112);\"><b>Kirchhof</b><br></span></span></span></td></tr><tr style=\"font-size: 11pt;\"><td valign=\"top\" style=\"width: 660px; padding: 5px 0px 0px;\">Personal</td></tr><tr style=\"font-size: 11pt;\"><td valign=\"top\" style=\"width: 660px; font-size: 11pt; color: rgb(0, 0, 0); padding: 5px 0px 0px;\">Handwerkskammer Berlin<br>Blücherstraße 68, 10961 Berlin<br>Tel: <a href=\"tel:+49 30 25903 305\">+49 30 25903 305</a> | <span>Fax: <a href=\"tel:+49 30 25903 322\">+49 30 25903 322</a></span><br></td></tr><tr><td valign=\"top\" style=\"width: 660px; font-size: 10pt; color: rgb(0, 0, 0); padding: 5px 0px 0px;\"></td></tr></tbody></table><p><br></p><table style=\"width: 200px;\" cellspacing=\"0\" cellpadding=\"0\"><tbody><tr><td valign=\"bottom\" style=\"width: 40px; font-size: 10pt; text-align: center; padding: 10px 0px;\"><a href=\"https://www.hwk-berlin.de\" rel=\"nofollow noreferrer noopener\" title=\"https://www.hwk-berlin.de\" target=\"_blank\"><img src=\"/api/v1/ticket_attachment/6916/66336/227357?view=inline\" style=\"max-width: 100%;\"></a> </td><td valign=\"middle\" style=\"width: 40px; font-size: 10pt; text-align: center;\"> <a href=\"https://www.facebook.com/hwkberlin/?locale=de_DE\" rel=\"nofollow noreferrer noopener\" title=\"https://www.facebook.com/hwkberlin/?locale=de_DE\" target=\"_blank\"><img src=\"/api/v1/ticket_attachment/6916/66336/227358?view=inline\" style=\"max-width: 100%;\"></a></td><td valign=\"middle\" style=\"width: 40px; font-size: 10pt; text-align: center;\"> <a href=\"https://www.instagram.com/handwerkskammer_berlin/\" rel=\"nofollow noreferrer noopener\" title=\"https://www.instagram.com/handwerkskammer_berlin/\" target=\"_blank\"><img src=\"/api/v1/ticket_attachment/6916/66336/227359?view=inline\" style=\"max-width: 100%;\"></a></td><td valign=\"middle\" style=\"width: 40px; font-size: 10pt; text-align: center;\"><a href=\"https://de.linkedin.com/company/handwerkskammer-berlin\" rel=\"nofollow noreferrer noopener\" title=\"https://de.linkedin.com/company/handwerkskammer-berlin\" target=\"_blank\"><img src=\"/api/v1/ticket_attachment/6916/66336/227360?view=inline\" style=\"max-width: 100%;\"></a> </td><td valign=\"middle\" style=\"wiLine truncated
"internal": false,
"preferences": {
"subtype": "reply",
"email_address_id": 6,
"delivery_retry": 1,
"delivery_channel_id": 23,
"delivery_status_message": null,
"delivery_status": "success",
"delivery_status_date": "2026-09-25T06:27:56.971Z"
},
"updated_by_id": 1764,
"created_by_id": 1764,
"origin_by_id": null,
"created_at": "2026-09-25T06:27:52.681Z",
"updated_at": "2026-09-25T06:27:56.972Z",
"detected_language": null,
"ai_stored_result_ids": [],
"attachments": [],
"body_rendering_error": false
},
{
"id": 66337,
"ticket_id": 6973,
"type_id": 1,
"sender_id": 1,
"from": "\"Michael Böhmländer via Zammad Helpdesk Support\" <consulting@aikava.de>",
"to": "sophie.ochsenbein@pierre-fabre.com",
"cc": "",
"subject": "Teilzeitfrei neu konfigurieren",
"reply_to": null,
"message_id": "<20260925063108.6973.491aa02d-8d2c-4528-9a07-10b362e2f542@aikava.zammad.com>",
"message_id_md5": "9a1b9f2c0384d6e7c0b5a9b1ca2ae9e3",
"in_reply_to": "",
"content_type": "text/html",
"body": "Hallo Frau Ochsenbein,<div><br></div><div>ich hatte hier vor einiger Zeit einen Call mit Frau Medina. Leider ist sie aktuell nicht erreichbar und ich würde gerne wissen, wie es um das untenstehende Angebot steht.</div><div><br></div><div>Vielen Dank und beste Grüße<br><br><div data-signature=\"true\" data-signature-id=\"3\"><div><b>Michael Böhmländer</b></div><div>Senior Software Consultant<br>Geplante Abwesenheiten:<br>\n</div><div><br></div><div><b>AIKAVA GmbH</b></div><div>Galgenfeld 7 | 93413 Cham</div><div><br></div><div>T: +49 9971 88999 40</div><div><a href=\"mailto:consulting.atoss@aikava.de\" title=\"mailto:consulting.atoss@aikava.de\">consulting@aikava.de</a></div><div>\n<a href=\"https://aikava.de/\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">aikava.de</a><br>\n</div><div><br></div><div><p>Amtsgericht Regensburg HRB 19554<br></p></div><div>Geschäftsführung Marco Christener</div><div><b><br></b></div><div><i>This e-mail may contain confidential and/or privileged information. If you are not the intended recipient or have received this e-mail in error please notify the sender immediately and delete this e-mail.</i></div></div><br><div>---Anfang der weitergeleiteten Nachricht:---<br><br></div><div><blockquote type=\"cite\"><div>Betreff: Teilzeitfrei neu konfigurieren<br>Datum: 24.09.2026 08:28<br>Von: \"Michael Böhmländer via Zammad Helpdesk Support\" &lt;consulting@aikava.de&gt;<br>An: Medina Adriana &lt;adriana.medina.ext@pierre-fabre.com&gt;<br><br></div>Hallo Frau Adriana,<div><br></div><div>leider habe ich bisher keine Rückmeldung bzgl. meiner Aufwandseinschätzung erhalten. Konnten Sie das Angebot bereits sichten und möchten Sie es umsetzen?</div><div><br></div><div>Viele Grüße<br><br><div data-signature=\"true\" data-signature-id=\"3\"><div><b>Michael Böhmländer</b></div><div>Senior Software Consultant<br>Geplante Abwesenheiten:<br>\n</div><div><br></div><div><b>AIKAVA GmbH</b></div><div>Galgenfeld 7 | 93413 Cham</div><div><br></div><div>T: +49 9971 88999 40</div><div><a href=\"mailto:consulting.atoss@aikava.de\" title=\"mailto:consulting.atoss@aikava.de\">consulting@aikava.de</a></div><div>\n<a href=\"https://aikava.de/\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">aikava.de</a><br>\n</div><div><br></div><div><p>Amtsgericht Regensburg HRB 19554<br></p></div><div>Geschäftsführung Marco Christener</div><div><b><br></b></div><div><i>This e-mail may contain confidential and/or privileged information. If you are not the intended recipient or have received this e-mail in error please notify the sender immediately and delete this e-mail.</i></div></div><div><br><br></div><div><blockquote type=\"cite\">Am Mittwoch, 16. September 2026 um 08:12:55, schrieb Michael Böhmländer via Zammad Helpdesk Support:<br><br><div></div><div>Hallo Frau Adriana,</div><div><br></div>vielen Dank für Ihre Anfrage. Nach Prüfung Ihrer Anforderungen möchten wir Sie darauf hinweisen, dass deren Umsetzung voraussichtlich einen Aufwand von etwa 6&nbsp; Stunden erfordert und nur im Rahmen einer kostenpflichtigen Dienstleistung möglich ist.<div><br></div><div>Gerne lassen wir Ihnen ein unverbindliches Angebot zukommen. Da es sich um eine Dienstleistung handelt, für die weniger als 8 Stunden benötigt werden, reicht Ihre Ihre schriftliche Freigabe aus. Die aktuellen Stundensätze entnehmen Sie bitte nachfolgender Tabelle:</div><div><br></div><div><img style=\"width: 1000px; max-width: 100%;\" src=\"/api/v1/ticket_attachment/6973/66337/227371?view=inline\"></div><div><br></div><div>Im vorliegenden Fall gilt der Stundensatz \"Senior Consultant\".</div><div><br></div><div>5,5 h</div><div>- Tagesart, berechneten Wert und freien Saldo anlegen</div><div>- bisherige Verarbeitungsregeln in MAG 40001 beenden</div><div>- neue Verarbeitungsregel konfigurieren</div><div>- Workshop zur Handhabung</div><div><br></div><div>0,5 h</div><div>- Dokumentation</div><div>- Kommunikation Mail/Telefon<br></div><div><br></div>Viele Grüße<br><br><div data-signature=\"true\" data-signature-id=\"3\"><div><b>Michael Böhmländer</b></div><div>Senior Software Consultant<br>Geplante Abwesenheiten:<br>\n</div><div><br></div><div><b>AIKAVA GmbH</b></div><div>Galgenfeld 7 | 93413 Cham</div><div><br></div><div>T: +49 9971 88999 40</div><div><a href=\"mailto:consulting.atoss@aikava.de\" title=\"mailto:consulting.atoss@aikava.de\">consulting@aikava.de</a></div><div>\n<a href=\"https://aikava.de/\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">aikava.de</a><br>\n</div><div><br></div><div><p>Amtsgericht Regensburg HRB 19554<br></p></div><div>Geschäftsführung Marco Christener</div><div><b><br></b></div><div><i>This e-mail may contain confidential and/or privileged information. If you are not the intended recipient or have received this e-mail in error please notify the sender immediately and delete this e-mail.</i></div></div><br></blockquote></div><div><br></div></div></blockquote></div><div><br>Line truncated
"internal": false,
"preferences": {
"subtype": "forward",
"email_address_id": 7,
"delivery_retry": 1,
"delivery_channel_id": 24,
"delivery_status_message": null,
"delivery_status": "success",
"delivery_status_date": "2026-09-25T06:31:11.458Z"
},
"updated_by_id": 1764,
"created_by_id": 1764,
"origin_by_id": null,
"created_at": "2026-09-25T06:31:08.180Z",
"updated_at": "2026-09-25T06:31:11.459Z",
"detected_language": null,
"ai_stored_result_ids": [],
"attachments": [],
"body_rendering_error": false
},
{
"id": 66342,
"ticket_id": 6910,
"type_id": 1,
"sender_id": 1,
"from": "\"Michael Böhmländer via Zammad Helpdesk Support\" <consulting@aikava.de>",
"to": "support@mdlink.de",
"cc": "tobias.ritter@primed-halberstadt.de, ralph.huxol@tw-stassfurt.de",
"subject": "[Ticket#202609161013] VPN Login",
"reply_to": null,
"message_id": "<20260925072842.6910.ded4c986-2030-4cc5-b4e0-08646cc6d72b@aikava.zammad.com>",
"message_id_md5": "5e46a2fb0a92173d5dc54e9bf27c96b2",
"in_reply_to": "<1790320644.705583.864936388@otter.in.mdlink.de>",
"content_type": "text/html",
"body": "Hallo Herr Braun,<div><br></div><div>vielen Dank, jetzt hat es funktioniert.</div><div><br></div><div>Viele Grüße<br><br><div data-signature=\"true\" data-signature-id=\"3\"><div><b>Michael Böhmländer</b></div><div>Senior Software Consultant<br>Geplante Abwesenheiten:<br>\n</div><div><br></div><div><b>AIKAVA GmbH</b></div><div>Galgenfeld 7 | 93413 Cham</div><div><br></div><div>T: +49 9971 88999 40</div><div><a href=\"mailto:consulting.atoss@aikava.de\" title=\"mailto:consulting.atoss@aikava.de\">consulting@aikava.de</a></div><div>\n<a href=\"https://aikava.de/\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">aikava.de</a><br>\n</div><div><br></div><div><p>Amtsgericht Regensburg HRB 19554<br></p></div><div>Geschäftsführung Marco Christener</div><div><b><br></b></div><div><i>This e-mail may contain confidential and/or privileged information. If you are not the intended recipient or have received this e-mail in error please notify the sender immediately and delete this e-mail.</i></div></div><div><br><br></div><div><blockquote type=\"cite\">Am Freitag, 25. September 2026 um 09:18:17, schrieb MDlink online service center GmbH:<br><br><div>Sehr geehrter Herr Böhmländer,</div><div><br></div><div>scheinbar wurde der Token vom MFA nicht bestätigt.</div><div>Vorsichtshalber habe ich Ihren bereits hinterlegten MFA-Token gelöscht.</div><div><br></div><div>Bitte melden Sie sich nochmal im VPN-User Portal an, mit:</div><div>- Username</div><div>- Passwort</div><div><br></div><div>Es wird nun ein neuer QR-Code angezeigt. Den alten bitte aus Ihren Authenticator löschen und diesen neuen Importieren.</div><div><br></div><div>Dann unmittelbar im VPN-Portal einloggen mit:</div><div>- Username</div><div>- PasswortTOTP</div><div><br></div><div>Erst dann wird der Token bestätigt.</div><div>Die Sophos hat die \"Eigenart\", dass die Tokens, die nicht durch ein direktes Logins bestätigt werden. Immer wieder neu generiert werden.</div><div><br></div><div>Also wenn Sie zwischen durch, irgendwie auf der Seite neu anmelden (ohne TOTP). Wird der Token neu generiert.</div><div>Erst mit einen gültigen Login mit PasswortTOTP wird der Token final gespeichert.</div><div><br></div><div>Ich sehe in der Firewall nur: \"Fehlerhaftes Passwort\". Das kann auch die oben genannte \"Eigenart\" sein.</div><div><br></div><div>Sofern Sie trotzdem noch dieses Probleme haben, können Sie gerne uns anrufen.</div><div><br></div><div>Mit freundlichen Grüßen</div><div>--</div><div><br></div><div>Urs Braun</div><div><br></div><div>MDlink online service center GmbH&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; mail: <a href=\"mailto:support@mdlink.de\" title=\"mailto:support@mdlink.de\">support@mdlink.de</a></div><div>Lorenzweg 42 Haus 3&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; phone:&nbsp;&nbsp;+49 391 25568 0</div><div>Magdeburg&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; fax:&nbsp;&nbsp;&nbsp;&nbsp;+49 391 25568 99</div><div>39124&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; <a href=\"http://www.mdlink.de\" title=\"http://www.mdlink.de\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">http://www.mdlink.de</a></div><div>Germany&nbsp;&nbsp;</div><div><br></div><div>Amtsgericht Stendal HRB 106650</div><div>Geschäftsführer: Olaf Müller</div><div>UST-ID: DE163829671</div><br></blockquote></div><div><br></div></div>",
"internal": false,
"preferences": {
"subtype": "reply",
"email_address_id": 7,
"delivery_retry": 1,
"delivery_channel_id": 24,
"delivery_status_message": null,
"delivery_status": "success",
"delivery_status_date": "2026-09-25T07:28:46.835Z"
},
"updated_by_id": 1764,
"created_by_id": 1764,
"origin_by_id": null,
"created_at": "2026-09-25T07:28:42.824Z",
"updated_at": "2026-09-25T07:28:46.836Z",
"detected_language": null,
"ai_stored_result_ids": [],
"attachments": [],
"body_rendering_error": false
},
{
"id": 66347,
"ticket_id": 4086,
"type_id": 1,
"sender_id": 1,
"from": "\"Michael Böhmländer via Zammad Helpdesk Support\" <consulting@aikava.de>",
"to": "johannes.fleck@schilling-holz.de",
"cc": "",
"subject": "Holzwerk Schilling // Neuinstallation ATOSS Time Control",
"reply_to": null,
"message_id": "<20260925075412.4086.0c72179c-4b24-41b0-b49f-9bc8d12a5ad9@aikava.zammad.com>",
"message_id_md5": "a9d395a7ce897a845b62ac47669d4479",
"in_reply_to": "<FR7P281MB65338CD0F117F888996AB7C9CB822@FR7P281MB6533.DEUP281.PROD.OUTLOOK.COM>",
"content_type": "text/html",
"body": "Hallo Herr Fleck,<div><br></div><div>die Software Eclipse ist meines Wissens nach kein Teil von Atoss selbst. Es handelt sich, normalerweise, um eine sogenannte IDE zur Softwareentwicklung für JAVA. Warum genau die Software hier installiert ist, weiß ich leider nicht. Eventuell wurde diese von CompData bei der Installation benötigt. Bitte fragen Sie hier die Kollegen von CompData, ob sie etwas wissen.</div><div><br></div><div>Viele Grüße<br><br><div data-signature=\"true\" data-signature-id=\"3\"><div><b>Michael Böhmländer</b></div><div>Senior Software Consultant<br>Geplante Abwesenheiten:<br>\n</div><div><br></div><div><b>AIKAVA GmbH</b></div><div>Galgenfeld 7 | 93413 Cham</div><div><br></div><div>T: +49 9971 88999 40</div><div><a href=\"mailto:consulting.atoss@aikava.de\" title=\"mailto:consulting.atoss@aikava.de\">consulting@aikava.de</a></div><div>\n<a href=\"https://aikava.de/\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">aikava.de</a><br>\n</div><div><br></div><div><p>Amtsgericht Regensburg HRB 19554<br></p></div><div>Geschäftsführung Marco Christener</div><div><b><br></b></div><div><i>This e-mail may contain confidential and/or privileged information. If you are not the intended recipient or have received this e-mail in error please notify the sender immediately and delete this e-mail.</i></div></div><div><br><br></div><div><blockquote type=\"cite\">Am Mittwoch, 23. September 2026 um 16:03:16, schrieb Johannes Fleck:<br><br><div>\n<p>Hallo Herr Böhmländer,</p><p>&nbsp;</p><p><span>vielleicht können Sie Frau Hopp direkt kontaktieren (<a href=\"mailto:birgit.hopp@schilling-holz.de\">birgit.hopp@schilling-holz.de</a>).</span></p><p>&nbsp;</p><p>Wegen der Fahrer würden wir es so machen, wie unten geschrieben. Falls ich da noch was brauche, melde ich mich.</p><p>&nbsp;</p><p>Ich habe noch etwas anderes. Es läuft wohl auf dem Server eine Software namens Eclipse. Hier gibt es laut Systemanalyse mehrere Schwachstellen. Kann man die Software updaten?</p><p>&nbsp;</p><div>\n<div>\n<p><span style=\"color:#201f1e;\"> </span></p><p><span style=\"color:#201f1e;\"><br>\n<br>\n</span></p><p><span style=\"color:#201f1e;\">Mit freundlichen Grüßen</span></p><p><span style=\"color:#201f1e;\"> </span></p><p><span style=\"color:#201f1e;\">i.A. Johannes Fleck</span></p><p><span style=\"color:#201f1e;\"> </span></p><p><b><span style=\"color:#201f1e;\">Holzwerk Schilling GmbH &amp; Co. KG</span></b><span style=\"color:#201f1e;\"></span></p><p><span style=\"color:#201f1e;\">Klosterstraße 49</span></p><p><span style=\"color:#201f1e;\">D- 88430 Rot an der Rot</span></p><p><span style=\"color:#201f1e;\"> </span></p><p><span style=\"color:#201f1e;\">Tel: +49 8395 9366-23</span></p><p><span style=\"color:#201f1e;\">Fax: +49 8395 9366-20</span></p><p><span style=\"color:#201f1e;\">E-Mail: </span><a href=\"mailto:johannes.fleck@schilling-holz.de\">johannes.fleck@schilling-holz.de</a></p><p><span style=\"color:#201f1e;\">Internet: </span><a href=\"http://www.schilling-holz.de/\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">www.schilling-holz.de</a></p><p><span style=\"color:#201f1e;\">USt.-IdNr.: DE815534186</span></p><p><span style=\"color:#201f1e;\">Amtsgericht Ulm HRA 724237</span></p><p><span style=\"color:#201f1e;\"> </span></p><p><span style=\"color:#201f1e;\">Persönlich haftende Gesellschafterin:</span></p><p><span style=\"color:#201f1e;\">Holzwerk Schilling Verwaltungs GmbH</span></p><p><span style=\"color:#201f1e;\">Sitz: Rot an der Rot</span></p><p><span style=\"color:#201f1e;\">Amtsgericht Ulm HRB 731409</span></p><p><span style=\"color:#201f1e;\">Geschäftsführer: Johannes S. Schilling</span></p></div></div><p>&nbsp;</p><div>\n<div>\n<span class=\"js-signatureMarker\"></span><p><b>Von:</b><span> Michael Böhmländer via Zammad Helpdesk Support &lt;consulting@aikava.de&gt; <br>\n<b>Gesendet:</b> Dienstag, 22. </span><span lang=\"EN-GB\">September 2026 10:58<br>\n<b>An:</b> Johannes Fleck &lt;johannes.fleck@schilling-holz.de&gt;<br>\n<b>Betreff:</b> RE: [Ticket#944079] Holzwerk Schilling // Neuinstallation ATOSS Time Control</span></p></div></div><p><span lang=\"EN-GB\"> </span></p><p>Hallo Herr Fleck,</p><div>\n<p>&nbsp;</p></div><div>\n<p>leider hat sich Frau Hopp bisher noch nicht bei mir gemeldet. Könnten Sie hier bitte noch einmal nachhaken?</p></div><div>\n<p>&nbsp;</p></div><div>\n<p>Bezüglich der Fahrer: Sie starten im Oktober einfach mal und geben mir dann Rückmeldung, falls noch Anpassungen notwendig sind, oder?</p></div><div>\n<p>&nbsp;</p></div><div>\n<p>Viele Grüße</p><div>\n<div>\n<p><b>Michael Böhmländer</b></p></div><div>\n<p><span>Senior Software Consultant<br> Geplante Abwesenheiten:</span></p></div><div>\n<p>&nbsp;</p></div><div>\n<p><b>AIKAVA GmbH</b></p></div><div>\n<p>Galgenfeld 7 | 93413 Cham</p></div><div>\n<p>&nbsp;</p></div><div>\n<p>T: +49 9971 88999 40</p></div><div>\n<p><a href=\"mailto:consulting.atoss@aikava.de\" title=\"mailto:consuLine truncated
"internal": false,
"preferences": {
"subtype": "reply",
"email_address_id": 7,
"delivery_retry": 1,
"delivery_channel_id": 24,
"delivery_status_message": null,
"delivery_status": "success",
"delivery_status_date": "2026-09-25T07:54:14.523Z"
},
"updated_by_id": 1764,
"created_by_id": 1764,
"origin_by_id": null,
"created_at": "2026-09-25T07:54:11.948Z",
"updated_at": "2026-09-25T07:54:14.524Z",
"detected_language": null,
"ai_stored_result_ids": [],
"attachments": [],
"body_rendering_error": false
},
{
"id": 66350,
"ticket_id": 4086,
"type_id": 1,
"sender_id": 1,
"from": "\"Michael Böhmländer via Zammad Helpdesk Support\" <consulting@aikava.de>",
"to": "birgit.hopp@schilling-holz.de",
"cc": "",
"subject": "Schnittstelle",
"reply_to": null,
"message_id": "<20260925081606.4086.cdbe02f0-4053-4ac9-a255-21cfdc926460@aikava.zammad.com>",
"message_id_md5": "2bc1e94239dd8a7315ba47fa61668419",
"in_reply_to": "",
"content_type": "text/html",
"body": "Hallo Frau Hopp,<div><br></div><div>ich bin aktuell mit Herrn Fleck in Kontakt bezüglich der Abschlussarbeiten für TimeControl. Er meinte, Sie würden sich bzgl. der DATEV-Übergabe bei mir melden. Ich würde gerne wissen, wie es mit der Übergabe aussieht? Funktioniert das schon, wird es bereits genutzt oder sind hier noch Anpassungen notwendig?</div><div><br></div><div>Viele Grüße<br><br><div data-signature=\"true\" data-signature-id=\"3\"><div><b>Michael Böhmländer</b></div><div>Senior Software Consultant<br>Geplante Abwesenheiten:<br>\n</div><div><br></div><div><b>AIKAVA GmbH</b></div><div>Galgenfeld 7 | 93413 Cham</div><div><br></div><div>T: +49 9971 88999 40</div><div><a href=\"mailto:consulting.atoss@aikava.de\" title=\"mailto:consulting.atoss@aikava.de\">consulting@aikava.de</a></div><div>\n<a href=\"https://aikava.de/\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">aikava.de</a><br>\n</div><div><br></div><div><p>Amtsgericht Regensburg HRB 19554<br></p></div><div>Geschäftsführung Marco Christener</div><div><b><br></b></div><div><i>This e-mail may contain confidential and/or privileged information. If you are not the intended recipient or have received this e-mail in error please notify the sender immediately and delete this e-mail.</i></div></div></div>",
"internal": false,
"preferences": {
"email_address_id": 7,
"delivery_retry": 1,
"delivery_channel_id": 24,
"delivery_status_message": null,
"delivery_status": "success",
"delivery_status_date": "2026-09-25T08:16:09.314Z"
},
"updated_by_id": 1764,
"created_by_id": 1764,
"origin_by_id": null,
"created_at": "2026-09-25T08:16:06.517Z",
"updated_at": "2026-09-25T08:16:09.315Z",
"detected_language": null,
"ai_stored_result_ids": [],
"attachments": [],
"body_rendering_error": false
}
]
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ -f ".env_dev" ]]; then
set -a
source .env_dev
set +a
fi
APP_PORT="${APP_PORT:-3911}"
BACKEND_PORT="${BACKEND_PORT:-3002}"
API_PROXY_TARGET="${API_PROXY_TARGET:-http://127.0.0.1:${BACKEND_PORT}}"
export APP_PORT BACKEND_PORT API_PROXY_TARGET
export NEXT_TELEMETRY_DISABLED=1
kill_port_processes() {
local port="$1"
local pids=""
if command -v lsof >/dev/null 2>&1; then
pids="${pids}"$'\n'"$(lsof -tiTCP:"${port}" -sTCP:LISTEN 2>/dev/null || true)"
fi
if command -v fuser >/dev/null 2>&1; then
pids="${pids}"$'\n'"$(fuser -n tcp "${port}" 2>/dev/null | tr ' ' '\n' || true)"
fi
if command -v ss >/dev/null 2>&1; then
pids="${pids}"$'\n'"$(ss -ltnp "sport = :${port}" 2>/dev/null | grep -o 'pid=[0-9]*' | cut -d= -f2 || true)"
fi
pids="$(printf '%s\n' "${pids}" | sed '/^$/d' | sort -u)"
if [[ -z "${pids}" ]]; then
echo "Port ${port} ist frei."
return
fi
echo "Beende Prozess(e) auf Port ${port}: ${pids//$'\n'/ }"
kill ${pids} 2>/dev/null || true
sleep 1
local remaining=""
for pid in ${pids}; do
if kill -0 "${pid}" 2>/dev/null; then
remaining="${remaining} ${pid}"
fi
done
if [[ -n "${remaining}" ]]; then
echo "Erzwinge Stop für:${remaining}"
kill -9 ${remaining} 2>/dev/null || true
fi
}
kill_port_processes "${BACKEND_PORT}"
if [[ "${APP_PORT}" != "${BACKEND_PORT}" ]]; then
kill_port_processes "${APP_PORT}"
fi
+63 -3
View File
@@ -13,24 +13,84 @@ API_PROXY_TARGET="${API_PROXY_TARGET:-http://127.0.0.1:${BACKEND_PORT}}"
export APP_PORT BACKEND_PORT API_PROXY_TARGET export APP_PORT BACKEND_PORT API_PROXY_TARGET
export NEXT_TELEMETRY_DISABLED=1 export NEXT_TELEMETRY_DISABLED=1
kill_port_processes() {
local port="$1"
local pids=""
if command -v lsof >/dev/null 2>&1; then
pids="${pids}"$'\n'"$(lsof -tiTCP:"${port}" -sTCP:LISTEN 2>/dev/null || true)"
fi
if command -v fuser >/dev/null 2>&1; then
pids="${pids}"$'\n'"$(fuser -n tcp "${port}" 2>/dev/null | tr ' ' '\n' || true)"
fi
if command -v ss >/dev/null 2>&1; then
pids="${pids}"$'\n'"$(ss -ltnp "sport = :${port}" 2>/dev/null | grep -o 'pid=[0-9]*' | cut -d= -f2 || true)"
fi
pids="$(printf '%s\n' "${pids}" | sed '/^$/d' | sort -u)"
if [[ -z "${pids}" ]]; then
echo "Port ${port} ist frei."
return
fi
echo "Beende Prozess(e) auf Port ${port}: ${pids//$'\n'/ }"
kill ${pids} 2>/dev/null || true
sleep 1
local remaining=""
for pid in ${pids}; do
if kill -0 "${pid}" 2>/dev/null; then
remaining="${remaining} ${pid}"
fi
done
if [[ -n "${remaining}" ]]; then
echo "Erzwinge Stop für:${remaining}"
kill -9 ${remaining} 2>/dev/null || true
fi
}
kill_port_processes "${BACKEND_PORT}"
if [[ "${APP_PORT}" != "${BACKEND_PORT}" ]]; then
kill_port_processes "${APP_PORT}"
fi
if [[ -z "${DATABASE_URL:-}" ]]; then if [[ -z "${DATABASE_URL:-}" ]]; then
echo "DATABASE_URL fehlt. Beispiel:" echo "DATABASE_URL fehlt. Beispiel:"
echo " export DATABASE_URL='postgresql://user:pass@localhost:5432/tickettracker'" echo " export DATABASE_URL='postgresql://user:pass@localhost:5432/tickettracker'"
exit 1 exit 1
fi fi
if [[ -z "${SECRET_ENCRYPTION_KEY:-}" && -z "${TICKETTRACKER_SECRET_KEY:-}" ]]; then
export SECRET_ENCRYPTION_KEY="${DATABASE_URL}"
fi
DATABASE_HOST="$(node -e 'const url = new URL(process.env.DATABASE_URL); process.stdout.write(url.hostname)' 2>/dev/null || true)" DATABASE_HOST="$(node -e 'const url = new URL(process.env.DATABASE_URL); process.stdout.write(url.hostname)' 2>/dev/null || true)"
DATABASE_PORT="$(node -e 'const url = new URL(process.env.DATABASE_URL); process.stdout.write(url.port || "5432")' 2>/dev/null || echo 5432)"
POSTGRES_CONTAINER="${POSTGRES_CONTAINER:-tickettracker-postgres}" POSTGRES_CONTAINER="${POSTGRES_CONTAINER:-tickettracker-postgres}"
if [[ "${DATABASE_HOST}" == "postgres" || "${DATABASE_HOST}" == "postgres-tickettracker" ]]; then if command -v docker >/dev/null 2>&1 && docker inspect "${POSTGRES_CONTAINER}" >/dev/null 2>&1; then
if ! getent hosts "${DATABASE_HOST}" >/dev/null 2>&1 && command -v docker >/dev/null 2>&1 && docker inspect "${POSTGRES_CONTAINER}" >/dev/null 2>&1; then
POSTGRES_CONTAINER_IP="$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "${POSTGRES_CONTAINER}")" POSTGRES_CONTAINER_IP="$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "${POSTGRES_CONTAINER}")"
if [[ -n "${POSTGRES_CONTAINER_IP}" ]]; then if [[ -n "${POSTGRES_CONTAINER_IP}" ]]; then
SHOULD_USE_CONTAINER_IP=false
if [[ "${DATABASE_HOST}" == "postgres" || "${DATABASE_HOST}" == "postgres-tickettracker" ]]; then
if ! getent hosts "${DATABASE_HOST}" >/dev/null 2>&1; then
SHOULD_USE_CONTAINER_IP=true
fi
elif [[ "${DATABASE_HOST}" =~ ^172\. ]] && [[ "${DATABASE_HOST}" != "${POSTGRES_CONTAINER_IP}" ]]; then
SHOULD_USE_CONTAINER_IP=true
fi
if [[ "${SHOULD_USE_CONTAINER_IP}" == "true" ]]; then
export DATABASE_URL="$( export DATABASE_URL="$(
DATABASE_HOST="${POSTGRES_CONTAINER_IP}" node -e 'const url = new URL(process.env.DATABASE_URL); url.hostname = process.env.DATABASE_HOST; process.stdout.write(url.toString())' DATABASE_HOST="${POSTGRES_CONTAINER_IP}" node -e 'const url = new URL(process.env.DATABASE_URL); url.hostname = process.env.DATABASE_HOST; process.stdout.write(url.toString())'
)" )"
echo "Nutze Docker-Postgres ${POSTGRES_CONTAINER} unter ${POSTGRES_CONTAINER_IP}:5432 für den lokalen Start." echo "Nutze Docker-Postgres ${POSTGRES_CONTAINER} unter ${POSTGRES_CONTAINER_IP}:${DATABASE_PORT} für den lokalen Start."
fi fi
fi fi
fi fi