Dashboard final version, TODO: update main sidebar menu
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user