bolts/frontend/src/pages/user-file.vue

263 lines
11 KiB
Vue

<script lang="ts" setup>
import { onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { auth } from '@/auth'
interface Field {
id: number
name: string
identifier: string
type: string
relationshipType?: 'ONE_TO_ONE' | 'ONE_TO_MANY'
}
interface LinkedEntity {
entity: { id: number, name: string, identifier: string, fields: Field[] }
relationshipField: Field
records: Record<string, unknown>[]
}
interface UserFile {
user: { id: number, username: string, role: string }
linkedEntities: LinkedEntity[]
}
const route = useRoute()
const router = useRouter()
const file = ref<UserFile>()
const loading = ref(true)
const errorMessage = ref('')
const saving = ref(false)
const addingChildKey = ref('')
const childValues = reactive<Record<string, Record<string, string | boolean>>>({})
function childKey (linked: LinkedEntity) {
return `${linked.entity.id}-${linked.relationshipField.id}`
}
function editableFields (linked: LinkedEntity) {
return linked.entity.fields.filter(field => field.id !== linked.relationshipField.id)
}
function isOneToOne (linked: LinkedEntity) {
return linked.relationshipField.relationshipType === 'ONE_TO_ONE'
}
function createLabel (linked: LinkedEntity) {
return `${isOneToOne(linked) ? 'Create' : 'Add'} ${linked.entity.name}`
}
function inputType (field: Field) {
if (['NUMBER', 'RELATIONSHIP', 'USER'].includes(field.type)) return 'number'
if (field.type === 'DATE') return 'date'
if (field.type === 'EMAIL') return 'email'
if (field.type === 'PHONE') return 'tel'
return 'text'
}
function startAddingChild (linked: LinkedEntity) {
const key = childKey(linked)
childValues[key] = {}
for (const field of editableFields(linked)) {
childValues[key][field.identifier] = field.type === 'BOOLEAN' ? false : ''
}
addingChildKey.value = key
errorMessage.value = ''
}
async function addChild (linked: LinkedEntity) {
const key = childKey(linked)
const body = new URLSearchParams()
for (const field of editableFields(linked)) {
body.set(field.identifier, String(childValues[key][field.identifier] ?? ''))
}
saving.value = true
errorMessage.value = ''
try {
const response = await fetch(
`/api/users/${route.params.userId}/children/${encodeURIComponent(linked.entity.name)}/${linked.relationshipField.id}`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${auth.token}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
body,
},
)
if (!response.ok) throw new Error('Child create failed')
const created = await response.json() as { values: Record<string, unknown> }
linked.records.push(created.values)
addingChildKey.value = ''
} catch {
errorMessage.value = `The ${linked.entity.name} could not be added. Check the field values and try again.`
} finally {
saving.value = false
}
}
function displayValue (value: unknown) {
if ([null, undefined, ''].includes(value as null | undefined | string)) return '—'
if (typeof value === 'boolean') return value ? 'Yes' : 'No'
return String(value)
}
onMounted(async () => {
try {
const response = await fetch(`/api/users/${route.params.userId}/file`, {
headers: { Authorization: `Bearer ${auth.token}` },
})
if (response.status === 404) {
await router.replace('/users')
return
}
if (!response.ok) throw new Error('User file request failed')
file.value = await response.json() as UserFile
} catch {
errorMessage.value = 'Unable to load this user file.'
} finally {
loading.value = false
}
})
</script>
<template>
<section class="user-file-page">
<router-link class="back-link" to="/users"> User Management</router-link>
<p v-if="loading">Loading user file</p>
<p v-else-if="errorMessage" role="alert">{{ errorMessage }}</p>
<template v-else-if="file">
<header>
<p class="eyebrow">User file</p>
<div class="title-row">
<h1>{{ file.user.username }}</h1>
<span class="role-badge">{{ file.user.role }}</span>
</div>
<p>All entity records linked to this user.</p>
</header>
<p v-if="file.linkedEntities.length === 0" class="empty-state">
No entities with user references are configured.
</p>
<article v-for="linked in file.linkedEntities" :key="childKey(linked)" class="entity-card">
<div class="entity-heading">
<div>
<h2>{{ linked.entity.name }}</h2>
<p v-if="isOneToOne(linked)">{{ linked.records.length === 0 ? 'Not created' : 'Created' }}</p>
<p v-else>{{ linked.records.length }} linked {{ linked.records.length === 1 ? 'record' : 'records' }}</p>
</div>
<button
v-if="!isOneToOne(linked) || linked.records.length === 0"
type="button"
@click="startAddingChild(linked)"
>
{{ createLabel(linked) }}
</button>
</div>
<form
v-if="addingChildKey === childKey(linked)"
:aria-label="createLabel(linked)"
class="child-form"
@submit.prevent="addChild(linked)"
>
<label v-for="field in editableFields(linked)" :key="field.id">
<span>{{ field.name }}</span>
<input v-if="field.type === 'BOOLEAN'" v-model="childValues[childKey(linked)][field.identifier]" type="checkbox">
<input v-else v-model="childValues[childKey(linked)][field.identifier]" :type="inputType(field)">
</label>
<div class="form-actions">
<button :disabled="saving" type="submit">{{ saving ? 'Saving…' : createLabel(linked) }}</button>
<button :disabled="saving" type="button" @click="addingChildKey = ''">Cancel</button>
</div>
</form>
<p v-if="linked.records.length === 0" class="no-records">
{{ isOneToOne(linked) ? `No ${linked.entity.name} has been created.` : 'No records linked to this user.' }}
</p>
<dl v-else-if="isOneToOne(linked)" class="record-details">
<template v-for="field in linked.entity.fields" :key="field.id">
<dt>{{ field.name }}</dt>
<dd>{{ displayValue(linked.records[0][field.identifier]) }}</dd>
</template>
</dl>
<div v-else class="table-scroll">
<table>
<thead>
<tr>
<th>ID</th>
<th v-for="field in linked.entity.fields" :key="field.id">{{ field.name }}</th>
<th><span class="sr-only">Actions</span></th>
</tr>
</thead>
<tbody>
<tr v-for="record in linked.records" :key="String(record.id)">
<td>{{ displayValue(record.id) }}</td>
<td v-for="field in linked.entity.fields" :key="field.id">
{{ displayValue(record[field.identifier]) }}
</td>
<td>
<router-link
:aria-label="`View ${linked.entity.name} record ${record.id}`"
class="view-link"
:to="`/users/${file.user.id}/entities/${encodeURIComponent(linked.entity.name)}/records/${record.id}`"
>
View
</router-link>
</td>
</tr>
</tbody>
</table>
</div>
</article>
</template>
</section>
</template>
<style scoped>
.user-file-page { width: 100%; max-width: 78rem; }
.back-link, .view-link { color: var(--v0-primary); font-weight: 700; text-decoration: none; }
.back-link:hover, .view-link:hover { text-decoration: underline; }
header { margin: 1.5rem 0 2rem; }
.eyebrow { margin: 0 0 0.5rem; color: var(--v0-primary); font-size: 0.75rem; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; }
.title-row { display: flex; align-items: center; gap: 0.8rem; }
h1, h2 { margin: 0; letter-spacing: -0.03em; }
h1 { font-size: 2rem; }
h2 { font-size: 1.25rem; }
header > p:last-child, .entity-heading p { color: var(--v0-on-surface-variant); }
.role-badge { padding: 0.25rem 0.6rem; border-radius: 999px; color: var(--v0-primary); background: color-mix(in srgb, var(--v0-primary) 14%, transparent); font-size: 0.8rem; font-weight: 700; }
.entity-card, .empty-state { margin-bottom: 1.25rem; overflow: hidden; border: 1px solid var(--v0-divider); border-radius: 1rem; background: var(--v0-surface); }
.entity-heading { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1.25rem; border-bottom: 1px solid var(--v0-divider); }
.entity-heading p { margin: 0.35rem 0 0; font-size: 0.9rem; }
.empty-state, .no-records { padding: 1.5rem; }
.table-scroll { overflow-x: auto; }
.record-details { display: grid; grid-template-columns: minmax(8rem, 12rem) 1fr; margin: 0; padding: 1.25rem; gap: 0.75rem 1rem; }
.record-details dt { color: var(--v0-on-surface-variant); font-size: 0.8rem; font-weight: 700; text-transform: uppercase; }
.record-details dd { margin: 0; }
table { width: 100%; border-collapse: collapse; text-align: left; white-space: nowrap; }
th, td { padding: 0.85rem 1rem; border-bottom: 1px solid var(--v0-divider); }
th { color: var(--v0-on-surface-variant); font-size: 0.75rem; text-transform: uppercase; }
tbody tr:last-child td { border-bottom: 0; }
button { padding: 0.65rem 0.9rem; border: 1px solid var(--v0-divider); border-radius: 0.6rem; color: inherit; background: var(--v0-surface); font: inherit; font-weight: 700; cursor: pointer; }
button[type="submit"] { color: var(--v0-on-primary); border-color: var(--v0-primary); background: var(--v0-primary); }
button:disabled { opacity: 0.55; cursor: wait; }
.child-form { display: grid; grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); gap: 1rem; padding: 1.25rem; border-bottom: 1px solid var(--v0-divider); }
.child-form label { display: grid; gap: 0.45rem; }
.child-form label > span { color: var(--v0-on-surface-variant); font-size: 0.8rem; font-weight: 700; text-transform: uppercase; }
input { width: 100%; padding: 0.65rem 0.75rem; border: 1px solid var(--v0-divider); border-radius: 0.5rem; color: inherit; background: var(--v0-background); font: inherit; }
input[type="checkbox"] { width: 1.1rem; }
.form-actions { display: flex; align-items: end; justify-content: flex-end; gap: 0.5rem; }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); }
</style>