Files
el-inv/src/lib/components/ItemModal.svelte
2025-11-21 15:11:33 +01:00

586 lines
19 KiB
Svelte

<script lang="ts">
import { enhance } from '$app/forms';
import { propertyConfig } from '$lib/config/properties';
import { formatValue } from '$lib/utils/units';
import { createEventDispatcher } from 'svelte';
export let show = false;
export let item: any = null;
export let categories: any[] = [];
export let folders: any[] = []; // For autocomplete
export let currentFolderId: string | null = null;
export let currentCategoryId: string | null = null;
export let user: any = null; // Current user
export let onClose: () => void;
let dialog: HTMLDialogElement;
let selectedCategoryId = '';
let folderSearch = '';
let showFolderSuggestions = false;
let arbitraryProperties: { key: string; value: string }[] = [];
// Data for view mode
let reservations: any[] = [];
let comments: any[] = [];
let loadingDetails = false;
$: if (show && dialog && !dialog.open) {
dialog.showModal();
if (item) {
selectedCategoryId = item.categoryId;
// Parse arbitrary properties from item.properties
arbitraryProperties = Object.entries(item.properties || {})
.filter(
([key]) =>
!categories.find((c) => c.id === item.categoryId)?.defaultProperties.includes(key)
)
.map(([key, value]) => ({ key, value: String(getDisplayValue(key, value)) }));
if (!currentFolderId) {
const folder = folders.find((f) => f.id === item.folderId);
if (folder) folderSearch = folder.name;
}
fetchDetails();
} else {
selectedCategoryId = currentCategoryId || '';
arbitraryProperties = [];
folderSearch = '';
reservations = [];
comments = [];
}
} else if (!show && dialog && dialog.open) {
dialog.close();
}
$: selectedCategory = categories.find((c) => c.id === selectedCategoryId);
// Filter folders for autocomplete
$: filteredFolders = folderSearch
? folders.filter((f) => f.name.toLowerCase().includes(folderSearch.toLowerCase())).slice(0, 5)
: [];
async function fetchDetails(showLoading = true) {
if (!item) return;
if (showLoading) loadingDetails = true;
try {
const res = await fetch(`/api/items/${item.id}/details`);
if (res.ok) {
const data = await res.json();
reservations = data.reservations;
comments = data.comments;
}
} catch (e) {
console.error('Failed to fetch details', e);
} finally {
if (showLoading) loadingDetails = false;
}
}
function getDisplayValue(key: string, value: any) {
if (propertyConfig[key]) {
return formatValue(value, propertyConfig[key], { includeUnit: false });
}
return value;
}
function selectFolder(folder: any) {
folderSearch = folder.name;
showFolderSuggestions = false;
}
function addProperty() {
arbitraryProperties = [...arbitraryProperties, { key: '', value: '' }];
}
function removeProperty(index: number) {
arbitraryProperties = arbitraryProperties.filter((_, i) => i !== index);
}
function close() {
onClose();
}
// Helper to find user's reservation
$: userReservation = user ? reservations.find((r) => r.userId === user.id) : null;
// Edit state for reservation count
let editingReservationId: string | null = null;
</script>
<dialog
bind:this={dialog}
class="m-auto w-full max-w-4xl rounded-lg border border-zinc-700 bg-zinc-950 p-0 text-white shadow-2xl backdrop:bg-black/80"
on:close={close}
on:click={(e) => {
if (e.target === dialog) close();
}}
>
<div class="relative flex h-[80vh] flex-col md:flex-row">
<button
on:click={close}
class="absolute top-4 right-4 z-50 rounded-md p-1 text-zinc-400 hover:bg-zinc-800 hover:text-white"
title="Close"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
</svg>
</button>
<!-- Left Side: Item Details (Edit/Create) -->
<div
class="flex-1 overflow-y-auto p-6 {item
? 'border-b border-zinc-800 md:border-r md:border-b-0'
: ''}"
>
<h2 class="mb-4 text-lg font-bold text-white">{item ? 'Edit Item' : 'Create New Item'}</h2>
<form
method="POST"
action={item ? '?/updateItem' : '?/createItem'}
use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'success') {
await update();
close();
}
};
}}
class="space-y-4"
>
{#if item}
<input type="hidden" name="id" value={item.id} />
{/if}
<div class="grid grid-cols-2 gap-4">
<div class="col-span-2">
<label for="itemName" class="mb-1 block text-sm font-medium text-zinc-400"
>Item Name</label
>
<input
type="text"
name="name"
id="itemName"
value={item?.name || ''}
required
class="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-white placeholder-zinc-600 transition-all outline-none focus:border-red-500 focus:ring-1 focus:ring-red-500"
placeholder="e.g. 10k Resistor"
/>
</div>
</div>
<!-- Folder Selection (only if not in a specific folder) -->
{#if !currentFolderId}
<div class="relative">
<label for="folderName" class="mb-1 block text-sm font-medium text-zinc-400"
>Location</label
>
<input
type="text"
id="folderName"
name="folderName"
bind:value={folderSearch}
on:focus={() => (showFolderSuggestions = true)}
on:input={() => (showFolderSuggestions = true)}
on:keydown={(e) => {
if (e.key === 'ArrowDown' && filteredFolders.length > 0) {
e.preventDefault();
const firstBtn = document.getElementById('folder-suggestion-0');
firstBtn?.focus();
}
}}
on:blur={(e) => {
// Check if the related target is one of our suggestion buttons
const relatedTarget = e.relatedTarget as HTMLElement;
if (!relatedTarget?.closest('.folder-suggestions')) {
setTimeout(() => (showFolderSuggestions = false), 200);
}
}}
autocomplete="off"
required
class="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-white placeholder-zinc-600 transition-all outline-none focus:border-red-500 focus:ring-1 focus:ring-red-500"
placeholder="Search for a location..."
/>
<input
type="hidden"
name="folderId"
value={folders.find((f) => f.name === folderSearch)?.id || ''}
/>
{#if showFolderSuggestions && filteredFolders.length > 0}
<div
class="folder-suggestions absolute z-10 mt-1 max-h-48 w-full overflow-y-auto rounded-md border border-zinc-700 bg-zinc-900 shadow-lg"
>
{#each filteredFolders as folder, i}
<button
type="button"
id="folder-suggestion-{i}"
class="w-full px-4 py-2 text-left text-sm text-zinc-300 hover:bg-zinc-800 focus:bg-zinc-800 focus:outline-none"
on:click={() => selectFolder(folder)}
>
{folder.name}
</button>
{/each}
</div>
{/if}
</div>
{:else}
<input type="hidden" name="folderId" value={currentFolderId} />
{/if}
<div class="grid grid-cols-2 gap-4">
<div>
<label for="itemCount" class="mb-1 block text-sm font-medium text-zinc-400">Count</label
>
<input
type="number"
name="count"
id="itemCount"
required
min="0"
value={item?.count || 1}
class="no-spin w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-white transition-all outline-none focus:border-red-500 focus:ring-1 focus:ring-red-500"
/>
</div>
<div>
<label for="categoryId" class="mb-1 block text-sm font-medium text-zinc-400"
>Category</label
>
<select
name="categoryId"
id="categoryId"
bind:value={selectedCategoryId}
required
class="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-white transition-all outline-none focus:border-red-500 focus:ring-1 focus:ring-red-500"
>
<option value="" disabled selected>Select Category</option>
{#each categories as cat}
<option value={cat.id}>{cat.name}</option>
{/each}
</select>
</div>
</div>
{#if selectedCategory}
<div class="mt-4 border-t border-zinc-700 pt-4">
<h3 class="mb-3 text-sm font-semibold text-zinc-300">Category Properties</h3>
<div class="grid grid-cols-2 gap-4">
{#each selectedCategory.defaultProperties as prop}
<div>
<label
for="prop_{prop}"
class="mb-1 block text-xs font-medium text-zinc-500 capitalize">{prop}</label
>
<div class="relative">
<input
type="text"
name="prop_{prop}"
id="prop_{prop}"
value={item?.properties ? getDisplayValue(prop, item.properties[prop]) : ''}
class="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-white transition-all outline-none focus:border-red-500 focus:ring-1 focus:ring-red-500"
/>
{#if propertyConfig[prop]}
<div
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-sm text-zinc-500"
>
{propertyConfig[prop].unit}
</div>
{/if}
</div>
</div>
{/each}
</div>
</div>
{/if}
<div class="mt-4 border-t border-zinc-700 pt-4">
<div class="mb-3 flex items-center justify-between">
<h3 class="text-sm font-semibold text-zinc-300">Additional Properties</h3>
<button
type="button"
on:click={addProperty}
class="text-xs font-medium text-red-500 hover:text-red-400"
>
+ Add Property
</button>
</div>
<div class="space-y-3">
{#each arbitraryProperties as prop, i}
<div class="flex items-start gap-2">
<input
type="text"
name="custom_key_{i}"
bind:value={prop.key}
placeholder="Name"
class="w-1/3 rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-white placeholder-zinc-600 outline-none focus:ring-1 focus:ring-red-500"
/>
<div class="relative flex-1">
<input
type="text"
name="custom_val_{i}"
bind:value={prop.value}
placeholder="Value"
class="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-white placeholder-zinc-600 outline-none focus:ring-1 focus:ring-red-500"
/>
{#if propertyConfig[prop.key]}
<div
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-sm text-zinc-500"
>
{propertyConfig[prop.key].unit}
</div>
{/if}
</div>
<button
type="button"
on:click={() => removeProperty(i)}
class="mt-2 px-2 text-zinc-600 hover:text-red-500"
>
&times;
</button>
</div>
{/each}
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<button
type="button"
on:click={close}
class="rounded-md px-4 py-2 font-medium text-zinc-400 transition-colors hover:bg-zinc-800"
>Cancel</button
>
<button
type="submit"
class="rounded-md bg-red-600 px-4 py-2 font-medium text-white shadow-lg shadow-red-900/20 transition-colors hover:bg-red-700"
>{item ? 'Save Changes' : 'Create Item'}</button
>
</div>
</form>
</div>
<!-- Right Side: Reservations & Comments (Only in Edit Mode) -->
{#if item}
<div class="flex flex-1 flex-col overflow-hidden bg-zinc-900/30">
<!-- Reservations Section -->
<div class="flex-1 overflow-y-auto border-b border-zinc-800 p-6">
<h3 class="mb-4 text-lg font-bold text-white">Reservations</h3>
{#if loadingDetails}
<div class="text-sm text-zinc-500">Loading...</div>
{:else}
<div class="space-y-3">
{#each reservations as reservation}
<div
class="flex items-center justify-between rounded-md border border-zinc-800 bg-zinc-900 p-3"
>
<div class="flex items-center gap-3">
<div
class="flex h-8 w-8 items-center justify-center rounded-full bg-zinc-800 text-xs font-bold text-zinc-400"
>
{reservation.user?.username?.[0]?.toUpperCase() || '?'}
</div>
<div>
<div class="text-sm font-medium text-white">
{reservation.user?.username || 'Unknown User'}
</div>
<div class="text-xs text-zinc-500">
{new Date(reservation.createdAt).toLocaleDateString()}
</div>
</div>
</div>
{#if user && reservation.userId === user.id}
<div class="flex items-center gap-2">
<form
action="/items?/updateReservation"
method="POST"
use:enhance={() => {
return async ({ result }) => {
if (result.type === 'success') fetchDetails(false);
};
}}
>
<input type="hidden" name="id" value={reservation.id} />
<input type="hidden" name="count" value={reservation.count - 1} />
<button
class="h-6 w-6 rounded bg-zinc-800 text-zinc-400 hover:bg-zinc-700 hover:text-white"
>-</button
>
</form>
{#if editingReservationId === reservation.id}
<form
action="/items?/updateReservation"
method="POST"
use:enhance={() => {
return async ({ result }) => {
if (result.type === 'success') {
editingReservationId = null;
fetchDetails(false);
}
};
}}
>
<input type="hidden" name="id" value={reservation.id} />
<input
type="number"
name="count"
value={reservation.count}
class="w-12 rounded bg-zinc-800 px-1 text-center text-sm font-bold text-white outline-none focus:ring-1 focus:ring-red-500"
autofocus
on:blur={(e) => e.currentTarget.form?.requestSubmit()}
/>
</form>
{:else}
<button
type="button"
class="min-w-[1.5rem] text-center text-sm font-bold text-white hover:text-red-400"
on:click={() => (editingReservationId = reservation.id)}
>
{reservation.count}
</button>
{/if}
<form
action="/items?/updateReservation"
method="POST"
use:enhance={() => {
return async ({ result }) => {
if (result.type === 'success') fetchDetails(false);
};
}}
>
<input type="hidden" name="id" value={reservation.id} />
<input type="hidden" name="count" value={reservation.count + 1} />
<button
class="h-6 w-6 rounded bg-zinc-800 text-zinc-400 hover:bg-zinc-700 hover:text-white"
>+</button
>
</form>
</div>
{:else}
<span class="rounded bg-zinc-800 px-2 py-1 text-sm font-medium text-zinc-300"
>{reservation.count} reserved</span
>
{/if}
</div>
{/each}
{#if reservations.length === 0}
<div class="text-sm text-zinc-500 italic">No reservations yet.</div>
{/if}
</div>
{#if user && !userReservation}
<div class="mt-4">
<form
action="/items?/reserve"
method="POST"
use:enhance={() => {
return async ({ result }) => {
if (result.type === 'success') fetchDetails(false);
};
}}
>
<input type="hidden" name="itemId" value={item.id} />
<input type="hidden" name="count" value="1" />
<button
class="w-full rounded-md border border-zinc-700 bg-zinc-800 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-700 hover:text-white"
>
Reserve Item
</button>
</form>
</div>
{/if}
{/if}
</div>
<!-- Comments Section -->
<div class="flex flex-1 flex-col overflow-hidden p-6">
<h3 class="mb-4 text-lg font-bold text-white">Comments</h3>
<div class="mb-4 flex-1 space-y-4 overflow-y-auto pr-2">
{#if loadingDetails}
<div class="text-sm text-zinc-500">Loading...</div>
{:else}
{#each comments as comment}
<div class="group relative rounded-md bg-zinc-900 p-3">
<div class="mb-1 flex items-center justify-between pr-6">
<div class="flex items-center gap-2">
<span class="text-xs font-bold text-zinc-300"
>{comment.user?.username || 'Unknown'}</span
>
<span class="text-[10px] text-zinc-600"
>{new Date(comment.createdAt).toLocaleDateString()}</span
>
</div>
</div>
<p class="text-sm text-zinc-400">{comment.text}</p>
{#if user && comment.userId === user.id}
<form
action="/items?/deleteComment"
method="POST"
class="absolute top-2 right-2 opacity-0 transition-opacity group-hover:opacity-100"
use:enhance={() => {
return async ({ result }) => {
if (result.type === 'success') fetchDetails(false);
};
}}
>
<input type="hidden" name="id" value={comment.id} />
<button class="text-zinc-600 hover:text-red-500" title="Delete">
&times;
</button>
</form>
{/if}
</div>
{/each}
{#if comments.length === 0}
<div class="text-sm text-zinc-500 italic">No comments yet.</div>
{/if}
{/if}
</div>
{#if user}
<form
action="/items?/comment"
method="POST"
class="flex gap-2"
use:enhance={({ formElement }) => {
return async ({ result }) => {
if (result.type === 'success') {
formElement.reset();
fetchDetails(false);
}
};
}}
>
<input type="hidden" name="itemId" value={item.id} />
<input
type="text"
name="text"
required
placeholder="Write a comment..."
class="flex-1 rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-white placeholder-zinc-600 outline-none focus:border-red-500 focus:ring-1 focus:ring-red-500"
/>
<button
class="rounded-md bg-zinc-800 px-4 py-2 text-sm font-medium text-white hover:bg-zinc-700"
>
Post
</button>
</form>
{/if}
</div>
</div>
{/if}
</div>
</dialog>