456 lines
15 KiB
Svelte
456 lines
15 KiB
Svelte
<script lang="ts">
|
|
import { page } from '$app/stores';
|
|
import { enhance } from '$app/forms';
|
|
import { invalidateAll } from '$app/navigation';
|
|
import ItemModal from '$lib/components/ItemModal.svelte';
|
|
import MoveItemModal from '$lib/components/MoveItemModal.svelte';
|
|
import DeleteConfirmModal from '$lib/components/DeleteConfirmModal.svelte';
|
|
|
|
import { propertyConfig } from '$lib/config/properties';
|
|
import { formatValue } from '$lib/utils/units';
|
|
|
|
export let data;
|
|
|
|
$: ({ category, items, folders, categories } = data);
|
|
|
|
// Item Modal State
|
|
let showItemModal = false;
|
|
let showMoveItemModal = false;
|
|
let showDeleteModal = false;
|
|
|
|
let editingItem: any = null;
|
|
|
|
// Bulk/Single Action State
|
|
let selectedItems = new Set<string>();
|
|
let actionItemIds: string[] = [];
|
|
let actionItemName: string | null = null; // For display in single item actions
|
|
|
|
// Reactive statement to handle selection updates
|
|
$: allSelected = items.length > 0 && selectedItems.size === items.length;
|
|
$: isIndeterminate = selectedItems.size > 0 && selectedItems.size < items.length;
|
|
|
|
function openCreateItemModal() {
|
|
editingItem = null;
|
|
showItemModal = true;
|
|
}
|
|
|
|
function openEditItemModal(item: any) {
|
|
editingItem = item;
|
|
showItemModal = true;
|
|
}
|
|
|
|
function openMoveItemModal(item: any) {
|
|
actionItemIds = [item.id];
|
|
actionItemName = item.name;
|
|
showMoveItemModal = true;
|
|
}
|
|
|
|
function openBulkMoveModal() {
|
|
actionItemIds = Array.from(selectedItems);
|
|
actionItemName = null;
|
|
showMoveItemModal = true;
|
|
}
|
|
|
|
function openDeleteModal(item: any) {
|
|
actionItemIds = [item.id];
|
|
actionItemName = item.name;
|
|
showDeleteModal = true;
|
|
}
|
|
|
|
function openBulkDeleteModal() {
|
|
actionItemIds = Array.from(selectedItems);
|
|
actionItemName = null;
|
|
showDeleteModal = true;
|
|
}
|
|
|
|
function toggleSelection(id: string) {
|
|
if (selectedItems.has(id)) {
|
|
selectedItems.delete(id);
|
|
} else {
|
|
selectedItems.add(id);
|
|
}
|
|
selectedItems = selectedItems; // trigger update
|
|
}
|
|
|
|
function toggleAll(e: Event) {
|
|
const checked = (e.target as HTMLInputElement).checked;
|
|
if (checked) {
|
|
selectedItems = new Set(items.map((i) => i.id));
|
|
} else {
|
|
selectedItems = new Set();
|
|
}
|
|
selectedItems = selectedItems;
|
|
}
|
|
|
|
function getFormattedValue(key: string, value: any) {
|
|
if (propertyConfig[key]) {
|
|
return formatValue(value, propertyConfig[key]);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function getOtherProperties(item: any) {
|
|
const defaultProps = new Set(category.defaultProperties);
|
|
return Object.entries(item.properties || {}).filter(([key]) => !defaultProps.has(key));
|
|
}
|
|
</script>
|
|
|
|
<div class="space-y-4">
|
|
<!-- Header & Breadcrumbs -->
|
|
<div class="flex items-center justify-between">
|
|
<div class="flex items-center gap-2 text-sm text-zinc-500">
|
|
<a href="/categories" class="transition-colors hover:text-white">Categories</a>
|
|
<span>/</span>
|
|
<span class="font-medium text-white">{category.name}</span>
|
|
</div>
|
|
<div class="relative flex gap-2">
|
|
{#if selectedItems.size > 0}
|
|
<div
|
|
class="animate-in fade-in slide-in-from-top-2 absolute top-1/2 right-full mr-4 flex -translate-y-1/2 items-center gap-2 whitespace-nowrap duration-200"
|
|
>
|
|
<span class="text-sm font-medium text-zinc-400">{selectedItems.size} selected</span>
|
|
<button
|
|
on:click={openBulkMoveModal}
|
|
class="flex items-center gap-1 rounded-md border border-zinc-700 bg-zinc-900 px-3 py-1.5 text-sm font-medium text-zinc-300 transition-colors hover:bg-zinc-800 hover:text-white"
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
class="h-4 w-4"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"
|
|
/>
|
|
</svg>
|
|
Move
|
|
</button>
|
|
<button
|
|
on:click={openBulkDeleteModal}
|
|
class="flex items-center gap-1 rounded-md border border-zinc-700 bg-zinc-900 px-3 py-1.5 text-sm font-medium text-zinc-300 transition-colors hover:border-red-900/30 hover:bg-red-900/20 hover:text-red-500"
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
class="h-4 w-4"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
|
/>
|
|
</svg>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
|
|
<button
|
|
on:click={openCreateItemModal}
|
|
class="rounded-md bg-red-600 px-3 py-1.5 text-sm font-medium text-white shadow-lg shadow-red-900/20 transition-colors hover:bg-red-700"
|
|
>
|
|
+ New Item
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Item Modal -->
|
|
<ItemModal
|
|
bind:show={showItemModal}
|
|
item={editingItem}
|
|
{categories}
|
|
{folders}
|
|
currentFolderId={null}
|
|
currentCategoryId={category.id}
|
|
user={data.user}
|
|
onClose={() => {
|
|
showItemModal = false;
|
|
invalidateAll();
|
|
}}
|
|
/>
|
|
|
|
<!-- Move Item Modal -->
|
|
<MoveItemModal
|
|
bind:show={showMoveItemModal}
|
|
itemIds={actionItemIds}
|
|
itemName={actionItemName}
|
|
{folders}
|
|
onClose={() => (showMoveItemModal = false)}
|
|
/>
|
|
|
|
<!-- Delete Confirm Modal -->
|
|
<DeleteConfirmModal
|
|
bind:show={showDeleteModal}
|
|
itemIds={actionItemIds}
|
|
itemName={actionItemName}
|
|
onClose={() => (showDeleteModal = false)}
|
|
/>
|
|
|
|
<!-- Items List -->
|
|
{#if items.length > 0}
|
|
<section>
|
|
<h2 class="mb-3 text-xs font-semibold tracking-wider text-zinc-500 uppercase">
|
|
Items in {category.name}
|
|
</h2>
|
|
<div class="overflow-hidden rounded-md border border-zinc-700 bg-zinc-900/30">
|
|
<table class="w-full text-left text-sm">
|
|
<thead class="border-b border-zinc-700 bg-zinc-900/50">
|
|
<tr>
|
|
<th class="w-12 px-4 py-2">
|
|
<input
|
|
type="checkbox"
|
|
checked={allSelected}
|
|
indeterminate={isIndeterminate}
|
|
on:change={toggleAll}
|
|
class="h-4 w-4 shrink-0 cursor-pointer appearance-none rounded border border-zinc-700 bg-zinc-900 bg-center bg-no-repeat transition-colors checked:border-zinc-700 checked:bg-zinc-900 checked:bg-[url('data:image/svg+xml,%3csvg%20viewBox=%270%200%2016%2016%27%20fill=%27white%27%20xmlns=%27http://www.w3.org/2000/svg%27%3e%3cpath%20d=%27M12.207%204.793a1%201%200%20010%201.414l-5%205a1%201%200%2001-1.414%200l-2-2a1%201%200%20011.414-1.414L6.5%209.086l4.293-4.293a1%201%200%20011.414%200z%27/%3e%3c/svg%3e')] checked:bg-[length:75%] focus:ring-1 focus:ring-red-500 focus:ring-offset-0"
|
|
/>
|
|
</th>
|
|
<th class="px-4 py-2 font-medium text-zinc-500">Name</th>
|
|
<th class="px-4 py-2 font-medium text-zinc-500">Location</th>
|
|
<th class="px-4 py-2 font-medium text-zinc-500">Count</th>
|
|
|
|
<!-- Dynamic Headers for Default Properties -->
|
|
{#each category.defaultProperties as prop}
|
|
<th class="px-4 py-2 font-medium text-zinc-500 capitalize">{prop}</th>
|
|
{/each}
|
|
<th class="px-4 py-2 font-medium text-zinc-500">Other Properties</th>
|
|
<th class="px-4 py-2 text-right font-medium text-zinc-500">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-zinc-800/50">
|
|
{#each items as item}
|
|
<tr
|
|
class="group transition-colors hover:bg-zinc-800/30 {selectedItems.has(item.id)
|
|
? 'bg-red-900/5'
|
|
: ''}"
|
|
>
|
|
<td class="px-4 py-2">
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedItems.has(item.id)}
|
|
on:change={() => toggleSelection(item.id)}
|
|
class="h-4 w-4 shrink-0 cursor-pointer appearance-none rounded border border-zinc-700 bg-zinc-900 bg-center bg-no-repeat transition-colors checked:border-zinc-700 checked:bg-zinc-900 checked:bg-[url('data:image/svg+xml,%3csvg%20viewBox=%270%200%2016%2016%27%20fill=%27white%27%20xmlns=%27http://www.w3.org/2000/svg%27%3e%3cpath%20d=%27M12.207%204.793a1%201%200%20010%201.414l-5%205a1%201%200%2001-1.414%200l-2-2a1%201%200%20011.414-1.414L6.5%209.086l4.293-4.293a1%201%200%20011.414%200z%27/%3e%3c/svg%3e')] checked:bg-[length:75%] focus:ring-1 focus:ring-red-500 focus:ring-offset-0"
|
|
/>
|
|
</td>
|
|
<td class="px-4 py-2 font-medium text-zinc-200">
|
|
<button
|
|
class="text-left hover:text-red-400 hover:underline"
|
|
on:click={() => openEditItemModal(item)}
|
|
>
|
|
{item.name}
|
|
</button>
|
|
</td>
|
|
<td class="px-4 py-2 text-zinc-500">
|
|
{#if item.folderPath}
|
|
<a
|
|
href="/folders/{item.folderPath}"
|
|
class="transition-colors hover:text-white hover:underline"
|
|
>
|
|
{item.folderName}
|
|
</a>
|
|
{:else}
|
|
Unassigned
|
|
{/if}
|
|
</td>
|
|
<td class="px-4 py-2 font-mono text-zinc-400">
|
|
<div class="group/count flex items-center gap-3">
|
|
<span>
|
|
{item.count}
|
|
{#if item.reservedCount > 0}
|
|
<span class="text-red-500"> - {item.reservedCount}</span>
|
|
{/if}
|
|
</span>
|
|
<div
|
|
class="flex items-center rounded-md border border-zinc-700 bg-zinc-800 opacity-0 shadow-sm transition-opacity group-hover/count:opacity-100"
|
|
>
|
|
<form method="POST" action="?/updateCount" use:enhance class="flex">
|
|
<input type="hidden" name="id" value={item.id} />
|
|
<input type="hidden" name="delta" value="-1" />
|
|
<button
|
|
type="submit"
|
|
class="rounded-l-md border-r border-zinc-700 p-1 text-zinc-400 transition-colors hover:bg-zinc-700 hover:text-white"
|
|
title="Decrement"
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
class="h-3 w-3"
|
|
viewBox="0 0 20 20"
|
|
fill="currentColor"
|
|
>
|
|
<path
|
|
fill-rule="evenodd"
|
|
d="M3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z"
|
|
clip-rule="evenodd"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
</form>
|
|
<form method="POST" action="?/updateCount" use:enhance class="flex">
|
|
<input type="hidden" name="id" value={item.id} />
|
|
<input type="hidden" name="delta" value="1" />
|
|
<button
|
|
type="submit"
|
|
class="rounded-r-md p-1 text-zinc-400 transition-colors hover:bg-zinc-700 hover:text-white"
|
|
title="Increment"
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
class="h-3 w-3"
|
|
viewBox="0 0 20 20"
|
|
fill="currentColor"
|
|
>
|
|
<path
|
|
fill-rule="evenodd"
|
|
d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z"
|
|
clip-rule="evenodd"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
|
|
<!-- Dynamic Values for Default Properties -->
|
|
{#each category.defaultProperties as prop}
|
|
<td class="px-4 py-2 text-zinc-400">
|
|
{#if prop === 'Datasheet' || prop === 'Link'}
|
|
{#if item.properties[prop]}
|
|
<a
|
|
href={item.properties[prop]}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
class="text-red-400 hover:underline">Link</a
|
|
>
|
|
{:else}
|
|
-
|
|
{/if}
|
|
{:else}
|
|
{item.properties[prop] ? getFormattedValue(prop, item.properties[prop]) : '-'}
|
|
{/if}
|
|
</td>
|
|
{/each}
|
|
<td class="px-4 py-2 text-xs text-zinc-500">
|
|
<div class="flex flex-col gap-0.5">
|
|
{#each Object.entries(item.properties) as [key, value]}
|
|
{#if !category.defaultProperties.includes(key)}
|
|
<span
|
|
><span class="font-medium text-zinc-400">{key}:</span>
|
|
{#if key === 'Datasheet' || key === 'Link'}
|
|
<a
|
|
href={value}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
class="text-red-400 hover:underline">Link</a
|
|
>
|
|
{:else}
|
|
{getFormattedValue(key, value)}
|
|
{/if}</span
|
|
>
|
|
{/if}
|
|
{/each}
|
|
</div>
|
|
</td>
|
|
<td
|
|
class="px-4 py-2 text-right opacity-0 transition-opacity group-hover:opacity-100"
|
|
>
|
|
<div class="flex justify-end gap-2">
|
|
<button
|
|
on:click={() => openMoveItemModal(item)}
|
|
class="text-zinc-600 transition-colors hover:text-white"
|
|
title="Move"
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
class="h-4 w-4"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
<button
|
|
on:click={() => openEditItemModal(item)}
|
|
class="text-zinc-600 transition-colors hover:text-white"
|
|
title="Edit"
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
class="h-4 w-4"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
<button
|
|
on:click={() => openDeleteModal(item)}
|
|
class="text-zinc-600 transition-colors hover:text-red-500"
|
|
title="Delete"
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
class="h-4 w-4"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
{:else}
|
|
<div class="rounded-md border border-dashed border-zinc-800 bg-zinc-900/20 py-12 text-center">
|
|
<div class="mb-2 text-zinc-600">
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
class="mx-auto h-12 w-12"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="1.5"
|
|
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<h3 class="text-lg font-medium text-zinc-300">No Items</h3>
|
|
<p class="mt-1 text-sm text-zinc-500">This category has no items yet.</p>
|
|
</div>
|
|
{/if}
|
|
</div>
|