feat: improve Lager file management with dedicated folder creation

Add ensureLagerFolder endpoint and getLagerFilesById to browse Lager files
by ID rather than manual path. Rework LagerDetail file browser UI and add
folder links in LagerList.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
shahondin1624
2026-04-12 16:10:31 +02:00
parent dc6e4d910d
commit 06ca348a0d
4 changed files with 451 additions and 68 deletions
+44 -22
View File
@@ -119,7 +119,7 @@ class FileController extends ApiController {
*/
public function ensureFolder(int $memberId): JSONResponse {
try {
$result = $this->fileLinkService->ensureMemberFolderById($memberId);
$result = $this->fileLinkService->ensureMemberFolderById($memberId, true);
if ($result['exists']) {
return new JSONResponse([
@@ -158,29 +158,13 @@ class FileController extends ApiController {
#[NoCSRFRequired]
public function lagerFiles(int $lagerId): JSONResponse {
try {
// For now, use the configured Lager base path + Lager folder path from DB
// The Lager entity stores its file folder path directly
$path = $this->request->getParam('path');
if ($path === null || $path === '') {
$result = $this->fileLinkService->getLagerFilesById($lagerId);
return new JSONResponse($result);
} catch (DoesNotExistException $e) {
return new JSONResponse(
['error' => 'path Parameter ist erforderlich'],
Http::STATUS_BAD_REQUEST
['error' => 'Lager nicht gefunden'],
Http::STATUS_NOT_FOUND
);
}
$files = $this->fileLinkService->listFolderContents($path);
return new JSONResponse([
'exists' => true,
'path' => $path,
'files' => $files,
]);
} catch (\OCP\Files\NotFoundException $e) {
return new JSONResponse([
'exists' => false,
'path' => $path ?? '',
'files' => [],
]);
} catch (\Exception $e) {
$this->logger->error('Failed to browse Lager files', [
'lagerId' => $lagerId,
@@ -194,5 +178,43 @@ class FileController extends ApiController {
}
}
/**
* Ensure a Lager's folder exists (creates it as an explicit user action).
*
* POST /api/v1/lager/{lagerId}/files/ensure-folder
*/
public function ensureLagerFolder(int $lagerId): JSONResponse {
try {
$result = $this->fileLinkService->ensureLagerFolderById($lagerId, true);
if ($result['exists']) {
return new JSONResponse([
'status' => 'ok',
'path' => $result['path'],
]);
}
return new JSONResponse(
['error' => 'Ordner konnte nicht erstellt werden.'],
Http::STATUS_BAD_REQUEST
);
} catch (DoesNotExistException $e) {
return new JSONResponse(
['error' => 'Lager nicht gefunden'],
Http::STATUS_NOT_FOUND
);
} catch (\Exception $e) {
$this->logger->error('Failed to ensure Lager folder', [
'lagerId' => $lagerId,
'exception' => $e,
'app' => 'mitgliederverwaltung',
]);
return new JSONResponse(
['error' => 'Internal server error'],
Http::STATUS_INTERNAL_SERVER_ERROR
);
}
}
// getRequestData() provided by ApiControllerTrait
}
+113 -5
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace OCA\Mitgliederverwaltung\Service;
use OCA\Mitgliederverwaltung\Db\Lager;
use OCA\Mitgliederverwaltung\Db\LagerMapper;
use OCA\Mitgliederverwaltung\Db\Member;
use OCA\Mitgliederverwaltung\Db\MemberMapper;
use OCP\AppFramework\Db\DoesNotExistException;
@@ -37,6 +38,7 @@ class FileLinkService {
private IRootFolder $rootFolder;
private IConfig $config;
private MemberMapper $memberMapper;
private LagerMapper $lagerMapper;
private LoggerInterface $logger;
private ?string $userId;
@@ -44,12 +46,14 @@ class FileLinkService {
IRootFolder $rootFolder,
IConfig $config,
MemberMapper $memberMapper,
LagerMapper $lagerMapper,
LoggerInterface $logger,
?string $userId
) {
$this->rootFolder = $rootFolder;
$this->config = $config;
$this->memberMapper = $memberMapper;
$this->lagerMapper = $lagerMapper;
$this->logger = $logger;
$this->userId = $userId;
}
@@ -87,12 +91,13 @@ class FileLinkService {
// ── Folder operations ───────────────────────────────────────────
/**
* Ensure a member's folder exists. Creates it if auto-create is enabled.
* Ensure a member's folder exists. Creates it if auto-create is enabled or force is true.
*
* @param Member $member The member entity
* @param bool $force Create the folder regardless of auto-create setting (for explicit user actions)
* @return bool True if folder exists after this call
*/
public function ensureMemberFolder(Member $member): bool {
public function ensureMemberFolder(Member $member, bool $force = false): bool {
if ($this->userId === null) {
$this->logger->warning('Cannot ensure folder: no user context', [
'app' => self::APP_ID,
@@ -109,7 +114,7 @@ class FileLinkService {
$userFolder->get($path);
return true; // Folder already exists
} catch (NotFoundException $e) {
if ($this->isAutoCreateEnabled()) {
if ($force || $this->isAutoCreateEnabled()) {
$userFolder->newFolder($path);
$this->logger->info('Created member folder', [
'memberId' => $member->getId(),
@@ -229,18 +234,121 @@ class FileLinkService {
* Ensure member folder exists by member ID.
*
* @param int $memberId The member ID
* @param bool $force Create the folder regardless of auto-create setting
* @return array{exists: bool, path: string}
* @throws DoesNotExistException
*/
public function ensureMemberFolderById(int $memberId): array {
public function ensureMemberFolderById(int $memberId, bool $force = false): array {
$member = $this->memberMapper->findById($memberId);
$exists = $this->ensureMemberFolder($member);
$exists = $this->ensureMemberFolder($member, $force);
return [
'exists' => $exists,
'path' => $this->getMemberFolderPath($member),
];
}
/**
* Ensure a Lager's folder exists. Creates it if force is true or auto-create is enabled.
*
* @param Lager $lager The Lager entity
* @param bool $force Create the folder regardless of auto-create setting
* @return bool True if folder exists after this call
*/
public function ensureLagerFolder(Lager $lager, bool $force = false): bool {
if ($this->userId === null) {
$this->logger->warning('Cannot ensure Lager folder: no user context', [
'app' => self::APP_ID,
]);
return false;
}
$path = $this->getLagerFolderPath($lager);
try {
$userFolder = $this->rootFolder->getUserFolder($this->userId);
try {
$userFolder->get($path);
return true;
} catch (NotFoundException $e) {
if ($force || $this->isAutoCreateEnabled()) {
$userFolder->newFolder($path);
$this->logger->info('Created Lager folder', [
'lagerId' => $lager->getId(),
'path' => $path,
'app' => self::APP_ID,
]);
return true;
}
return false;
}
} catch (\Exception $e) {
$this->logger->error('Failed to ensure Lager folder', [
'lagerId' => $lager->getId(),
'path' => $path,
'exception' => $e,
'app' => self::APP_ID,
]);
return false;
}
}
/**
* Ensure Lager folder exists by Lager ID.
*
* @param int $lagerId The Lager ID
* @param bool $force Create the folder regardless of auto-create setting
* @return array{exists: bool, path: string}
* @throws DoesNotExistException
*/
public function ensureLagerFolderById(int $lagerId, bool $force = false): array {
$lager = $this->lagerMapper->findById($lagerId);
$exists = $this->ensureLagerFolder($lager, $force);
return [
'exists' => $exists,
'path' => $this->getLagerFolderPath($lager),
];
}
/**
* Get files for a Lager's folder.
*
* @param int $lagerId The Lager ID
* @return array{exists: bool, path: string, files: array}
* @throws DoesNotExistException
*/
public function getLagerFilesById(int $lagerId): array {
$lager = $this->lagerMapper->findById($lagerId);
$path = $this->getLagerFolderPath($lager);
try {
$files = $this->listFolderContents($path);
return [
'exists' => true,
'path' => $path,
'files' => $files,
];
} catch (NotFoundException $e) {
return [
'exists' => false,
'path' => $path,
'files' => [],
];
} catch (\Exception $e) {
$this->logger->error('Failed to list Lager files', [
'lagerId' => $lagerId,
'path' => $path,
'exception' => $e,
'app' => self::APP_ID,
]);
return [
'exists' => false,
'path' => $path,
'files' => [],
];
}
}
// ── Settings ────────────────────────────────────────────────────
/**
+231 -27
View File
@@ -14,12 +14,38 @@
Schließen
</NcButton>
</div>
<div v-if="lagerStore.success" class="lager-detail__message lager-detail__message--success">
{{ lagerStore.success }}
<NcButton @click="lagerStore.clearSuccess()">
Schließen
</NcButton>
</div>
<div v-if="camp" class="lager-detail__content">
<!-- Camp info -->
<div class="lager-detail__section">
<div class="lager-detail__section-header">
<h3>Details</h3>
<div class="lager-detail__info-grid">
<div v-if="!editing" class="lager-detail__edit-actions">
<NcButton type="primary" @click="startEditing">
<template #icon>
<Pencil :size="20" />
</template>
Bearbeiten
</NcButton>
</div>
<div v-else class="lager-detail__edit-actions">
<NcButton @click="cancelEditing">
Abbrechen
</NcButton>
<NcButton type="primary"
:disabled="!editForm.name || !editForm.startdatum || !editForm.enddatum"
@click="saveEditing">
Speichern
</NcButton>
</div>
</div>
<div v-if="!editing" class="lager-detail__info-grid">
<div><strong>Startdatum:</strong> {{ formatDate(camp.startdatum) }}</div>
<div><strong>Enddatum:</strong> {{ formatDate(camp.enddatum) }}</div>
<div><strong>Ort:</strong> {{ camp.ort || '-' }}</div>
@@ -27,6 +53,23 @@
<strong>Beschreibung:</strong> {{ camp.beschreibung }}
</div>
</div>
<div v-else class="lager-detail__edit-form">
<label>Name *
<input v-model="editForm.name" type="text">
</label>
<label>Startdatum *
<input v-model="editForm.startdatum" type="date">
</label>
<label>Enddatum *
<input v-model="editForm.enddatum" type="date">
</label>
<label>Ort
<input v-model="editForm.ort" type="text">
</label>
<label>Beschreibung
<textarea v-model="editForm.beschreibung" rows="3" />
</label>
</div>
</div>
<!-- Participants -->
@@ -34,10 +77,15 @@
<div class="lager-detail__section-header">
<h3>Teilnehmer ({{ camp.teilnehmer?.length || 0 }})</h3>
<div class="lager-detail__add-form">
<input v-model="newMemberId"
type="number"
placeholder="Mitglied-ID"
class="lager-detail__input">
<NcSelect :model-value="selectedMember"
:options="memberOptions"
:reduce="o => o.value"
label="label"
placeholder="Mitglied auswählen..."
:loading="membersLoading"
:clearable="true"
class="lager-detail__member-select"
@update:model-value="selectedMember = $event" />
<select v-model="newRolle" class="lager-detail__select">
<option value="Teilnehmer">
Teilnehmer
@@ -47,7 +95,7 @@
</option>
</select>
<NcButton type="primary"
:disabled="!newMemberId"
:disabled="!selectedMember"
@click="doAddTeilnehmer">
Hinzufügen
</NcButton>
@@ -82,15 +130,52 @@
<!-- Files -->
<div class="lager-detail__section">
<h3>Dateien ({{ camp.files?.length || 0 }})</h3>
<ul v-if="camp.files?.length">
<li v-for="file in camp.files" :key="file.id">
{{ file.label || 'Datei' }}: {{ file.filePath }}
</li>
</ul>
<p v-else class="lager-detail__empty">
Keine Dateien verknuepft
<h3>Dateien</h3>
<NcLoadingIcon v-if="folderLoading" :size="32" />
<div v-else-if="!folderExists" class="lager-detail__folder-missing">
<p class="lager-detail__empty">
Der Ordner <strong>{{ folderPath }}</strong> existiert noch nicht.
</p>
<NcButton type="primary" @click="createLagerFolder">
<template #icon>
<FolderPlus :size="20" />
</template>
Ordner erstellen
</NcButton>
</div>
<div v-else>
<div class="lager-detail__folder-toolbar">
<span class="lager-detail__folder-path">{{ folderPath }}</span>
<NcButton @click="openInFiles">
<template #icon>
<OpenInNew :size="20" />
</template>
In Nextcloud Files öffnen
</NcButton>
</div>
<p v-if="folderFiles.length === 0" class="lager-detail__empty">
Ordner ist leer
</p>
<table v-else class="lager-detail__table">
<thead>
<tr>
<th>Dateiname</th>
<th>Größe</th>
<th>Geändert</th>
</tr>
</thead>
<tbody>
<tr v-for="file in folderFiles" :key="file.name">
<td>{{ file.name }}</td>
<td>{{ file.type === 'folder' ? '—' : formatSize(file.size) }}</td>
<td>{{ formatTimestamp(file.mtime) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
@@ -99,32 +184,142 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { NcButton } from '@nextcloud/vue'
import { NcButton, NcLoadingIcon, NcSelect } from '@nextcloud/vue'
import axios from '@nextcloud/axios'
import { generateUrl } from '@nextcloud/router'
import { useLagerStore } from '../stores/lager.js'
import { formatDate } from '../utils/dateFormat.js'
import { formatDate, formatTimestamp } from '../utils/dateFormat.js'
import FolderPlus from 'vue-material-design-icons/FolderPlus.vue'
import OpenInNew from 'vue-material-design-icons/OpenInNew.vue'
import Pencil from 'vue-material-design-icons/Pencil.vue'
const route = useRoute()
const lagerStore = useLagerStore()
const newMemberId = ref(null)
const newRolle = ref('Teilnehmer')
const camp = computed(() => lagerStore.currentCamp)
// formatDate imported from utils/dateFormat.js
// ── Edit mode ───────────────────────────────────────────────────────
const editing = ref(false)
const editForm = ref({ name: '', startdatum: '', enddatum: '', ort: '', beschreibung: '' })
function startEditing() {
editForm.value = {
name: camp.value.name || '',
startdatum: camp.value.startdatum || '',
enddatum: camp.value.enddatum || '',
ort: camp.value.ort || '',
beschreibung: camp.value.beschreibung || '',
}
editing.value = true
}
function cancelEditing() {
editing.value = false
}
async function saveEditing() {
try {
await lagerStore.updateCamp(camp.value.id, editForm.value)
editing.value = false
} catch {
// Error handled by store
}
}
// ── Participants ────────────────────────────────────────────────────
const selectedMember = ref(null)
const newRolle = ref('Teilnehmer')
const allMembers = ref([])
const membersLoading = ref(false)
async function loadAllMembers() {
membersLoading.value = true
try {
const url = generateUrl('/apps/mitgliederverwaltung/api/v1/members')
const response = await axios.get(url, { params: { limit: 9999, offset: 0 } })
allMembers.value = response.data.data || []
} catch {
allMembers.value = []
} finally {
membersLoading.value = false
}
}
const memberOptions = computed(() => {
const existingIds = new Set((camp.value?.teilnehmer || []).map(t => t.memberId))
return allMembers.value
.filter(m => !existingIds.has(m.id))
.map(m => ({
value: m.id,
label: `${m.nachname}, ${m.vorname}`,
}))
})
async function doAddTeilnehmer() {
if (!newMemberId.value) return
await lagerStore.addTeilnehmer(camp.value.id, parseInt(newMemberId.value), newRolle.value)
newMemberId.value = null
if (!selectedMember.value) return
await lagerStore.addTeilnehmer(camp.value.id, selectedMember.value, newRolle.value)
selectedMember.value = null
}
async function doRemoveTeilnehmer(memberId) {
await lagerStore.removeTeilnehmer(camp.value.id, memberId)
}
onMounted(() => {
lagerStore.fetchCamp(parseInt(route.params.id))
// ── Files ───────────────────────────────────────────────────────────
const folderLoading = ref(false)
const folderExists = ref(false)
const folderPath = ref('')
const folderFiles = ref([])
async function fetchLagerFiles(lagerId) {
folderLoading.value = true
try {
const url = generateUrl(`/apps/mitgliederverwaltung/api/v1/lager/${lagerId}/files/browse`)
const response = await axios.get(url)
folderExists.value = response.data.exists || false
folderPath.value = response.data.path || ''
folderFiles.value = response.data.files || []
} catch {
folderExists.value = false
} finally {
folderLoading.value = false
}
}
async function createLagerFolder() {
if (!camp.value) return
folderLoading.value = true
try {
const url = generateUrl(`/apps/mitgliederverwaltung/api/v1/lager/${camp.value.id}/files/ensure-folder`)
const response = await axios.post(url)
if (response.data.status === 'ok') {
await fetchLagerFiles(camp.value.id)
}
} catch (err) {
lagerStore.error = err.response?.data?.error || 'Fehler beim Erstellen des Ordners'
} finally {
folderLoading.value = false
}
}
function openInFiles() {
const url = generateUrl('/apps/files/?dir={dir}', { dir: folderPath.value })
window.open(url, '_blank')
}
function formatSize(bytes) {
if (bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(1024))
const size = (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0)
return size + ' ' + units[i]
}
onMounted(async () => {
const lagerId = parseInt(route.params.id)
await lagerStore.fetchCamp(lagerId)
fetchLagerFiles(lagerId)
loadAllMembers()
})
</script>
@@ -132,18 +327,27 @@ onMounted(() => {
.lager-detail { padding: 20px; max-width: 1000px; }
.lager-detail__header { display: flex; align-items: center; gap: 16px; margin-bottom: 20px; }
.lager-detail__header h2 { margin: 0; }
.lager-detail__message { padding: 12px; border-radius: 8px; margin-bottom: 16px; display: flex; justify-content: space-between; }
.lager-detail__message { padding: 12px; border-radius: 8px; margin-bottom: 16px; display: flex; justify-content: space-between; align-items: center; }
.lager-detail__message--error { background: var(--color-error-hover); color: var(--color-error); }
.lager-detail__message--success { background: var(--color-success-hover, #e8f5e9); color: var(--color-success, #2e7d32); }
.lager-detail__section { margin-bottom: 24px; padding: 16px; background: var(--color-background-dark); border-radius: 8px; }
.lager-detail__section h3 { margin: 0 0 12px; }
.lager-detail__section-header { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 12px; margin-bottom: 12px; }
.lager-detail__section-header h3 { margin: 0; }
.lager-detail__edit-actions { display: flex; gap: 8px; }
.lager-detail__info-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 8px; }
.lager-detail__edit-form { display: flex; flex-direction: column; gap: 12px; }
.lager-detail__edit-form label { display: flex; flex-direction: column; gap: 4px; font-weight: 600; font-size: 13px; }
.lager-detail__edit-form input,
.lager-detail__edit-form textarea { padding: 6px 8px; border: 1px solid var(--color-border); border-radius: 4px; width: 100%; box-sizing: border-box; }
.lager-detail__add-form { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.lager-detail__input { padding: 6px 8px; border: 1px solid var(--color-border); border-radius: 4px; width: 120px; }
.lager-detail__member-select { min-width: 250px; }
.lager-detail__select { padding: 6px 8px; border: 1px solid var(--color-border); border-radius: 4px; }
.lager-detail__table { width: 100%; border-collapse: collapse; }
.lager-detail__table th { background: var(--color-primary); color: white; padding: 6px 12px; text-align: left; }
.lager-detail__table td { padding: 6px 12px; border-bottom: 1px solid var(--color-border); }
.lager-detail__empty { text-align: center; color: var(--color-text-lighter); padding: 12px; }
.lager-detail__folder-missing { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 20px; }
.lager-detail__folder-toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.lager-detail__folder-path { font-family: monospace; font-size: 0.9em; color: var(--color-text-lighter); }
</style>
+59 -10
View File
@@ -51,11 +51,20 @@
<table v-else class="lager-list__table">
<thead>
<tr>
<th v-for="col in visibleColumns" :key="col.key">{{ col.label }}</th>
<th v-for="col in visibleColumns" :key="col.key"
class="lager-list__th"
:class="{ 'lager-list__th--sortable': col.sortable }"
@click="col.sortable && toggleSort(col.sortField || col.key)">
{{ col.label }}
<SortIcon v-if="col.sortable"
:field="col.sortField || col.key"
:current-sort="sortField"
:sort-asc="sortAsc" />
</th>
</tr>
</thead>
<tbody>
<tr v-for="camp in lagerStore.camps"
<tr v-for="camp in sortedCamps"
:key="camp.id"
class="lager-list__row"
@click="$router.push({ name: 'lager-detail', params: { id: camp.id } })">
@@ -113,12 +122,13 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { NcButton } from '@nextcloud/vue'
import { useLagerStore } from '../stores/lager.js'
import { useStufenStore } from '../stores/stufen.js'
import { useColumnVisibility } from '../utils/useColumnVisibility.js'
import ColumnPicker from '../components/ColumnPicker.vue'
import SortIcon from '../components/SortIcon.vue'
import { formatDate } from '../utils/dateFormat.js'
const lagerStore = useLagerStore()
@@ -126,14 +136,16 @@ const stufenStore = useStufenStore()
const showCreate = ref(false)
const newCamp = ref({ name: '', startdatum: '', enddatum: '', ort: '', beschreibung: '' })
const sortField = ref('startdatum')
const sortAsc = ref(false)
const allColumns = [
{ key: 'name', label: 'Name', alwaysVisible: true, render: c => c.name },
{ key: 'startdatum', label: 'Startdatum', render: c => formatDate(c.startdatum) },
{ key: 'enddatum', label: 'Enddatum', render: c => formatDate(c.enddatum) },
{ key: 'ort', label: 'Ort', render: c => c.ort || '-' },
{ key: 'name', label: 'Name', sortable: true, sortField: 'name', alwaysVisible: true, render: c => c.name },
{ key: 'startdatum', label: 'Startdatum', sortable: true, sortField: 'startdatum', render: c => formatDate(c.startdatum) },
{ key: 'enddatum', label: 'Enddatum', sortable: true, sortField: 'enddatum', render: c => formatDate(c.enddatum) },
{ key: 'ort', label: 'Ort', sortable: true, sortField: 'ort', render: c => c.ort || '-' },
{ key: 'beschreibung', label: 'Beschreibung', render: c => c.beschreibung ? (c.beschreibung.length > 50 ? c.beschreibung.substring(0, 50) + '\u2026' : c.beschreibung) : '\u2014' },
{ key: 'teilnehmer', label: 'Teilnehmer', render: c => c.teilnehmer?.length || 0 },
{ key: 'teilnehmer', label: 'Teilnehmer', sortable: true, sortField: '_teilnehmerCount', render: c => c.teilnehmer?.length || 0 },
{ key: 'aktionen', label: 'Aktionen', render: () => '' },
]
@@ -143,6 +155,41 @@ const { visibleKeys, visibleColumns, setVisibleKeys } = useColumnVisibility(
['name', 'startdatum', 'enddatum', 'ort', 'teilnehmer', 'aktionen'],
)
const sortedCamps = computed(() => {
const camps = [...lagerStore.camps]
const field = sortField.value
const asc = sortAsc.value
return camps.sort((a, b) => {
let valA, valB
if (field === '_teilnehmerCount') {
valA = a.teilnehmer?.length || 0
valB = b.teilnehmer?.length || 0
} else {
valA = a[field] ?? ''
valB = b[field] ?? ''
}
if (typeof valA === 'string') {
valA = valA.toLowerCase()
valB = String(valB).toLowerCase()
}
if (valA < valB) return asc ? -1 : 1
if (valA > valB) return asc ? 1 : -1
return 0
})
})
function toggleSort(field) {
if (sortField.value === field) {
sortAsc.value = !sortAsc.value
} else {
sortField.value = field
sortAsc.value = true
}
}
function onYearChange(val) {
lagerStore.filterYear = val ? parseInt(val) : null
lagerStore.fetchCamps()
@@ -186,7 +233,9 @@ onMounted(() => {
.lager-list__message--error { background: var(--color-error-hover, #fce4e4); color: var(--color-error); }
.lager-list__loading { padding: 40px; text-align: center; color: var(--color-text-lighter); }
.lager-list__table { width: 100%; border-collapse: collapse; font-size: 14px; }
.lager-list__table th { background: var(--color-primary); color: white; padding: 8px 12px; text-align: left; }
.lager-list__th { background: var(--color-primary); color: white; padding: 8px 12px; text-align: left; white-space: nowrap; }
.lager-list__th--sortable { cursor: pointer; user-select: none; }
.lager-list__th--sortable:hover { background-color: var(--color-primary-element-hover); }
.lager-list__table td { padding: 8px 12px; border-bottom: 1px solid var(--color-border); }
.lager-list__row { cursor: pointer; }
.lager-list__row:hover { background: var(--color-background-hover); }
@@ -195,6 +244,6 @@ onMounted(() => {
.lager-list__dialog { background: var(--color-main-background); padding: 24px; border-radius: 12px; width: 480px; max-width: 90vw; }
.lager-list__form { display: flex; flex-direction: column; gap: 12px; margin: 16px 0; }
.lager-list__form label { display: flex; flex-direction: column; gap: 4px; font-weight: 600; font-size: 13px; }
.lager-list__form input, .lager-list__form textarea { padding: 6px 8px; border: 1px solid var(--color-border); border-radius: 4px; }
.lager-list__form input, .lager-list__form textarea { padding: 6px 8px; border: 1px solid var(--color-border); border-radius: 4px; width: 100%; box-sizing: border-box; }
.lager-list__dialog-actions { display: flex; justify-content: flex-end; gap: 8px; }
</style>