-- Planner: independent case allocations, capacity sources and permissions. ALTER TABLE sag_sager ADD COLUMN IF NOT EXISTS estimated_minutes INTEGER; ALTER TABLE sag_sager ADD COLUMN IF NOT EXISTS type VARCHAR(50) NOT NULL DEFAULT 'ticket'; CREATE TABLE IF NOT EXISTS planner_settings ( id SMALLINT PRIMARY KEY DEFAULT 1 CHECK (id = 1), warning_percent INTEGER NOT NULL DEFAULT 80 CHECK (warning_percent BETWEEN 1 AND 99), full_percent INTEGER NOT NULL DEFAULT 100 CHECK (full_percent >= 100), default_allocation_minutes INTEGER NOT NULL DEFAULT 60 CHECK (default_allocation_minutes > 0), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_by INTEGER REFERENCES users(user_id) ); INSERT INTO planner_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING; CREATE TABLE IF NOT EXISTS planner_work_schedules ( id BIGSERIAL PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, weekday SMALLINT NOT NULL CHECK (weekday BETWEEN 0 AND 6), start_time TIME NOT NULL DEFAULT '08:00', end_time TIME NOT NULL DEFAULT '16:00', break_minutes INTEGER NOT NULL DEFAULT 30 CHECK (break_minutes >= 0), valid_from DATE, valid_to DATE, active BOOLEAN NOT NULL DEFAULT TRUE, CHECK (end_time > start_time) ); CREATE INDEX IF NOT EXISTS idx_planner_work_user ON planner_work_schedules(user_id, weekday); INSERT INTO planner_work_schedules (user_id, weekday, start_time, end_time, break_minutes) SELECT u.user_id, d.weekday, '08:00', '16:00', 30 FROM users u CROSS JOIN (VALUES (0),(1),(2),(3),(4)) AS d(weekday) WHERE u.is_active AND NOT EXISTS ( SELECT 1 FROM planner_work_schedules w WHERE w.user_id=u.user_id AND w.weekday=d.weekday ); CREATE TABLE IF NOT EXISTS planner_absences ( id BIGSERIAL PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, starts_at TIMESTAMPTZ NOT NULL, ends_at TIMESTAMPTZ NOT NULL, kind VARCHAR(40) NOT NULL DEFAULT 'absence', note TEXT, CHECK (ends_at > starts_at) ); CREATE INDEX IF NOT EXISTS idx_planner_absence_period ON planner_absences(user_id, starts_at, ends_at); CREATE TABLE IF NOT EXISTS planner_external_busy ( id BIGSERIAL PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, provider VARCHAR(40) NOT NULL, external_id VARCHAR(255) NOT NULL, starts_at TIMESTAMPTZ NOT NULL, ends_at TIMESTAMPTZ NOT NULL, synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CHECK (ends_at > starts_at), UNIQUE(provider, external_id) ); CREATE INDEX IF NOT EXISTS idx_planner_external_period ON planner_external_busy(user_id, starts_at, ends_at); CREATE TABLE IF NOT EXISTS planner_integrations ( user_id INTEGER PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE, provider VARCHAR(40), status VARCHAR(30) NOT NULL DEFAULT 'not_connected', last_success_at TIMESTAMPTZ, last_error_at TIMESTAMPTZ, last_error TEXT, CHECK (status IN ('connected', 'degraded', 'not_connected')) ); CREATE TABLE IF NOT EXISTS planner_team_scope ( editor_user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, target_user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, PRIMARY KEY (editor_user_id, target_user_id) ); CREATE TABLE IF NOT EXISTS planner_allocations ( id BIGSERIAL PRIMARY KEY, sag_id INTEGER NOT NULL REFERENCES sag_sager(id), user_id INTEGER NOT NULL REFERENCES users(user_id), starts_at TIMESTAMPTZ NOT NULL, ends_at TIMESTAMPTZ NOT NULL, note TEXT, source VARCHAR(40) NOT NULL DEFAULT 'manual', conflict_status VARCHAR(30) NOT NULL DEFAULT 'none', locked BOOLEAN NOT NULL DEFAULT FALSE, version INTEGER NOT NULL DEFAULT 1, created_by INTEGER NOT NULL REFERENCES users(user_id), updated_by INTEGER NOT NULL REFERENCES users(user_id), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), deleted_at TIMESTAMPTZ, CHECK (ends_at > starts_at), CHECK (conflict_status IN ('none', 'overlap', 'overbooked', 'overlap_and_overbooked')) ); ALTER TABLE planner_allocations ADD COLUMN IF NOT EXISTS locked BOOLEAN NOT NULL DEFAULT FALSE; CREATE INDEX IF NOT EXISTS idx_planner_alloc_period ON planner_allocations(user_id, starts_at, ends_at) WHERE deleted_at IS NULL; CREATE INDEX IF NOT EXISTS idx_planner_alloc_case ON planner_allocations(sag_id) WHERE deleted_at IS NULL; CREATE TABLE IF NOT EXISTS planner_allocation_history ( id BIGSERIAL PRIMARY KEY, allocation_id BIGINT NOT NULL REFERENCES planner_allocations(id), sag_id INTEGER NOT NULL REFERENCES sag_sager(id), action VARCHAR(20) NOT NULL, changed_by INTEGER NOT NULL REFERENCES users(user_id), before_value JSONB, after_value JSONB, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_planner_history_case ON planner_allocation_history(sag_id, created_at DESC); INSERT INTO permissions (code, description, category) VALUES ('planner.view', 'Se relevante planer', 'planner'), ('planner.edit_own', 'Redigér egen plan', 'planner'), ('planner.edit_team', 'Redigér tilladte medarbejderes planer', 'planner'), ('planner.admin', 'Administrér kapacitet og grænser', 'planner') ON CONFLICT (code) DO NOTHING; INSERT INTO group_permissions (group_id, permission_id) SELECT g.id, p.id FROM groups g CROSS JOIN permissions p WHERE g.name = 'Administrators' AND p.category = 'planner' ON CONFLICT DO NOTHING; INSERT INTO group_permissions (group_id, permission_id) SELECT g.id, p.id FROM groups g CROSS JOIN permissions p WHERE g.name = 'Managers' AND p.code IN ('planner.view', 'planner.edit_own', 'planner.edit_team') ON CONFLICT DO NOTHING; INSERT INTO group_permissions (group_id, permission_id) SELECT g.id, p.id FROM groups g CROSS JOIN permissions p WHERE g.name = 'Technicians' AND p.code IN ('planner.view', 'planner.edit_own') ON CONFLICT DO NOTHING; INSERT INTO group_permissions (group_id, permission_id) SELECT g.id, p.id FROM groups g CROSS JOIN permissions p WHERE g.name = 'Viewers' AND p.code = 'planner.view' ON CONFLICT DO NOTHING;