dad798a904
Deploy to TrueNAS / deploy (push) Failing after 33s
- Landing page with hero, feature grid, trust signals - Split-layout login redesign (admin + portal) - Pricing page with storage tiers (5GB/50GB/unlimited) - StorageQuotaService backend (V36 migration, 402 on exceeded) - Frontend storage integration + 402 error handling - StorageController uses TenantContext for tenant isolation - onTierChange() hook for subscription tier updates
178 lines
4.3 KiB
TypeScript
178 lines
4.3 KiB
TypeScript
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
|
|
|
import { apiClient } from "@/lib/api-client"
|
|
|
|
// --- Constants ---
|
|
|
|
const CLUB_ID = "00000000-0000-0000-0000-000000000001"
|
|
|
|
// --- Types ---
|
|
|
|
export type DocumentCategory =
|
|
| "SATZUNG"
|
|
| "PROTOKOLL"
|
|
| "VERTRAG"
|
|
| "VERSICHERUNG"
|
|
| "GENEHMIGUNG"
|
|
| "SONSTIGES"
|
|
|
|
export type DocumentAccessLevel = "ALL_MEMBERS" | "BOARD_ONLY"
|
|
|
|
export interface ClubDocument {
|
|
id: string
|
|
title: string
|
|
category: DocumentCategory
|
|
filename: string
|
|
contentType: string
|
|
fileSize: number
|
|
accessLevel: DocumentAccessLevel
|
|
description: string | null
|
|
uploadedBy: string
|
|
createdAt: string
|
|
updatedAt: string | null
|
|
}
|
|
|
|
export interface StorageUsage {
|
|
bytesUsed: number
|
|
}
|
|
|
|
export interface UploadDocumentRequest {
|
|
title: string
|
|
category: DocumentCategory
|
|
accessLevel: DocumentAccessLevel
|
|
description: string | null
|
|
file: File
|
|
}
|
|
|
|
// --- Raw API functions ---
|
|
|
|
export async function uploadDocument(
|
|
clubId: string,
|
|
title: string,
|
|
category: DocumentCategory,
|
|
accessLevel: DocumentAccessLevel,
|
|
description: string | null,
|
|
file: File
|
|
): Promise<ClubDocument> {
|
|
const formData = new FormData()
|
|
formData.append("file", file)
|
|
|
|
const params = new URLSearchParams({
|
|
clubId,
|
|
title,
|
|
category,
|
|
accessLevel,
|
|
})
|
|
if (description) params.append("description", description)
|
|
|
|
// Multipart upload — use raw fetch since apiClient assumes JSON
|
|
const res = await fetch(
|
|
`/api/backend/documents/upload?${params.toString()}`,
|
|
{
|
|
method: "POST",
|
|
body: formData,
|
|
}
|
|
)
|
|
if (!res.ok) {
|
|
if (res.status === 402) {
|
|
const problem = await res.json()
|
|
const error = new Error("Storage quota exceeded") as Error & {
|
|
status: number
|
|
problemDetail: unknown
|
|
}
|
|
error.status = 402
|
|
error.problemDetail = problem
|
|
throw error
|
|
}
|
|
throw new Error("Upload failed")
|
|
}
|
|
return res.json()
|
|
}
|
|
|
|
export function listDocuments(
|
|
clubId: string,
|
|
category?: DocumentCategory,
|
|
accessLevel?: DocumentAccessLevel
|
|
): Promise<ClubDocument[]> {
|
|
const params = new URLSearchParams({ clubId })
|
|
if (category) params.append("category", category)
|
|
if (accessLevel) params.append("accessLevel", accessLevel)
|
|
return apiClient<ClubDocument[]>(`/documents?${params.toString()}`)
|
|
}
|
|
|
|
export async function downloadDocument(id: string): Promise<Blob> {
|
|
const res = await fetch(`/api/backend/documents/${id}/download`)
|
|
if (!res.ok) throw new Error("Download failed")
|
|
return res.blob()
|
|
}
|
|
|
|
export function deleteDocument(id: string, clubId: string): Promise<void> {
|
|
return apiClient<void>(`/documents/${id}?clubId=${clubId}`, {
|
|
method: "DELETE",
|
|
})
|
|
}
|
|
|
|
export function getStorageUsage(clubId: string): Promise<StorageUsage> {
|
|
return apiClient<StorageUsage>(`/documents/usage?clubId=${clubId}`)
|
|
}
|
|
|
|
export function getPortalDocuments(clubId: string): Promise<ClubDocument[]> {
|
|
return apiClient<ClubDocument[]>(`/portal/documents?clubId=${clubId}`)
|
|
}
|
|
|
|
// --- React Query Hooks ---
|
|
|
|
export function useDocumentsQuery(category?: DocumentCategory) {
|
|
return useQuery({
|
|
queryKey: ["documents", CLUB_ID, category],
|
|
queryFn: () => listDocuments(CLUB_ID, category),
|
|
})
|
|
}
|
|
|
|
export function useUploadDocumentMutation() {
|
|
const queryClient = useQueryClient()
|
|
return useMutation({
|
|
mutationFn: (data: UploadDocumentRequest) =>
|
|
uploadDocument(
|
|
CLUB_ID,
|
|
data.title,
|
|
data.category,
|
|
data.accessLevel,
|
|
data.description,
|
|
data.file
|
|
),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["documents"] })
|
|
},
|
|
})
|
|
}
|
|
|
|
export function useDeleteDocumentMutation() {
|
|
const queryClient = useQueryClient()
|
|
return useMutation({
|
|
mutationFn: (id: string) => deleteDocument(id, CLUB_ID),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["documents"] })
|
|
},
|
|
})
|
|
}
|
|
|
|
// --- Helper: format file size ---
|
|
|
|
export function formatFileSize(bytes: number): string {
|
|
if (bytes < 1024) return `${bytes} B`
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
|
}
|
|
|
|
// --- Category labels ---
|
|
|
|
export const categoryLabels: Record<DocumentCategory, string> = {
|
|
SATZUNG: "Satzung",
|
|
PROTOKOLL: "Protokoll",
|
|
VERTRAG: "Vertrag",
|
|
VERSICHERUNG: "Versicherung",
|
|
GENEHMIGUNG: "Genehmigung",
|
|
SONSTIGES: "Sonstiges",
|
|
}
|