feat(fullstack): Add file upload

This commit is contained in:
2026-01-25 17:57:04 +01:00
parent c0f3adac07
commit 954c6d00e1
28 changed files with 1606 additions and 42 deletions

View File

@@ -74,6 +74,7 @@ pnpm run api:generate
- Use TypeScript interfaces for props and emits
- Follow Vue 3 Composition API patterns
- Use Nuxt UI components for consistent design
- API calls are wrapped in composables or Pinia actions. The `/composables/applicationFormTemplate` are a good reference.
### i18n Best Practices

View File

@@ -15,6 +15,8 @@
:disabled="props.disabled"
:all-form-elements="props.allFormElements"
:table-row-preset="formElementItem.formElement.tableRowPreset"
:application-form-id="props.applicationFormId"
:form-element-reference="formElementItem.formElement.reference"
@update:form-options="updateFormOptions($event, formElementItem)"
/>
<div v-if="formElementItem.formElement.isClonable && !props.disabled" class="mt-3">
@@ -171,6 +173,8 @@ function getResolvedComponent(formElement: FormElementDto) {
return resolveComponent('TheDate')
case 'TABLE':
return resolveComponent('TheTable')
case 'FILE_UPLOAD':
return resolveComponent('TheFileUpload')
default:
return resolveComponent('Unimplemented')
}

View File

@@ -0,0 +1,234 @@
<template>
<div class="space-y-4">
<!-- Upload Area -->
<div>
<UFileUpload
v-model="selectedFiles"
:accept="allowedFileTypes"
:multiple="true"
:disabled="isUploading || disabled"
:label="t('applicationForms.formElements.fileUpload.label')"
:description="t('applicationForms.formElements.fileUpload.allowedTypes')"
variant="area"
layout="list"
position="inside"
@change="handleFileSelect"
/>
</div>
<!-- Upload Progress -->
<div v-if="isUploading" class="space-y-2">
<UProgress :value="uploadProgress" />
<p class="text-sm text-gray-600">
{{ t('applicationForms.formElements.fileUpload.uploading') }}
</p>
</div>
<!-- Error Message -->
<UAlert
v-if="errorMessage"
color="error"
variant="soft"
:title="t('applicationForms.formElements.fileUpload.uploadError')"
:description="errorMessage"
:close-button="{ icon: 'i-ph-x', color: 'red', variant: 'link' }"
@close="errorMessage = ''"
/>
<!-- Uploaded Files List -->
<div v-if="uploadedFiles.length > 0" class="space-y-2">
<p class="text-sm font-medium">
{{ t('applicationForms.formElements.fileUpload.uploadedFiles') }}
</p>
<div class="space-y-2">
<div
v-for="file in uploadedFiles"
:key="file.fileId"
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
>
<div class="flex items-center gap-3 flex-1 min-w-0">
<UIcon :name="getFileIcon(file.mimeType)" class="text-xl flex-shrink-0" />
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate">{{ file.filename }}</p>
<p class="text-xs text-gray-500">{{ formatFileSize(file.size) }}</p>
</div>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<UButton
v-if="isViewableInBrowser(file.mimeType)"
icon="i-ph-eye"
color="neutral"
variant="ghost"
size="sm"
:title="t('applicationForms.formElements.fileUpload.view')"
@click="viewFile(file.fileId)"
/>
<UButton
icon="i-ph-download"
color="info"
variant="ghost"
size="sm"
:disabled="isDownloading"
@click="downloadFile(file.fileId, file.filename)"
/>
<UButton
icon="i-ph-trash"
color="error"
variant="ghost"
size="sm"
:disabled="disabled || isDeleting"
@click="deleteFile(file.fileId)"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { EmployeeDataCategory, type FormOptionDto, ProcessingPurpose } from '~~/.api-client'
import { useFile, type UploadedFileMetadata } from '~/composables/file/useFile'
import { useUserStore } from '~~/stores/useUserStore'
const props = defineProps<{
formOptions: FormOptionDto[]
disabled?: boolean
applicationFormId?: string
formElementReference?: string
}>()
const emit = defineEmits<{
(e: 'update:formOptions', value: FormOptionDto[]): void
}>()
const { t } = useI18n()
const {
uploadFile: uploadFileApi,
downloadFile: downloadFileApi,
viewFile: viewFileApi,
deleteFile: deleteFileApi,
parseUploadedFiles,
createFileMetadata,
getFileIcon,
formatFileSize,
isViewableInBrowser
} = useFile()
const isUploading = ref(false)
const isDownloading = ref(false)
const isDeleting = ref(false)
const uploadProgress = ref(0)
const errorMessage = ref('')
const selectedFiles = ref<File[] | null>(null)
const allowedFileTypes = '.pdf,.docx,.doc,.odt,.jpg,.jpeg,.png,.zip'
// Parse uploaded files from formOptions
const uploadedFiles = computed<UploadedFileMetadata[]>(() => {
const values = props.formOptions.map((option) => option.value)
return parseUploadedFiles(values)
})
const userStore = useUserStore()
const organizationId = computed(() => userStore.selectedOrganization?.id)
const handleFileSelect = async () => {
if (!selectedFiles.value || selectedFiles.value.length === 0) return
const files = Array.isArray(selectedFiles.value) ? selectedFiles.value : [selectedFiles.value]
errorMessage.value = ''
for (const file of files) {
const maxFileSize = 10 * 1024 * 1024 // 10 MB
if (file.size > maxFileSize) {
errorMessage.value = t('applicationForms.formElements.fileUpload.fileTooLarge', {
filename: file.name,
maxSize: formatFileSize(maxFileSize)
})
continue
}
await uploadFile(file)
}
selectedFiles.value = null
}
const uploadFile = async (file: File) => {
if (!props.formElementReference) {
errorMessage.value = 'Missing required context: formElementReference'
return
}
isUploading.value = true
uploadProgress.value = 0
try {
const response = await uploadFileApi({
file,
applicationFormId: props.applicationFormId,
formElementReference: props.formElementReference,
organizationId: organizationId.value
})
const metadata = createFileMetadata(response)
const newOption: FormOptionDto = {
value: JSON.stringify(metadata),
label: props.formElementReference,
processingPurpose: props.formOptions[0]?.processingPurpose ?? ProcessingPurpose.None,
employeeDataCategory: props.formOptions[0]?.employeeDataCategory ?? EmployeeDataCategory.None
}
const updatedOptions = [...props.formOptions, newOption]
emit('update:formOptions', updatedOptions)
uploadProgress.value = 100
} catch (error: unknown) {
errorMessage.value = error instanceof Error ? error.message : String(error)
} finally {
isUploading.value = false
uploadProgress.value = 0
}
}
const downloadFile = async (fileId: string, filename: string) => {
isDownloading.value = true
try {
await downloadFileApi(fileId, filename)
} catch (error: unknown) {
errorMessage.value = error instanceof Error ? error.message : String(error)
} finally {
isDownloading.value = false
}
}
const viewFile = (fileId: string) => {
viewFileApi(fileId)
}
const deleteFile = async (fileId: string) => {
if (!confirm(t('common.confirmDelete'))) return
isDeleting.value = true
try {
await deleteFileApi(fileId)
// Remove from formOptions
const updatedOptions = props.formOptions.filter((option) => {
try {
const metadata = JSON.parse(option.value) as UploadedFileMetadata
return metadata.fileId !== fileId
} catch {
return true
}
})
emit('update:formOptions', updatedOptions)
} catch (error: unknown) {
errorMessage.value = error instanceof Error ? error.message : String(error)
} finally {
isDeleting.value = false
}
}
</script>

View File

@@ -6,9 +6,56 @@ export function useApplicationForm() {
const applicationFormApi = useApplicationFormApi()
const logger = useLogger().withTag('applicationForm')
/**
* Extract all file IDs from FILE_UPLOAD form elements
*/
function extractFileIdsFromForm(applicationFormDto: ApplicationFormDto): string[] {
const fileIds: string[] = []
applicationFormDto.formElementSections?.forEach((section) => {
section.formElementSubSections?.forEach((subsection) => {
subsection.formElements?.forEach((element) => {
if (element.type === 'FILE_UPLOAD') {
element.options?.forEach((option) => {
try {
const metadata = JSON.parse(option.value)
if (metadata.fileId) {
fileIds.push(metadata.fileId)
}
} catch {
// Ignore parsing errors
}
})
}
})
})
})
return fileIds
}
/**
* Creates an application form with atomic file association.
*
* File IDs are included in the DTO and associated atomically on the backend.
* If file association fails, the entire operation rolls back (form is not created).
*/
async function createApplicationForm(applicationFormDto: ApplicationFormDto): Promise<ApplicationFormDto> {
try {
return await applicationFormApi.createApplicationForm(applicationFormDto)
// Extract all temporary file IDs and include them in the DTO for atomic association
const fileIds = extractFileIdsFromForm(applicationFormDto)
// Single atomic API call - backend handles form creation and file association transactionally
const createdForm = await applicationFormApi.createApplicationForm({
...applicationFormDto,
fileIds: fileIds.length > 0 ? fileIds : undefined
})
if (fileIds.length > 0) {
logger.debug(`Created form ${createdForm.id} with ${fileIds.length} files atomically associated`)
}
return createdForm
} catch (e: unknown) {
logger.error('Failed creating application form:', e)
return Promise.reject(e)

View File

@@ -0,0 +1,150 @@
import type { UploadedFileDto } from '~~/.api-client'
import { useFileApi } from './useFileApi'
import { useLogger } from '../useLogger'
export interface UploadedFileMetadata {
fileId: string
filename: string
size: number
mimeType: string
uploadedAt: string
}
export function useFile() {
const fileApi = useFileApi()
const logger = useLogger().withTag('file')
const { t } = useI18n()
async function uploadFile(params: {
file: File
applicationFormId?: string
formElementReference: string
organizationId?: string
}): Promise<UploadedFileDto> {
try {
logger.debug('Uploading file:', params.file.name)
return await fileApi.uploadFile(params)
} catch (e: unknown) {
logger.error('Failed uploading file:', e)
// Enhanced error handling with user-friendly messages
if (e && typeof e === 'object' && 'status' in e) {
const error = e as { status: number }
if (error.status === 413) {
return Promise.reject(
new Error(
t('applicationForms.formElements.fileUpload.fileTooLarge', {
filename: params.file.name,
maxSize: '10MB'
})
)
)
} else if (error.status === 415) {
return Promise.reject(new Error(t('applicationForms.formElements.fileUpload.unsupportedType')))
}
}
return Promise.reject(new Error(t('applicationForms.formElements.fileUpload.uploadFailed')))
}
}
async function downloadFile(id: string, filename: string): Promise<void> {
try {
logger.debug('Downloading file:', id)
const blob = await fileApi.downloadFileContent(id)
// Create download link
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
} catch (e: unknown) {
logger.error('Failed downloading file:', e)
return Promise.reject(new Error(t('applicationForms.formElements.fileUpload.downloadFailed')))
}
}
function isViewableInBrowser(mimeType: string): boolean {
return mimeType === 'application/pdf' || mimeType.startsWith('image/')
}
function viewFile(id: string): void {
const url = fileApi.getFileViewUrl(id)
window.open(url, '_blank')
}
async function deleteFile(id: string): Promise<void> {
try {
logger.debug('Deleting file:', id)
return await fileApi.deleteFile(id)
} catch (e: unknown) {
logger.error('Failed deleting file:', e)
return Promise.reject(new Error(t('applicationForms.formElements.fileUpload.deleteFailed')))
}
}
async function associateFilesWithApplicationForm(applicationFormId: string, fileIds: string[]): Promise<void> {
try {
logger.debug('Associating files with application form:', { applicationFormId, fileIds })
return await fileApi.associateFilesWithApplicationForm(applicationFormId, fileIds)
} catch (e: unknown) {
logger.error('Failed associating files with application form:', e)
return Promise.reject(e)
}
}
function parseUploadedFiles(formOptionsValues: string[]): UploadedFileMetadata[] {
return formOptionsValues
.map((value) => {
try {
return JSON.parse(value) as UploadedFileMetadata
} catch {
return null
}
})
.filter((file): file is UploadedFileMetadata => file !== null)
}
function createFileMetadata(response: UploadedFileDto): UploadedFileMetadata {
return {
fileId: response.id,
filename: response.originalFilename,
size: response.size,
mimeType: response.mimeType,
uploadedAt: response.uploadedAt.toISOString()
}
}
function getFileIcon(mimeType: string): string {
if (mimeType.startsWith('image/')) return 'i-ph-image'
if (mimeType === 'application/pdf') return 'i-ph-file-pdf'
if (mimeType.includes('word')) return 'i-ph-file-doc'
if (mimeType.includes('zip')) return 'i-ph-file-zip'
return 'i-ph-file'
}
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`
}
return {
uploadFile,
downloadFile,
viewFile,
deleteFile,
associateFilesWithApplicationForm,
parseUploadedFiles,
createFileMetadata,
getFileIcon,
formatFileSize,
isViewableInBrowser
}
}

View File

@@ -0,0 +1,64 @@
import {
FileApi,
Configuration,
type UploadedFileDto,
type AssociateFilesWithApplicationFormRequest
} from '~~/.api-client'
import { cleanDoubleSlashes, withoutTrailingSlash } from 'ufo'
import { wrappedFetchWrap } from '~/utils/wrappedFetch'
export function useFileApi() {
const appBaseUrl = useRuntimeConfig().app.baseURL
const { serverApiBasePath, clientProxyBasePath } = useRuntimeConfig().public
const basePath = withoutTrailingSlash(
cleanDoubleSlashes(import.meta.client ? appBaseUrl + clientProxyBasePath : clientProxyBasePath + serverApiBasePath)
)
const fileApiClient = new FileApi(new Configuration({ basePath, fetchApi: wrappedFetchWrap(useRequestFetch()) }))
async function uploadFile(params: {
file: File
applicationFormId?: string
formElementReference: string
organizationId?: string
}): Promise<UploadedFileDto> {
return fileApiClient.uploadFile(params)
}
async function downloadFileContent(id: string, inline = false): Promise<Blob> {
return fileApiClient.downloadFileContent({ id, inline })
}
function getFileViewUrl(id: string): string {
return `${basePath}/files/${id}/content?inline=true`
}
async function deleteFile(id: string): Promise<void> {
return fileApiClient.deleteFile({ id })
}
async function getFilesByApplicationForm(
applicationFormId: string,
formElementReference?: string
): Promise<UploadedFileDto[]> {
return fileApiClient.getFilesByApplicationForm({ applicationFormId, formElementReference })
}
async function associateFilesWithApplicationForm(applicationFormId: string, fileIds: string[]): Promise<void> {
const associateFilesWithApplicationFormRequest: AssociateFilesWithApplicationFormRequest = { fileIds }
return fileApiClient.associateFilesWithApplicationForm({
applicationFormId,
associateFilesWithApplicationFormRequest
})
}
return {
uploadFile,
downloadFileContent,
getFileViewUrl,
deleteFile,
getFilesByApplicationForm,
associateFilesWithApplicationForm
}
}

View File

@@ -32,6 +32,23 @@
"selectValue": "Wert auswählen",
"enlargeTable": "Tabelle vergrößern",
"editTable": "Tabelle bearbeiten"
},
"fileUpload": {
"label": "Dateien hochladen",
"dropFiles": "Dateien hier ablegen",
"orClickToUpload": "oder klicken Sie, um Dateien auszuwählen",
"selectFiles": "Dateien auswählen",
"allowedTypes": "Erlaubt: PDF, DOCX, DOC, ODT, JPG, PNG, ZIP (max. 10MB pro Datei)",
"uploadedFiles": "Hochgeladene Dateien",
"uploading": "Wird hochgeladen...",
"uploadError": "Upload-Fehler",
"uploadFailed": "Datei-Upload fehlgeschlagen. Bitte versuchen Sie es erneut.",
"fileTooLarge": "Die Datei \"{filename}\" ist zu groß. Maximale Größe: {maxSize}",
"unsupportedType": "Dieser Dateityp wird nicht unterstützt.",
"deleteFailed": "Datei konnte nicht gelöscht werden.",
"downloadFailed": "Datei konnte nicht heruntergeladen werden.",
"viewFailed": "Datei konnte nicht zur Ansicht geöffnet werden.",
"view": "Ansehen"
}
},
"status": {
@@ -190,6 +207,7 @@
"save": "Speichern",
"cancel": "Abbrechen",
"delete": "Löschen",
"confirmDelete": "Möchten Sie dies wirklich löschen?",
"edit": "Bearbeiten",
"close": "Schließen",
"confirm": "Bestätigen",

View File

@@ -32,6 +32,23 @@
"selectValue": "Select value",
"enlargeTable": "Enlarge table",
"editTable": "Edit table"
},
"fileUpload": {
"label": "Upload files",
"dropFiles": "Drop files here",
"orClickToUpload": "or click to select files",
"selectFiles": "Select files",
"allowedTypes": "Allowed: PDF, DOCX, DOC, ODT, JPG, PNG, ZIP (max. 10MB per file)",
"uploadedFiles": "Uploaded files",
"uploading": "Uploading...",
"uploadError": "Upload error",
"uploadFailed": "File upload failed. Please try again.",
"fileTooLarge": "The file \"{filename}\" is too large. Maximum size: {maxSize}",
"unsupportedType": "This file type is not supported.",
"deleteFailed": "Could not delete file.",
"downloadFailed": "Could not download file.",
"viewFailed": "Could not open file for viewing.",
"view": "View"
}
},
"status": {
@@ -190,6 +207,7 @@
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"confirmDelete": "Do you really want to delete this?",
"edit": "Edit",
"close": "Close",
"confirm": "Confirm",