perbaikan dashboard
This commit is contained in:
118
src/components/HeroIcon.svelte
Normal file
118
src/components/HeroIcon.svelte
Normal file
@@ -0,0 +1,118 @@
|
||||
<script lang="ts">
|
||||
export let name: string;
|
||||
export let colorClass: string = "text-gray-500"; // Default color
|
||||
|
||||
// This is a simplified way. For a real app, you might import from a library or
|
||||
// have a map of names to actual SVG paths.
|
||||
// For now, we'll keep the direct SVG paths as in your original component,
|
||||
// but just moved into this new file.
|
||||
</script>
|
||||
|
||||
<div class={`w-8 h-8 mb-2 ${colorClass}`}>
|
||||
{#if name === "exclamation-triangle"}
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if name === "folder"}
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 7h4l2 3h10a1 1 0 011 1v6a2 2 0 01-2 2H5a2 2 0 01-2-2V7z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if name === "document"}
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 16h8M8 12h8m-6-8h4a2 2 0 012 2v12a2 2 0 01-2 2h-4a2 2 0 01-2-2V6a2 2 0 012-2z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if name === "home"}
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 12l2-2m0 0l7-7 7 7m-9 2v6m0 0H5a2 2 0 01-2-2v-4a2 2 0 012-2h3m4 6h4a2 2 0 002-2v-4a2 2 0 00-2-2h-3"
|
||||
/>
|
||||
</svg>
|
||||
{:else if name === "clock"}
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 6v6l4 2m-6 8a9 9 0 100-18 9 9 0 000 18z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if name === "cube"}
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M20 7l-8-4-8 4v10l8 4 8-4V7z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 3v18"
|
||||
/>
|
||||
</svg>
|
||||
{:else if name === "building-storefront"}
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 4h16v4H4V4zm0 4v12h16V8m-2 4h-4v4h4v-4z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if name === "user-group"}
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M16 11a4 4 0 11-8 0 4 4 0 018 0zm-6 5a6.002 6.002 0 00-5.775 4.5A10.001 10.001 0 0112 21a10.001 10.001 0 017.775-5.5A6.002 6.002 0 0012 16h-2z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if name === "truck"}
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 16v1a2 2 0 002 2h14a2 2 0 002-2v-1m-1-3V7a2 2 0 00-2-2H7l-4 4v6m18-6h1a1 1 0 011 1v3a1 1 0 01-1 .993L20 .993V7zM5.5 17a1.5 1.5 0 11-3 .001A1.5 1.5 0 015.517zM18.5,17a1.5,1.5,0,1,1,3,.001A1.5,1.5,0,0,1,18.517,17Z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if name === "utensils"}
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8V3m0 5a2 2 0 11-4 0 2 2 0 014 0zm0 0v10m0-10h4a2 2 0 012 2v6a2 2 0 01-2 2h-4a2 2 0 01-2-2V8a2 2 0 012-2z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if name === "comments"}
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 10h.01M12 10h.01M16 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
311
src/components/InventoryFormModal.svelte
Normal file
311
src/components/InventoryFormModal.svelte
Normal file
@@ -0,0 +1,311 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
|
||||
type ConditionType =
|
||||
| "NEW"
|
||||
| "GOOD"
|
||||
| "AVERAGE"
|
||||
| "POOR"
|
||||
| "SUBSTANDARD"
|
||||
| "BROKEN";
|
||||
|
||||
type InventoryInsert = {
|
||||
item_name: string;
|
||||
villa_id: string;
|
||||
item_location: string;
|
||||
brand_color_material: string;
|
||||
condition: ConditionType;
|
||||
remarks: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
type Villa = {
|
||||
id: string;
|
||||
villa_name: string;
|
||||
};
|
||||
|
||||
export let showModal: boolean;
|
||||
export let isEditing: boolean;
|
||||
export let formData: InventoryInsert;
|
||||
export let villaItems: Villa[];
|
||||
export let isLoading: boolean = false;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
let formErrors: Record<string, string> = {};
|
||||
|
||||
function validateForm(): boolean {
|
||||
formErrors = {};
|
||||
let isValid = true;
|
||||
|
||||
if (!formData.item_name) {
|
||||
formErrors.item_name = "Item name is required.";
|
||||
isValid = false;
|
||||
}
|
||||
if (!formData.villa_id) {
|
||||
formErrors.villa_id = "Villa name is required.";
|
||||
isValid = false;
|
||||
}
|
||||
if (!formData.item_location) {
|
||||
formErrors.item_location = "Item location is required.";
|
||||
isValid = false;
|
||||
}
|
||||
if (!formData.brand_color_material) {
|
||||
formErrors.brand_color_material =
|
||||
"Brand/Color/Material is required.";
|
||||
isValid = false;
|
||||
}
|
||||
if (!formData.condition) {
|
||||
formErrors.condition = "Condition is required.";
|
||||
isValid = false;
|
||||
}
|
||||
if (formData.remarks && formData.remarks.length > 500) {
|
||||
formErrors.remarks = "Remarks cannot exceed 500 characters.";
|
||||
isValid = false;
|
||||
}
|
||||
return isValid;
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
if (validateForm()) {
|
||||
dispatch("submit", formData);
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
formErrors = {}; // Clear errors when closing
|
||||
dispatch("close");
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if showModal}
|
||||
<div
|
||||
class="fixed inset-0 bg-black bg-opacity-50 z-50 overflow-y-auto flex items-center justify-center p-4"
|
||||
on:click|self={closeModal}
|
||||
>
|
||||
<form
|
||||
on:submit|preventDefault={handleSubmit}
|
||||
class="w-full max-w-lg bg-white p-8 rounded-2xl shadow-2xl space-y-5 transform transition-all duration-300 ease-out scale-100 opacity-100"
|
||||
>
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-2xl font-bold text-gray-800">
|
||||
{isEditing ? "Edit Item" : "Add New Item"}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="text-gray-500 hover:text-gray-700 p-2 rounded-full hover:bg-gray-100 transition"
|
||||
on:click={closeModal}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="item_name"
|
||||
class="block text-sm font-medium text-gray-700 mb-1"
|
||||
>Item Name</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="item_name"
|
||||
class="w-full border border-gray-300 p-3 rounded-lg focus:ring-blue-500 focus:border-blue-500 transition {formErrors.item_name
|
||||
? 'border-red-500'
|
||||
: ''}"
|
||||
bind:value={formData.item_name}
|
||||
placeholder="e.g., Living Room Sofa"
|
||||
required
|
||||
/>
|
||||
{#if formErrors.item_name}
|
||||
<p class="text-red-500 text-xs mt-1">
|
||||
{formErrors.item_name}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="villa_id"
|
||||
class="block text-sm font-medium text-gray-700 mb-1"
|
||||
>Villa Name</label
|
||||
>
|
||||
<select
|
||||
id="villa_id"
|
||||
class="w-full border border-gray-300 p-3 rounded-lg focus:ring-blue-500 focus:border-blue-500 transition {formErrors.villa_id
|
||||
? 'border-red-500'
|
||||
: ''}"
|
||||
bind:value={formData.villa_id}
|
||||
required
|
||||
>
|
||||
<option value="" disabled>Select Villa</option>
|
||||
{#each villaItems as villa}
|
||||
<option value={villa.id}>
|
||||
{villa.villa_name}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
{#if formErrors.villa_id}
|
||||
<p class="text-red-500 text-xs mt-1">
|
||||
{formErrors.villa_id}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="item_location"
|
||||
class="block text-sm font-medium text-gray-700 mb-1"
|
||||
>Item Location</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="item_location"
|
||||
class="w-full border border-gray-300 p-3 rounded-lg focus:ring-blue-500 focus:border-blue-500 transition {formErrors.item_location
|
||||
? 'border-red-500'
|
||||
: ''}"
|
||||
bind:value={formData.item_location}
|
||||
placeholder="e.g., Master Bedroom, Kitchen"
|
||||
required
|
||||
/>
|
||||
{#if formErrors.item_location}
|
||||
<p class="text-red-500 text-xs mt-1">
|
||||
{formErrors.item_location}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="brand_color_material"
|
||||
class="block text-sm font-medium text-gray-700 mb-1"
|
||||
>Brand/Color/Material</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="brand_color_material"
|
||||
class="w-full border border-gray-300 p-3 rounded-lg focus:ring-blue-500 focus:border-blue-500 transition {formErrors.brand_color_material
|
||||
? 'border-red-500'
|
||||
: ''}"
|
||||
bind:value={formData.brand_color_material}
|
||||
placeholder="e.g., IKEA / White / Wood"
|
||||
required
|
||||
/>
|
||||
{#if formErrors.brand_color_material}
|
||||
<p class="text-red-500 text-xs mt-1">
|
||||
{formErrors.brand_color_material}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="condition"
|
||||
class="block text-sm font-medium text-gray-700 mb-1"
|
||||
>Condition</label
|
||||
>
|
||||
<select
|
||||
id="condition"
|
||||
class="w-full border border-gray-300 p-3 rounded-lg focus:ring-blue-500 focus:border-blue-500 transition {formErrors.condition
|
||||
? 'border-red-500'
|
||||
: ''}"
|
||||
bind:value={formData.condition}
|
||||
required
|
||||
>
|
||||
<option value="NEW">New</option>
|
||||
<option value="GOOD">Good</option>
|
||||
<option value="AVERAGE">Average</option>
|
||||
<option value="POOR">Poor</option>
|
||||
<option value="SUBSTANDARD">Substandard</option>
|
||||
<option value="BROKEN">Broken</option>
|
||||
</select>
|
||||
{#if formErrors.condition}
|
||||
<p class="text-red-500 text-xs mt-1">
|
||||
{formErrors.condition}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="remarks"
|
||||
class="block text-sm font-medium text-gray-700 mb-1"
|
||||
>Remarks</label
|
||||
>
|
||||
<textarea
|
||||
id="remarks"
|
||||
class="w-full border border-gray-300 p-3 rounded-lg focus:ring-blue-500 focus:border-blue-500 transition {formErrors.remarks
|
||||
? 'border-red-500'
|
||||
: ''}"
|
||||
bind:value={formData.remarks}
|
||||
placeholder="Optional remarks about the item"
|
||||
rows="3"
|
||||
></textarea>
|
||||
{#if formErrors.remarks}
|
||||
<p class="text-red-500 text-xs mt-1">
|
||||
{formErrors.remarks}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full bg-blue-600 text-white font-semibold px-4 py-3 rounded-lg hover:bg-blue-700 transition duration-200 shadow-md flex items-center justify-center gap-2"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{#if isLoading}
|
||||
<svg
|
||||
class="animate-spin h-5 w-5 text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
Saving...
|
||||
{:else}
|
||||
{isEditing ? "Update Item" : "Add Item"}
|
||||
{/if}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* Modal entry animation */
|
||||
.transform {
|
||||
transition: all 0.3s ease-out;
|
||||
}
|
||||
.scale-100 {
|
||||
transform: scale(1);
|
||||
}
|
||||
.opacity-100 {
|
||||
opacity: 1;
|
||||
}
|
||||
/* Initial state for hidden modal - you'd set this with a conditional class or just let Tailwind apply it on removal */
|
||||
</style>
|
||||
8
src/lib/utils/debounce.ts
Normal file
8
src/lib/utils/debounce.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export function debounce<T extends (...args: any[]) => void>(func: T, delay: number): (...args: Parameters<T>) => void {
|
||||
let timeout: ReturnType<typeof setTimeout>;
|
||||
return function (this: ThisParameterType<T>, ...args: Parameters<T>) {
|
||||
const context = this;
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => func.apply(context, args), delay);
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { supabase } from "$lib/supabaseClient";
|
||||
import HeroIcon from "../../components/HeroIcon.svelte";
|
||||
import { quintOut } from "svelte/easing";
|
||||
import { tweened } from "svelte/motion";
|
||||
import type { Tweened } from "svelte/motion";
|
||||
|
||||
type StatKey =
|
||||
| "vb_purchase_orders"
|
||||
@@ -15,6 +19,22 @@
|
||||
| "vb_dinning"
|
||||
| "vb_feedback";
|
||||
|
||||
// This object will hold our `tweened` store instances
|
||||
const tweenedStats: Record<StatKey, Tweened<number>> = {
|
||||
vb_purchase_orders: tweened(0),
|
||||
vb_issues: tweened(0),
|
||||
vb_villas: tweened(0),
|
||||
vb_timesheet: tweened(0),
|
||||
vb_inventory: tweened(0),
|
||||
vb_projects: tweened(0),
|
||||
vb_vendor: tweened(0),
|
||||
vb_employee: tweened(0),
|
||||
vb_transport: tweened(0),
|
||||
vb_dinning: tweened(0),
|
||||
vb_feedback: tweened(0),
|
||||
};
|
||||
|
||||
// This object will hold the *values* from the tweened stores, and will be reactive
|
||||
let stats: Record<StatKey, number> = {
|
||||
vb_purchase_orders: 0,
|
||||
vb_issues: 0,
|
||||
@@ -29,277 +49,194 @@
|
||||
vb_feedback: 0,
|
||||
};
|
||||
|
||||
let isLoading = true;
|
||||
|
||||
const items: {
|
||||
label: string;
|
||||
key: StatKey;
|
||||
color: string;
|
||||
icon: string;
|
||||
bgColor: string;
|
||||
}[] = [
|
||||
{
|
||||
label: "Total Issue",
|
||||
label: "Total Issues",
|
||||
key: "vb_issues",
|
||||
color: "text-red-600",
|
||||
icon: "exclamation-triangle",
|
||||
bgColor: "bg-red-100",
|
||||
},
|
||||
{
|
||||
label: "Total Project",
|
||||
label: "Total Projects",
|
||||
key: "vb_projects",
|
||||
color: "text-purple-600",
|
||||
icon: "folder",
|
||||
bgColor: "bg-purple-100",
|
||||
},
|
||||
{
|
||||
label: "Total PO",
|
||||
label: "Total POs",
|
||||
key: "vb_purchase_orders",
|
||||
color: "text-blue-600",
|
||||
icon: "document",
|
||||
bgColor: "bg-blue-100",
|
||||
},
|
||||
{
|
||||
label: "Total Villa",
|
||||
label: "Total Villas",
|
||||
key: "vb_villas",
|
||||
color: "text-green-600",
|
||||
icon: "home",
|
||||
bgColor: "bg-green-100",
|
||||
},
|
||||
{
|
||||
label: "Total Timesheet",
|
||||
label: "Total Timesheets",
|
||||
key: "vb_timesheet",
|
||||
color: "text-gray-800",
|
||||
icon: "clock",
|
||||
bgColor: "bg-gray-100",
|
||||
},
|
||||
{
|
||||
label: "Total Inventories",
|
||||
key: "vb_inventory",
|
||||
color: "text-yellow-600",
|
||||
icon: "cube",
|
||||
bgColor: "bg-yellow-100",
|
||||
},
|
||||
{
|
||||
label: "Total Vendor",
|
||||
label: "Total Vendors",
|
||||
key: "vb_vendor",
|
||||
color: "text-orange-600",
|
||||
icon: "building-storefront",
|
||||
bgColor: "bg-orange-100",
|
||||
},
|
||||
{
|
||||
label: "Total Employee",
|
||||
label: "Total Employees",
|
||||
key: "vb_employee",
|
||||
color: "text-teal-600",
|
||||
icon: "user-group",
|
||||
bgColor: "bg-teal-100",
|
||||
},
|
||||
{
|
||||
label: "Total Transport",
|
||||
key: "vb_transport",
|
||||
color: "text-indigo-600",
|
||||
icon: "truck",
|
||||
bgColor: "bg-indigo-100",
|
||||
},
|
||||
{
|
||||
label: "Total Dinning",
|
||||
label: "Total Dining",
|
||||
key: "vb_dinning",
|
||||
color: "text-pink-600",
|
||||
icon: "utensils",
|
||||
bgColor: "bg-pink-100",
|
||||
},
|
||||
{
|
||||
label: "Total Feedback",
|
||||
key: "vb_feedback",
|
||||
color: "text-gray-600",
|
||||
icon: "comments",
|
||||
bgColor: "bg-gray-100",
|
||||
},
|
||||
];
|
||||
|
||||
onMount(async () => {
|
||||
const fetchCount = async (table: string) => {
|
||||
const { count } = await supabase
|
||||
const { count, error } = await supabase
|
||||
.from(table)
|
||||
.select("*", { count: "exact", head: true });
|
||||
if (error) {
|
||||
console.error(
|
||||
`Error fetching count for ${table}:`,
|
||||
error.message,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
return count || 0;
|
||||
};
|
||||
|
||||
for (const key of Object.keys(stats) as StatKey[]) {
|
||||
stats[key] = await fetchCount(key);
|
||||
}
|
||||
const promises = items.map(async (item) => {
|
||||
const count = await fetchCount(item.key);
|
||||
// Set the tweened store's value
|
||||
tweenedStats[item.key].set(count, {
|
||||
duration: 1000,
|
||||
easing: quintOut,
|
||||
});
|
||||
|
||||
console.log("Dashboard data fetched successfully");
|
||||
// Subscribe to the tweened store and update the 'stats' variable
|
||||
// Using a reactive statement `$: stats[item.key] = $tweenedStats[item.key]`
|
||||
// for each item in the loop is not ideal.
|
||||
// A better way is to update 'stats' directly within the subscription,
|
||||
// or just bind to `tweenedStats[item.key]` in the template.
|
||||
// For simplicity and direct reactivity:
|
||||
tweenedStats[item.key].subscribe((val) => {
|
||||
stats = { ...stats, [item.key]: Math.round(val) };
|
||||
});
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
isLoading = false;
|
||||
console.log("Dashboard data fetched successfully and animated!");
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- You can extract SVGs from https://heroicons.com or install them via a package for better maintainability -->
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<h1
|
||||
class="text-3xl font-extrabold text-gray-900 mb-8 text-center sm:text-left"
|
||||
>
|
||||
Dashboard Overview
|
||||
</h1>
|
||||
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<div
|
||||
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"
|
||||
>
|
||||
{#each items as item}
|
||||
<div
|
||||
class="bg-white p-6 rounded-xl shadow text-center flex flex-col items-center"
|
||||
class="bg-white p-6 rounded-xl shadow-lg border border-gray-100
|
||||
flex flex-col items-center justify-center text-center
|
||||
transform transition-all duration-300 ease-in-out
|
||||
hover:scale-105 hover:shadow-xl hover:border-blue-300
|
||||
{isLoading ? 'animate-pulse-light' : ''}"
|
||||
>
|
||||
<!-- ICON -->
|
||||
{#if item.icon === "exclamation-triangle"}
|
||||
<svg
|
||||
class="w-8 h-8 mb-2 text-red-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if item.icon === "folder"}
|
||||
<svg
|
||||
class="w-8 h-8 mb-2 text-purple-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 7h4l2 3h10a1 1 0 011 1v6a2 2 0 01-2 2H5a2 2 0 01-2-2V7z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if item.icon === "document"}
|
||||
<svg
|
||||
class="w-8 h-8 mb-2 text-blue-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 16h8M8 12h8m-6-8h4a2 2 0 012 2v12a2 2 0 01-2 2h-4a2 2 0 01-2-2V6a2 2 0 012-2z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if item.icon === "home"}
|
||||
<svg
|
||||
class="w-8 h-8 mb-2 text-green-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 12l2-2m0 0l7-7 7 7m-9 2v6m0 0H5a2 2 0 01-2-2v-4a2 2 0 012-2h3m4 6h4a2 2 0 002-2v-4a2 2 0 00-2-2h-3"
|
||||
/>
|
||||
</svg>
|
||||
{:else if item.icon === "clock"}
|
||||
<svg
|
||||
class="w-8 h-8 mb-2 text-gray-800"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 6v6l4 2m-6 8a9 9 0 100-18 9 9 0 000 18z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if item.icon === "cube"}
|
||||
<svg
|
||||
class="w-8 h-8 mb-2 text-yellow-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M20 7l-8-4-8 4v10l8 4 8-4V7z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 3v18"
|
||||
/>
|
||||
</svg>
|
||||
{:else if item.icon === "building-storefront"}
|
||||
<svg
|
||||
class="w-8 h-8 mb-2 text-orange-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 4h16v4H4V4zm0 4v12h16V8m-2 4h-4v4h4v-4z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if item.icon === "user-group"}
|
||||
<svg
|
||||
class="w-8 h-8 mb-2 text-teal-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M16 11a4 4 0 11-8 0 4 4 0 018 0zm-6 5a6.002 6.002 0 00-5.775 4.5A10.001 10.001 0 0112 21a10.001 10.001 0 017.775-5.5A6.002 6.002 0 0012 16h-2z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if item.icon === "truck"}
|
||||
<svg
|
||||
class="w-8 h-8 mb-2 text-indigo-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 16v1a2 2 0 002 2h14a2 2 0 002-2v-1m-1-3V7a2 2 0 00-2-2H7l-4 4v6m18-6h1a1 1 0 011 1v3a1 1 0 01-1 .993L20 .993V7zM5.5 17a1.5 1.5 0 11-3 .001A1.5 1.5 0 015.517zM18.5,17a1.5,1.5,0,1,1,3,.001A1.5,1.5,0,0,1,18.517,17Z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if item.icon === "utensils"}
|
||||
<svg
|
||||
class="w-8 h-8 mb-2 text-pink-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8V3m0 5a2 2 0 11-4 0 2 2 0 014 0zm0 0v10m0-10h4a2 2 0 012 2v6a2 2 0 01-2 2h-4a2 2 0 01-2-2V8a2 2 0 012-2z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if item.icon === "comments"}
|
||||
<svg
|
||||
class="w-8 h-8 mb-2 text-gray-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 10h.01M12 10h.01M16 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
<div
|
||||
class="relative w-14 h-14 rounded-full {item.bgColor}
|
||||
flex items-center justify-center mb-4 transition-all duration-300 ease-in-out
|
||||
group-hover:scale-110"
|
||||
>
|
||||
<HeroIcon name={item.icon} colorClass={item.color} />
|
||||
<div
|
||||
class="absolute inset-0 rounded-full ring-2 ring-transparent transition-all duration-300 ease-in-out
|
||||
group-hover:ring-blue-200"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- LABEL & VALUE -->
|
||||
<h2 class="text-lg font-semibold text-gray-700 mb-1">
|
||||
<h2
|
||||
class="text-md font-semibold text-gray-700 mb-2 whitespace-nowrap"
|
||||
>
|
||||
{item.label}
|
||||
</h2>
|
||||
<p class={`text-2xl font-bold ${item.color}`}>
|
||||
{stats[item.key]}
|
||||
<p class={`text-3xl font-extrabold ${item.color}`}>
|
||||
{#if isLoading}
|
||||
<div class="h-8 bg-gray-200 rounded w-24 mx-auto"></div>
|
||||
{:else}
|
||||
{stats[item.key]}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Custom animations for a smoother feel */
|
||||
@keyframes pulse-light {
|
||||
0%,
|
||||
100% {
|
||||
background-color: #ffffff; /* white */
|
||||
}
|
||||
50% {
|
||||
background-color: #f3f4f6; /* gray-100 */
|
||||
}
|
||||
}
|
||||
|
||||
.animate-pulse-light {
|
||||
animation: pulse-light 2s infinite ease-in-out;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user