Dashboard final version, TODO: update main sidebar menu

This commit is contained in:
Simon Pocrnjič
2025-11-23 21:33:01 +01:00
parent c3de189e9d
commit c1ac92efbf
67 changed files with 5195 additions and 844 deletions
@@ -0,0 +1,100 @@
<script setup>
import { CheckIcon, ChevronsUpDownIcon } from "lucide-vue-next";
import { computed, ref } from "vue";
import { cn } from "@/lib/utils";
import { Button } from "@/Components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/Components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/Components/ui/popover";
const props = defineProps({
modelValue: {
type: [String, Number],
default: "",
},
items: {
type: Array,
default: () => [],
},
placeholder: {
type: String,
default: "Select item...",
},
searchPlaceholder: {
type: String,
default: "Search...",
},
emptyText: {
type: String,
default: "No item found.",
},
disabled: {
type: Boolean,
default: false,
},
buttonClass: {
type: String,
default: "w-[200px]",
},
});
const emit = defineEmits(["update:modelValue"]);
const open = ref(false);
const selectedItem = computed(() =>
props.items.find((item) => item.value === props.modelValue)
);
function selectItem(selectedValue) {
const newValue = selectedValue === props.modelValue ? "" : selectedValue;
emit("update:modelValue", newValue);
open.value = false;
}
</script>
<template>
<Popover v-model:open="open">
<PopoverTrigger as-child>
<Button
variant="outline"
role="combobox"
:aria-expanded="open"
:disabled="disabled"
:class="cn('justify-between', buttonClass)"
>
{{ selectedItem?.label || placeholder }}
<ChevronsUpDownIcon class="opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent class="p-0" :class="buttonClass">
<Command>
<CommandInput class="h-9" :placeholder="searchPlaceholder" />
<CommandList>
<CommandEmpty>{{ emptyText }}</CommandEmpty>
<CommandGroup>
<CommandItem
v-for="item in items"
:key="item.value"
:value="item.value"
@select="selectItem"
>
{{ item.label }}
<CheckIcon
:class="
cn('ml-auto', modelValue === item.value ? 'opacity-100' : 'opacity-0')
"
/>
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</template>
@@ -0,0 +1,173 @@
<script setup>
import { ref, computed, watch } from "vue";
import { Popover, PopoverTrigger, PopoverContent } from "@/Components/ui/popover";
import {
Command,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
} from "@/Components/ui/command";
import { Button } from "@/Components/ui/button";
import { Badge } from "@/Components/ui/badge";
import { Check, ChevronsUpDown, X } from "lucide-vue-next";
const props = defineProps({
modelValue: { type: Array, default: () => [] },
items: { type: Array, default: () => [] }, // [{ value, label }]
placeholder: { type: String, default: "Izberi..." },
searchPlaceholder: { type: String, default: "Išči..." },
emptyText: { type: String, default: "Ni zadetkov." },
disabled: { type: Boolean, default: false },
max: { type: Number, default: null },
clearable: { type: Boolean, default: true },
contentClass: { type: String, default: "p-0 w-[300px]" },
showSelectedChips: { type: Boolean, default: true },
chipVariant: { type: String, default: "secondary" },
});
const emit = defineEmits(["update:modelValue"]);
const open = ref(false);
const query = ref("");
const internal = ref([...props.modelValue]);
watch(
() => props.modelValue,
(val) => {
if (!Array.isArray(val)) return;
internal.value = [...val];
}
);
const valueSet = computed(() => new Set(internal.value.map(String)));
const filteredItems = computed(() => {
const q = query.value.trim().toLowerCase();
if (!q) return props.items;
return props.items.filter((i) => i.label.toLowerCase().includes(q));
});
function toggle(value) {
if (props.disabled) return;
const v = String(value);
const set = new Set(internal.value.map(String));
if (set.has(v)) {
set.delete(v);
} else {
if (props.max && set.size >= props.max) return;
set.add(v);
}
internal.value = Array.from(set);
emit("update:modelValue", internal.value);
// Clear search so full list remains visible after selection
query.value = "";
}
function removeChip(value) {
const v = String(value);
internal.value = internal.value.filter((x) => String(x) !== v);
emit("update:modelValue", internal.value);
}
function clearAll() {
if (!props.clearable || props.disabled) return;
internal.value = [];
emit("update:modelValue", internal.value);
}
const summaryText = computed(() => {
if (internal.value.length === 0) return props.placeholder;
if (!props.showSelectedChips) return `${internal.value.length} izbranih`;
const labels = internal.value.map((v) => {
const found = props.items.find((i) => String(i.value) === String(v));
return found?.label || v;
});
if (labels.length <= 3) return labels.join(', ');
const firstThree = labels.slice(0, 3).join(', ');
const remaining = labels.length - 3;
return `${firstThree}, … +${remaining}`; // show ellipsis and remaining count
});
</script>
<template>
<Popover v-model:open="open">
<PopoverTrigger as-child>
<Button
variant="outline"
role="combobox"
:disabled="disabled"
class="w-full justify-between gap-2"
>
<span
class="truncate"
:class="{ 'text-muted-foreground': internal.length === 0 }"
>
{{ summaryText }}
</span>
<ChevronsUpDown class="h-4 w-4 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent :class="contentClass">
<Command>
<CommandInput v-model="query" :placeholder="searchPlaceholder" />
<CommandList>
<CommandEmpty class="px-3 py-2 text-sm text-muted-foreground">{{
emptyText
}}</CommandEmpty>
<CommandGroup>
<CommandItem
v-for="item in filteredItems"
:key="item.value"
:value="String(item.value)"
@select="() => toggle(item.value)"
class="flex items-center gap-2 px-2 py-1.5 rounded-sm cursor-pointer hover:bg-accent hover:text-accent-foreground"
>
<Check
class="h-4 w-4"
:class="valueSet.has(String(item.value)) ? 'opacity-100' : 'opacity-0'"
/>
<span class="flex-1">{{ item.label }}</span>
</CommandItem>
</CommandGroup>
</CommandList>
<div class="border-t p-2 flex items-center justify-between gap-2">
<Button
v-if="clearable"
variant="outline"
size="sm"
:disabled="internal.length === 0 || disabled"
@click="clearAll"
>Počisti</Button
>
<Button size="sm" :disabled="disabled" @click="open = false">Zapri</Button>
</div>
<div
v-if="showSelectedChips && internal.length"
class="border-t p-2 flex flex-wrap gap-1"
>
<Badge
v-for="val in internal"
:key="val"
:variant="chipVariant"
class="flex items-center gap-1"
>
<span class="truncate max-w-[140px]">
{{ items.find((i) => String(i.value) === String(val))?.label || val }}
</span>
<button
type="button"
class="hover:text-foreground/80"
@click.stop="removeChip(val)"
:title="'Odstrani'"
>
<X class="h-3 w-3" />
</button>
</Badge>
</div>
</Command>
</PopoverContent>
</Popover>
</template>
@@ -0,0 +1,63 @@
<script setup>
import { ref } from "vue";
import { Popover, PopoverContent, PopoverTrigger } from "@/Components/ui/popover";
const props = defineProps({
open: {
type: Boolean,
default: undefined,
},
align: {
type: String,
default: "center",
validator: (value) => ["start", "center", "end"].includes(value),
},
side: {
type: String,
default: "bottom",
validator: (value) => ["top", "right", "bottom", "left"].includes(value),
},
sideOffset: {
type: Number,
default: 4,
},
contentClass: {
type: String,
default: "",
},
disabled: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["update:open"]);
const internalOpen = ref(false);
const isControlled = props.open !== undefined;
function handleOpenChange(value) {
if (isControlled) {
emit("update:open", value);
} else {
internalOpen.value = value;
}
}
</script>
<template>
<Popover :open="isControlled ? open : internalOpen" @update:open="handleOpenChange">
<PopoverTrigger as-child :disabled="disabled">
<slot name="trigger" />
</PopoverTrigger>
<PopoverContent
:align="align"
:side="side"
:side-offset="sideOffset"
:class="contentClass"
>
<slot />
</PopoverContent>
</Popover>
</template>
@@ -0,0 +1,92 @@
<script lang="ts" setup>
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter,
} from "@/Components/ui/card";
import { cn } from "@/lib/utils";
import { computed, HTMLAttributes } from "vue";
interface Props {
title?: string;
description?: string;
loading?: boolean;
padding?: "default" | "none" | "tight";
hover?: boolean; // subtle hover style
clickable?: boolean; // adds cursor + focus ring
disabled?: boolean;
class?: HTMLAttributes["class"];
headerClass?: HTMLAttributes["class"];
bodyClass?: HTMLAttributes["class"];
}
const props = defineProps<Props>();
// Emit click for consumers if clickable
const emit = defineEmits<{ (e: "click", ev: MouseEvent): void }>();
const wrapperClasses = computed(() => {
const base = "relative transition-colors";
const hover = props.hover ? "hover:bg-muted/50" : "";
const clickable =
props.clickable && !props.disabled
? "cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
: "";
const disabled = props.disabled ? "opacity-60 pointer-events-none" : "";
return [base, hover, clickable, disabled].filter(Boolean).join(" ");
});
const paddingClasses = computed(() => {
switch (props.padding) {
case "none":
return "p-0";
case "tight":
return "p-3 sm:p-4";
default:
return "p-4 sm:p-6";
}
});
</script>
<template>
<Card
:class="cn(wrapperClasses, props.class)"
@click="props.clickable && emit('click', $event)"
>
<!-- Header Slot / Fallback -->
<CardHeader
v-if="title || description || $slots.header"
:class="cn('space-y-1', headerClass)"
>
<template v-if="$slots.header">
<slot name="header" />
</template>
<template v-else>
<CardTitle v-if="title">{{ title }}</CardTitle>
<CardDescription v-if="description">{{ description }}</CardDescription>
</template>
</CardHeader>
<!-- Loading Skeleton -->
<div v-if="loading" class="animate-pulse space-y-3 px-4 py-4">
<div class="h-4 w-1/3 rounded bg-muted" />
<div class="h-3 w-1/2 rounded bg-muted" />
<div class="h-32 rounded bg-muted" />
</div>
<!-- Content Slot -->
<CardContent v-else :class="cn(paddingClasses, bodyClass)">
<slot />
</CardContent>
<!-- Footer Slot -->
<CardFooter v-if="$slots.footer" class="border-t px-4 py-3 sm:px-6">
<slot name="footer" />
</CardFooter>
</Card>
</template>
<style scoped></style>
@@ -0,0 +1,8 @@
<script lang="ts" setup>
import type { HTMLAttributes } from "vue";
</script>
<template>
<div></div>
</template>
<style></style>
@@ -0,0 +1,35 @@
<script setup lang="ts">
import { cn } from "@/lib/utils";
import type { HTMLAttributes } from "vue";
import AppChartToolbar from "./AppChartToolbar.vue";
const props = defineProps<{
name: string;
class?: HTMLAttributes["class"];
}>();
</script>
<template>
<div
:class="
cn(
'group relative flex flex-col overflow-hidden rounded-xl border transition-all duration-200 ease-in-out hover:z-30',
props.class
)
"
>
<AppChartToolbar
:name
class="bg-card text-card-foreground relative z-20 flex justify-end border-b px-3 py-2.5"
>
<slot />
</AppChartToolbar>
<div
class="relative z-10 [&>div]:rounded-none [&>div]:border-none [&>div]:shadow-none"
>
<slot />
</div>
</div>
</template>
<style></style>
@@ -0,0 +1,57 @@
<script lang="ts" setup>
import { Button } from "@/Components/ui/button";
import { Separator } from "@/Components/ui/separator";
import TooltipProvider from "@/Components/ui/tooltip/TooltipProvider.vue";
import { cn } from "@/lib/utils";
import {
AreaChartIcon,
BarChartBigIcon,
HexagonIcon,
LineChartIcon,
MousePointer2Icon,
PieChartIcon,
RadarIcon,
} from "lucide-vue-next";
import type { HTMLAttributes } from "vue";
const props = defineProps<{
name: string;
//code: string;
class?: HTMLAttributes["class"];
}>();
</script>
<template>
<div :class="cn('flex items-center gap-2', props.class)">
<div
class="text-muted-foreground flex items-center gap-1.5 pl-1 text-[13px] [&>svg]:h-[0.9rem] [&>svg]:w-[0.9rem]"
>
<template v-if="name.includes('ChartLine')">
<LineChartIcon /> Line Chart
</template>
<template v-else-if="name.includes('ChartBar')">
<BarChartBigIcon /> Bar Chart
</template>
<template v-else-if="name.includes('ChartPie')">
<PieChartIcon /> Pie Chart
</template>
<template v-else-if="name.includes('ChartArea')">
<AreaChartIcon /> Area Chart
</template>
<template v-else-if="name.includes('ChartRadar')">
<HexagonIcon /> Radar Chart
</template>
<template v-else-if="name.includes('ChartRadial')">
<RadarIcon /> Radial Chart
</template>
<template v-else-if="name.includes('ChartTooltip')">
<MousePointer2Icon /> Tooltip
</template>
</div>
<div class="ml-auto flex items-center gap-2 [&>form]:flex">
<Separator orientation="vertical" class="mx-0 hidden h-4! md:flex" />
</div>
</div>
</template>
<style></style>