586 lines
19 KiB
Svelte
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"
|
|
>
|
|
×
|
|
</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">
|
|
×
|
|
</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>
|