Sichert den lokalen Entwicklungsstand nach Gitea: Nav/Dashboard, Personen-Excel/Archiv, Team-Setup, Assignment-Übergabe und C6/C7/T2. Co-authored-by: Cursor <cursoragent@cursor.com>
267 lines
9.6 KiB
TypeScript
267 lines
9.6 KiB
TypeScript
import {
|
|
getAllRows, getRowById, getRowsWhereIn,
|
|
insertRow, updateRow, deleteRowById, deleteRowsWhereEquals, deleteRowsWhereIn,
|
|
} from '../crud'
|
|
import type {
|
|
Group, Consultant, Assignment, AssignmentFeedback, FeedbackCategoryRating,
|
|
FeedbackCategory, ConversationEntry, RatingScore,
|
|
ConsultantImportRow, ConsultantImportResult, HardDeleteConsultantResult,
|
|
ConsultantAssignmentSummary, ConsultantDetail,
|
|
} from '../../../src/db/types'
|
|
|
|
export type {
|
|
ConsultantImportRow, ConsultantImportResult, HardDeleteConsultantResult,
|
|
ConsultantAssignmentSummary, ConsultantDetail,
|
|
}
|
|
|
|
export function normalizeEmail(email: string): string {
|
|
return email.trim().toLowerCase()
|
|
}
|
|
|
|
export function listGroups(): Group[] {
|
|
return getAllRows<Group>('groups').sort((a, b) =>
|
|
a.cohortYear !== b.cohortYear ? b.cohortYear - a.cohortYear : a.name.localeCompare(b.name),
|
|
)
|
|
}
|
|
|
|
export const getGroup = (id: number): Group | undefined => getRowById<Group>('groups', id)
|
|
|
|
export const addGroup = (group: Omit<Group, 'id'>): number => insertRow('groups', group)
|
|
|
|
export function updateGroup(id: number, patch: Partial<Group>): number {
|
|
return updateRow('groups', id, patch)
|
|
}
|
|
|
|
/** Nur leere Gruppen — sonst Fehlertext. */
|
|
export function deleteGroup(id: number): { ok: true } | { ok: false; reason: string; memberCount: number } {
|
|
const members = getAllRows<Consultant>('consultants').filter(c => c.groupId === id)
|
|
if (members.length > 0) {
|
|
return {
|
|
ok: false,
|
|
reason: 'Gruppe hat noch Personen — zuerst umziehen oder archivieren/löschen.',
|
|
memberCount: members.length,
|
|
}
|
|
}
|
|
deleteRowById('groups', id)
|
|
return { ok: true }
|
|
}
|
|
|
|
export const listConsultants = (): Consultant[] => getAllRows<Consultant>('consultants')
|
|
|
|
export function listActiveConsultants(): Consultant[] {
|
|
return listConsultants().filter(c => !c.archivedAt)
|
|
}
|
|
|
|
export function listArchivedConsultants(): Consultant[] {
|
|
return listConsultants().filter(c => !!c.archivedAt)
|
|
}
|
|
|
|
export const getConsultant = (id: number): Consultant | undefined => getRowById<Consultant>('consultants', id)
|
|
|
|
export const getConsultantsByIds = (ids: number[]): Consultant[] =>
|
|
getRowsWhereIn<Consultant>('consultants', 'id', ids)
|
|
|
|
export function findConsultantByEmail(email: string): Consultant | undefined {
|
|
const key = normalizeEmail(email)
|
|
if (!key) return undefined
|
|
return listConsultants().find(c => normalizeEmail(c.email ?? '') === key)
|
|
}
|
|
|
|
function assertUniqueEmail(email: string, exceptId?: number): void {
|
|
const key = normalizeEmail(email)
|
|
if (!key) throw new Error('E-Mail ist Pflicht.')
|
|
const hit = listConsultants().find(c => normalizeEmail(c.email ?? '') === key && c.id !== exceptId)
|
|
if (hit) throw new Error(`E-Mail „${key}" ist bereits vergeben (${hit.firstName} ${hit.lastName}).`)
|
|
}
|
|
|
|
export function addConsultant(consultant: Omit<Consultant, 'id'>): number {
|
|
const email = normalizeEmail(consultant.email ?? '')
|
|
assertUniqueEmail(email)
|
|
return insertRow('consultants', {
|
|
...consultant,
|
|
email,
|
|
firstName: consultant.firstName.trim(),
|
|
lastName: consultant.lastName.trim(),
|
|
})
|
|
}
|
|
|
|
export function updateConsultant(id: number, patch: Partial<Consultant>): number {
|
|
const next: Partial<Consultant> = { ...patch }
|
|
if (patch.email !== undefined) {
|
|
const email = normalizeEmail(patch.email)
|
|
assertUniqueEmail(email, id)
|
|
next.email = email
|
|
}
|
|
if (patch.firstName !== undefined) next.firstName = patch.firstName.trim()
|
|
if (patch.lastName !== undefined) next.lastName = patch.lastName.trim()
|
|
return updateRow('consultants', id, next)
|
|
}
|
|
|
|
export function archiveConsultant(id: number): number {
|
|
return updateRow('consultants', id, { archivedAt: new Date().toISOString() })
|
|
}
|
|
|
|
export function unarchiveConsultant(id: number): number {
|
|
return updateRow('consultants', id, { archivedAt: null as unknown as undefined })
|
|
}
|
|
|
|
/**
|
|
* Import in eine gewählte Zielgruppe. Abgleich nur über E-Mail.
|
|
* moveIfDifferentGroup: bekannte Personen mit anderer Gruppe in die Zielgruppe verschieben.
|
|
*/
|
|
export function importConsultantsToGroup(
|
|
groupId: number,
|
|
rows: ConsultantImportRow[],
|
|
moveIfDifferentGroup = true,
|
|
): ConsultantImportResult {
|
|
const group = getGroup(groupId)
|
|
if (!group) throw new Error('Zielgruppe nicht gefunden.')
|
|
|
|
const result: ConsultantImportResult = { created: 0, updated: 0, moved: 0, skipped: 0, errors: [] }
|
|
const seenInFile = new Set<string>()
|
|
|
|
for (let i = 0; i < rows.length; i++) {
|
|
const row = rows[i]!
|
|
const line = i + 1
|
|
const email = normalizeEmail(row.email ?? '')
|
|
const firstName = (row.firstName ?? '').trim()
|
|
const lastName = (row.lastName ?? '').trim()
|
|
|
|
if (!email) {
|
|
result.errors.push(`Zeile ${line}: E-Mail fehlt`)
|
|
result.skipped++
|
|
continue
|
|
}
|
|
if (!firstName || !lastName) {
|
|
result.errors.push(`Zeile ${line}: Name unvollständig (${email})`)
|
|
result.skipped++
|
|
continue
|
|
}
|
|
if (seenInFile.has(email)) {
|
|
result.errors.push(`Zeile ${line}: doppelte E-Mail in Datei (${email})`)
|
|
result.skipped++
|
|
continue
|
|
}
|
|
seenInFile.add(email)
|
|
|
|
const existing = findConsultantByEmail(email)
|
|
if (!existing?.id) {
|
|
insertRow('consultants', { groupId, firstName, lastName, email })
|
|
result.created++
|
|
continue
|
|
}
|
|
|
|
const patch: Partial<Consultant> = { firstName, lastName }
|
|
if (existing.groupId !== groupId) {
|
|
if (moveIfDifferentGroup) {
|
|
patch.groupId = groupId
|
|
result.moved++
|
|
}
|
|
}
|
|
updateRow('consultants', existing.id, patch)
|
|
result.updated++
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
/** Feedback + personenbezogene Meeting-Daten weg; Assignment bleibt, Person wird entkoppelt. */
|
|
export function hardDeleteConsultant(id: number): HardDeleteConsultantResult {
|
|
const person = getConsultant(id)
|
|
if (!person) throw new Error('Person nicht gefunden.')
|
|
|
|
const feedbacks = getAllRows<AssignmentFeedback>('assignmentFeedbacks').filter(f => f.instituteeId === id)
|
|
const feedbackIds = feedbacks.map(f => f.id!).filter(Boolean)
|
|
if (feedbackIds.length) {
|
|
deleteRowsWhereIn('feedbackCategoryRatings', 'assignmentFeedbackId', feedbackIds)
|
|
deleteRowsWhereIn('feedbackDimensionTexts', 'assignmentFeedbackId', feedbackIds)
|
|
deleteRowsWhereIn('feedbackCategoryTexts', 'assignmentFeedbackId', feedbackIds)
|
|
}
|
|
deleteRowsWhereEquals('assignmentFeedbacks', 'instituteeId', id)
|
|
deleteRowsWhereEquals('feedbackDrafts', 'instituteeId', id)
|
|
|
|
const entries = getAllRows<ConversationEntry>('conversationEntries').filter(e => e.instituteeId === id)
|
|
const entryIds = entries.map(e => e.id!).filter(Boolean)
|
|
if (entryIds.length) {
|
|
deleteRowsWhereIn('conversationSkillScores', 'conversationEntryId', entryIds)
|
|
}
|
|
deleteRowsWhereEquals('conversationEntries', 'instituteeId', id)
|
|
deleteRowsWhereEquals('assessments', 'instituteeId', id)
|
|
deleteRowsWhereEquals('meetingCategoryRatings', 'instituteeId', id)
|
|
deleteRowsWhereEquals('checklistResponses', 'instituteeId', id)
|
|
|
|
const emptiedAssignmentIds: number[] = []
|
|
let assignmentsTouched = 0
|
|
const assignments = getAllRows<Assignment>('assignments')
|
|
for (const a of assignments) {
|
|
if (!a.id || !a.instituteeIds.includes(id)) continue
|
|
assignmentsTouched++
|
|
const instituteeIds = a.instituteeIds.filter(x => x !== id)
|
|
const instituteeEnrollments = (a.instituteeEnrollments ?? []).filter(e => e.instituteeId !== id)
|
|
updateRow('assignments', a.id, { instituteeIds, instituteeEnrollments })
|
|
if (instituteeIds.length === 0) emptiedAssignmentIds.push(a.id)
|
|
}
|
|
|
|
deleteRowById('consultants', id)
|
|
return {
|
|
emptiedAssignmentIds,
|
|
feedbacksDeleted: feedbacks.length,
|
|
assignmentsTouched,
|
|
}
|
|
}
|
|
|
|
/** @deprecated — nutze hardDeleteConsultant; bleibt als Alias für alte Client-Aufrufe. */
|
|
export function deleteConsultant(id: number): void {
|
|
hardDeleteConsultant(id)
|
|
}
|
|
|
|
export function getConsultantDetail(id: number): ConsultantDetail | undefined {
|
|
const consultant = getConsultant(id)
|
|
if (!consultant) return undefined
|
|
|
|
const group = getGroup(consultant.groupId)
|
|
const types = getAllRows<{ id?: number; name: string }>('assignmentTypes')
|
|
const typeMap = new Map(types.map(t => [t.id!, t.name]))
|
|
const categories = getAllRows<FeedbackCategory>('feedbackCategories')
|
|
const catMap = new Map(categories.map(c => [c.id!, c.name]))
|
|
const allFeedbacks = getAllRows<AssignmentFeedback>('assignmentFeedbacks')
|
|
const allRatings = getAllRows<FeedbackCategoryRating>('feedbackCategoryRatings')
|
|
|
|
const assignments = getAllRows<Assignment>('assignments')
|
|
.filter(a => a.instituteeIds.includes(id))
|
|
.map(a => {
|
|
const fb = allFeedbacks.find(f => f.assignmentId === a.id && f.instituteeId === id)
|
|
const ratings = fb?.id
|
|
? allRatings
|
|
.filter(r => r.assignmentFeedbackId === fb.id)
|
|
.map(r => ({
|
|
categoryId: r.feedbackCategoryId,
|
|
categoryName: catMap.get(r.feedbackCategoryId) ?? `Kategorie ${r.feedbackCategoryId}`,
|
|
rating: (r.rating ?? null) as RatingScore | null,
|
|
}))
|
|
: []
|
|
return {
|
|
assignmentId: a.id!,
|
|
title: a.title,
|
|
assignmentNumber: a.assignmentNumber,
|
|
client: a.client,
|
|
status: a.status,
|
|
deletedAt: a.deletedAt,
|
|
archivedAt: a.archivedAt,
|
|
plannedStart: a.plannedStart,
|
|
plannedEnd: a.plannedEnd,
|
|
createdAt: a.createdAt,
|
|
typeName: typeMap.get(a.assignmentTypeId) ?? '—',
|
|
feedbackStatus: (!fb ? 'none' : fb.status === 'final' ? 'final' : 'draft') as 'none' | 'draft' | 'final',
|
|
categoryRatings: ratings,
|
|
}
|
|
})
|
|
.sort((a, b) => {
|
|
const ae = a.plannedEnd ?? a.createdAt
|
|
const be = b.plannedEnd ?? b.createdAt
|
|
return be.localeCompare(ae)
|
|
})
|
|
|
|
return { consultant, group, assignments }
|
|
}
|