tenant admin startseite schicker gestaltet und super-admin und tenant admin (filament) aufgesplittet.
Es gibt nun task collections und vordefinierte tasks für alle. Onboarding verfeinert und webseite-carousel gefixt (logging später entfernen!)
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { authorizedFetch } from './auth/tokens';
|
||||
import i18n from './i18n';
|
||||
|
||||
type JsonValue = Record<string, any>;
|
||||
|
||||
@@ -108,25 +109,93 @@ export type CreditLedgerEntry = {
|
||||
|
||||
export type TenantTask = {
|
||||
id: number;
|
||||
slug: string;
|
||||
title: string;
|
||||
title_translations: Record<string, string>;
|
||||
description: string | null;
|
||||
description_translations: Record<string, string | null>;
|
||||
example_text: string | null;
|
||||
example_text_translations: Record<string, string>;
|
||||
priority: 'low' | 'medium' | 'high' | 'urgent' | null;
|
||||
difficulty: 'easy' | 'medium' | 'hard' | null;
|
||||
due_date: string | null;
|
||||
is_completed: boolean;
|
||||
collection_id: number | null;
|
||||
source_task_id: number | null;
|
||||
source_collection_id: number | null;
|
||||
assigned_events_count: number;
|
||||
assigned_events?: TenantEvent[];
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
};
|
||||
|
||||
export type TenantTaskCollection = {
|
||||
id: number;
|
||||
slug: string;
|
||||
name: string;
|
||||
name_translations: Record<string, string>;
|
||||
description: string | null;
|
||||
description_translations: Record<string, string | null>;
|
||||
tenant_id: number | null;
|
||||
is_global: boolean;
|
||||
event_type?: {
|
||||
id: number;
|
||||
slug: string;
|
||||
name: string;
|
||||
name_translations: Record<string, string>;
|
||||
icon: string | null;
|
||||
} | null;
|
||||
tasks_count: number;
|
||||
position: number | null;
|
||||
source_collection_id: number | null;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
};
|
||||
|
||||
export type TenantEmotion = {
|
||||
id: number;
|
||||
name: string;
|
||||
name_translations: Record<string, string>;
|
||||
description: string | null;
|
||||
description_translations: Record<string, string | null>;
|
||||
icon: string;
|
||||
color: string;
|
||||
sort_order: number;
|
||||
is_active: boolean;
|
||||
is_global: boolean;
|
||||
tenant_id: number | null;
|
||||
event_types: Array<{
|
||||
id: number;
|
||||
slug: string;
|
||||
name: string;
|
||||
name_translations: Record<string, string>;
|
||||
}>;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
};
|
||||
|
||||
export type TaskPayload = Partial<{
|
||||
title: string;
|
||||
title_translations: Record<string, string>;
|
||||
description: string | null;
|
||||
description_translations: Record<string, string | null>;
|
||||
example_text: string | null;
|
||||
example_text_translations: Record<string, string | null>;
|
||||
collection_id: number | null;
|
||||
priority: 'low' | 'medium' | 'high' | 'urgent';
|
||||
due_date: string | null;
|
||||
is_completed: boolean;
|
||||
difficulty: 'easy' | 'medium' | 'hard';
|
||||
}>;
|
||||
|
||||
export type EmotionPayload = Partial<{
|
||||
name: string;
|
||||
description: string | null;
|
||||
icon: string;
|
||||
color: string;
|
||||
sort_order: number;
|
||||
is_active: boolean;
|
||||
event_type_ids: number[];
|
||||
}>;
|
||||
|
||||
export type EventMember = {
|
||||
@@ -197,6 +266,62 @@ function buildPagination(payload: JsonValue | null, defaultCount: number): Pagin
|
||||
};
|
||||
}
|
||||
|
||||
function translationLocales(): string[] {
|
||||
const locale = i18n.language;
|
||||
const base = locale?.includes('-') ? locale.split('-')[0] : locale;
|
||||
const fallback = ['de', 'en'];
|
||||
return [locale, base, ...fallback].filter(
|
||||
(value, index, self): value is string => Boolean(value) && self.indexOf(value) === index
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeTranslationMap(value: unknown, fallback?: string, allowEmpty = false): Record<string, string> {
|
||||
if (typeof value === 'string') {
|
||||
const map: Record<string, string> = {};
|
||||
for (const locale of translationLocales()) {
|
||||
map[locale] = value;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
const entries: Record<string, string> = {};
|
||||
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (typeof entry === 'string') {
|
||||
entries[key] = entry;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(entries).length > 0) {
|
||||
return entries;
|
||||
}
|
||||
}
|
||||
|
||||
if (fallback) {
|
||||
const locales = translationLocales();
|
||||
return locales.reduce<Record<string, string>>((acc, locale) => {
|
||||
acc[locale] = fallback;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
return allowEmpty ? {} : {};
|
||||
}
|
||||
|
||||
function pickTranslatedText(translations: Record<string, string>, fallback: string): string {
|
||||
const locales = translationLocales();
|
||||
for (const locale of locales) {
|
||||
if (translations[locale]) {
|
||||
return translations[locale]!;
|
||||
}
|
||||
}
|
||||
const first = Object.values(translations)[0];
|
||||
if (first) {
|
||||
return first;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function normalizeEvent(event: TenantEvent): TenantEvent {
|
||||
return {
|
||||
...event,
|
||||
@@ -260,14 +385,29 @@ function normalizeTenantPackage(pkg: JsonValue): TenantPackageSummary {
|
||||
}
|
||||
|
||||
function normalizeTask(task: JsonValue): TenantTask {
|
||||
const titleTranslations = normalizeTranslationMap(task.title_translations ?? task.title ?? {});
|
||||
const descriptionTranslations = normalizeTranslationMap(task.description_translations ?? task.description ?? {});
|
||||
const exampleTranslations = normalizeTranslationMap(task.example_text ?? {});
|
||||
|
||||
return {
|
||||
id: Number(task.id ?? 0),
|
||||
title: String(task.title ?? 'Ohne Titel'),
|
||||
description: task.description ?? null,
|
||||
tenant_id: task.tenant_id ?? null,
|
||||
slug: String(task.slug ?? `task-${task.id ?? ''}`),
|
||||
title: pickTranslatedText(titleTranslations, 'Ohne Titel'),
|
||||
title_translations: titleTranslations,
|
||||
description: Object.keys(descriptionTranslations).length
|
||||
? pickTranslatedText(descriptionTranslations, '')
|
||||
: null,
|
||||
description_translations: Object.keys(descriptionTranslations).length ? descriptionTranslations : {},
|
||||
example_text: Object.keys(exampleTranslations).length ? pickTranslatedText(exampleTranslations, '') : null,
|
||||
example_text_translations: Object.keys(exampleTranslations).length ? exampleTranslations : {},
|
||||
priority: (task.priority ?? null) as TenantTask['priority'],
|
||||
difficulty: (task.difficulty ?? null) as TenantTask['difficulty'],
|
||||
due_date: task.due_date ?? null,
|
||||
is_completed: Boolean(task.is_completed ?? false),
|
||||
collection_id: task.collection_id ?? null,
|
||||
source_task_id: task.source_task_id ?? null,
|
||||
source_collection_id: task.source_collection_id ?? null,
|
||||
assigned_events_count: Number(task.assigned_events_count ?? 0),
|
||||
assigned_events: Array.isArray(task.assigned_events) ? task.assigned_events.map(normalizeEvent) : undefined,
|
||||
created_at: task.created_at ?? null,
|
||||
@@ -275,6 +415,75 @@ function normalizeTask(task: JsonValue): TenantTask {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTaskCollection(raw: JsonValue): TenantTaskCollection {
|
||||
const nameTranslations = normalizeTranslationMap(raw.name_translations ?? raw.name ?? {});
|
||||
const descriptionTranslations = normalizeTranslationMap(raw.description_translations ?? raw.description ?? {}, true);
|
||||
|
||||
const eventTypeRaw = raw.event_type ?? raw.eventType ?? null;
|
||||
let eventType: TenantTaskCollection['event_type'] = null;
|
||||
if (eventTypeRaw && typeof eventTypeRaw === 'object') {
|
||||
const eventNameTranslations = normalizeTranslationMap(eventTypeRaw.name ?? {});
|
||||
eventType = {
|
||||
id: Number(eventTypeRaw.id ?? 0),
|
||||
slug: String(eventTypeRaw.slug ?? ''),
|
||||
name: pickTranslatedText(eventNameTranslations, String(eventTypeRaw.slug ?? '')),
|
||||
name_translations: eventNameTranslations,
|
||||
icon: eventTypeRaw.icon ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: Number(raw.id ?? 0),
|
||||
slug: String(raw.slug ?? `collection-${raw.id ?? ''}`),
|
||||
name: pickTranslatedText(nameTranslations, 'Unbenannte Sammlung'),
|
||||
name_translations: nameTranslations,
|
||||
description: descriptionTranslations ? pickTranslatedText(descriptionTranslations, '') : null,
|
||||
description_translations: descriptionTranslations ?? {},
|
||||
tenant_id: raw.tenant_id ?? null,
|
||||
is_global: !raw.tenant_id,
|
||||
event_type: eventType,
|
||||
tasks_count: Number(raw.tasks_count ?? raw.tasksCount ?? 0),
|
||||
position: raw.position !== undefined ? Number(raw.position) : null,
|
||||
source_collection_id: raw.source_collection_id ?? null,
|
||||
created_at: raw.created_at ?? null,
|
||||
updated_at: raw.updated_at ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeEmotion(raw: JsonValue): TenantEmotion {
|
||||
const nameTranslations = normalizeTranslationMap(raw.name_translations ?? raw.name ?? {});
|
||||
const descriptionTranslations = normalizeTranslationMap(raw.description_translations ?? raw.description ?? {}, true);
|
||||
|
||||
const eventTypes = Array.isArray(raw.event_types ?? raw.eventTypes)
|
||||
? (raw.event_types ?? raw.eventTypes)
|
||||
: [];
|
||||
|
||||
return {
|
||||
id: Number(raw.id ?? 0),
|
||||
name: pickTranslatedText(nameTranslations, 'Emotion'),
|
||||
name_translations: nameTranslations,
|
||||
description: descriptionTranslations ? pickTranslatedText(descriptionTranslations, '') : null,
|
||||
description_translations: descriptionTranslations ?? {},
|
||||
icon: String(raw.icon ?? 'lucide-smile'),
|
||||
color: String(raw.color ?? '#6366f1'),
|
||||
sort_order: Number(raw.sort_order ?? 0),
|
||||
is_active: Boolean(raw.is_active ?? true),
|
||||
is_global: !raw.tenant_id,
|
||||
tenant_id: raw.tenant_id ?? null,
|
||||
event_types: (eventTypes as JsonValue[]).map((eventType) => {
|
||||
const translations = normalizeTranslationMap(eventType.name ?? {});
|
||||
return {
|
||||
id: Number(eventType.id ?? 0),
|
||||
slug: String(eventType.slug ?? ''),
|
||||
name: pickTranslatedText(translations, String(eventType.slug ?? '')),
|
||||
name_translations: translations,
|
||||
};
|
||||
}),
|
||||
created_at: raw.created_at ?? null,
|
||||
updated_at: raw.updated_at ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMember(member: JsonValue): EventMember {
|
||||
return {
|
||||
id: Number(member.id ?? 0),
|
||||
@@ -479,6 +688,8 @@ type LedgerResponse = {
|
||||
|
||||
type TaskCollectionResponse = {
|
||||
data?: JsonValue[];
|
||||
collection?: JsonValue;
|
||||
message?: string;
|
||||
meta?: Partial<PaginationMeta>;
|
||||
current_page?: number;
|
||||
last_page?: number;
|
||||
@@ -685,6 +896,102 @@ export async function syncCreditBalance(payload: {
|
||||
return jsonOrThrow(response, 'Failed to sync credit balance');
|
||||
}
|
||||
|
||||
export async function getTaskCollections(params: {
|
||||
page?: number;
|
||||
per_page?: number;
|
||||
search?: string;
|
||||
event_type?: string;
|
||||
scope?: 'global' | 'tenant';
|
||||
} = {}): Promise<PaginatedResult<TenantTaskCollection>> {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params.page) searchParams.set('page', String(params.page));
|
||||
if (params.per_page) searchParams.set('per_page', String(params.per_page));
|
||||
if (params.search) searchParams.set('search', params.search);
|
||||
if (params.event_type) searchParams.set('event_type', params.event_type);
|
||||
if (params.scope) searchParams.set('scope', params.scope);
|
||||
|
||||
const queryString = searchParams.toString();
|
||||
const response = await authorizedFetch(
|
||||
`/api/v1/tenant/task-collections${queryString ? `?${queryString}` : ''}`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await safeJson(response);
|
||||
console.error('[API] Failed to load task collections', response.status, payload);
|
||||
throw new Error('Failed to load task collections');
|
||||
}
|
||||
|
||||
const json = (await response.json()) as TaskCollectionResponse;
|
||||
const collections = Array.isArray(json.data) ? json.data.map(normalizeTaskCollection) : [];
|
||||
|
||||
return {
|
||||
data: collections,
|
||||
meta: buildPagination(json as JsonValue, collections.length),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getTaskCollection(collectionId: number): Promise<TenantTaskCollection> {
|
||||
const response = await authorizedFetch(`/api/v1/tenant/task-collections/${collectionId}`);
|
||||
const json = await jsonOrThrow<{ data: JsonValue }>(response, 'Failed to load task collection');
|
||||
return normalizeTaskCollection(json.data);
|
||||
}
|
||||
|
||||
export async function importTaskCollection(
|
||||
collectionId: number,
|
||||
eventSlug: string
|
||||
): Promise<TenantTaskCollection> {
|
||||
const response = await authorizedFetch(`/api/v1/tenant/task-collections/${collectionId}/activate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ event_slug: eventSlug }),
|
||||
});
|
||||
|
||||
const json = await jsonOrThrow<TaskCollectionResponse>(response, 'Failed to import task collection');
|
||||
if (json.collection) {
|
||||
return normalizeTaskCollection(json.collection);
|
||||
}
|
||||
|
||||
if (json.data && json.data.length === 1) {
|
||||
return normalizeTaskCollection(json.data[0]!);
|
||||
}
|
||||
|
||||
throw new Error('Missing collection payload');
|
||||
}
|
||||
|
||||
export async function getEmotions(): Promise<TenantEmotion[]> {
|
||||
const response = await authorizedFetch('/api/v1/tenant/emotions');
|
||||
if (!response.ok) {
|
||||
const payload = await safeJson(response);
|
||||
console.error('[API] Failed to load emotions', response.status, payload);
|
||||
throw new Error('Failed to load emotions');
|
||||
}
|
||||
|
||||
const json = (await response.json()) as { data?: JsonValue[] };
|
||||
return Array.isArray(json.data) ? json.data.map(normalizeEmotion) : [];
|
||||
}
|
||||
|
||||
export async function createEmotion(payload: EmotionPayload): Promise<TenantEmotion> {
|
||||
const response = await authorizedFetch('/api/v1/tenant/emotions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const json = await jsonOrThrow<{ data: JsonValue }>(response, 'Failed to create emotion');
|
||||
return normalizeEmotion(json.data);
|
||||
}
|
||||
|
||||
export async function updateEmotion(emotionId: number, payload: EmotionPayload): Promise<TenantEmotion> {
|
||||
const response = await authorizedFetch(`/api/v1/tenant/emotions/${emotionId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const json = await jsonOrThrow<{ data: JsonValue }>(response, 'Failed to update emotion');
|
||||
return normalizeEmotion(json.data);
|
||||
}
|
||||
|
||||
export async function getTasks(params: { page?: number; per_page?: number; search?: string } = {}): Promise<PaginatedResult<TenantTask>> {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params.page) searchParams.set('page', String(params.page));
|
||||
|
||||
Reference in New Issue
Block a user