263 lines
11 KiB
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>
|